feat(maf): Phase 5 - action coordinator, approval flow, frontend MAF roundtable pages

- Backend: action_coordinator, capability_snapshot, event_normalizer, tool_executor updates
- Backend: approved_tool_executor with write guard, action leases, migration script
- Frontend: maf-roundtable module (API client, event timeline, approval cards, fact flow panel)
- Frontend: MafRoutes, MafSessionsPage, MafRunDetailPage
- Tests: action_leases, migration_phase5, phase5_entry_e2e, real_write_e2e/http_e2e
- Docs: update implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-02 13:07:21 +08:00
1 parent 21c4c898e7
commit f82363d81f
55 files changed
+6304 -167

No files matched your search

@@ -0,0 +1,122 @@
# MAF 多智能体会商 Phase-4 前端实施计划
## 决策回顾(已与用户确认)
- **入口**:独立顶级菜单 `/page/maf/*`,新增侧边栏一级项「MAF 会商」
- **实时更新**:纯前端轮询,`GET /runs/{id}/events?after_seq=N&limit=1000`,间隔 1.8s,复用现有后端,零 SSE、零后端改动
- **流程图**:要。基于 `confirmed_facts`(来自 `run.maf_checkpoint.fact_ledger`)+ 末轮 `final_result.content` 解析的 `flow-json`,复用 `@xyflow/react` + `flow-graph.ts` + `FlowGraphPanel`
- **审批**:卡片式,仿 `IntentClarificationCard` 的视觉/交互模式(参数摘要 + 批准/拒绝按钮 + 备注 textarea),落到 `POST /approvals/{id}/decision`
## 关键数据契约(已核实后端源码)
- **后端只读**:16 个端点全部命中,无改动。`GET /health` 返回 `enabled` 决定页面内容(入口仍可见,未启用时显示空态)。
- **事件流**:单调 `(run_id, seq)`,`GET /events?after_seq=N` 返回 `{events:[...], latest_seq}`;前端推进 `after_seq=latest_seq`。payload 已在后端**写入时脱敏**(`sanitize_payload`),前端直接渲染。
- **事件分类**(`event_normalizer.py`):`run.started/completed/failed/cancelled`、`agent.turn_started/message.delta/message.completed`、`tool.requested/started/completed/failed`、`skill.started/completed/failed/selected`、`artifact.created`、`action.waiting_approval/approved/rejected`、`workflow.checkpointed`。
- **事实账本**(`fact_ledger.empty_ledger`):`candidate_facts`/`confirmed_facts`/`evidence`/`tool_results`/`executed_actions`/`pending_actions`/`failed_or_unknown_actions`/`conflicts`。`confirmed_facts` 条目含 `{candidate_id, text, agent_id, confirmed_by, evidence_refs[]}`。
- **Run 字段**(`run_to_dict`):`id, session_id, status, maf_checkpoint(含 fact_ledger + final_result + seat_outcomes), last_event_seq, started_at, finished_at, error_code, error_message_safe, token_usage, latency_ms`。
- **终态判定**:`runIsLive(status) = ["pending","running","waiting_approval","cancelling","cancel_failed"].includes(status)`,非 live 即停轮询。
- **Action/Approval**:`GET /runs/{id}/actions`、`GET /runs/{id}/approvals`;`POST /approvals/{id}/decision` `{decision:"approved"|"rejected"}`。
## 复用清单(已定位到文件:行)
- `apiFetch`(`core/api/fetch-client.ts`):内部自动注入 `Authorization`(`getAuthorizationHeaderValue()`)+ `credentials:"include"`,**返回 Response 不解析、不拼 URL**。调用方拼 `${getBackendBaseURL()}/api/maf-roundtable/...`,自行 `res.ok` 检查 + `res.json()`。
- `getBackendBaseURL`(`core/config/index.ts`):未设时返回 `""`,相对路径同源可用。
- TanStack Query 模式(`strategy-components/api/task-buttons.ts`):`useQuery`+`refetchOnWindowFocus:false`;`useMutation`+`onSuccess invalidate`;queryKey 用嵌套数组 `["maf-roundtable", runId, "events"]`。
- 轮询模式(`core/scheduled-tasks/hooks.ts:105-115`):函数式 `refetchInterval: (query) => runIsLive(query.state.data?.status) ? ms : false`。
- `FlowGraphPanel`(`roundtable-planning/components/FlowGraphPanel.tsx`):props `{data: FlowGraphData, streaming, onClose, ...}`,自动 `useTheme()` 取明暗。`FlowGraphData = {title, nodes:[{id,label,type,detail?,group?}], edges:[{from,to,label?}]}`。`extractFlowGraph(text)`(`flow-graph.ts:263`)容错解析 ```flow-json``` 代码块(含半截 JSON)。
- `IntentClarificationCard`(`roundtable-planning/components/IntentClarificationCard.tsx`):卡片视觉模板(`rounded-xl border-red-100` + 「需要确认」徽标 + 选项按钮 + textarea + 提交)。
- 时间线 feed(`roundtable-planning/components/Step1Panel.tsx` + `MessageBubble` + `useAutoScroll` + `ScrollToBottomButton`):数组入、按 seq 序渲染。
- `useDesensitizeSubtree`(`roundtable-planning/lib/use-desensitize-subtree.ts`):仅挂 header/面板框架;时间线 body 不挂(payload 已脱敏 + hook 自身警告勿用于消息列表)。
- 路由(`pages/PageRoutes.tsx:143-169`):`<Route path="maf/*" element={<MafRoutes/>}/>`(仿 `RoundtableRoutes.tsx`)。
- 菜单(`core/page-layout/sidebar-menu.ts`):在 `sidebarMenuItems` 数组加一级项,`icon` 用 lucide 组件(非字符串 key)。新节点经 `use-sidebar-menu.ts` 自动走 static→inject→override→role→desensitize 流水线,无需额外注册。
## 文件夹结构(镜像 `roundtable-planning/`,全新 `src/maf-roundtable/`)
```
frontend-web/src/maf-roundtable/
├── api/
│ ├── types.ts # TS 类型(snake_case 直传,后端即 snake_case)
│ ├── client.ts # BASE()=getBackendBaseURL()+"/api/maf-roundtable";apiFetch 封装 + readJson<T>/parseDetail
│ ├── sessions.ts # 会话 CRUD hooks(list/get/create/update/delete)
│ ├── runs.ts # run hooks(get/list/create/cancel/resume/recover)+ useMafHealth
│ ├── events.ts # listEvents(runId, afterSeq) 原始 fetch(供轮询 hook 用)
│ └── approvals.ts # 审批 hooks(list + decide mutation)
├── hooks/
│ ├── use-maf-run-status.ts # GET /runs/{id} useQuery,函数式 refetchInterval 停终态
│ └── use-maf-events.ts # ★ cursor 轮询 hook(核心)
├── lib/
│ ├── event-timeline.ts # 纯函数:events[] → TimelineEntry[](delta 聚合、tool 生命周期折叠、approval 锚点)
│ └── fact-flow-graph.ts # 纯函数:confirmed_facts+evidence → FlowGraphData
├── components/
│ ├── MafRunHeader.tsx # 状态徽标 + token/延迟 + 取消/恢复/重试
│ ├── MafEventTimeline.tsx # 时间线 feed(MessageBubble + useAutoScroll)
│ ├── MafSeatStatusPanel.tsx # 席位状态矩阵 + execution_stage 徽标
│ ├── MafFactFlowPanel.tsx # 包一层 FlowGraphPanel,数据来自 fact-flow-graph
│ ├── MafApprovalCard.tsx # 卡片式审批(批准/拒绝 + 备注)
│ └── MafHealthGate.tsx # health.enabled=false → 空态
├── pages/
│ ├── MafSessionsPage.tsx # 会话列表 + 新建
│ └── MafRunDetailPage.tsx # 单 run 工作区(三栏)
└── index.ts
```
## 核心:cursor 轮询 hook(`hooks/use-maf-events.ts`)
不用纯 `useQuery+refetchInterval`:cursor `after_seq` 需每次推进,queryFn 不持有「上次 latest_seq」,塞 queryKey 会抖动。**用自定义 hook**(结合 scheduled-tasks 的 refetchInterval 思路 + roundtable-planning 的 reader-loop 家族):
```ts
export function useMafEvents(runId?: string, active=false) {
const [events, setEvents] = useState<MafEvent[]>([]);
const afterSeqRef = useRef(0); // 避免 setInterval 闭包旧值
useEffect(() => {
if (!runId || !active) return;
let cancelled = false;
const poll = async () => {
const res = await listEvents(runId, afterSeqRef.current);
if (cancelled || !res.events.length) return;
setEvents(prev => mergeBySeq(prev, res.events)); // seq 去重
afterSeqRef.current = res.latest_seq;
};
poll(); // 进入即补拉(after_seq=0 全量历史)
const id = setInterval(poll, 1800);
return () => { cancelled = true; clearInterval(id); };
}, [runId, active]);
return events;
}
```
- **补拉历史**:进入页面 `after_seq=0` 立即拉全部已落库事件 → 天然支持断线补拉/刷新恢复。
- **终态停止**:`active` 由 `useMafRunStatus` 的 `runIsLive(status)` 传入,终态时 effect 清理 interval。
- **去重**:后端 `(run_id,seq)` + `source_event_id` 双重去重,前端再按 `seq` 做 `mergeBySeq`,幂等。
- **切换 run**:`runId` 变 → effect 重置(events 清空、afterSeq 归零)。
## 流程图数据流(`lib/fact-flow-graph.ts`)
两源按优先级合并 → `FlowGraphData`:
1. **`confirmed_facts`(主)**:每条已确认事实 → `task` 节点(label=text 截断、detail=全文、group=confirmed_by);`evidence_refs` 连到 `evidence`(→ `role`)或 `tool_results`(`tool:{name}:{turn}` → `deliverable`)节点;证据根用 `start`。
2. **`final_result.content` 的 ```flow-json```(次)**:用现成 `extractFlowGraph(text)` 解析叠加为「结论流程图」。
输出喂 `<FlowGraphPanel data={...} streaming={!terminal}/>`(非终态蚂蚁线动)。两源皆空 → 空态「尚无可绘制事实」。
## 页面布局(`MafRunDetailPage`,三栏 / 窄屏堆叠)
- **左:时间线**(`MafEventTimeline`)—— `event-timeline.ts` 折叠成可读条目(delta 聚合气泡、tool 折叠「调用 X→完成」、action.waiting_approval 插锚点),复用 MessageBubble + useAutoScroll + ScrollToBottomButton。
- **中上:席位状态**(`MafSeatStatusPanel`)—— `maf_checkpoint.seat_outcomes` 的 agent × status 矩阵 + `execution_stage` 徽标。
- **中下:事实/结论流程图**(`MafFactFlowPanel`)—— 折叠面板,默认展开。
- **右:审批卡片**(`MafApprovalCard` 列表)—— 仅 pending 时显示;含工具名、参数摘要(脱敏)、批准/拒绝按钮 + 备注 textarea → `POST /approvals/{id}/decision`,成功后 invalidate `["maf-roundtable","approvals",runId]`,事件轮询自然看到 `action.approved/rejected`。
## 路由 + 菜单接入(3 处)
1. **新建** `pages/MafRoutes.tsx`(仿 `RoundtableRoutes.tsx`):`index→MafSessionsPage`、`runs/:runId→MafRunDetailPage`、`*→重定向`。
2. **改** `pages/PageRoutes.tsx`:加 `import MafRoutes` + `<Route path="maf/*" element={<MafRoutes/>}/>`(插 `notebook` 后)。
3. **改** `core/page-layout/sidebar-menu.ts`:数组末尾加 `{ id:"maf-center", label:"MAF 会商", icon:Network, children:[{id:"maf-roundtable", label:"会商列表", icon:MessageSquareText, path:"/page/maf"}] }`。**不加 `adminOnly`**(全员可见;`/health.enabled=false` 由 `MafHealthGate` 客户端控制页面空态)。
## 实施顺序
1. `api/types.ts` + `api/client.ts`(地基)
2. `api/{sessions,runs,events,approvals}.ts`(端点 hook)
3. `hooks/{use-maf-run-status,use-maf-events}.ts`(轮询核心)
4. `lib/{event-timeline,fact-flow-graph}.ts`(纯函数)
5. `components/*`(6 个组件)
6. `pages/*`(2 个页面)+ `index.ts`
7. `pages/MafRoutes.tsx` + 接入 `PageRoutes.tsx` + `sidebar-menu.ts`
8. `pnpm typecheck` + 手动核对路由/菜单显隐
## 文件清单
- **新建**(14):`maf-roundtable/api/{types,client,sessions,runs,events,approvals}.ts`、`maf-roundtable/hooks/{use-maf-run-status,use-maf-events}.ts`、`maf-roundtable/lib/{event-timeline,fact-flow-graph}.ts`、`maf-roundtable/components/{MafRunHeader,MafEventTimeline,MafSeatStatusPanel,MafFactFlowPanel,MafApprovalCard,MafHealthGate}.tsx`、`maf-roundtable/pages/{MafSessionsPage,MafRunDetailPage}.tsx`、`maf-roundtable/index.ts`、`pages/MafRoutes.tsx`
- **改动**(2):`pages/PageRoutes.tsx`、`core/page-layout/sidebar-menu.ts`
- **后端**:无改动
## 验证
- `pnpm typecheck`(tsc --noEmit)通过
- 手动:菜单「MAF 会商」→ 列表 → 新建会话 → 启动 run → 时间线滚动 → 席位状态更新 → 流程图随 confirmed_facts 增长 → 审批可批准 → 终态停轮询
- 退路:`/health.enabled=false` → 入口可见但页面显示未启用提示;旧 `roundtable-planning` 零改动
@@ -1,17 +1,87 @@
# Microsoft Agent Framework 多智能体会商对照页实施计划
> 文档状态:实施中;阶段 1(独立后端骨架与持久化)已验收通过
> 更新时间:2026-08-01
> 文档状态:实施中;已按当前工作区代码重新盘点,阶段 5 安全闭环处于收口验收,阶段 4 动态会商仍未达到退出标准
> 更新时间:2026-08-02
> 目标版本:实验性独立功能,不替换现有多智能体会商
> 建议页面名称:`MAF 会商(实验)`
## 0. 当前实施状态与前后端隔离决议
### 0.1 已完成并验收的阶段 1
### 0.1 代码盘点结论(以 2026-08-02 当前工作区为准)
新的后端骨架已经独立落在 `/api/maf-roundtable` 及 `maf_roundtable_*` 专用表中;会话、运行、事件、审批、用户隔离、启动/取消原子事务与配置校验均不复用旧会商的持久化状态。当前验收证据为:78 项 MAF 后端回归测试通过,且 Harness → App 导入边界检查通过。
本节的状态以当前 `master-dev`、尚未提交的 MAF 改动和本次实际运行结果为准,不沿用同事口头的阶段编号或测试数量。`已实现`不等于`退出验收通过`;只有满足本阶段退出标准、测试证据完整且没有 P0/P1 缺口时才能进入下一阶段的正式验收。
阶段 1 完成并不表示功能已对用户开放:`maf_roundtable.enabled` 继续默认关闭,直到后续 MAF 编排和新前端页面均完成验收。
| 阶段 | 当前状态 | 已有代码/证据 | 尚未满足的退出条件 |
|---|---|---|---|
| 0 依赖 Spike | 部分完成 | `agent-framework-core` 已锁定到 lock 中的 `1.13.0`;真实 SDK `WorkflowBuilder` 路径可由测试运行。 | 尚无当前部署模型的 OpenAI 兼容流式实测、离线 wheelhouse 全新安装实测和正式 Spike 记录。 |
| 1 独立后端骨架 | 代码完成 | `/api/maf-roundtable`、独立 `maf_roundtable_*` 表、事务化启动/取消、事件序号、用户隔离、管理员只读诊断均已实现。 | 本工作区缺少文档提及的 `tests/test_harness_boundary.py`,需在 CI 或恢复该用例后补一份边界验证证据。 |
| 2 DeerFlow Agent Adapter | 代码完成,集成验收待补 | 独立 thread、用户 principal、运行参数、SSE 标准化、事件脱敏、取消级联、seat-run 持久化均已实现;相关单测覆盖。 | 尚未用已部署模型和真实 MCP/技能完成一条从 API 到 DeerFlow Runtime 的人工验收记录。 |
| 3 固定流程编排 | 代码完成,测试通过 | `WorkflowBuilder` 的 planner → 并行席位 → 交叉审查 → 审批 → finalizer、事实账本、格式修复、checkpoint/恢复与 `execution_stage` 身份均已实现。 | 需在真实模型/真实能力快照下完成一次演示验收;不阻塞阶段 5 的安全收口。 |
| 4 动态 Group Chat | 部分实现,不能验收 | `group_chat` 模式能走真实 SDK 工作流路径,且会写 `manager.decision` 事件。 | 当前实质是 `WorkflowBuilder` 的单次 round-robin:没有主持人 Agent selector、没有多轮同席位发言策略、没有完整的总超时/弱模型收敛测试;详见阶段 4。 |
| 5 外部写入与审批 | 框架闭环完成,UAT/部署待输入 | 已有可信工具元数据、WriteGuard、加密参数、审批超时、服务端派生的目标租约、原子一次执行、`unknown` 失败关闭、审计 API、真实 `ToolNode` outbox 及 HTTP 决策→恢复全链路测试。 | 尚未指定 UAT 写入工具、最小权限凭据和核验/补偿协议;生产部署尚未注入 `MAF_ACTION_SECRET_KEY`。 |
| 6 前端工作台 | 原型已存在,未按新约束验收 | 独立 `/page/maf/*` 路由、健康门、会话/运行页、纯轮询、审批卡、事件代次隔离已实现;前端逻辑测试 19 项通过。 | 现有可视组件主要仍使用旧的本地 shadcn/Radix 封装,未改为用户指定的 `tdesign-react`;仍直接引用旧会商目录中的脱敏 hook;恢复/失败重试和组件级 API 验收未完成。 |
| 7 对比评测 | 未开始 | 无。 | 依赖阶段 4、5、6 的正式验收。 |
| 8 测试、离线打包与灰度 | 未开始 | 无。 | 依赖可验收版本。 |
本次重新运行的证据:
- 后端安全闭环重点用例:`293 passed in 140.10s`(包含真实入口 ToolNode/outbox、能力快照、WriteGuard、审批过期与目标租约、旧 DeerFlow 回归)。
- 后端编排重点用例:`40 passed in 5.46s`(真实 SDK structured/group_chat 路径、Agent Adapter、阶段 3 主持人流程)。
- 前端 MAF 逻辑用例:`19 passed`;`pnpm build` 通过。
- 全量 `test_maf_*.py` 已在无短时命令限制的终端完成,最终结果为 `293 passed in 140.10s`;PowerShell 下使用文档中的文件枚举方式展开测试文件。
阶段 1 完成并不表示旧会商被替换。当前配置已打开新的 MAF 功能开关;前端入口由健康检查和权限控制。旧 DeerFlow、旧 roundtable、AI 写作不携带 `maf_run_id`,因此不进入 MAF WriteGuard 分支。
### 0.3 阶段 5:真实工具、技能与外部写入审批闭环(当前收口)
本阶段已补齐以下安全边界:
- `use-maf-events.ts` 为每次 `runId`、`enabled`、`polling`、`resetSignal` 变化分配请求代次;旧请求返回后不能污染新 run,终态补拉不会被旧请求阻塞。
- 能力快照的工具副作用元数据只从服务端读取,优先级为 MAF 专用精确工具名配置、已有 `ToolConfig.extra`、内置白名单、保守默认 `write/R3`。浏览器、会话请求和模型输出不能提供或覆盖这些字段。
- 生产写工具登记时,必须在同一服务端元数据中配置 `target_key_fields`(稳定的顶层租户/业务对象字段)和可选 `target_key_namespace`;这些字段只参与服务端稳定哈希,禁止直接提交 `target_key`。未登记或无法识别对象的写操作共用保守串行目标,并在 `unknown` 后保持人工核验屏障。
- 当前真实发现的只读 MCP 工具名为 `weknora-retrieval_weknora_list_knowledge_bases` 和 `weknora-retrieval_weknora_search`,已按精确名称登记为 `read/R0`;不按 server 名或名称前缀猜测。
- 测试环境提供真实 LangGraph `ToolNode` 写工具 `maf_test_outbox_write`,只写临时 outbox;它经过真实 WriteGuard、加密参数、审批、`execute_approved_tool` 和一次性执行器。生产外部写入目标仍需业务方指定,不能用检索 MCP 代替,也不能擅自猜测业务系统。
- `write_guard.enabled=true` 后,缺少 `MAF_ACTION_SECRET_KEY` 时只读工具和技能仍可工作,外部写入在执行前失败关闭;健康检查只返回 `enabled`、`ready`、`secret_configured` 和状态码,不返回密钥。
本轮框架代码已完成阶段 5 的可自行实现项;下列项目必须由业务方/运维补齐后才能将阶段 5 标记为完整完成:
- `maf_test_outbox_write` 是测试专用写工具,已经证明真实 LangGraph `ToolNode` 与一次性执行器可用,但不等于已接入业务系统。
- 当前机器未注入生产 `MAF_ACTION_SECRET_KEY`,健康状态会显示 `missing_secret`;这符合失败关闭设计,但不能用于生产写入验收。
上线前必须由部署环境注入 `MAF_ACTION_SECRET_KEY`,不得写入仓库、`config.yaml`、前端、事件、checkpoint 或模型上下文。当前测试验收命令为:
```powershell
$env:PYTHONPATH="."
$env:MAF_ACTION_SECRET_KEY="仅测试环境临时值"
$mafTests = Get-ChildItem tests -Filter "test_maf_*.py" | ForEach-Object FullName
uv run --extra maf pytest $mafTests -q -W error
uv run ruff check app/maf_roundtable packages/harness/deerflow
```
### 0.3.1 同事 A:MAF-05-03A / 03B / 04A / 08A 收口记录
本轮已完成前阶段 5 的代码与自动化验收项,但按约定仍不将阶段 5 标记为最终验收完成:真实 UAT 写入系统、测试租户、最小权限凭据、核验 API/补偿协议以及生产环境密钥注入仍待业务方和运维提供。
- `MAF-05-03A`:Action 服务层使用公开白名单 DTO;`target_key` 与 `idempotency_key` 仅留在持久层和内部仲裁路径,不能通过两个 actions GET 接口、事件或前端类型返回。目标键只读取服务端登记的稳定标量字段,并使用 `MAF_ACTION_SECRET_KEY` 做域隔离 HMAC-SHA-256;缺少密钥时真实写入失败关闭。
- `MAF-05-03B`:`scripts/migrate_maf_roundtable_phase5.py` 按数据库方言生成 DDL:PostgreSQL 使用 `TIMESTAMP WITH TIME ZONE`,MySQL/MariaDB 使用 `DATETIME(6)`,SQLite 使用 `DATETIME`。迁移可重复执行,表、缺失索引和可安全补齐的列会自动补齐;已覆盖 SQLite 实际迁移以及三种方言 DDL 测试。
- `MAF-05-04A`:Gateway lifespan 启动和关闭 MAF 专用审批过期扫描任务,间隔由 `maf_roundtable.approval_scan_interval_seconds` 配置。扫描使用已有条件式过期 CAS,然后只对受影响 run 调用 `dispatcher.resume()`;普通审批 GET 保持只读,决定/恢复仍在操作前执行失败关闭过期检查。
- `MAF-05-08A`:入口级测试从 HTTP 创建 session/run 开始,确定性假实现只位于模型边界;真实 Agent Runtime、WriteGuard、LangGraph ToolNode、加密参数和 outbox 均参与执行。覆盖 HTTP 审批后恰好一次写入,以及同目标并发时第二个 run 的 `waiting_target_lease` 与显式 resume。
本轮证据:全部 `test_maf_*.py` 共 `293 passed`;阶段 5 定向回归 `15 passed`;`git diff --check` 通过。仓库现有的全目录 Ruff 基线仍有与本轮无关的历史问题,提交前需在 CI 中单独清理;本轮新增/修改文件已做定向检查。
### 0.4 下一批开工顺序与责任边界
当前应正式进入**阶段 5 收口**;阶段 4 的动态会商可与其中不依赖业务系统的任务并行。不要把阶段 6 原型继续扩展成正式页面,直到阶段 5 的动作一致性契约固定。
| 工作包 | 对应任务 | 难度 | 是否可立即开始 | 交付边界 |
|---|---|---:|---|---|
| WP-F1 目标一致性 P1 收口 | `MAF-05-03A`、`MAF-05-03B` | D4/D5 | 已完成 | 已封闭 `target_key`/内部幂等键公开边界,并使 Phase-5 迁移兼容 PostgreSQL/MySQL;真实 UAT 目标仍由 WP-F3 负责。 |
| WP-F2 审批恢复验收补强 | `MAF-05-04A`、`MAF-05-08A` | D4/D5 | 已完成 | 已完成主动过期、并发/重启补扫,以及从真实工作流入口生成审批到 HTTP 决策、ToolNode/outbox 的全链路验收。 |
| WP-F3 真实 UAT 工具 | `MAF-05-05`、`MAF-05-09`、`MAF-05-10` | D5 | 需业务方输入 | 仅在拿到目标系统、最小权限凭据、幂等与查询核验协议后实施;这是唯一不能自行假设的工作包。 |
| WP-E 动态会商 | `MAF-04-01` ~ `MAF-04-07` | D5 | 可以并行 | 真实多轮 selector、上下文/终止状态机和弱模型收敛测试;不得修改阶段 5 的 Action Coordinator。 |
| WP-G 前端重构准备 | `MAF-06-00` | D3 | 可以并行 | 仅拆除对旧会商 hook 的直接依赖、固定 API view-model 和测试基线;TDesign 页面重构等阶段 5 契约稳定后开始。 |
WP-F1、WP-F2 与 WP-E 必须分别使用独立分支/提交;公共 `config.yaml`、Gateway 注册和前端路由由集成人统一合并。WP-F3 进入开发前需由业务方书面确认第一个写入目标,缺少该信息时应保持阻塞状态而不是接入猜测的 MCP。
### 0.2 新前端页面的强制隔离原则
@@ -42,7 +112,7 @@
### 2.1 本期必须实现
- 新页面:`/page/strategy/qa/maf-roundtable`。
- 新页面:`/page/maf/*`(当前已落地为独立顶级菜单,不嵌入旧会商页)。
- 两种 MAF 编排模式:
- `structured`:专家并行分析 → 交叉审查 → 主持人综合。
- `group_chat`:主持人动态选择下一位发言人,多轮讨论后收敛。
@@ -79,7 +149,7 @@
### 2.4 新前端与新后台的对接边界
新前端页面固定使用独立路由 `/page/strategy/qa/maf-roundtable`,并且只允许请求以下新接口前缀:
新前端页面固定使用独立路由 `/page/maf/*`,并且只允许请求以下新接口前缀:
```text
GET /api/maf-roundtable/health
@@ -278,6 +348,18 @@ flowchart LR
快照用于审计和复现,不代表运行期间复制工具实例。
MAF 的副作用分类是服务端可信配置,不由上述请求体或模型生成的能力描述决定。部署配置使用实际运行时工具名作为键,例如:
```yaml
maf_roundtable:
tool_metadata:
weknora-retrieval_weknora_search:
side_effect: read
risk_level: R0
```
未登记工具保持保守的 `write/R3`,避免未知外部工具被误放行。技能可以继续使用,但技能触发的实际外部副作用必须落到已分类工具调用,不能绕过 WriteGuard。
### 6.2 技能接入
- 继续使用 Agent 当前技能白名单。
@@ -313,6 +395,8 @@ Action Coordinator 只负责执行一致性,不替代工具:
这套分级不禁止外部写入,而是保证它在不同运行策略下可控、可恢复、可审计。
当前已完成的可执行验收工具是测试专用 `maf_test_outbox_write`(`R2/write`),副作用限制在 `MAF_E2E_OUTBOX_DIR` 指定的临时目录。生产验收还需要业务方明确目标系统、测试租户、最小权限凭证和幂等/核验接口;在此之前不得宣称已经完成生产业务写入接入。
### 6.4 沙箱与产物冲突
- 一个席位一个 thread,一个 thread 一个沙箱上下文。
@@ -559,7 +643,7 @@ offline-backend-20260512/backend/
### 10.0 独立前端模块、路由和状态
新增目录固定为 `frontend-web/src/maf-roundtable/`,其 API client 只能封装 `/api/maf-roundtable/*`。新页面根组件为 `MafRoundtablePage.tsx`,路由仅新增 `/page/strategy/qa/maf-roundtable`;不得替换、重命名、迁移或修改旧 `multi-agent-roundtable` 路由及其页面组件。
新增目录固定为 `frontend-web/src/maf-roundtable/`,其 API client 只能封装 `/api/maf-roundtable/*`。当前页面根路由为 `MafRoutes.tsx`,路由仅新增 `/page/maf/*`;不得替换、重命名、迁移或修改旧 `multi-agent-roundtable` 路由及其页面组件。
入口展示前先读取 `GET /api/maf-roundtable/health`:`enabled=false` 时隐藏入口并禁止创建会商;开启后页面必须明确展示“实验版 / 独立后端”标识。TanStack Query 的 key 使用独立命名空间,例如 `['maf-roundtable', 'sessions', ...]`,不得复用任何旧 `roundtable`、`draft` 或 `job` key。所有页面本地状态、事件 reducer、轮询/补拉游标、取消控制器和历史恢复逻辑也必须归属于 MAF 模块。
@@ -745,15 +829,17 @@ frontend-web/src/maf-roundtable/
前置依赖:阶段 2;建议先完成阶段 3 以复用事实账本和恢复机制
主要评价点:弱模型条件下能否收敛、上下文是否准确、硬终止是否可靠
当前代码状态:`workflow_factory.py` 已为 `group_chat` 建了真实 MAF `WorkflowBuilder` 图,`CoordinatorExecutor` 会写 `manager.decision`,并有一条 SDK 路径测试。但它目前按 round-robin 让每个席位最多发言一次;这只是可运行的占位编排,不是本阶段承诺的动态多轮会商。
任务:
- [ ] `MAF-04-01`(D3)使用 `GroupChatBuilder` 建立主持人和参与席位。
- [ ] `MAF-04-02`(D4)实现确定性 selector 与主持人 Agent selector 两种选择方式。
- [ ] `MAF-04-03`(D4)实现最大轮次、连续发言限制、总超时和单席位超时。
- [ ] `MAF-04-04`(D5)实现完整上下文同步、长工具输出裁剪、引用保真和 token 预算。
- [ ] `MAF-04-05`(D4)实现主持人完成判定、schema 校验和结构化终稿。
- [ ] `MAF-04-06`(D3)把主持人调度原因写入 `manager.decision` 事件。
- [ ] `MAF-04-07`(D5)构造弱模型不结束、错误选人、重复发言等测试并保证工作流可收敛。
- [ ] `MAF-04-01`(D4)改为使用锁定 MAF 版本实际提供的 Group Chat 编排 API;若该版本没有名为 `GroupChatBuilder` 的公开类,必须在设计说明中给出等价的官方 API、版本和最小可运行证明。禁止把手写 `WorkflowBuilder` 分支称为已完成的 Group Chat。
- [ ] `MAF-04-02`(D5)实现两种 selector:可复现的确定性 selector,以及由主持人 Agent 根据已验证事实账本选择下一发言人的 selector。两者均须验证候选席位、返回结构和失败降级,不能相信模型随意返回的 agent id。
- [ ] `MAF-04-03`(D4)实现最大轮次、连续发言上限、总超时和单席位超时;状态必须持久化到 checkpoint,恢复后继续计数而不是重置。
- [ ] `MAF-04-04`(D5)以事实账本和摘要引用作为群聊输入来源,保留已执行/待审批/失败动作语义;超长工具结果只传播摘要和可追溯引用,并执行明确 token/字符预算。
- [ ] `MAF-04-05`(D4)由主持人显式输出完成判断与结构化终稿;不合法输出仅允许一次定向修复,仍失败时走确定性降级并留下原因事件。
- [x] `MAF-04-06`(D3)已写入 `manager.decision` 事件;后续须把现有 `round_robin` 原因扩展为 selector 类型、候选集、拒绝原因和终止依据。
- [ ] `MAF-04-07`(D5)新增弱模型不结束、幻觉席位、重复选人、席位超时、空输出和恢复后继续轮次的测试;至少包含一次真实 SDK 的多轮(同一席位可在合规策略下再次发言)验收。
退出标准:
@@ -771,14 +857,22 @@ frontend-web/src/maf-roundtable/
任务:
- [ ] `MAF-05-01`(D3)建立工具风险元数据、默认分类和覆盖规则。
- [ ] `MAF-05-02`(D3)实现 `auto`、`confirm_high_risk`、`confirm_all_writes` 策略解析。
- [ ] `MAF-05-03`(D5)实现 Action Coordinator、唯一幂等键、状态机和目标租约锁。
- [ ] `MAF-05-04`(D5)实现审批暂停、决策、checkpoint 恢复及过期处理。
- [ ] `MAF-05-05`(D5)实现网络结果未知时的核验流程与可选补偿动作。
- [ ] `MAF-05-06`(D4)实现凭据、参数、结果和异常日志脱敏,并编写泄漏测试。
- [ ] `MAF-05-07`(D3)实现审计查询页面所需 API。
- [ ] `MAF-05-08`(D5)实现并发/重启/重复事件故障注入测试,证明外部写入不会重复。
- [x] `MAF-05-01`(D3)已建立服务端可信的工具风险元数据、精确名称覆盖和未知工具 `write/R3` 保守分类;会话请求、浏览器和模型输出均不可覆盖它。
- [x] `MAF-05-02`(D3)已实现 `auto`、`confirm_high_risk`、`confirm_all_writes` 策略解析,并在 WriteGuard 之前执行分类。
- [x] `MAF-05-03`(D5)服务端目标租约与一次性执行核心已实现,并已完成 03A/03B 的公开边界和跨数据库迁移收口;阶段 5 仍受真实 UAT 工具与部署输入约束,不能据此标记为最终验收完成。
- [x] `MAF-05-03A`(D4,分配同事 A)已封闭目标租约键的公开边界:`target_key` 与内部 `idempotency_key` 仅留在持久层和内部仲裁路径,两个 actions GET、事件、日志和前端类型均不返回;目标键只接受服务端登记的稳定标量业务字段,并使用域隔离 HMAC-SHA-256 派生。HTTP 回归同时证明存储层仍能使用目标键完成租约仲裁。
- [x] `MAF-05-03B`(D4,分配同事 A)已修复 `migrate_maf_roundtable_phase5.py` 的跨数据库 DDL:PostgreSQL 使用 `TIMESTAMP WITH TIME ZONE`、MySQL/MariaDB 使用 `DATETIME(6)`、SQLite 使用 `DATETIME`;表、缺失索引和可安全补齐列可重复自动补齐。已覆盖 SQLite 实际迁移及 PostgreSQL/MySQL DDL 测试。
- [x] `MAF-05-04`(D5)审批创建时写入部署级超时,到期后在同一事务内标记 Approval=`expired`、Action=`rejected` 和审计事件。已覆盖真实 HTTP:`POST /approvals/{id}/decision` → `dispatcher.resume()` → `WorkflowRunner.resume()` → `ToolExecutor` → LangGraph `ToolNode` → 测试 outbox;重复决策/恢复仍只执行一次。
- [x] `MAF-05-04A`(D4,分配同事 A)已将审批过期收口为 Gateway lifespan 中可取消的 MAF 周期扫描任务;间隔可配置,扫描使用条件式 CAS 后恢复受影响 run,普通 GET 保持只读。已覆盖并发扫描、重复/重启补扫、决策并发和过期后 Action 拒绝且工具执行次数为零,测试不使用 `sleep`。
- [ ] `MAF-05-05`(D5)为指定的第一个真实写工具实现结果未知核验:外部请求必须携带业务幂等 operation id;网络超时/断连后先调用目标系统查询接口核验,再决定 `succeeded`、`failed` 或保留 `unknown`。补偿动作必须独立审批,不能由通用框架自动猜测或自动重试。
- [x] `MAF-05-06`(D4)参数加密、API/事件/日志脱敏和泄漏测试已覆盖;继续在新增真实工具适配器中复用,禁止把明文凭据或原始参数放进 checkpoint、事件或模型上下文。
- [x] `MAF-05-07`(D3)已提供按 run 查询 actions、approvals、事件及健康状态的 API,前端原型可显示审批和外部动作审计信息。
- [x] `MAF-05-08`(D5)已覆盖两个 Agent 同目标租约串行、进程重启后的过期租约 `unknown` 恢复、外部成功但本地终态落库失败时禁止重放、审批超时、HTTP 决策全链路、并发占用、重复 `resume()`、拒绝、缺失/损坏密钥、真实 ToolNode/outbox 与旧 DeerFlow 不回归。
- [x] `MAF-05-08A`(D5,分配同事 A)已补齐入口级验收:测试从 HTTP 创建 MAF session/run 和真实工作流入口开始,仅在模型边界使用确定性假实现;真实 WriteGuard、ToolNode、加密参数、HTTP 审批和测试 outbox 均参与,单次写入恰好一次。同目标双 run 覆盖 `waiting_target_lease`,第一个完成后第二个只能显式 `/runs/{id}/resume`,且无重复执行;公开 API 同时断言没有 `target_key`/内部幂等键。
- [ ] `MAF-05-09`(D5,需业务方输入)接入首个真实 UAT 写工具/MCP。开始前必须明确工具名与参数 schema、目标 UAT 地址/租户、最小权限凭据的注入方式、目标键提取规则、外部幂等键、查询核验 API、可选补偿边界和回滚联系人;未提供这些信息不得猜测并接入生产系统。
- [ ] `MAF-05-10`(D3)在部署环境通过环境变量注入 `MAF_ACTION_SECRET_KEY`,重启后确认 `/api/maf-roundtable/health` 返回 `write_guard_ready=true`;密钥不得提交到仓库或写入 `config.yaml`。
阶段 5 的当前可交付边界:测试专用 `maf_test_outbox_write` 已验证 WriteGuard → 审批超时/决策 → dispatcher → checkpoint 恢复 → 目标租约 → 直接受控 ToolNode 执行一次的链路,03A、03B、04A、08A 的框架代码与自动化验收项已完成。该结论仍仅代表**框架核心已实现**:真实 UAT 写工具、业务系统查询/补偿协议、最小权限凭据和生产 `MAF_ACTION_SECRET_KEY` 注入尚未提供,因此阶段 5 不得写为“验收完成”,也不得在生产执行真实外部写入。部署时先运行 `PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase5.py`;它只证明框架安全性,不替代 `MAF-05-09` 的真实业务系统验收。
退出标准:
@@ -787,40 +881,68 @@ frontend-web/src/maf-roundtable/
- 并行 Agent 写同一目标时不会互相覆盖。
- 高风险写入在相应策略下能暂停和恢复。
- 每个外部动作可追溯到用户、会商、run、Agent 和工具调用。
- `target_key`、内部幂等键和明文目标标识均不从公开 API、事件、日志或前端状态泄露;Phase-5 迁移能在项目支持的 SQLite、PostgreSQL、MySQL 上幂等执行。
- 审批即使无人刷新页面也会在到期后被后台任务关闭,且完整工作流入口到 HTTP 决策、真实 ToolNode 的链路经一次性测试验证。
### 阶段 6:前端工作台
阶段难度:**D4(较难)**
建议负责人:熟悉 React、TanStack Query、SSE 状态归并和复杂工作台交互的中高级前端开发
建议负责人:熟悉 React、TanStack Query、轮询事件归并和复杂工作台交互的中高级前端开发
前置依赖:阶段 1 已验收的 API 基线与正式事件契约;reducer 可使用本地 fixture 开发,但不得依赖公开 mock 事件接口;可与阶段 2–5 并行开发
主要评价点:事件状态准确、刷新恢复、审批交互、复杂状态下 UI 不误导用户
任务:
- [ ] `MAF-06-00`(D4)新建 MAF 专属目录、独立 query key、独立路由与 feature-flag 入口;确认旧会商页面、hooks、store、草稿与路由零改动。
- [ ] `MAF-06-01`(D2)新增路由、菜单、特性开关和实验标识。
- [ ] `MAF-06-02`(D3)实现创建会商、模型配置和席位选择。
- [ ] `MAF-06-03`(D2)实现 MAF 两种模式及写入策略切换。
- [ ] `MAF-06-04`(D5)实现支持断线、补拉、去重和乱序保护的 SSE reducer。
- [ ] `MAF-06-05`(D3)实现 Agent、工具、技能和外部动作时间线卡片。
- [ ] `MAF-06-06`(D4)实现审批卡片、澄清输入及并发操作防重。
- [ ] `MAF-06-07`(D3)实现事实账本、最终结论和产物展示。
- [ ] `MAF-06-08`(D4)实现取消、恢复、失败重试及对应状态禁用逻辑。
- [ ] `MAF-06-09`(D4)实现刷新恢复、历史列表和跨页面返回。
- [ ] `MAF-06-10`(D3)为 reducer 和关键交互编写前端测试。
组件与实现约束:
- 新 MAF 工作台保持独立路由和独立目录,**不修改**原多智能体会商的页面、组件、状态或视觉。
- 所有新增的可视 UI 组件优先使用项目本地已安装的 `tdesign-react`(React TDesign);可沿用项目既有的 TDesign 用法与主题配置。
- 不得接入远程 UI 组件库、CDN 样式/脚本、在线组件生成服务或运行时远程加载的组件。图标、样式和交互资源必须随前端构建产物离线交付。
- 第一版实时更新采用已确定的**纯前端轮询**:基于 `GET events?after_seq=N` 补拉、去重和游标推进;本阶段不新增 SSE 端点。若后续改用 SSE,另立任务并保持轮询回退路径。
- 使用 TDesign 时应复用 `Dialog`、`Form`、`Input`、`Select`、`Checkbox`、`Button`、`Tag`、`Card`、`Timeline`、`Loading`、`Message` 等本地组件;业务状态、权限判断、事件归并仍由 MAF 专属 hooks/API 层负责,不能写入组件内部的临时全局状态。
当前代码状态:已有独立 `frontend-web/src/maf-roundtable/`、`MafRoutes.tsx`、`/page/maf/*`、会话/运行页、轮询事件 hook、审批卡和 19 项逻辑测试。这些只能视作可编译原型,不能直接作为阶段 6 交付:当前页面主要使用既有 shadcn/Radix 封装,且直接引用 `roundtable-planning/lib/use-desensitize-subtree`,均与本阶段的独立性和 React TDesign 约束不一致。
任务:
- [ ] `MAF-06-00`(D4,部分完成)保留已有 MAF 专属目录、query key 和 `/page/maf/*`,把直接引用旧会商目录的脱敏 hook 抽到中性共享位置或通过公共服务使用;证明旧会商页面、hooks、store、草稿与路由零改动。
- [ ] `MAF-06-01`(D3,部分完成)保留现有独立菜单、健康门和实验标识,但把新增可视 UI 统一迁移到项目已安装的本地 `tdesign-react`;不加载任何远程 UI/CDN/在线资源。
- [ ] `MAF-06-02`(D3,部分完成)以 TDesign `Dialog`/`Form`/`Input`/`Select`/`Checkbox` 重做创建会商、模型配置和席位选择;保留经理与席位互斥、智能体 loading/error/empty 状态和 1–64 轮次校验。
- [ ] `MAF-06-03`(D2,部分完成)以 TDesign 表单控件呈现 `structured`/`group_chat` 与写入策略;后端配置默认值必须可见,前端不得自行伪造能力快照或风险等级。
- [x] `MAF-06-04`(D5)纯轮询事件归并已实现:断线重试、分页补拉、按 seq 去重、游标推进、终态补拉和 run/generation 隔离均已有逻辑测试。后续 UI 改造不得改弱这些语义。
- [ ] `MAF-06-05`(D3,部分完成)以 TDesign `Timeline`/`Card`/`Tag` 重做 Agent、工具、技能和外部动作时间线;事件展示必须只读取 MAF API,且不得显示原始敏感参数。
- [ ] `MAF-06-06`(D4,部分完成)以 TDesign `Card`/`Dialog`/`Button` 重做审批卡;批准/拒绝必须禁用重复提交、以实际 run id 刷新 actions/approvals,并展示后端的失败关闭/未知状态。澄清输入仅在后端提供正式 `waiting_input` 契约后接入,不能先造假接口。
- [ ] `MAF-06-07`(D3,部分完成)以本地 TDesign 呈现事实账本、最终结论和产物;明确区分“候选事实”“已确认事实”“待审批动作”“已执行动作”和“失败/未知动作”。
- [ ] `MAF-06-08`(D4)补齐取消、审批后恢复、失败后创建新 run/重试的交互与状态禁用;不存在的后端 retry 接口不得由前端伪造为已恢复。
- [ ] `MAF-06-09`(D4,部分完成)完成刷新恢复、会话历史、run 深链和跨页面返回;切换 run 时不串台,终态页面必须补齐完整历史。
- [ ] `MAF-06-10`(D4,部分完成)保留现有 19 项纯逻辑测试,并新增挂载 hook/组件级 API mock 测试:慢请求切换 run、终态补拉、审批重复点击、health 缺密钥提示、取消/恢复和错误状态。完整 `pnpm typecheck` 的既有非 MAF 错误需单独基线记录,不能以它们掩盖 MAF 新错误。
退出标准:
- 刷新、切页、重新登录后能恢复当前运行。
- 工具和技能调用过程可见。
- 外部写入状态不会仅显示在聊天文本中。
- 页面不请求或加载任何远程 UI 组件、CDN 样式或脚本;断网环境下仍可完整呈现 MAF 工作台。
- 新增工作台 UI 组件来自本地 `tdesign-react` 或项目已有本地基础组件,未引入第二套远程 UI 依赖。
- 原会商页面视觉和交互没有变化。
### 阶段 6.5:旧会商体验融合(阶段 6 验收后)
阶段难度:**D4(较难)**
建议负责人:熟悉现有会商产品交互、React 状态边界和 React TDesign 的中高级前端开发
前置依赖:阶段 5 的审批闭环验收、阶段 6 的独立工作台验收
主要评价点:保留用户认可的体验,不把旧业务状态或旧 API 重新耦合进 MAF
目标是让 MAF 页面借鉴旧会商的视觉层级、意图理解入口和“总控智能体派活”的可理解性,而不是把旧页面或其数据层搬过来。技术 session/thread 是 MAF 内部实现,普通用户不需要看见“创建线程”。
- [ ] `MAF-06.5-01`(D3)梳理旧会商的用户已认可布局、文案、信息层级和关键交互,形成只读视觉/交互对照清单;不复制旧的 store、jobs、草稿或 API 调用。
- [ ] `MAF-06.5-02`(D4)以本地 `tdesign-react` 重构 MAF 创建入口:用户输入任务/意图后,由 MAF 后端静默创建 session、冻结能力快照并启动 run,不展示底层 thread 创建步骤。
- [ ] `MAF-06.5-03`(D4)把旧体验中的“意图理解 → 总控智能体分工 → 席位执行 → 会商结论”映射到 MAF 的 planner/manager、事实账本和事件时间线;映射只在 MAF view-model/API adapter 层完成。
- [ ] `MAF-06.5-04`(D4)对照旧会商完成视觉和交互回归:新旧两个入口、路由、草稿和运行状态完全独立;关闭 `maf_roundtable.enabled` 后旧会商零变化。
### 阶段 7:对比评测
阶段难度:**D3(中等)**
建议负责人:具备数据分析意识、理解 Agent 错误分类的全栈/测试开发/算法工程师
前置依赖:阶段 3、阶段 4;指标接口可提前开发
前置依赖:阶段 3、阶段 4、阶段 5、阶段 6;指标接口可提前开发
主要评价点:对照是否公平、指标是否可解释、是否能区分模型/工具/编排错误
任务:
@@ -944,24 +1066,26 @@ frontend-web/src/maf-roundtable/
## 15. 发布与回退
- 配置项 `maf_roundtable.enabled` 控制后端 API 与前端入口。
- `write_guard.enabled` 已设为 `true`,但只有带 `maf_run_id` 的新 MAF 会商进入 WriteGuard;旧 DeerFlow、旧 roundtable 和 AI 写作保持透传。
- 上线前由启动环境注入 `MAF_ACTION_SECRET_KEY`;不在仓库或配置文件保存该值。访问 `/api/maf-roundtable/health`,确认 `write_guard_status=ready`、`write_guard_ready=true` 后,才允许验证真实外部写入。
- 上线顺序:先部署只读 MAF 与精确工具元数据 → 注入密钥并检查健康状态 → 在 UAT/测试租户启用已指定的真实写工具 → 验证审批、恢复、拒绝、重启和 unknown 场景 → 再扩大灰度。
- 首期仅管理员或指定白名单可见。
- 关闭入口时,已有运行可选择继续完成或管理员取消。
- 回退只需关闭特性开关和停止新 MAF jobs;旧 roundtable 不需要迁移或回滚。
- 出现异常时先关闭 `maf_roundtable.enabled` 停止新的 MAF 写入;必要时再关闭 `write_guard.enabled`。若密钥或外部系统异常,保持 MAF 开启也会因失败关闭而拒绝写操作。旧 roundtable 不需要迁移或回滚。
- MAF 独立表保留用于审计,不在代码回退时删除。
- MAF SDK 使用延迟导入,关闭功能时不影响 Gateway 启动。
## 16. 推荐开发顺序与工作量级
建议严格按以下顺序推进:
完整项目的依赖顺序仍是阶段 0 → 8;但基于当前代码盘点,**当前恢复开发的顺序**调整为:
1. 依赖/模型/离线 wheel Spike。
2. 独立 session、event 和 SSE 骨架。
3. DeerFlow AgentAdapter,先跑通真实工具、技能和一次外部写入。
4. 固定流程编排。
5. 动态 Group Chat。
6. Action Coordinator、审批和恢复。
7. 前端完整工作台。
8. 对比评测、回归和灰度。
1. 阶段 5:先完成 target-key 租约锁和 HTTP 审批恢复全链路;真实 UAT 工具仅在业务方提供协议后接入。
2. 阶段 4:与阶段 5 的无外部依赖任务并行,补齐真实多轮 Group Chat、selector 和弱模型收敛。
3. 阶段 6:在阶段 5 的动作/API 契约稳定后,将现有原型改为本地 React TDesign 工作台并完成组件级测试。
4. 阶段 6.5:以独立 MAF view-model 复用旧会商的视觉与交互思路,隐藏技术 session/thread,不复用旧状态。
5. 阶段 0 遗留项和阶段 1–3 的真实环境演示验收可穿插进行,但不再阻塞上述代码收口。
6. 阶段 7:在两种编排和前端均验收后做公平对比。
7. 阶段 8:最后完成全量回归、离线安装、灰度和观测。
在熟悉当前代码的前提下,这是一个中等偏大的完整功能,建议按 3 个可演示里程碑交付:
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, Network, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -196,6 +196,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/maf")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/maf">
<Network />
<span>MAF 会商</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -30,6 +30,7 @@ import {
List,
ListChecks,
ListTodo,
MessageSquareText,
Network,
Radar,
Settings,
@@ -0,0 +1,348 @@
/**
* MAF 会商前端回归测试。
*
* 运行(当前前端未引入 Vitest):
* pnpm exec esbuild src/maf-roundtable/__tests__/maf-roundtable.test.ts --bundle --platform=node --format=esm --outfile=.maf-roundtable.test.mjs --banner:js="import.meta.env={};"
* node --test .maf-roundtable.test.mjs
*
* 覆盖:
* 1. 经理与席位重复 → participants 构建时自动排除
* 2. 事件翻页 → 超过 PAGE_LIMIT 时 mergeBySeq 不丢不重
* 3. 终态历史 → 终态 run 的事件仍被折叠为时间线
* 4. 延迟出现的审批卡 → pending 审批在 actions 到达后正确关联
* 5. 风险等级映射 → R0–R4 正确映射
*/
import assert from "node:assert/strict";
import { describe, test } from "node:test";
// @ts-ignore Node's test runner resolves .ts directly.
import { foldEventsToTimeline, type TimelineEntry } from "../lib/event-timeline.ts";
// @ts-ignore
import { runIsLive, type MafEvent } from "../api/types.ts";
// @ts-ignore Node's test runner resolves .ts directly.
import { createMafEventRequestCoordinator } from "../hooks/use-maf-events.ts";
// ── helpers ─────────────────────────────────────────────────────────────────
function makeEvent(overrides: Partial<MafEvent> & { seq: number }): MafEvent {
return {
id: `ev-${overrides.seq}`,
session_id: "s1",
run_id: "r1",
event_type: "agent.message.delta",
agent_id: "agent-a",
turn_index: 0,
payload: { text: `msg-${overrides.seq}` },
created_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
/** 模拟 mergeBySeq(与 hook 内部逻辑一致)。 */
function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
if (incoming.length === 0) return prev;
const bySeq = new Map<number, MafEvent>();
for (const e of prev) bySeq.set(e.seq, e);
for (const e of incoming) bySeq.set(e.seq, e);
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
}
// ── 1. 经理与席位重复 ──────────────────────────────────────────────────────
describe("participants 构建:经理与席位互斥", () => {
test("切换经理后,席位列表自动排除该 agent", () => {
// 模拟 UI 逻辑:managerAgentId 变化时 filter
const seatAgentIds = ["a1", "a2", "a3"];
const managerAgentId = "a2";
const filtered = seatAgentIds.filter((id) => id !== managerAgentId);
assert.deepEqual(filtered, ["a1", "a3"]);
assert.ok(!filtered.includes(managerAgentId));
});
test("toggleSeat 拒绝添加当前经理", () => {
const managerAgentId = "a1";
const seatAgentIds = ["a2", "a3"];
// 模拟 toggleSeat 逻辑
const agentId = "a1";
const result = agentId === managerAgentId
? seatAgentIds // 不变
: seatAgentIds.includes(agentId)
? seatAgentIds.filter((id) => id !== agentId)
: [...seatAgentIds, agentId];
assert.deepEqual(result, ["a2", "a3"]);
assert.ok(!result.includes("a1"));
});
});
// ── 2. 事件翻页(超过 PAGE_LIMIT 不丢不重)─────────────────────────────────
describe("事件翻页 mergeBySeq", () => {
test("两页各 1000 条,合并后 2000 条无重复", () => {
const page1 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1 }));
const page2 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1001 }));
let merged: MafEvent[] = [];
merged = mergeBySeq(merged, page1);
assert.equal(merged.length, 1000);
merged = mergeBySeq(merged, page2);
assert.equal(merged.length, 2000);
// 验证升序
for (let i = 1; i < merged.length; i++) {
assert.ok(merged[i].seq > merged[i - 1].seq, `seq not ascending at ${i}`);
}
});
test("重叠页(网络重传)不产生重复", () => {
const page1 = Array.from({ length: 100 }, (_, i) => makeEvent({ seq: i + 1 }));
// 第二页重叠了 seq 91-100,并新增 101-120
const page2 = Array.from({ length: 30 }, (_, i) => makeEvent({ seq: i + 91 }));
let merged: MafEvent[] = [];
merged = mergeBySeq(merged, page1);
merged = mergeBySeq(merged, page2);
assert.equal(merged.length, 120);
});
test("游标只推进到实际收到的最大 seq", () => {
// 模拟 fetchBacklog 的游标推进逻辑
const page = [makeEvent({ seq: 5 }), makeEvent({ seq: 3 }), makeEvent({ seq: 7 })];
let cursor = 0;
let maxSeq = cursor;
for (const e of page) {
if (e.seq > maxSeq) maxSeq = e.seq;
}
assert.equal(maxSeq, 7);
// 不是 latest_seq(可能是 100),而是实际最大 seq
assert.ok(maxSeq <= 7);
});
});
// ── 3. 终态历史(终态 run 的事件仍被折叠)──────────────────────────────────
describe("终态 run 事件折叠", () => {
test("completed run 的事件正常折叠为时间线", () => {
const events: MafEvent[] = [
makeEvent({ seq: 1, event_type: "run.started", payload: {} }),
makeEvent({ seq: 2, event_type: "agent.message.delta", payload: { text: "Hello " } }),
makeEvent({ seq: 3, event_type: "agent.message.delta", payload: { text: "world" } }),
makeEvent({ seq: 4, event_type: "agent.message.completed", payload: { content: "Hello world" } }),
makeEvent({ seq: 5, event_type: "run.completed", payload: {} }),
];
const timeline = foldEventsToTimeline(events);
assert.ok(timeline.length >= 3, `expected >=3 entries, got ${timeline.length}`);
// run.started 和 run.completed 应存在
const kinds = timeline.map((e) => e.eventType);
assert.ok(kinds.includes("run.started"));
assert.ok(kinds.includes("run.completed"));
// delta 聚合为一条 message
const messages = timeline.filter((e) => e.kind === "message");
assert.equal(messages.length, 1);
assert.equal(messages[0].body, "Hello world");
});
test("runIsLive 对终态返回 false", () => {
assert.equal(runIsLive("completed"), false);
assert.equal(runIsLive("failed"), false);
assert.equal(runIsLive("cancelled"), false);
assert.equal(runIsLive(null), false);
assert.equal(runIsLive(undefined), false);
});
test("runIsLive 对活跃态返回 true", () => {
assert.equal(runIsLive("running"), true);
assert.equal(runIsLive("pending"), true);
assert.equal(runIsLive("waiting_approval"), true);
assert.equal(runIsLive("cancelling"), true);
assert.equal(runIsLive("cancel_failed"), true);
});
});
// ── 4. 延迟出现的审批卡 ────────────────────────────────────────────────────
describe("审批关联", () => {
test("pending 审批在 actions 到达后正确关联", () => {
// 模拟 indexApprovalsByAction + indexActions
const approvals = [
{ id: "ap1", action_id: "act1", status: "pending", summary: "写入文件" },
{ id: "ap2", action_id: null, status: "pending", summary: "无关联动作" },
];
const actions = [
{ id: "act1", tool_name: "write_file", risk_level: "R3", arguments: { path: "/tmp/x" } },
];
// indexApprovalsByAction:只索引有 action_id 的 pending
const byAction = new Map<string, typeof approvals[0]>();
for (const a of approvals) {
if (a.status === "pending" && a.action_id) byAction.set(a.action_id, a);
}
assert.equal(byAction.size, 1);
assert.ok(byAction.has("act1"));
assert.ok(!byAction.has("")); // null action_id 不索引
// indexActions
const actMap = new Map<string, typeof actions[0]>();
for (const a of actions) actMap.set(a.id, a);
// 卡片取 action
const ap1 = approvals[0];
const linkedAction = ap1.action_id ? actMap.get(ap1.action_id) : undefined;
assert.equal(linkedAction?.tool_name, "write_file");
assert.equal(linkedAction?.risk_level, "R3");
// 无 action_id 的审批不报错
const ap2 = approvals[1];
const noAction = ap2.action_id ? actMap.get(ap2.action_id) : undefined;
assert.equal(noAction, undefined);
});
});
// ── 5. 风险等级映射 R0–R4 ─────────────────────────────────────────────────
describe("风险等级映射", () => {
const RISK_TONE: Record<string, string> = {
R0: "text-emerald-600 bg-emerald-50",
R1: "text-emerald-600 bg-emerald-50",
R2: "text-amber-600 bg-amber-50",
R3: "text-red-600 bg-red-50",
R4: "text-red-600 bg-red-50",
low: "text-emerald-600 bg-emerald-50",
medium: "text-amber-600 bg-amber-50",
high: "text-red-600 bg-red-50",
};
test("R0/R1 → 低风险(绿)", () => {
assert.ok(RISK_TONE["R0"].includes("emerald"));
assert.ok(RISK_TONE["R1"].includes("emerald"));
});
test("R2 → 中风险(琥珀)", () => {
assert.ok(RISK_TONE["R2"].includes("amber"));
});
test("R3/R4 → 高风险(红)", () => {
assert.ok(RISK_TONE["R3"].includes("red"));
assert.ok(RISK_TONE["R4"].includes("red"));
});
test("未知等级有兜底", () => {
// 未知等级不在 map 中,组件用 ?? 兜底
const fallback = RISK_TONE["R99"] ?? "text-slate-500 bg-slate-50";
assert.ok(fallback.includes("slate"));
});
});
// ── 6. 快速切换 run 时事件不串台(generation 隔离逻辑验证)─────────────────
describe("事件流代次隔离", () => {
test("generation 递增后旧回调被丢弃", () => {
// 模拟 generationRef 逻辑
let generation = 0;
const results: string[] = [];
// 模拟 run A 的请求(慢)
const genA = generation;
// 切换到 run B → generation++
generation += 1;
const genB = generation;
// run A 的响应到达(检查代次)
if (generation === genA) {
results.push("A-stale");
}
// run B 的响应到达
if (generation === genB) {
results.push("B-fresh");
}
assert.deepEqual(results, ["B-fresh"]);
assert.ok(!results.includes("A-stale"));
});
test("同一代次内多次 poll 正常累积", () => {
let generation = 0;
const gen = generation;
const events: number[] = [];
// 同一代次内三次 poll
for (const seq of [1, 2, 3]) {
if (generation === gen) {
events.push(seq);
}
}
assert.deepEqual(events, [1, 2, 3]);
});
test("旧请求延迟返回时,终态新请求仍执行且旧事件被丢弃", async () => {
const coordinator = createMafEventRequestCoordinator();
let resolveOld!: () => void;
const oldResponse = new Promise<void>((resolve) => {
resolveOld = resolve;
});
const acceptedEvents: number[] = [];
let latestSeq = 0;
const acceptPage = (generation: number, page: number[]) => {
if (!coordinator.isCurrent(generation)) return;
acceptedEvents.push(...page);
latestSeq = Math.max(latestSeq, ...page);
};
const oldGeneration = coordinator.nextGeneration();
assert.equal(coordinator.start(oldGeneration), true);
const oldRequest = (async () => {
await oldResponse;
// This is the delayed live poll response from the old run.
acceptPage(oldGeneration, [99]);
coordinator.finish(oldGeneration);
})();
// running -> completed creates a new generation. Its backlog request must
// not see the stale generation's in-flight slot.
const terminalGeneration = coordinator.nextGeneration();
assert.equal(coordinator.start(terminalGeneration), true);
// The terminal request starts from cursor 0 and returns the complete
// history, even though the old live request is still unresolved.
acceptPage(terminalGeneration, [1, 2, 3]);
coordinator.finish(terminalGeneration);
resolveOld();
await oldRequest;
assert.deepEqual(acceptedEvents, [1, 2, 3]);
assert.equal(latestSeq, 3);
assert.equal(coordinator.start(terminalGeneration), true);
});
});
// ── 7. maxRounds 校验 ─────────────────────────────────────────────────────
describe("maxRounds 前端校验", () => {
function validateMaxRounds(input: string): { valid: boolean; value?: number } {
if (!input.trim()) return { valid: true }; // 空 = 使用默认
const n = Number(input);
if (!Number.isInteger(n) || n < 1 || n > 64) return { valid: false };
return { valid: true, value: n };
}
test("空字符串 → 有效(使用默认)", () => {
assert.ok(validateMaxRounds("").valid);
assert.ok(validateMaxRounds(" ").valid);
});
test("1–64 整数 → 有效", () => {
assert.ok(validateMaxRounds("1").valid);
assert.ok(validateMaxRounds("64").valid);
assert.ok(validateMaxRounds("32").valid);
});
test("0 / 65 / 负数 / 小数 / 非数字 → 无效", () => {
assert.ok(!validateMaxRounds("0").valid);
assert.ok(!validateMaxRounds("65").valid);
assert.ok(!validateMaxRounds("-1").valid);
assert.ok(!validateMaxRounds("3.5").valid);
assert.ok(!validateMaxRounds("abc").valid);
});
});
@@ -0,0 +1,126 @@
/**
* MAF 审批 —— `/runs/{id}/approvals`、`/approvals/{id}/decision`、`/runs/{id}/actions`。
*
* decide 成功后 invalidate approvals + actions + events(事件轮询会自然看到
* action.approved/rejected,但显式 invalidate 让卡片状态即时翻转)。
*
* P0-4:hook 签名改为 (runId, isLive)。run 运行期间每 2s 刷新一次;终态停止。
* 不能以"当前有没有 pending 审批"决定是否刷新——审批通常是进入页面后才由工作流产生的。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BASE, getJson, sendJson } from "./client";
import type {
MafAction,
MafActionListResponse,
MafApproval,
MafApprovalDecisionRequest,
MafApprovalListResponse,
} from "./types";
const KEY = "maf-roundtable";
/** live 时审批/动作轮询间隔(ms)。 */
const LIVE_POLL_INTERVAL = 2000;
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
export async function listApprovals(runId: string): Promise<MafApprovalListResponse> {
return getJson<MafApprovalListResponse>(
`${BASE()}/runs/${runId}/approvals`,
"获取审批列表",
);
}
export async function listActions(runId: string): Promise<MafActionListResponse> {
return getJson<MafActionListResponse>(
`${BASE()}/runs/${runId}/actions`,
"获取动作列表",
);
}
export async function decideApproval(
approvalId: string,
body: MafApprovalDecisionRequest,
): Promise<MafApproval> {
return sendJson<MafApproval>(
`${BASE()}/approvals/${approvalId}/decision`,
"POST",
body,
"提交审批决策",
);
}
// ── React Query hooks ─────────────────────────────────────────────────────
/**
* 审批列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
* 不能以"当前有没有 pending"决定是否刷新——审批是进入页面后才产生的。
*/
export function useMafApprovals(runId: string | undefined, isLive: boolean) {
return useQuery({
queryKey: [KEY, "runs", runId, "approvals"],
queryFn: () => listApprovals(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
});
}
/**
* 动作列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
*/
export function useMafActions(runId: string | undefined, isLive: boolean) {
return useQuery({
queryKey: [KEY, "runs", runId, "actions"],
queryFn: () => listActions(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
});
}
export function useMafApprovalActions(runId: string | undefined) {
const queryClient = useQueryClient();
return {
decide: useMutation({
mutationFn: ({
approvalId,
decision,
}: {
approvalId: string;
decision: "approved" | "rejected";
}) => decideApproval(approvalId, { decision }),
onSuccess: () => {
if (!runId) return;
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "approvals"] });
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "actions"] });
// run 状态会因 resume 改变(approved → running),强制下一次轮询立即生效。
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId] });
},
}),
};
}
// ── 辅助:按 action_id 把 approval 和 action 关联起来 ─────────────────────
/** 把 pending 审批按 action_id 索引,便于卡片查找对应 action 的工具名/参数。 */
export function indexApprovalsByAction(
approvals: MafApproval[] | undefined,
): Map<string, MafApproval> {
const map = new Map<string, MafApproval>();
for (const a of approvals ?? []) {
if (a.status === "pending" && a.action_id) map.set(a.action_id, a);
}
return map;
}
/** 按 id 索引 action,供审批卡片取 tool_name/arguments。 */
export function indexActions(
actions: MafAction[] | undefined,
): Map<string, MafAction> {
const map = new Map<string, MafAction>();
for (const a of actions ?? []) map.set(a.id, map.get(a.id) ?? a);
return map;
}
@@ -0,0 +1,58 @@
/**
* MAF 多智能体会商 API 客户端地基。
*
* 复用全局 `apiFetch`(自动注入 Authorization + credentials:include),这里只做:
* - 拼 URL(`${getBackendBaseURL()}/api/maf-roundtable/...`)
* - 统一 `res.ok` 检查 + detail 提取(仿 task-buttons 的 readJson)
* - JSON 解析
*
* 不在此做 snake_case ⇄ camelCase 转换——后端即 snake_case,类型层直传(见 types.ts)。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
/** MAF roundtable 后端根路径(无尾斜杠)。 */
export const BASE = (): string => `${getBackendBaseURL()}/api/maf-roundtable`;
/**
* 检查 Response 并解析 JSON;失败时抛出带后端 `detail` 的 Error。
*
* - 404 = 不存在或非本人(后端统一返回 404 防止泄漏他人资源存在性)
* - 403 = 非所有者写(admin 也不能写别人的)
* - 503 = 功能关闭 / store 未就绪
*
* 这里不区分状态码语义,统一把 detail 透传给上层 toast/错误展示。
*/
export async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} 失败(HTTP ${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
/** GET JSON 的快捷封装。 */
export async function getJson<T>(url: string, ctx: string): Promise<T> {
const res = await apiFetch(url, { method: "GET" });
return readJson<T>(res, ctx);
}
/** 发送 JSON body 的快捷封装。 */
export async function sendJson<T>(
url: string,
method: "POST" | "PUT" | "DELETE" | "PATCH",
body: unknown,
ctx: string,
): Promise<T> {
const res = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return readJson<T>(res, ctx);
}
@@ -0,0 +1,34 @@
/**
* MAF 事件断线补拉 —— `GET /runs/{id}/events?after_seq=N&limit=...`。
*
* 这是**裸 fetch 函数**(非 hook):cursor `after_seq` 需在每次拉取后推进,
* useQuery 的 queryFn 不持有「上次 latest_seq」,所以轮询逻辑放在
* hooks/use-maf-events.ts(自定义 interval hook),这里只负责一次请求。
*
* 后端契约(events.py list_events):返回 `seq > after_seq` 升序;payload 已脱敏。
*/
import { BASE, getJson } from "./client";
import type { MafEventListResponse } from "./types";
/**
* 拉取 `after_seq` 之后的事件(不含 after_seq 本身)。
*
* @param runId run id
* @param afterSeq 上一次拉到的 latest_seq;首次传 0 补拉全部历史
* @param limit 单次上限,默认 1000、最大 10000(后端 Query 约束)
*/
export async function listEvents(
runId: string,
afterSeq = 0,
limit = 1000,
): Promise<MafEventListResponse> {
const qs = new URLSearchParams({
after_seq: String(afterSeq),
limit: String(limit),
});
return getJson<MafEventListResponse>(
`${BASE()}/runs/${runId}/events?${qs.toString()}`,
"获取会商事件",
);
}
+132
View File
@@ -0,0 +1,132 @@
/**
* MAF run 生命周期 + health 探测 —— `/sessions/{id}/runs`、`/runs/{id}`、`/health`。
*
* 轮询策略:
* - `useMafRun` 用函数式 `refetchInterval`:run 仍 live(pending/running/
* waiting_approval/cancelling/cancel_failed)时每 1.8s 重拉;终态停。
* 镜像 core/scheduled-tasks/hooks.ts:105-115 的模式。
* - events 轮询不在此处,见 hooks/use-maf-events.ts(cursor 推进需自定义 hook)。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BASE, getJson, sendJson } from "./client";
import { runIsLive, type MafHealthResponse, type MafRun, type MafRunListResponse } from "./types";
const KEY = "maf-roundtable";
/** run 轮询间隔(ms)。1.8s —— 平衡实时感与后端负载。 */
export const RUN_POLL_INTERVAL = 1800;
// ── health ────────────────────────────────────────────────────────────────
export async function getMafHealth(): Promise<MafHealthResponse> {
return getJson<MafHealthResponse>(`${BASE()}/health`, "获取 MAF 健康状态");
}
/** health 探测:决定入口页是否显示「功能未启用」空态。失败时视为 disabled。 */
export function useMafHealth() {
return useQuery({
queryKey: [KEY, "health"],
queryFn: getMafHealth,
refetchOnWindowFocus: false,
staleTime: 60_000,
retry: false, // 探测失败不重试,直接走 disabled 空态
});
}
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
export async function getRun(runId: string): Promise<MafRun> {
return getJson<MafRun>(`${BASE()}/runs/${runId}`, "获取会商 run");
}
export async function listRuns(sessionId: string): Promise<MafRunListResponse> {
return getJson<MafRunListResponse>(
`${BASE()}/sessions/${sessionId}/runs`,
"获取 run 列表",
);
}
export async function createRun(
sessionId: string,
workflowVersion?: string,
): Promise<MafRun> {
return sendJson<MafRun>(
`${BASE()}/sessions/${sessionId}/runs`,
"POST",
workflowVersion ? { workflow_version: workflowVersion } : {},
"启动会商 run",
);
}
export async function cancelRun(runId: string): Promise<MafRun> {
return sendJson<MafRun>(`${BASE()}/runs/${runId}/cancel`, "POST", {}, "取消会商");
}
export async function resumeRun(runId: string): Promise<MafRun> {
return sendJson<MafRun>(`${BASE()}/runs/${runId}/resume`, "POST", {}, "恢复会商");
}
export async function recoverRun(runId: string): Promise<MafRun> {
return sendJson<MafRun>(`${BASE()}/runs/${runId}/recover`, "POST", {}, "重试会商");
}
// ── React Query hooks ─────────────────────────────────────────────────────
/**
* 单个 run —— live 时每 1.8s 轮询,终态停。
* 这是事件轮询的「开关」:父组件用 `runIsLive(run.status)` 决定是否拉事件。
*/
export function useMafRun(runId: string | undefined | null) {
return useQuery({
queryKey: [KEY, "runs", runId],
queryFn: () => getRun(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
refetchInterval: (query) =>
runIsLive(query.state.data?.status) ? RUN_POLL_INTERVAL : false,
});
}
export function useMafRuns(sessionId: string | undefined) {
return useQuery({
queryKey: [KEY, "sessions", sessionId, "runs"],
queryFn: () => listRuns(sessionId!),
enabled: !!sessionId,
refetchOnWindowFocus: false,
staleTime: 10_000,
});
}
export function useMafRunActions() {
const queryClient = useQueryClient();
return {
start: useMutation({
mutationFn: ({ sessionId, workflowVersion }: { sessionId: string; workflowVersion?: string }) =>
createRun(sessionId, workflowVersion),
onSuccess: (run) => {
queryClient.invalidateQueries({ queryKey: [KEY, "sessions", run.session_id, "runs"] });
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
},
}),
cancel: useMutation({
mutationFn: cancelRun,
onSuccess: (run) => {
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
},
}),
resume: useMutation({
mutationFn: resumeRun,
onSuccess: (run) => {
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
},
}),
recover: useMutation({
mutationFn: recoverRun,
onSuccess: (run) => {
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
},
}),
};
}
@@ -0,0 +1,143 @@
/**
* MAF 会话 CRUD —— `/sessions`。
*
* 镜像 strategy-components/api/task-buttons.ts 的 colocated hook 模式:
* queryKey 用嵌套数组;mutation 成功后 invalidate 同前缀。
* snake_case 直传(见 types.ts)。
*
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BASE, getJson, sendJson } from "./client";
import type {
MafSession,
MafSessionListResponse,
} from "./types";
const KEY = "maf-roundtable";
// ── 请求体(对齐 schemas.py CreateSessionRequest / UpdateSessionRequest)──
export interface ParticipantInput {
agent_id: string;
display_name?: string;
role: "manager" | "seat";
sort_order?: number;
excluded_tools?: string[];
}
export interface CreateSessionBody {
title: string;
task_text: string;
task_payload?: Record<string, unknown> | null;
mode?: "structured" | "group_chat";
model_name?: string | null;
reasoning_effort?: string | null;
manager_agent_id?: string | null;
max_rounds?: number;
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
participants: ParticipantInput[];
excluded_tools?: string[];
}
export interface UpdateSessionBody {
title?: string;
task_text?: string;
task_payload?: Record<string, unknown> | null;
mode?: "structured" | "group_chat";
model_name?: string | null;
reasoning_effort?: string | null;
manager_agent_id?: string | null;
max_rounds?: number;
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
participants?: ParticipantInput[];
fact_ledger?: Record<string, unknown> | null;
final_result?: Record<string, unknown> | null;
}
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
export async function listSessions(params?: {
status?: string;
mode?: string;
limit?: number;
offset?: number;
}): Promise<MafSessionListResponse> {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.mode) qs.set("mode", params.mode);
if (params?.limit != null) qs.set("limit", String(params.limit));
if (params?.offset != null) qs.set("offset", String(params.offset));
const query = qs.toString();
return getJson<MafSessionListResponse>(
`${BASE()}/sessions${query ? `?${query}` : ""}`,
"获取会话列表",
);
}
export async function getSession(id: string): Promise<MafSession> {
return getJson<MafSession>(`${BASE()}/sessions/${id}`, "获取会话");
}
export async function createSession(body: CreateSessionBody): Promise<MafSession> {
return sendJson<MafSession>(`${BASE()}/sessions`, "POST", body, "创建会话");
}
export async function updateSession(
id: string,
body: UpdateSessionBody,
): Promise<MafSession> {
return sendJson<MafSession>(`${BASE()}/sessions/${id}`, "PUT", body, "更新会话");
}
export async function deleteSession(id: string): Promise<void> {
await sendJson<{ deleted: boolean }>(
`${BASE()}/sessions/${id}`,
"DELETE",
undefined,
"删除会话",
);
}
// ── React Query hooks ─────────────────────────────────────────────────────
export function useMafSessions(params?: {
status?: string;
mode?: string;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: [KEY, "sessions", params],
queryFn: () => listSessions(params),
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
export function useMafSession(id: string | undefined) {
return useQuery({
queryKey: [KEY, "sessions", id],
queryFn: () => getSession(id!),
enabled: !!id,
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
export function useMafSessionActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [KEY, "sessions"] });
return {
create: useMutation({ mutationFn: createSession, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({ id, body }: { id: string; body: UpdateSessionBody }) =>
updateSession(id, body),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteSession, onSuccess: invalidate }),
};
}
@@ -0,0 +1,325 @@
/**
* MAF 多智能体会商 —— 后端 `/api/maf-roundtable` 的 TypeScript 类型。
*
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py。
* 后端持久化即 snake_case(见 packages/harness/deerflow/persistence/maf_roundtable),
* 这里**直传不转换**:与后端字段一一对应,避免边界处多一层映射。
* 所有 `payload` 已在后端写入时脱敏(`sanitize_payload`),前端拿到的就是可渲染的。
*/
// ── health(GET /health)───────────────────────────────────────────────────
export interface MafHealthResponse {
enabled: boolean;
store_available: boolean;
default_mode: string;
default_write_policy: string;
default_max_rounds: number;
admin_diagnostic_access: boolean;
write_guard_enabled: boolean;
write_guard_ready: boolean;
action_secret_configured: boolean;
write_guard_status:
| "disabled"
| "provider_unavailable"
| "missing_secret"
| "ready";
}
// ── session(/sessions)────────────────────────────────────────────────────
export type MafSessionStatus =
| "draft"
| "running"
| "waiting_approval"
| "waiting_input"
| "completed"
| "failed"
| "cancelled";
export interface MafParticipant {
id: string;
session_id: string;
agent_id: string;
display_name: string;
role: "manager" | "seat";
sort_order: number;
thread_id: string | null;
status: string;
last_turn_index: number | null;
capability_snapshot: Record<string, unknown> | null;
created_at: string | null;
updated_at: string | null;
}
export interface MafSession {
id: string;
user_id: string;
title: string;
task_text: string;
task_payload: Record<string, unknown> | null;
mode: "structured" | "group_chat";
status: MafSessionStatus;
model_name: string | null;
reasoning_effort: string | null;
manager_agent_id: string | null;
max_rounds: number;
write_policy: "auto" | "confirm_high_risk" | "confirm_all_writes";
capability_snapshot: Record<string, unknown> | null;
fact_ledger: Record<string, unknown> | null;
final_result: Record<string, unknown> | null;
participants: MafParticipant[];
created_at: string | null;
updated_at: string | null;
}
export interface MafSessionListResponse {
sessions: MafSession[];
total: number;
}
// ── run(/sessions/{id}/runs, /runs/{id})──────────────────────────────────
/**
* Run 状态机。`runIsLive` 判定为 true 的状态会继续轮询;其余(completed/failed/
* cancelled)是终态,停止轮询。cancelling / cancel_failed 是非终态中间态,仍需轮询。
*/
export type MafRunStatus =
| "pending"
| "running"
| "waiting_approval"
| "completed"
| "failed"
| "cancelled"
| "cancelling"
| "cancel_failed";
export interface MafRun {
id: string;
session_id: string;
status: MafRunStatus;
workflow_version: string | null;
/**
* MAF 工作流检查点。结构见 RoundtableState:含 fact_ledger / final_result /
* seat_outcomes / current_round 等。这里用宽松类型——前端只读其中几个已知键。
*/
maf_checkpoint: MafCheckpoint | null;
last_event_seq: number;
started_at: string | null;
finished_at: string | null;
error_code: string | null;
error_message_safe: string | null;
token_usage: Record<string, number> | null;
latency_ms: number | null;
created_at: string | null;
updated_at: string | null;
}
export interface MafRunListResponse {
runs: MafRun[];
}
// ── checkpoint 内部结构(maf_checkpoint,宽松类型)──────────────────────────
/** 事实账本(fact_ledger.empty_ledger 的形状)。 */
export interface MafFactLedger {
candidate_facts: MafCandidateFact[];
confirmed_facts: MafConfirmedFact[];
evidence: MafEvidence[];
tool_results: MafToolResult[];
executed_actions: MafLedgerAction[];
pending_actions: MafLedgerAction[];
failed_or_unknown_actions: MafLedgerAction[];
conflicts: MafLedgerConflict[];
proposed_actions: MafLedgerAction[];
}
export interface MafCandidateFact {
candidate_id: string;
text: string;
agent_id?: string;
source?: string;
confirmed?: boolean;
}
export interface MafConfirmedFact {
candidate_id: string;
text: string;
agent_id?: string;
source?: string;
confirmed_by?: string;
evidence_refs: string[];
}
export interface MafEvidence {
ref?: string;
text?: string;
agent_id?: string;
[k: string]: unknown;
}
export interface MafToolResult {
tool_name: string;
status: string; // "completed" | "waiting_approval" | "failed" | "unknown"
agent_id?: string;
turn_index?: number;
detail?: Record<string, unknown>;
}
export interface MafLedgerAction {
action_id?: string;
tool_name?: string;
status?: string;
agent_id?: string;
external_object_id?: string;
text?: string;
[k: string]: unknown;
}
export interface MafLedgerConflict {
text: string;
agent_id?: string;
}
/** 席位产出(maf_checkpoint.seat_outcomes 的 value)。 */
export interface MafSeatOutcome {
status: string; // "completed" | "running" | "failed" | ...
execution_stage?: string;
agent_id?: string;
[k: string]: unknown;
}
export interface MafFinalResult {
content: string;
agent_id?: string;
partial_failure?: boolean;
degraded?: boolean;
note?: string;
}
export interface MafCheckpoint {
fact_ledger?: MafFactLedger;
seat_outcomes?: Record<string, MafSeatOutcome>;
final_result?: MafFinalResult;
current_round?: number;
seat_agent_ids?: string[];
[k: string]: unknown;
}
// ── event(/runs/{id}/events)──────────────────────────────────────────────
/**
* 事件分类(见后端 app/maf_roundtable/event_normalizer.py)。
* payload 已脱敏,结构因 event_type 而异,这里用 Record 宽松承载。
*/
export type MafEventType =
| "run.started"
| "run.completed"
| "run.failed"
| "run.cancelled"
| "agent.turn_started"
| "agent.message.delta"
| "agent.message.completed"
| "tool.requested"
| "tool.started"
| "tool.completed"
| "tool.failed"
| "skill.selected"
| "skill.started"
| "skill.completed"
| "skill.failed"
| "artifact.created"
| "action.waiting_approval"
| "action.approved"
| "action.rejected"
| "workflow.checkpointed"
| string; // 兜底:后端可能新增,前端不强断
export interface MafEvent {
id: string;
session_id: string;
run_id: string;
seq: number;
event_type: MafEventType;
agent_id: string | null;
turn_index: number | null;
payload: Record<string, unknown> | null;
created_at: string | null;
}
export interface MafEventListResponse {
events: MafEvent[];
latest_seq: number;
}
// ── action(/runs/{id}/actions, /actions/{id})─────────────────────────────
export type MafActionStatus =
| "pending"
| "waiting_approval"
| "approved"
| "rejected"
| "executed"
| "failed"
| "unknown"
| "expired"
| string;
export interface MafAction {
id: string;
session_id: string;
run_id: string | null;
agent_id: string | null;
tool_name: string;
logical_action_id: string | null;
risk_level: string;
status: MafActionStatus;
arguments: Record<string, unknown> | null;
result: Record<string, unknown> | null;
external_object_id: string | null;
approval_id: string | null;
attempt_count: number;
started_at: string | null;
finished_at: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface MafActionListResponse {
actions: MafAction[];
}
// ── approval(/runs/{id}/approvals, /approvals/{id}/decision)──────────────
export type MafApprovalStatus = "pending" | "approved" | "rejected" | string;
export interface MafApproval {
id: string;
session_id: string;
run_id: string | null;
action_id: string | null;
requested_by_agent_id: string | null;
status: MafApprovalStatus;
summary: string;
payload: Record<string, unknown> | null;
decided_by: string | null;
decided_at: string | null;
expires_at: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface MafApprovalListResponse {
approvals: MafApproval[];
}
export interface MafApprovalDecisionRequest {
decision: "approved" | "rejected";
}
// ── 终态判定 ───────────────────────────────────────────────────────────────
const LIVE_STATUSES: ReadonlySet<MafRunStatus> = new Set([
"pending",
"running",
"waiting_approval",
"cancelling",
"cancel_failed",
]);
/** run 是否仍可能产生新事件 —— true 时继续轮询。 */
export function runIsLive(status: string | null | undefined): boolean {
return !!status && (LIVE_STATUSES as Set<string>).has(status);
}
@@ -0,0 +1,170 @@
/**
* MAF 写工具审批卡片。
*
* P0-4 对齐后端 schemas.py:
* - 标题:action.tool_name,没有 action 时显示 approval.summary
* - 风险:展示 action.risk_level(R0–R4 配色)
* - 参数:展示 action.arguments(已脱敏)
* - 审批摘要:展示 approval.summary
* - 审批 payload:仅在有值时展示已脱敏 JSON
* - 备注 textarea 已删除(后端 ApprovalDecisionRequest 只接收 decision,不保存备注)
*
* P1:风险等级按 R0–R4 配色(R0/R1 低、R2 中、R3/R4 高)。
* P1:invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。
*
* 仅 pending 审批渲染;approved/rejected 后由父组件过滤掉。
*/
import { Check, Loader2, ShieldCheck, X } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useMafApprovalActions } from "../api/approvals";
import type { MafAction, MafApproval } from "../api/types";
interface MafApprovalCardProps {
approval: MafApproval;
action: MafAction | undefined;
/** 当前页面的 runId(用于 invalidate,不依赖 approval.run_id 可选字段)。 */
runId: string;
}
function shortId(id: string | null): string {
if (!id) return "—";
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
}
/**
* 风险等级配色。后端实际使用 R0–R4(不是 low/medium/high):
* R0/R1 → 低风险(绿)
* R2 → 中风险(琥珀)
* R3/R4 → 高风险(红)
*/
const RISK_TONE: Record<string, string> = {
R0: "text-emerald-600 bg-emerald-50",
R1: "text-emerald-600 bg-emerald-50",
R2: "text-amber-600 bg-amber-50",
R3: "text-red-600 bg-red-50",
R4: "text-red-600 bg-red-50",
// 兜底:后端可能返回旧值
low: "text-emerald-600 bg-emerald-50",
medium: "text-amber-600 bg-amber-50",
high: "text-red-600 bg-red-50",
};
const RISK_LABEL: Record<string, string> = {
R0: "R0 · 低",
R1: "R1 · 低",
R2: "R2 · 中",
R3: "R3 · 高",
R4: "R4 · 高",
};
export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProps) {
const actions = useMafApprovalActions(runId);
const pending = actions.decide.isPending;
const onDecide = (decision: "approved" | "rejected") => {
actions.decide.mutate(
{ approvalId: approval.id, decision },
{
onSuccess: () => {
toast.success(decision === "approved" ? "已批准" : "已拒绝");
},
onError: (e: Error) => toast.error(`决策失败:${e.message}`),
},
);
};
// 标题:action.tool_name 优先,没有 action 时用 approval.summary
const title = action?.tool_name ?? approval.summary;
const riskLevel = action?.risk_level;
const args = action?.arguments;
const approvalPayload = approval.payload;
return (
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-sm">
<header className="mb-2 flex items-center gap-1.5">
<Badge className="bg-red-50 text-red-600 hover:bg-red-50">
<ShieldCheck className="h-3 w-3" />
需要你的确认
</Badge>
{action?.id && (
<span className="text-[10px] font-mono text-slate-400">
#{shortId(approval.action_id ?? action.id)}
</span>
)}
</header>
{/* 标题 */}
<div className="mb-1.5 text-sm font-semibold text-slate-800">
{title || "未知操作"}
</div>
{/* 风险等级(R0–R4) */}
{riskLevel && (
<div className="mb-2 flex items-center gap-1.5">
<span className="text-[10px] text-slate-400">风险等级:</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${RISK_TONE[riskLevel] ?? "text-slate-500 bg-slate-50"}`}>
{RISK_LABEL[riskLevel] ?? riskLevel}
</span>
</div>
)}
{/* 审批摘要 */}
{approval.summary && (
<div className="mb-2 text-xs text-slate-600">
{approval.summary}
</div>
)}
{/* 参数(来自 action.arguments,已脱敏) */}
{args && Object.keys(args).length > 0 && (
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
参数(已脱敏)
</div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
{JSON.stringify(args, null, 2)}
</pre>
</div>
)}
{/* 审批 payload(仅有值时展示) */}
{approvalPayload && Object.keys(approvalPayload).length > 0 && (
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
审批载荷(已脱敏)
</div>
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
{JSON.stringify(approvalPayload, null, 2)}
</pre>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onDecide("rejected")}
disabled={pending}
className="border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
>
{pending ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <X className="mr-1 h-3.5 w-3.5" />}
拒绝
</Button>
<Button
size="sm"
onClick={() => onDecide("approved")}
disabled={pending}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
{pending ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1 h-3.5 w-3.5" />}
批准
</Button>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
/**
* MAF 事件时间线 —— 把折叠后的 TimelineEntry[] 渲染成可读的对话流。
*
* 复用 roundtable-planning 的 useAutoScroll + ScrollToBottomButton(滚动跟随语义)。
* 视觉上借鉴 MessageBubble(头像 + header + 正文),但 MAF 事件类型多样(消息/工具/
* 审批/run),这里用统一的「条目卡片」而非消息气泡——每条带类型徽标 + 时间 + 内容。
*/
import { useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
AlertTriangle,
CheckCircle2,
FileText,
GitBranch,
Hammer,
Loader2,
MessageSquare,
Package,
ShieldQuestion,
Sparkles,
Workflow,
XCircle,
} from "lucide-react";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { shortTime, type TimelineEntry, type TimelineEntryKind } from "../lib/event-timeline";
interface MafEventTimelineProps {
entries: TimelineEntry[];
/** 仍在流式(run live)→ 显示底部 spinner;停则不显示。 */
streaming: boolean;
}
const KIND_ICON: Record<TimelineEntryKind, React.ReactNode> = {
message: <MessageSquare className="h-3.5 w-3.5" />,
tool: <Hammer className="h-3.5 w-3.5" />,
skill: <Sparkles className="h-3.5 w-3.5" />,
approval: <ShieldQuestion className="h-3.5 w-3.5" />,
artifact: <Package className="h-3.5 w-3.5" />,
run: <Workflow className="h-3.5 w-3.5" />,
checkpoint: <GitBranch className="h-3.5 w-3.5" />,
turn: <FileText className="h-3.5 w-3.5" />,
};
const KIND_TONE: Record<TimelineEntryKind, string> = {
message: "border-blue-100 bg-blue-50/40",
tool: "border-cyan-100 bg-cyan-50/40",
skill: "border-purple-100 bg-purple-50/40",
approval: "border-amber-100 bg-amber-50/50",
artifact: "border-emerald-100 bg-emerald-50/40",
run: "border-slate-200 bg-slate-50/60",
checkpoint: "border-slate-200 bg-slate-50/40",
turn: "border-slate-100 bg-white",
};
function toolStatusIcon(entry: TimelineEntry): React.ReactNode {
const status = (entry.meta?.status as string) ?? "";
if (status === "completed") return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />;
if (status === "failed") return <XCircle className="h-3.5 w-3.5 text-red-600" />;
if (status === "started" || status === "requested")
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />;
return null;
}
export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [entries], {
contentRef,
});
return (
<div className="relative flex h-full min-h-0 flex-1 flex-col">
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-slate-50/30"
>
<div ref={contentRef} className="space-y-2 p-4">
{entries.length === 0 && !streaming && (
<div className="flex h-full items-center justify-center py-12 text-sm text-slate-400">
<AlertTriangle className="mr-1.5 h-4 w-4" />
暂无事件
</div>
)}
{entries.map((entry) => (
<TimelineEntryCard key={entry.key} entry={entry} />
))}
{streaming && (
<div className="flex items-center gap-2 py-2 pl-7 text-xs text-blue-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
会商进行中…
</div>
)}
</div>
</div>
{!atBottom && (
<ScrollToBottomButton onClick={scrollToBottom} label="回到底部" />
)}
</div>
);
}
function TimelineEntryCard({ entry }: { entry: TimelineEntry }) {
const isMessage = entry.kind === "message";
return (
<div
className={cn(
"rounded-lg border px-3 py-2 transition",
KIND_TONE[entry.kind],
)}
>
<header className="mb-1 flex items-center gap-1.5">
<span className="text-slate-500">{KIND_ICON[entry.kind]}</span>
{entry.kind === "tool" && toolStatusIcon(entry)}
<span className="text-xs font-semibold text-slate-700">{entry.title}</span>
{entry.agentId && (
<Badge variant="outline" className="ml-1 text-[10px] text-slate-500">
{entry.agentId}
</Badge>
)}
<span className="ml-auto text-[10px] text-slate-400">{shortTime(entry.createdAt)}</span>
</header>
{entry.body && (
isMessage ? (
<div className="prose prose-sm max-w-none text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
</div>
) : (
<p className="whitespace-pre-wrap break-words text-xs text-slate-600">{entry.body}</p>
)
)}
</div>
);
}
@@ -0,0 +1,129 @@
/**
* MAF 事实/结论流程图面板。
*
* 包一层 roundtable-planning 的 FlowGraphPanel:数据来自 lib/fact-flow-graph.ts
* (confirmed_facts 证据图 + final_result 的 flow-json 结论图)。run live 时
* streaming=true 让蚂蚁线动;终态转实线。
*
* FlowGraphPanel 是 portal 全屏弹窗风格(自带 onClose),这里用 Collapsible 折叠面板
* 内嵌一个固定高度的画布预览 + 「展开大图」按钮触发 FlowGraphPanel。
*/
import { useState } from "react";
import { GitBranch, Maximize2 } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import type { MafCheckpoint } from "../api/types";
import { buildMafFlowGraph } from "../lib/fact-flow-graph";
interface MafFactFlowPanelProps {
checkpoint: MafCheckpoint | null;
/** run 仍 live → 蚂蚁线动;终态 → 实线。 */
streaming: boolean;
}
export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProps) {
const [open, setOpen] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
const graph: FlowGraphData | null = buildMafFlowGraph(checkpoint);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="rounded-lg border border-slate-200 bg-white"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2">
<div className="flex items-center gap-1.5">
<GitBranch className="h-4 w-4 text-slate-500" />
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
{graph && (
<span className="text-xs text-slate-400">
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
</span>
)}
</div>
<span className="text-xs text-slate-400">{open ? "收起" : "展开"}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="relative h-64 border-t border-slate-100">
{graph ? (
<>
{/* 内嵌预览:复用 FlowGraphPanel 的横向布局,占满面板高度 */}
<FlowGraphPreview graph={graph} streaming={streaming} />
<Button
size="sm"
variant="outline"
className="absolute right-2 top-2 z-10 h-7 gap-1 bg-white/90 text-xs"
onClick={() => setFullscreen(true)}
>
<Maximize2 className="h-3.5 w-3.5" />
大图
</Button>
</>
) : (
<div className="flex h-full items-center justify-center text-xs text-slate-400">
{streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"}
</div>
)}
</div>
</CollapsibleContent>
{/* 全屏弹窗(portal 到 body,仿 Step3SummaryMode 用法) */}
{fullscreen && graph && (
<FlowGraphPanel
data={graph}
streaming={streaming}
onClose={() => setFullscreen(false)}
orientation="vertical"
miniMap
/>
)}
</Collapsible>
);
}
/**
* 内嵌预览。FlowGraphPanel 是 portal 全屏组件,不适合直接嵌进小面板;
* 这里用一个轻量的「节点列表」预览代替画布(点击「大图」看完整交互画布)。
*/
function FlowGraphPreview({ graph, streaming }: { graph: FlowGraphData; streaming: boolean }) {
return (
<div className="flex h-full flex-col overflow-hidden p-2">
{graph.title && (
<div className="mb-1 truncate text-xs font-medium text-slate-500">{graph.title}</div>
)}
<div className="flex-1 overflow-auto">
<ul className="space-y-1">
{graph.nodes.map((n) => {
const incomers = graph.edges.filter((e) => e.to === n.id).map((e) => e.from);
return (
<li key={n.id} className="rounded border border-slate-100 bg-slate-50/70 px-2 py-1">
<div className="flex items-center gap-1.5">
<span className="rounded bg-white px-1 py-0.5 text-[9px] font-mono text-slate-400">
{n.type}
</span>
<span className="truncate text-xs text-slate-700">{n.label}</span>
{n.group && (
<span className="ml-auto shrink-0 text-[10px] text-slate-400">{n.group}</span>
)}
</div>
{incomers.length > 0 && (
<div className="mt-0.5 text-[10px] text-slate-400">← {incomers.join(", ")}</div>
)}
</li>
);
})}
</ul>
</div>
{streaming && (
<div className="mt-1 text-center text-[10px] text-blue-400">绘制中…</div>
)}
</div>
);
}
@@ -0,0 +1,103 @@
/**
* MAF 功能开关守卫。
*
* P1:分别处理三种状态:
* - enabled=false → 「功能未启用」
* - enabled=true && store_available=false → 「功能已开启,但持久化存储未就绪」
* - 请求异常 → 「无法连接 MAF 服务」+ 重试按钮
*/
import { DatabaseZap, Loader2, RefreshCw, ShieldAlert, ShieldOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useMafHealth } from "../api/runs";
interface MafHealthGateProps {
/** enabled 时渲染的子树。 */
children: React.ReactNode;
/** 探测中时渲染的占位(默认 spinner)。 */
fallback?: React.ReactNode;
}
export function MafHealthGate({ children, fallback }: MafHealthGateProps) {
const { data, isLoading, isError, refetch, isFetching } = useMafHealth();
if (isLoading) {
return (
<>{fallback ?? (
<div className="flex h-full w-full items-center justify-center text-slate-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在检查 MAF 会商状态…
</div>
)}</>
);
}
// 请求异常 → 无法连接,给重试按钮
if (isError) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
<ShieldOff className="h-10 w-10 text-slate-300" />
<div className="text-base font-medium text-slate-500">无法连接 MAF 服务</div>
<p className="max-w-md text-sm text-slate-400">
请求 /api/maf-roundtable/health 失败,请检查后端服务是否正常运行。
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
重试
</Button>
</div>
);
}
// 明确 disabled → 功能未启用
if (!data?.enabled) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
<ShieldOff className="h-10 w-10 text-slate-300" />
<div className="text-base font-medium text-slate-500">MAF 会商功能未启用</div>
<p className="max-w-md text-sm text-slate-400">
后端未开启多智能体会商功能(maf_roundtable.enabled=false)。
请联系管理员在 config.yaml 中启用后重试。
</p>
</div>
);
}
// enabled=true 但 store_available=false → 存储未就绪
if (!data.store_available) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
<DatabaseZap className="h-10 w-10 text-amber-400" />
<div className="text-base font-medium text-slate-500">持久化存储未就绪</div>
<p className="max-w-md text-sm text-slate-400">
MAF 会商功能已开启,但持久化存储(数据库)尚未配置或连接失败。
写操作将返回 503。请检查数据库配置后重试。
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
重新检测
</Button>
</div>
);
}
const writeGuardNotReady =
data.write_guard_enabled && !data.write_guard_ready;
const writeGuardMessage =
data.write_guard_status === "missing_secret"
? "WriteGuard 未就绪:未注入 MAF_ACTION_SECRET_KEY,外部写入将失败关闭;只读工具和技能仍可用。"
: "WriteGuard 未就绪:外部写入将失败关闭,请检查审批桥接配置;只读工具和技能仍可用。";
return (
<div className="flex h-full min-h-0 flex-col">
{writeGuardNotReady && (
<div className="flex shrink-0 items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800">
<ShieldAlert className="h-4 w-4 shrink-0" />
{writeGuardMessage}
</div>
)}
<div className="min-h-0 flex-1">{children}</div>
</div>
);
}
@@ -0,0 +1,138 @@
/**
* MAF run 顶部状态条:状态徽标 + token/延迟 + 生命周期操作按钮。
*
* 操作:
* - live(pending/running/waiting_approval)→ 取消
* - waiting_approval → 恢复(resume,配合审批后流程)
* - cancel_failed → 重试取消(仍可点取消)
* - failed → 重试(recover)
*
* 状态徽标配色按 run.status 区分。token_usage/latency_ms 展示在右侧。
*/
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, Square, XCircle } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useMafRunActions } from "../api/runs";
import { runIsLive, type MafRun, type MafRunStatus } from "../api/types";
interface MafRunHeaderProps {
run: MafRun;
}
/** run 状态 → 中文 + 徽标 variant + 图标。 */
function statusMeta(status: MafRunStatus): {
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
icon: React.ReactNode;
tone: string;
} {
switch (status) {
case "pending":
return { label: "排队中", variant: "secondary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-slate-500" };
case "running":
return { label: "进行中", variant: "default", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-blue-600" };
case "waiting_approval":
return { label: "等待审批", variant: "default", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-amber-600" };
case "completed":
return { label: "已完成", variant: "outline", icon: <CheckCircle2 className="h-3 w-3" />, tone: "text-emerald-600" };
case "failed":
return { label: "失败", variant: "destructive", icon: <XCircle className="h-3 w-3" />, tone: "text-red-600" };
case "cancelled":
return { label: "已取消", variant: "outline", icon: <Square className="h-3 w-3" />, tone: "text-slate-500" };
case "cancelling":
return { label: "取消中", variant: "secondary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-amber-600" };
case "cancel_failed":
return { label: "取消失败", variant: "destructive", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-red-600" };
default:
return { label: status, variant: "outline", icon: null, tone: "text-slate-500" };
}
}
export function MafRunHeader({ run }: MafRunHeaderProps) {
const actions = useMafRunActions();
const meta = statusMeta(run.status);
const live = runIsLive(run.status);
const onCancel = () => {
actions.cancel.mutate(run.id, {
onSuccess: () => toast.success("已发起取消"),
onError: (e: Error) => toast.error(`取消失败:${e.message}`),
});
};
const onResume = () => {
actions.resume.mutate(run.id, {
onSuccess: () => toast.success("已恢复会商"),
onError: (e: Error) => toast.error(`恢复失败:${e.message}`),
});
};
const onRecover = () => {
actions.recover.mutate(run.id, {
onSuccess: () => toast.success("已重试会商"),
onError: (e: Error) => toast.error(`重试失败:${e.message}`),
});
};
// token 汇总
const tokenTotal = run.token_usage
? Object.values(run.token_usage).reduce((a, b) => a + (b || 0), 0)
: null;
return (
<div className="flex flex-wrap items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5">
<div className={`flex items-center gap-1.5 ${meta.tone}`}>
{meta.icon}
<span className="text-sm font-semibold">{meta.label}</span>
</div>
<Badge variant={meta.variant} className="text-[11px]">
{run.workflow_version ?? "maf"}
</Badge>
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
{tokenTotal != null && (
<span title="累计 token">tokens: {tokenTotal.toLocaleString()}</span>
)}
{run.latency_ms != null && (
<span title="耗时">{(run.latency_ms / 1000).toFixed(1)}s</span>
)}
{run.last_event_seq > 0 && (
<span title="最新事件序号">seq#{run.last_event_seq}</span>
)}
</div>
<div className="flex items-center gap-2">
{run.status === "waiting_approval" && (
<Button size="sm" variant="outline" onClick={onResume} disabled={actions.resume.isPending}>
恢复
</Button>
)}
{run.status === "failed" && (
<Button size="sm" variant="outline" onClick={onRecover} disabled={actions.recover.isPending}>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
重试
</Button>
)}
{(live || run.status === "cancel_failed") && (
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={actions.cancel.isPending}
className="text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Square className="mr-1 h-3.5 w-3.5" />
取消
</Button>
)}
</div>
{run.error_message_safe && (
<div className="w-full text-xs text-red-600">
错误:{run.error_message_safe}
</div>
)}
</div>
);
}
@@ -0,0 +1,91 @@
/**
* MAF 席位状态矩阵。
*
* 数据来自 `run.maf_checkpoint.seat_outcomes`({agent_id: {status, execution_stage, ...}})。
* 每个席位一行:agent_id + status 徽标 + execution_stage 徽标。
*
* execution_stage 取值(Phase-3 闭合枚举):
* legacy / planner / expert / format_repair / cross_review / cross_review_repair /
* finalizer / group_chat
*/
import { Badge } from "@/components/ui/badge";
import type { MafCheckpoint, MafSeatOutcome } from "../api/types";
interface MafSeatStatusPanelProps {
checkpoint: MafCheckpoint | null;
/** 当前轮次(展示用)。 */
currentRound?: number;
}
const STAGE_LABELS: Record<string, string> = {
legacy: "旧版",
planner: "规划",
expert: "专家分析",
format_repair: "格式修复",
cross_review: "交叉评审",
cross_review_repair: "评审修复",
finalizer: "总结",
group_chat: "群聊",
};
const SEAT_STATUS_TONE: Record<string, string> = {
completed: "text-emerald-600",
running: "text-blue-600",
pending: "text-slate-500",
failed: "text-red-600",
cancelled: "text-slate-400",
};
function seatStatusBadge(status: string) {
const variant =
status === "completed"
? ("outline" as const)
: status === "failed"
? ("destructive" as const)
: ("secondary" as const);
return <Badge variant={variant} className="text-[10px]">{status}</Badge>;
}
export function MafSeatStatusPanel({ checkpoint, currentRound }: MafSeatStatusPanelProps) {
const outcomes = checkpoint?.seat_outcomes;
const entries: Array<[string, MafSeatOutcome]> = outcomes
? Object.entries(outcomes)
: [];
return (
<section className="rounded-lg border border-slate-200 bg-white p-3">
<header className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-700">席位状态</h3>
{typeof currentRound === "number" && (
<span className="text-xs text-slate-400">第 {currentRound + 1} 轮</span>
)}
</header>
{entries.length === 0 ? (
<p className="py-4 text-center text-xs text-slate-400">尚无席位产出</p>
) : (
<ul className="space-y-1.5">
{entries.map(([agentId, outcome]) => (
<li
key={agentId}
className="flex items-center gap-2 rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5"
>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-slate-700" title={agentId}>
{agentId}
</span>
{outcome.execution_stage && (
<Badge variant="outline" className="text-[10px] text-slate-500">
{STAGE_LABELS[outcome.execution_stage] ?? outcome.execution_stage}
</Badge>
)}
<span className={SEAT_STATUS_TONE[outcome.status ?? ""] ?? "text-slate-500"}>
{seatStatusBadge(outcome.status ?? "unknown")}
</span>
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,233 @@
/**
* MAF 事件 cursor 轮询 hook —— Phase-4 实时更新的核心。
*
* 为什么不用 `useQuery + refetchInterval`:
* cursor `after_seq` 必须在每次拉取后推进。useQuery 的 queryFn 不持有「上次的
* latest_seq」;把 after_seq 塞进 queryKey 会导致每次推进都重置查询、反复重拉全量,
* 产生抖动。所以用自定义 interval hook:闭包里用 ref 持有 cursor。
*
* P0-3 修复:
* 1. 终态 run 也拉历史事件(enabled=true 即补拉,不管是否 live)。
* 2. 一次超过 PAGE_LIMIT 条事件时,循环翻页直到返回数 < PAGE_LIMIT 或无更大 seq。
* 3. inFlightRef 防止网络慢时上一个请求尚未结束、下一个 interval 又开始请求。
* 4. 游标只推进到「本次实际收到的最大 seq」,不直接跳到 latest_seq。
*
* P0 竞态修复:
* 用 generationRef 隔离不同 runId/请求代次。切换 run 时 generation++,
* 所有异步回调在写 state 前检查 generation 是否仍匹配——旧 run 的慢响应
* 到达时 generation 已变,直接丢弃,不会把旧事件写进新页面。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { listEvents } from "../api/events";
import { useMafRun } from "../api/runs";
import { runIsLive, type MafEvent } from "../api/types";
/** 事件轮询间隔(ms)。1.5s —— 略快于 run 状态轮询,保证事件即时涌现。 */
const POLL_INTERVAL = 1500;
/** 单次拉取上限(后端最大 10000)。1000 足够覆盖一轮多席位的并发事件。 */
const PAGE_LIMIT = 1000;
/** 按 seq 去重合并(新事件覆盖同 seq 旧事件,正常不会发生但做幂等保险)。 */
function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
if (incoming.length === 0) return prev;
const bySeq = new Map<number, MafEvent>();
for (const e of prev) bySeq.set(e.seq, e);
for (const e of incoming) bySeq.set(e.seq, e);
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
}
/**
* Request-generation gate shared by the hook and its race-condition tests.
* Each generation has its own in-flight slot, so a stale request can finish
* independently without blocking a newer run's terminal-state backlog pull.
*/
export interface MafEventRequestCoordinator {
readonly currentGeneration: number;
nextGeneration: () => number;
start: (generation: number) => boolean;
finish: (generation: number) => void;
isCurrent: (generation: number) => boolean;
}
export function createMafEventRequestCoordinator(): MafEventRequestCoordinator {
let currentGeneration = 0;
const inFlight = new Set<number>();
return {
get currentGeneration() {
return currentGeneration;
},
nextGeneration() {
currentGeneration += 1;
return currentGeneration;
},
start(generation) {
if (inFlight.has(generation)) return false;
inFlight.add(generation);
return true;
},
finish(generation) {
inFlight.delete(generation);
},
isCurrent(generation) {
return generation === currentGeneration;
},
};
}
export interface UseMafEventsResult {
/** 按 seq 升序的事件列表(已去重)。 */
events: MafEvent[];
/** 当前已拉到的最大 seq(供调试/展示)。 */
latestSeq: number;
/** 手动重置(清空事件、cursor 归零、立即补拉)。 */
reset: () => void;
}
/**
* 轮询某 run 的事件流。
*
* @param runId run id;undefined/null 时不轮询
* @param opts.enabled 是否启用(有 run 即 true,无论终态/live——终态也要补拉历史)
* @param opts.polling 是否启动定时器(一般传 `runIsLive(run.status)`)
*/
export function useMafEvents(
runId: string | undefined | null,
opts: { enabled: boolean; polling: boolean },
): UseMafEventsResult {
const { enabled, polling } = opts;
const [events, setEvents] = useState<MafEvent[]>([]);
const [latestSeq, setLatestSeq] = useState(0);
const afterSeqRef = useRef(0);
const requestCoordinatorRef = useRef(createMafEventRequestCoordinator());
const [resetSignal, setResetSignal] = useState(0);
const initializedRef = useRef(false);
const lastRunIdRef = useRef<string | undefined | null>(runId);
const lastResetSignalRef = useRef(resetSignal);
const reset = useCallback(() => setResetSignal((n) => n + 1), []);
// Every runId/enabled/polling/resetSignal change starts a new request
// generation. Only run changes and explicit reset clear the accumulated
// data; toggling enabled/polling must cancel old work without losing the
// already accepted backlog.
useEffect(() => {
const identityChanged =
!initializedRef.current ||
lastRunIdRef.current !== runId ||
lastResetSignalRef.current !== resetSignal;
requestCoordinatorRef.current.nextGeneration();
if (identityChanged) {
setEvents([]);
setLatestSeq(0);
afterSeqRef.current = 0;
}
lastRunIdRef.current = runId;
lastResetSignalRef.current = resetSignal;
initializedRef.current = true;
}, [runId, enabled, polling, resetSignal]);
useEffect(() => {
if (!runId || !enabled) return;
const myGeneration = requestCoordinatorRef.current.currentGeneration;
let cancelled = false;
/**
* 从 afterSeqRef.current 开始循环翻页拉取,直到:
* - 返回事件数 < PAGE_LIMIT(没有更多了)
* - 本页最大 seq <= cursor(无进展,防死循环)
* - generation 已变(切换了 run,丢弃结果)
*/
async function fetchBacklog() {
if (!requestCoordinatorRef.current.start(myGeneration)) return;
try {
let cursor = afterSeqRef.current;
// eslint-disable-next-line no-constant-condition
while (true) {
if (
cancelled ||
!requestCoordinatorRef.current.isCurrent(myGeneration)
) return;
const res = await listEvents(runId!, cursor, PAGE_LIMIT);
// 响应回来后再次检查代次(网络慢时可能已切换 run)
if (
cancelled ||
!requestCoordinatorRef.current.isCurrent(myGeneration)
) return;
const page = res.events;
// 本页最大 seq(只推进到实际收到的最大 seq,不跳 latest_seq)
let maxSeq = cursor;
for (const e of page) {
if (e.seq > maxSeq) maxSeq = e.seq;
}
if (page.length > 0) {
setEvents((prev) => mergeBySeq(prev, page));
}
// 无进展 → 停
if (maxSeq <= cursor) break;
cursor = maxSeq;
afterSeqRef.current = cursor;
setLatestSeq(cursor);
// 不满一页 → 没有更多了
if (page.length < PAGE_LIMIT) break;
}
} catch {
// 单次拉取失败(网络抖动/瞬断)静默——下次 interval 重试。
} finally {
// Never clear a newer generation's in-flight marker from a stale
// request. This is what lets a terminal-state refetch start while
// the old live poll is still unresolved.
requestCoordinatorRef.current.finish(myGeneration);
}
}
// 进入即补拉历史(after_seq=0 全量,终态 run 也走这里)
void fetchBacklog();
// polling=true 才启动定时器
if (!polling) {
return () => { cancelled = true; };
}
const timer = window.setInterval(() => void fetchBacklog(), POLL_INTERVAL);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [runId, enabled, polling, resetSignal]);
return { events, latestSeq, reset };
}
/**
* 组合 hook:同时跟踪 run 状态 + 轮询事件。
*
* 用法:`const { run, events } = useMafRunWithEvents(runId)`。
* run live 时自动轮询事件;终态时 run 仍按 refetchInterval 停,事件补拉历史后停。
*/
export function useMafRunWithEvents(runId: string | undefined | null) {
const runQuery = useMafRun(runId);
const live = runIsLive(runQuery.data?.status);
const hasRun = !!runQuery.data;
const { events, latestSeq, reset } = useMafEvents(runId, {
enabled: hasRun,
polling: live,
});
return {
run: runQuery.data,
isLoadingRun: runQuery.isLoading,
runError: runQuery.error,
events,
latestSeq,
resetEvents: reset,
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* MAF 多智能体会商前端 —— barrel 导出。
*
* 对外只暴露页面(路由用)和必要的 hook/类型;内部 api/lib 组件按需从子路径导入。
*/
export { default as MafSessionsPage } from "./pages/MafSessionsPage";
export { default as MafRunDetailPage } from "./pages/MafRunDetailPage";
// 常用 hook(页面/外部消费)
export { useMafHealth, useMafRun, useMafRuns, useMafRunActions } from "./api/runs";
export { useMafSessions, useMafSession, useMafSessionActions } from "./api/sessions";
export { useMafApprovals, useMafActions, useMafApprovalActions } from "./api/approvals";
export { useMafEvents, useMafRunWithEvents } from "./hooks/use-maf-events";
// 类型
export type {
MafRun,
MafRunStatus,
MafSession,
MafEvent,
MafEventType,
MafApproval,
MafAction,
MafCheckpoint,
MafFactLedger,
MafHealthResponse,
} from "./api/types";
export { runIsLive } from "./api/types";
// 纯函数(可单测)
export { foldEventsToTimeline } from "./lib/event-timeline";
export type { TimelineEntry, TimelineEntryKind } from "./lib/event-timeline";
export { buildMafFlowGraph, buildFactFlowGraph, extractConclusionFlow } from "./lib/fact-flow-graph";
// 组件
export { MafHealthGate } from "./components/MafHealthGate";
export { MafRunHeader } from "./components/MafRunHeader";
export { MafEventTimeline } from "./components/MafEventTimeline";
export { MafSeatStatusPanel } from "./components/MafSeatStatusPanel";
export { MafFactFlowPanel } from "./components/MafFactFlowPanel";
export { MafApprovalCard } from "./components/MafApprovalCard";
@@ -0,0 +1,301 @@
/**
* 把 MAF 原始事件流(按 seq 升序)折叠成时间线可读条目。
*
* 后端事件分类见 app/maf_roundtable/event_normalizer.py:
* agent.message.delta —— 流式 token 片段,需聚合为一条消息
* agent.message.completed —— 一轮完整回复(含 content)
* tool.* —— 工具生命周期,折叠为单条「调用 → 结果」
* action.waiting_approval —— 审批锚点(右侧审批卡片入口)
* run.* / skill.* —— 状态/生命周期条目
*
* 设计:**纯函数**,无 React、无副作用,便于单测。输入 events[](已去重升序),
* 输出 TimelineEntry[](时间线渲染顺序)。
*/
import type { MafEvent } from "../api/types";
export type TimelineEntryKind =
| "message" // 智能体消息(delta 聚合 或 completed)
| "tool" // 工具调用生命周期(requested→completed/failed 折叠)
| "skill" // 技能生命周期
| "approval" // 审批锚点(waiting/approved/rejected)
| "artifact" // 产出物
| "run" // run 生命周期(started/completed/failed/cancelled)
| "checkpoint" // 工作流检查点
| "turn"; // 席位轮次开始
export interface TimelineEntry {
/** 用于 React key,全局唯一。 */
key: string;
kind: TimelineEntryKind;
/** 原始 event_type(供组件按类型配色/图标)。 */
eventType: string;
/** 触发该条目的 agent_id(席位/智能体标识)。 */
agentId: string | null;
/** 轮次索引(便于分组)。 */
turnIndex: number | null;
seq: number;
createdAt: string | null;
/** 主标题(工具名 / 状态文案 / 席位名)。 */
title: string;
/** 正文(消息内容 / 工具结果摘要 / 错误信息)。 */
body?: string;
/** 关联的二级信息(action_id/approval_id/tool_call_id 等)。 */
meta?: Record<string, unknown> | null;
}
/** delta 聚合的分组键:(agent_id, turn_index) —— 同一席位同一轮的 delta 拼成一条。 */
function deltaGroupKey(ev: MafEvent): string {
return `delta:${ev.agent_id ?? ""}:${ev.turn_index ?? 0}`;
}
/**
* 折叠事件流为时间线条目。
*
* 聚合规则:
* 1. `agent.message.delta` 按 (agent,turn) 分组拼接 → 一条 message(取最大 seq 为排序键)。
* 若同组有 `agent.message.completed`,则用 completed 的完整 content 覆盖。
* 2. `tool.requested`/`tool.started`/`tool.completed`/`tool.failed` 按 tool_call_id 折叠
* 为一条 tool 条目(最终状态 + 名称)。
* 3. 其余每事件一条。
*/
export function foldEventsToTimeline(events: MafEvent[]): TimelineEntry[] {
const entries: TimelineEntry[] = [];
// delta 分组缓冲:groupKey → { texts[], lastSeq, agent, turn, createdAt }
const deltaBuf = new Map<
string,
{ texts: string[]; seq: number; ev: MafEvent; completed?: MafEvent }
>();
// tool 折叠:tool_call_id → entry(先建占位,后续更新)
const toolMap = new Map<string, TimelineEntry>();
for (const ev of events) {
const p = ev.payload ?? {};
switch (ev.event_type) {
case "agent.message.delta": {
const text = typeof p.text === "string" ? p.text : "";
if (!text) break;
const k = deltaGroupKey(ev);
const cur = deltaBuf.get(k);
if (cur) {
cur.texts.push(text);
if (ev.seq > cur.seq) {
cur.seq = ev.seq;
cur.ev = ev;
}
} else {
deltaBuf.set(k, { texts: [text], seq: ev.seq, ev });
}
break;
}
case "agent.message.completed": {
const k = deltaGroupKey(ev);
const cur = deltaBuf.get(k);
const content = typeof p.content === "string" ? p.content : "";
if (cur) {
cur.completed = ev;
if (content) cur.texts = [content]; // completed 的完整内容覆盖 delta 拼接
if (ev.seq > cur.seq) cur.seq = ev.seq;
} else {
deltaBuf.set(k, {
texts: content ? [content] : [],
seq: ev.seq,
ev,
completed: ev,
});
}
break;
}
case "tool.requested":
case "tool.started":
case "tool.completed":
case "tool.failed": {
const tcId = typeof p.tool_call_id === "string" ? p.tool_call_id : "";
const toolName = typeof p.tool_name === "string" ? p.tool_name : "";
const mapKey = tcId || `${ev.event_type}:${ev.seq}`;
const status =
ev.event_type === "tool.failed"
? "failed"
: ev.event_type === "tool.completed"
? "completed"
: ev.event_type === "tool.started"
? "started"
: "requested";
const existing = toolMap.get(mapKey);
if (existing) {
// 更新到最新状态(seq 更大者覆盖)
if (ev.seq >= existing.seq) {
existing.seq = ev.seq;
existing.title = toolName || existing.title;
existing.body =
typeof p.content_preview === "string"
? p.content_preview
: existing.body;
existing.meta = { ...existing.meta, ...p, status };
}
} else {
toolMap.set(mapKey, {
key: `tool:${mapKey}`,
kind: "tool",
eventType: ev.event_type,
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq: ev.seq,
createdAt: ev.created_at,
title: toolName || "工具调用",
body: typeof p.content_preview === "string" ? p.content_preview : undefined,
meta: { ...p, status },
});
}
break;
}
default: {
// 其它事件一一映射(含 approval/run/skill/artifact/checkpoint/turn)。
const entry = mapSingleEvent(ev);
if (entry) entries.push(entry);
}
}
}
// delta 缓冲落盘
for (const { texts, seq, ev, completed } of deltaBuf.values()) {
entries.push({
key: `msg:${deltaGroupKey(ev)}`,
kind: "message",
eventType: completed ? "agent.message.completed" : "agent.message.delta",
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq,
createdAt: ev.created_at,
title: ev.agent_id ?? "智能体",
body: texts.join("").trim() || undefined,
meta: completed?.payload,
});
}
// tool 折叠落盘
for (const entry of toolMap.values()) entries.push(entry);
// 按 seq 升序(聚合条目用其最大 seq,自然排到出现时刻之后)
entries.sort((a, b) => a.seq - b.seq);
return entries;
}
/** 把单个非聚合事件映射成 TimelineEntry。 */
function mapSingleEvent(ev: MafEvent): TimelineEntry | null {
const p = ev.payload ?? {};
const base = {
key: `${ev.event_type}:${ev.id}`,
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq: ev.seq,
createdAt: ev.created_at,
};
switch (ev.event_type) {
case "agent.turn_started":
return {
...base,
kind: "turn",
eventType: ev.event_type,
title: `${ev.agent_id ?? "席位"} 开始第 ${(ev.turn_index ?? 0) + 1} 轮`,
meta: p,
};
case "run.started":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商开始", meta: p };
case "run.completed":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商完成", meta: p };
case "run.failed":
return {
...base,
kind: "run",
eventType: ev.event_type,
title: "会商失败",
body: typeof p.message === "string" ? p.message : undefined,
meta: p,
};
case "run.cancelled":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商已取消", meta: p };
case "skill.selected":
case "skill.started":
case "skill.completed":
case "skill.failed": {
const skillName = typeof p.skill_name === "string" ? p.skill_name : "技能";
const phase = ev.event_type.split(".")[1];
const labelMap: Record<string, string> = {
selected: "选择",
started: "开始",
completed: "完成",
failed: "失败",
};
return {
...base,
kind: "skill",
eventType: ev.event_type,
title: `技能「${skillName}」${labelMap[phase] ?? phase}`,
body:
typeof p.result_preview === "string"
? p.result_preview
: typeof p.error === "string"
? p.error
: undefined,
meta: p,
};
}
case "artifact.created":
return {
...base,
kind: "artifact",
eventType: ev.event_type,
title: `产出:${typeof p.title === "string" ? p.title : typeof p.artifact_type === "string" ? p.artifact_type : "产物"}`,
meta: p,
};
case "action.waiting_approval":
case "action.approved":
case "action.rejected": {
const phase = ev.event_type.split(".")[1];
const labelMap: Record<string, string> = {
waiting_approval: "等待审批",
approved: "已批准",
rejected: "已拒绝",
};
return {
...base,
kind: "approval",
eventType: ev.event_type,
title: `写工具「${typeof p.tool_name === "string" ? p.tool_name : "未知"}」${labelMap[phase] ?? phase}`,
body: typeof p.summary === "string" ? p.summary : undefined,
meta: p,
};
}
case "workflow.checkpointed":
return {
...base,
kind: "checkpoint",
eventType: ev.event_type,
title: "工作流已检查点",
body: typeof p.reason === "string" ? p.reason : undefined,
meta: p,
};
default:
// 兜底:未知事件类型仍展示,避免遗漏。
return {
...base,
kind: "run",
eventType: ev.event_type,
title: ev.event_type,
meta: p,
};
}
}
/** 时间线条目的人类可读时间(HH:MM:SS)。null/无效返回空串。 */
export function shortTime(iso: string | null): string {
if (!iso) return "";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return "";
}
}
@@ -0,0 +1,139 @@
/**
* 把 MAF 事实账本 + 末轮结论转成 FlowGraphPanel 可渲染的 FlowGraphData。
*
* 两个数据源,优先级合并:
* 1. `final_result.content` 里的 ```flow-json``` 块(次源,结论流程图)—— 若 final_result
* 含 flow-json,用现成的 extractFlowGraph 解析,直接作为「结论流程图」返回(它已是
* 模型精心编排的层级图,比纯事实更有结构)。
* 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其
* evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable);
* 证据根用 start。
*
* 设计:**纯函数**,输入 checkpoint(宽松类型),输出 FlowGraphData | null。
* null 表示无任何可绘制数据,由组件显示空态。
*/
import {
extractFlowGraph,
type FlowGraphData,
type FlowGraphEdge,
type FlowGraphNode,
} from "@/roundtable-planning/lib/flow-graph";
import type { MafCheckpoint, MafFactLedger } from "../api/types";
/** 截断长文本用于节点 label(detail 保留全文)。 */
function truncate(text: string, max = 28): string {
const t = text.trim().replace(/\s+/g, " ");
return t.length <= max ? t : `${t.slice(0, max)}…`;
}
/**
* 尝试从 final_result.content 解析 flow-json 结论图。
* 有则优先返回(模型编排的结论图比纯事实更有结构)。
*/
export function extractConclusionFlow(checkpoint: MafCheckpoint | null): FlowGraphData | null {
const content = checkpoint?.final_result?.content;
if (!content || typeof content !== "string") return null;
const graph = extractFlowGraph(content);
return graph && graph.nodes.length > 0 ? graph : null;
}
/**
* 把 confirmed_facts + evidence + tool_results 编织成事实证据图。
*
* 布局:
* start(根) ──► evidence/tool(role/deliverable) ──► fact(task)
*
* 每个事实节点 group 标 confirmed_by(席位),detail 是全文 + evidence_refs。
*/
export function buildFactFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
const confirmed = ledger?.confirmed_facts ?? [];
if (confirmed.length === 0) return null;
const nodes: FlowGraphNode[] = [];
const edges: FlowGraphEdge[] = [];
const nodeIds = new Set<string>();
const addNode = (n: FlowGraphNode) => {
if (!nodeIds.has(n.id)) {
nodeIds.add(n.id);
nodes.push(n);
}
};
const addEdge = (from: string, to: string, label?: string) => {
if (from === to) return;
edges.push({ from, to, label });
};
// 证据索引:ref → 来源类型,便于给节点配 type。
const evidenceByText = new Map<string, "evidence" | "tool">();
for (const e of ledger?.evidence ?? []) {
const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : "";
if (ref) evidenceByText.set(ref, "evidence");
}
for (const tr of ledger?.tool_results ?? []) {
if (tr.status !== "completed") continue; // 只有成功的工具算证据
const name = tr.tool_name ?? "";
const turn = tr.turn_index;
const ref =
name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : "";
if (ref) evidenceByText.set(ref, "tool");
}
// 根节点
const ROOT_ID = "root";
addNode({ id: ROOT_ID, label: "已确认事实", type: "start" });
for (const fact of confirmed) {
const factId = `fact:${fact.candidate_id}`;
addNode({
id: factId,
label: truncate(fact.text),
type: "task",
detail: fact.text,
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
});
if ((fact.evidence_refs ?? []).length === 0) {
// 无证据引用的事实:直接连根(理论上 Phase-3 校验后不应出现,兜底)。
addEdge(ROOT_ID, factId);
continue;
}
for (const ref of fact.evidence_refs) {
const refStr = String(ref);
const evKind = evidenceByText.get(refStr) ?? "evidence";
const evId = `ev:${refStr}`;
// 证据节点(role=外部证据 / deliverable=工具产出)
addNode({
id: evId,
label: truncate(refStr.replace(/^tool:/, ""), 24),
type: evKind === "tool" ? "deliverable" : "role",
detail: refStr,
group: evKind === "tool" ? "工具证据" : "外部证据",
});
addEdge(ROOT_ID, evId);
addEdge(evId, factId);
}
}
// 边去重(同 from→to 只留一条)
const seen = new Set<string>();
const dedupEdges = edges.filter((e) => {
const k = `${e.from}->${e.to}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return { title: "事实证据图", nodes, edges: dedupEdges };
}
/**
* 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。
*/
export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
return extractConclusionFlow(checkpoint) ?? buildFactFlowGraph(checkpoint);
}
@@ -0,0 +1,153 @@
/**
* MAF 单 run 工作区(路由 runs/:runId)。
*
* 三栏布局(桌面)/ 纵向堆叠(窄屏):
* 左:事件时间线(MafEventTimeline)—— run live 时 cursor 轮询事件
* 中:席位状态(MafSeatStatusPanel)+ 事实/结论流程图(MafFactFlowPanel)
* 右:审批卡片(MafApprovalCard)—— 仅 pending 审批时显示
*
* 顶部:MafRunHeader(状态徽标 + 操作)。整体由 MafHealthGate 守卫。
*/
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Loader2, ShieldQuestion } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { useMafActions, useMafApprovals } from "../api/approvals";
import { runIsLive, type MafAction } from "../api/types";
import { useMafRunWithEvents } from "../hooks/use-maf-events";
import { foldEventsToTimeline } from "../lib/event-timeline";
import { MafApprovalCard } from "../components/MafApprovalCard";
import { MafEventTimeline } from "../components/MafEventTimeline";
import { MafFactFlowPanel } from "../components/MafFactFlowPanel";
import { MafHealthGate } from "../components/MafHealthGate";
import { MafRunHeader } from "../components/MafRunHeader";
import { MafSeatStatusPanel } from "../components/MafSeatStatusPanel";
export default function MafRunDetailPage() {
const { runId } = useParams<{ runId: string }>();
return (
<MafHealthGate>
{runId ? <RunBody runId={runId} /> : <div className="p-6 text-slate-400">缺少 runId</div>}
</MafHealthGate>
);
}
function RunBody({ runId }: { runId: string }) {
const { run, isLoadingRun, runError, events } = useMafRunWithEvents(runId);
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
const live = runIsLive(run?.status);
const approvalsQuery = useMafApprovals(runId, live);
const actionsQuery = useMafActions(runId, live);
const pendingApprovals = (approvalsQuery.data?.approvals ?? []).filter(
(a) => a.status === "pending",
);
const actionsById = useMemo(() => {
const m = new Map<string, MafAction>();
for (const a of actionsQuery.data?.actions ?? []) m.set(a.id, a);
return m;
}, [actionsQuery.data]);
const timeline = useMemo(() => foldEventsToTimeline(events), [events]);
if (isLoadingRun) {
return (
<div className="flex h-full items-center justify-center text-slate-400">
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> 加载会商…
</div>
);
}
if (runError || !run) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-500">
<p>加载失败:{(runError as Error)?.message ?? "run 不存在或无权访问"}</p>
<Link to="/page/maf">
<Button variant="outline" size="sm">
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> 返回会话列表
</Button>
</Link>
</div>
);
}
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div ref={headerRef}>
<MafRunHeader run={run} />
</div>
<div className="grid flex-1 min-h-0 grid-cols-1 gap-2 overflow-hidden p-2 lg:grid-cols-[1fr_360px_320px]">
{/* 左:时间线 */}
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<MafEventTimeline entries={timeline} streaming={live} />
</section>
{/* 中:席位 + 流程图 */}
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
<MafSeatStatusPanel
checkpoint={run.maf_checkpoint}
currentRound={typeof run.maf_checkpoint?.current_round === "number"
? run.maf_checkpoint.current_round
: undefined}
/>
<MafFactFlowPanel checkpoint={run.maf_checkpoint} streaming={live} />
{/* final_result 展示 */}
{run.maf_checkpoint?.final_result?.content && (
<div className="rounded-lg border border-slate-200 bg-white p-3">
<h3 className="mb-1.5 text-sm font-semibold text-slate-700">最终结论</h3>
<div className="prose prose-sm max-h-72 max-w-none overflow-auto text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{/* final_result.content 可能含 flow-json 块;这里只展示文字部分(流程图已在上方画布) */}
<pre className="whitespace-pre-wrap break-words font-sans text-xs">
{stripFlowJson(run.maf_checkpoint.final_result.content)}
</pre>
</div>
</div>
)}
</section>
{/* 右:审批 */}
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
<div className="flex items-center gap-1.5 px-1 text-xs font-medium uppercase tracking-wide text-slate-400">
<ShieldQuestion className="h-3.5 w-3.5" />
写工具审批
{pendingApprovals.length > 0 && (
<span className="rounded-full bg-red-100 px-1.5 text-[10px] font-bold text-red-600">
{pendingApprovals.length}
</span>
)}
</div>
{pendingApprovals.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 bg-white/50 p-4 text-center text-xs text-slate-400">
{live ? "暂无待审批的写操作" : "本次会商无待审批项"}
</div>
) : (
pendingApprovals.map((approval) => (
<MafApprovalCard
key={approval.id}
approval={approval}
action={approval.action_id ? actionsById.get(approval.action_id) : undefined}
runId={runId}
/>
))
)}
</section>
</div>
</div>
);
}
/** 把 final_result.content 里的 ```flow-json 块替换成占位提示(流程图已在画布展示)。 */
function stripFlowJson(text: string): string {
const FENCE = "```flow-json";
const start = text.lastIndexOf(FENCE);
if (start < 0) return text;
const after = text.slice(start + FENCE.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
return `${text.slice(0, start).trimEnd()}\n\n[结构化流程数据已在上方「事实/结论流程图」画布展示]${tail}`;
}
@@ -0,0 +1,565 @@
/**
* MAF 会商会话列表页(路由 index)。
*
* 左:会话列表(按创建时间倒序);右:选中会话的 run 列表 + 「启动会商」。
* 点击某条 run → 跳 `/page/maf/runs/:runId`。
*
* P0-2:新建弹窗包含 task_text(必填)、模式、经理智能体、席位智能体多选、
* 可选高级项(模型/推理强度/最大轮数/写入策略)。智能体列表复用 @/core/agents。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Loader2, MessageSquarePlus, Play, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useAgents } from "@/core/agents";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { cn } from "@/lib/utils";
import { useMafSessionActions, useMafSessions, type CreateSessionBody, type ParticipantInput } from "../api/sessions";
import { useMafHealth, useMafRuns, useMafRunActions } from "../api/runs";
import { runIsLive, type MafSessionStatus } from "../api/types";
import { MafHealthGate } from "../components/MafHealthGate";
const STATUS_LABEL: Record<string, string> = {
draft: "草稿",
running: "会商中",
waiting_approval: "等待审批",
waiting_input: "等待输入",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
function statusTone(status: string): string {
switch (status) {
case "running":
return "text-blue-600";
case "waiting_approval":
case "waiting_input":
return "text-amber-600";
case "completed":
return "text-emerald-600";
case "failed":
case "cancelled":
return "text-red-500";
default:
return "text-slate-500";
}
}
export default function MafSessionsPage() {
return (
<MafHealthGate>
<SessionsBody />
</MafHealthGate>
);
}
function SessionsBody() {
const sessionsQuery = useMafSessions({ limit: 100 });
const [selectedId, setSelectedId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
const sessions = sessionsQuery.data?.sessions ?? [];
const active = selectedId
? sessions.find((s) => s.id === selectedId)
: sessions[0];
return (
<div className="flex h-full w-full">
{/* 左:会话列表 */}
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-200 bg-white">
<div ref={headerRef} className="flex items-center justify-between border-b border-slate-100 px-3 py-2.5">
<h2 className="text-sm font-semibold text-slate-700">会话</h2>
<Button size="sm" variant="outline" className="h-7 gap-1" onClick={() => setCreateOpen(true)}>
<MessageSquarePlus className="h-3.5 w-3.5" />
新建
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{sessionsQuery.isLoading && (
<div className="flex items-center justify-center py-8 text-slate-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
</div>
)}
{sessionsQuery.isError && (
<div className="p-3 text-xs text-red-500">
加载失败:{(sessionsQuery.error as Error)?.message ?? "未知错误"}
</div>
)}
{!sessionsQuery.isLoading && sessions.length === 0 && (
<div className="p-4 text-center text-xs text-slate-400">
暂无会话,点击「新建」开始
</div>
)}
<ul>
{sessions.map((s) => (
<li key={s.id}>
<button
type="button"
onClick={() => setSelectedId(s.id)}
className={cn(
"w-full border-b border-slate-50 px-3 py-2.5 text-left transition hover:bg-slate-50",
active?.id === s.id && "bg-blue-50/60",
)}
>
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-700">
{s.title || "未命名会话"}
</span>
<span className={cn("text-[10px]", statusTone(s.status))}>
{STATUS_LABEL[s.status] ?? s.status}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-slate-400">
<Badge variant="outline" className="text-[9px]">{s.mode}</Badge>
<span className="truncate">{s.task_text || "无任务说明"}</span>
</div>
</button>
</li>
))}
</ul>
</div>
</aside>
{/* 右:选中会话的 run 列表 */}
<main className="flex flex-1 flex-col overflow-hidden bg-slate-50/40">
{active ? (
<SessionDetail sessionId={active.id} title={active.title} mode={active.mode} status={active.status} />
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-400">
选择左侧会话查看详情,或新建一个
</div>
)}
</main>
<CreateSessionDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={(id) => setSelectedId(id)} />
</div>
);
}
function SessionDetail({
sessionId,
title,
mode,
status,
}: {
sessionId: string;
title: string;
mode: string;
status: MafSessionStatus;
}) {
const runsQuery = useMafRuns(sessionId);
const actions = useMafRunActions();
const navigate = useNavigate();
const runs = runsQuery.data?.runs ?? [];
// 存在运行中 run 时禁用「启动会商」
const liveRun = runs.find((r) => runIsLive(r.status));
const onStart = () => {
actions.start.mutate(
{ sessionId },
{
onSuccess: (run) => navigate(`/page/maf/runs/${run.id}`),
onError: (e: Error) => toast.error(`启动失败:${e.message}`),
},
);
};
return (
<>
<header className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5">
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-slate-800">{title || "未命名会话"}</h1>
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
<Badge variant="outline" className="text-[10px]">{mode}</Badge>
<span className={statusTone(status)}>{STATUS_LABEL[status] ?? status}</span>
</div>
</div>
<div className="flex items-center gap-2">
{liveRun && (
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/page/maf/runs/${liveRun.id}`)}
>
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin text-blue-500" />
已有会商进行中
</Button>
)}
<Button size="sm" onClick={onStart} disabled={actions.start.isPending || !!liveRun}>
{actions.start.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1 h-3.5 w-3.5" />
)}
启动会商
</Button>
</div>
</header>
<div className="flex-1 overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-400">历史 run</h3>
{runsQuery.isLoading && (
<div className="flex items-center py-6 text-slate-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
</div>
)}
{runs.length === 0 && !runsQuery.isLoading && (
<p className="py-6 text-center text-xs text-slate-400">暂无会商记录,点击「启动会商」开始</p>
)}
<ul className="space-y-2">
{runs.map((run) => (
<li key={run.id}>
<button
type="button"
onClick={() => navigate(`/page/maf/runs/${run.id}`)}
className="flex w-full items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-left transition hover:border-blue-300 hover:shadow-sm"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400">{run.id.slice(0, 12)}…</span>
{runIsLive(run.status) && (
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
)}
</div>
<div className="mt-0.5 text-[11px] text-slate-400">
{run.started_at ? new Date(run.started_at).toLocaleString() : "未开始"}
</div>
</div>
<Badge variant="outline" className="text-[10px]">{run.status}</Badge>
</button>
</li>
))}
</ul>
</div>
</>
);
}
function CreateSessionDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onCreated: (id: string) => void;
}) {
const health = useMafHealth();
const sessionActions = useMafSessionActions();
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents();
const [title, setTitle] = useState("");
const [taskText, setTaskText] = useState("");
const [mode, setMode] = useState("structured");
const [managerAgentId, setManagerAgentId] = useState("");
const [seatAgentIds, setSeatAgentIds] = useState<string[]>([]);
// 高级项
const [showAdvanced, setShowAdvanced] = useState(false);
const [modelName, setModelName] = useState("");
const [reasoningEffort, setReasoningEffort] = useState("");
const [maxRounds, setMaxRounds] = useState("");
const [writePolicy, setWritePolicy] = useState("");
// 默认 mode 跟随后端 default_mode(在 effect 中设置,避免 render 内 setState)
useEffect(() => {
if (health.data?.default_mode) {
setMode(health.data.default_mode);
}
}, [health.data?.default_mode]);
// P0:切换经理时,自动从已选席位中移除(后端参与者唯一约束:同一 agent 不能同时是 manager 和 seat)
useEffect(() => {
if (managerAgentId) {
setSeatAgentIds((prev) => prev.filter((id) => id !== managerAgentId));
}
}, [managerAgentId]);
const toggleSeat = (agentId: string) => {
// 经理不能同时作为席位
if (agentId === managerAgentId) return;
setSeatAgentIds((prev) =>
prev.includes(agentId)
? prev.filter((id) => id !== agentId)
: [...prev, agentId],
);
};
const onSubmit = () => {
// 前端校验
if (!taskText.trim()) {
toast.error("请填写任务说明");
return;
}
if (!managerAgentId) {
toast.error("请选择经理智能体");
return;
}
if (seatAgentIds.length === 0) {
toast.error("请至少选择一个席位智能体");
return;
}
// 构建 participants
const participants: ParticipantInput[] = [];
// 经理
const managerAgent = agents.find((a) => a.id === managerAgentId);
participants.push({
agent_id: managerAgentId,
display_name: managerAgent?.name ?? "",
role: "manager",
sort_order: 0,
});
// 席位
seatAgentIds.forEach((agentId, idx) => {
const agent = agents.find((a) => a.id === agentId);
participants.push({
agent_id: agentId,
display_name: agent?.name ?? "",
role: "seat",
sort_order: idx + 1,
});
});
const body: CreateSessionBody = {
title: title.trim(),
task_text: taskText.trim(),
mode: mode as "structured" | "group_chat",
manager_agent_id: managerAgentId,
participants,
};
// 高级项(仅非空时传)
if (modelName.trim()) body.model_name = modelName.trim();
if (reasoningEffort.trim()) body.reasoning_effort = reasoningEffort.trim();
if (maxRounds.trim()) {
const rounds = Number(maxRounds);
if (!Number.isInteger(rounds) || rounds < 1 || rounds > 64) {
toast.error("最大轮数必须为 1–64 的整数");
return;
}
body.max_rounds = rounds;
}
if (writePolicy) body.write_policy = writePolicy as CreateSessionBody["write_policy"];
sessionActions.create.mutate(
body,
{
onSuccess: (s) => {
toast.success("会话已创建");
// 重置表单
setTitle("");
setTaskText("");
setManagerAgentId("");
setSeatAgentIds([]);
setModelName("");
setReasoningEffort("");
setMaxRounds("");
setWritePolicy("");
setShowAdvanced(false);
onOpenChange(false);
onCreated(s.id);
},
onError: (e: Error) => toast.error(`创建失败:${e.message}`),
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>新建 MAF 会商会话</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
{/* 标题 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">会商标题</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="例如:某任务多视角研讨" />
</div>
{/* 任务说明(必填) */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
任务说明 <span className="text-red-500">*</span>
</label>
<Textarea
value={taskText}
onChange={(e) => setTaskText(e.target.value)}
placeholder="描述本次会商要解决的问题、背景和目标(必填)"
className="min-h-24"
/>
</div>
{/* 模式 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">会商模式</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2 text-sm"
>
<option value="structured">structured(规划→专家→评审→总结)</option>
<option value="group_chat">group_chat(协调员动态点名)</option>
</select>
</div>
{/* 经理智能体 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
经理智能体 <span className="text-red-500">*</span>
</label>
{agentsLoading ? (
<div className="flex h-9 items-center px-2 text-xs text-slate-400">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载智能体…
</div>
) : agentsError ? (
<div className="flex h-9 items-center px-2 text-xs text-red-500">
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
</div>
) : (
<select
value={managerAgentId}
onChange={(e) => setManagerAgentId(e.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2 text-sm"
>
<option value="">请选择…</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
)}
</div>
{/* 席位智能体多选 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
席位智能体(至少一个) <span className="text-red-500">*</span>
</label>
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-200 p-2">
{agentsLoading && (
<div className="flex items-center justify-center py-2 text-xs text-slate-400">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载中…
</div>
)}
{agentsError && (
<div className="py-2 text-center text-xs text-red-500">
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
</div>
)}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="py-2 text-center text-xs text-slate-400">暂无可用智能体</div>
)}
{!agentsLoading && !agentsError && agents.map((a) => (
<label key={a.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-slate-50">
<input
type="checkbox"
checked={seatAgentIds.includes(a.id)}
onChange={() => toggleSeat(a.id)}
disabled={a.id === managerAgentId}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
<span className="text-sm text-slate-700">{a.name}</span>
{a.id === managerAgentId && (
<span className="text-[10px] text-slate-400">(已选为经理)</span>
)}
</label>
))}
</div>
{seatAgentIds.length > 0 && (
<div className="mt-1 text-[10px] text-slate-400">
已选 {seatAgentIds.length} 个席位
</div>
)}
</div>
{/* 高级项折叠 */}
<div>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-blue-600 hover:underline"
>
{showAdvanced ? "收起高级选项" : "展开高级选项"}
</button>
{showAdvanced && (
<div className="mt-2 space-y-2 rounded-md border border-slate-100 bg-slate-50/50 p-2">
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">模型名称</label>
<Input value={modelName} onChange={(e) => setModelName(e.target.value)} placeholder="留空使用默认" className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">推理强度</label>
<Input value={reasoningEffort} onChange={(e) => setReasoningEffort(e.target.value)} placeholder="low / medium / high" className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">最大轮数</label>
<Input value={maxRounds} onChange={(e) => setMaxRounds(e.target.value)} placeholder="留空使用默认" type="number" min={1} max={64} className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">写入策略</label>
<select
value={writePolicy}
onChange={(e) => setWritePolicy(e.target.value)}
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
>
<option value="">默认</option>
<option value="auto">auto(自动执行)</option>
<option value="confirm_high_risk">confirm_high_risk(高风险确认)</option>
<option value="confirm_all_writes">confirm_all_writes(全部确认)</option>
</select>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={onSubmit} disabled={sessionActions.create.isPending}>
{sessionActions.create.isPending && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 删除会话按钮(预留导出)。
export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
const actions = useMafSessionActions();
return (
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={() => {
actions.remove.mutate(sessionId, {
onSuccess: () => toast.success("已删除"),
onError: (e: Error) => toast.error(`删除失败:${e.message}`),
});
}}
disabled={actions.remove.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
删除
</Button>
);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* MAF 多智能体会商路由树(/page/maf/*)。
*
* 仿 RoundtableRoutes:顶层包一层满高容器,内部 <Routes>:
* index → 会话列表
* runs/:runId → 单 run 工作区(时间线/席位/流程图/审批)
* * → 重定向到 index
*/
import { Navigate, Route, Routes } from "react-router-dom";
import MafRunDetailPage from "@/maf-roundtable/pages/MafRunDetailPage";
import MafSessionsPage from "@/maf-roundtable/pages/MafSessionsPage";
export default function MafRoutes() {
return (
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<MafSessionsPage />} />
<Route path="runs/:runId" element={<MafRunDetailPage />} />
<Route path="*" element={<Navigate to="/page/maf" replace />} />
</Routes>
</div>
);
}
+2
View File
@@ -27,6 +27,7 @@ import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
import NotebookRoutes from "./NotebookRoutes";
import MafRoutes from "./MafRoutes";
import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
function PageLayout() {
@@ -166,6 +167,7 @@ function PageLayout() {
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
<Route path="roundtable/*" element={<RoundtableRoutes />} />
<Route path="notebook/*" element={<NotebookRoutes />} />
<Route path="maf/*" element={<MafRoutes />} />
</Routes>
</main>
</div>
@@ -6,8 +6,9 @@ import sys
import threading
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime
from time import perf_counter
from datetime import datetime
from time import perf_counter
from typing import Any
from dotenv import load_dotenv
from fastapi import FastAPI
@@ -958,8 +959,63 @@ def _warn_if_multi_worker() -> None:
pass
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
async def _scan_maf_approval_expiry(app: Any) -> list[str]:
"""Expire due MAF approvals once, then resume affected workflows.
The store owns the conditional state transition and critical audit event.
Only the worker that wins that CAS receives an affected run, so repeated
scans cannot resume the same expiry twice.
"""
service = getattr(app.state, "maf_roundtable_service", None)
if service is None or not getattr(service, "enabled", False):
return []
expired = await service.store.expire_due_approvals()
run_ids = list(dict.fromkeys(
str(item["run_id"])
for item in expired
if item.get("run_id")
))
if not run_ids:
return []
from app.maf_roundtable.dispatcher import get_dispatcher
dispatcher = get_dispatcher(app)
resumed: list[str] = []
for run_id in run_ids:
run = await service.store.runs.get_run(run_id)
if run is None:
continue
session = await service.store.sessions.get_session(run["session_id"], user_id=None)
if session is None:
continue
# Dispatcher.resume is a no-op for a terminal/non-waiting run; keeping
# this call centralized preserves the restart and run-level CAS path.
await dispatcher.resume(run_id, user_id=str(session.get("user_id") or "default"))
resumed.append(run_id)
return resumed
async def _maf_approval_scanner_loop(app: Any) -> None:
"""Continuously fail closed expired MAF approvals until shutdown."""
service = getattr(app.state, "maf_roundtable_service", None)
config = getattr(service, "config", None)
interval = max(1, int(getattr(config, "approval_scan_interval_seconds", 10) or 10))
while True:
try:
await _scan_maf_approval_expiry(app)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("MAF approval expiry scan failed (non-fatal)")
await asyncio.sleep(interval)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
# ?????? worker ?? ?? ?? AI ?? / ? agent ???????
@@ -1123,12 +1179,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Concurrency (system-pressure) sampler — lightweight, singleton-locked,
# runtime-toggleable via system_settings.concurrency_monitor.enabled.
try:
app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
except Exception:
logger.exception("Concurrency sampler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
except Exception:
logger.exception("Concurrency sampler failed to start (non-fatal)")
# MAF-only approval expiry scanner. Legacy DeerFlow, old roundtable
# and AI-writing paths never enter this loop.
try:
maf_service = getattr(app.state, "maf_roundtable_service", None)
if maf_service is not None and getattr(maf_service, "enabled", False):
app.state.maf_approval_scanner_task = asyncio.create_task(
_maf_approval_scanner_loop(app)
)
logger.info(
"MAF approval expiry scanner started (interval=%ss)",
getattr(maf_service.config, "approval_scan_interval_seconds", 10),
)
except Exception:
logger.exception("MAF approval expiry scanner failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
@@ -1187,16 +1258,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.exception("Leaderboard snapshot scheduler shutdown error")
concurrency_sampler_task = getattr(app.state, "concurrency_sampler_task", None)
if concurrency_sampler_task is not None:
concurrency_sampler_task.cancel()
try:
await concurrency_sampler_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Concurrency sampler shutdown error")
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
if concurrency_sampler_task is not None:
concurrency_sampler_task.cancel()
try:
await concurrency_sampler_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Concurrency sampler shutdown error")
maf_approval_scanner_task = getattr(app.state, "maf_approval_scanner_task", None)
if maf_approval_scanner_task is not None:
maf_approval_scanner_task.cancel()
try:
await maf_approval_scanner_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("MAF approval expiry scanner shutdown error")
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
# thread ??? LangGraph Server ? checkpointer ???lifespan ???
# ``langgraph_runtime()`` ? contextmanager ???????????????
@@ -112,18 +112,25 @@ async def get_health(request: Request) -> HealthResponse:
"""Feature-flag probe. Open read — the frontend reads ``enabled`` to
decide whether to show the MAF entry. Does not require the store.
"""
from app.maf_roundtable.service import get_maf_write_guard_readiness
from deerflow.config import get_app_config
cfg = get_app_config().maf_roundtable
app_config = get_app_config()
cfg = app_config.maf_roundtable
svc = _get_service(request)
store_available = svc is not None and svc.store is not None
readiness = get_maf_write_guard_readiness(app_config=app_config)
return HealthResponse(
enabled=cfg.enabled,
enabled=bool(cfg.enabled and svc is not None and svc.enabled),
store_available=store_available,
default_mode=cfg.default_mode,
default_write_policy=cfg.default_write_policy,
default_max_rounds=cfg.default_max_rounds,
admin_diagnostic_access=cfg.admin_diagnostic_access,
write_guard_enabled=readiness["enabled"],
write_guard_ready=readiness["ready"],
action_secret_configured=readiness["secret_configured"],
write_guard_status=readiness["status"],
)
@@ -359,6 +359,50 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
}
)
# These fields activate or authorize MAF WriteGuard behavior. They may only
# cross the Gateway boundary on the typed, server-created request from
# ``DeerFlowRuntimeClient``. In particular, accepting them from a public
# ``body.config`` would let a caller forge an active MAF run or a post-approval
# passthrough.
_MAF_PRIVATE_CONTEXT_KEYS: frozenset[str] = frozenset(
{
"maf_run_id",
"maf_session_id",
"maf_user_id",
"turn_index",
"write_policy",
"capability_snapshot",
"maf_approved_tool",
"maf_approved_action_id",
}
)
def _is_internal_maf_request(request: Any) -> bool:
state = getattr(request, "state", None)
return bool(getattr(state, "maf_internal", False))
def _merge_internal_maf_context(config: dict[str, Any], context: Mapping[str, Any], request: Any) -> None:
"""Forward server-generated MAF context, never public request fields."""
if not _is_internal_maf_request(request):
for container_name in ("context", "configurable"):
container = config.get(container_name)
if isinstance(container, dict):
for key in _MAF_PRIVATE_CONTEXT_KEYS:
container.pop(key, None)
return
configurable = config.setdefault("configurable", {})
runtime_context = config.setdefault("context", {})
for key in _MAF_PRIVATE_CONTEXT_KEYS:
if key not in context:
continue
if isinstance(configurable, dict):
configurable.setdefault(key, context[key])
if isinstance(runtime_context, dict):
runtime_context.setdefault(key, context[key])
def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None) -> None:
"""Merge whitelisted keys from ``body.context`` into both ``config['configurable']``
@@ -1022,6 +1066,7 @@ async def start_run(
if value is not None and top_level_key not in body_context:
body_context[top_level_key] = value
merge_run_context_overrides(config, body_context or None)
_merge_internal_maf_context(config, body_context, request)
stream_modes = normalize_stream_modes(body.stream_mode)
@@ -54,8 +54,12 @@ from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import os
from datetime import UTC, datetime, timedelta
from math import isfinite
from typing import Any
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
@@ -140,6 +144,90 @@ def tool_needs_approval(
return risk_val >= 3
def derive_target_key(
tool_name: str,
raw_args: Any,
capability_snapshot: dict[str, Any] | None,
agent_id: str | None = None,
) -> str:
"""Return a service-derived, opaque target lease key for a write action.
The model and browser never get a target-key input. Only the frozen,
server-built capability metadata chooses the parameter fields used to
identify a business object. Only stable scalar values are accepted;
structured or otherwise unstable values conservatively collapse to the
unidentified target. The canonical value is protected with a
domain-separated HMAC keyed by ``MAF_ACTION_SECRET_KEY`` before it is
persisted, so a database reader cannot brute-force ordinary object IDs
with a public hash.
An unclassified/missing identifier deliberately shares one conservative
key across writes (rather than guessing that different requests are safe
to parallelize). Such tools are already write/R3 by default and require
normal approval under the default policy.
"""
metadata: dict[str, Any] | None = None
snapshot_tools = (capability_snapshot or {}).get("tools")
if isinstance(snapshot_tools, list):
metadata = next(
(item for item in snapshot_tools if isinstance(item, dict) and item.get("name") == tool_name),
None,
)
if metadata is None and agent_id:
for agent in (capability_snapshot or {}).get("agents") or []:
if not isinstance(agent, dict) or agent.get("agent_id") != agent_id:
continue
for item in agent.get("tools") or []:
if isinstance(item, dict) and item.get("name") == tool_name:
metadata = item
break
if metadata is not None:
break
fields = metadata.get("target_key_fields") if isinstance(metadata, dict) else None
namespace = metadata.get("target_key_namespace") if isinstance(metadata, dict) else None
if not isinstance(fields, list) or not fields or not isinstance(raw_args, dict):
canonical = {"scope": "unidentified"}
else:
valid_fields = [
field for field in fields
if isinstance(field, str) and field != "target_key" and field in raw_args
]
if len(valid_fields) != len(fields) or any(
not _is_stable_scalar(raw_args[field]) for field in valid_fields
):
canonical = {"scope": "unidentified"}
else:
canonical = {
"scope": "identified",
"tool": tool_name,
"namespace": namespace if isinstance(namespace, str) and namespace else "default",
"target": {field: raw_args[field] for field in sorted(valid_fields)},
}
encoded = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
raw_secret = os.getenv("MAF_ACTION_SECRET_KEY", "")
if not raw_secret.strip():
raise MafActionSecretKeyError(
"MAF_ACTION_SECRET_KEY is required before deriving a real-write target key"
)
domain = "maf-target-key:v1"
digest = hmac.new(
raw_secret.encode("utf-8"),
domain.encode("ascii") + b"\x00" + encoded.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"maf-target:v1:{digest}"
def _is_stable_scalar(value: Any) -> bool:
"""Whether a configured target field can safely identify one target."""
if value is None or isinstance(value, (str, bool, int)):
return True
return isinstance(value, float) and isfinite(value)
# ── Encryption ─────────────────────────────────────────────────────────────
@@ -230,6 +318,10 @@ class ActionCoordinator:
self._store = service.store
self._user_id = user_id
def _approval_expires_at(self) -> datetime:
seconds = int(getattr(self._service.config, "approval_timeout_seconds", 3600) or 3600)
return datetime.now(UTC) + timedelta(seconds=seconds)
# ── Post-turn: capture intercepted write tool calls ──────────────────
async def intercept_write_tool_calls(
@@ -268,6 +360,7 @@ class ActionCoordinator:
_side_effect, risk_level = classify_tool(tool_name, capability_snapshot, agent_id)
idempotency_key = f"{run_id}:{agent_id}:{turn_index}:{tool_call_id or tool_name}"
target_key = derive_target_key(tool_name, raw_args, capability_snapshot, agent_id)
# Sanitize args for audit visibility (never expose raw args).
sanitized_args = sanitize(raw_args)
@@ -290,7 +383,7 @@ class ActionCoordinator:
logical_action_id=tool_call_id or None,
idempotency_key=idempotency_key,
risk_level=risk_level,
target_key=None,
target_key=target_key,
turn_index=turn_index,
arguments_sanitized=sanitized_args,
arguments_encrypted=encrypted_args,
@@ -303,6 +396,7 @@ class ActionCoordinator:
},
audit_event_agent_id=agent_id,
audit_event_turn_index=turn_index,
approval_expires_at=self._approval_expires_at(),
)
created.append(action)
@@ -322,7 +416,10 @@ class ActionCoordinator:
approved. This guarantees at-most-once execution under concurrent
``resume()`` / retries / restart.
"""
return await self._store.actions.claim_for_execution(action_id)
lease_seconds = int(getattr(self._service.config, "target_lease_seconds", 900) or 900)
return await self._store.claim_action_for_execution(
action_id, lease_seconds=lease_seconds
)
async def record_result(
self,
@@ -344,7 +441,7 @@ class ActionCoordinator:
is a no-op — it must NOT overwrite the real result.
"""
sanitized_result = sanitize_payload(result) if result else None
await self._store.actions.record_terminal_result(
await self._store.record_action_terminal_result(
action_id,
status=status,
result=sanitized_result,
@@ -66,7 +66,7 @@ def _seat_tools_for_agent(
session_model_name: str | None,
session_reasoning_effort: str | None,
requested_excluded: list[str],
) -> tuple[list[str], list[str], list[dict[str, str]]]:
) -> tuple[list[str], list[str], list[dict[str, Any]]]:
"""Resolve allowed names, effective excluded, and tool metadata for one seat."""
agent_config = load_agent_config(agent_id)
tool_groups = getattr(agent_config, "tool_groups", None) if agent_config else None
@@ -88,10 +88,19 @@ def _seat_tools_for_agent(
effective_excluded = sorted(excluded_set & allowed_set)
effective_allowed = sorted(allowed_set - set(effective_excluded))
tools_meta: list[dict[str, str]] = []
tools_meta: list[dict[str, Any]] = []
maf_tool_metadata = getattr(
getattr(app_config, "maf_roundtable", None), "tool_metadata", None
) or {}
for name in effective_allowed:
extra = _config_extra_for_tool(app_config, name)
tools_meta.append(resolve_tool_metadata(name, config_extra=extra))
tools_meta.append(
resolve_tool_metadata(
name,
config_extra=extra,
maf_metadata=maf_tool_metadata,
)
)
return effective_allowed, effective_excluded, tools_meta
@@ -108,7 +117,7 @@ async def build_trusted_capability_snapshot(
) -> dict[str, Any]:
"""Build the frozen snapshot stored on the session and used by WriteGuard."""
agents_out: list[dict[str, Any]] = []
flat_tools: dict[str, dict[str, str]] = {}
flat_tools: dict[str, dict[str, Any]] = {}
for p in participants:
agent_id = str(p.get("agent_id") or "")
@@ -137,6 +137,16 @@ def make_action_rejected(agent_id: str, turn_index: int, *, action_id: str, appr
return _make("action.rejected", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "decided_by": decided_by})
def make_action_unknown(agent_id: str, turn_index: int, *, action_id: str, reason_code: str) -> dict[str, Any]:
"""Record an inconclusive external outcome without leaking tool arguments."""
return _make("action.unknown", agent_id, turn_index, {"action_id": action_id, "reason_code": reason_code})
def make_action_waiting_target_lease(agent_id: str, turn_index: int, *, action_id: str, tool_name: str) -> dict[str, Any]:
"""A second approved write is serialized behind an active target lease."""
return _make("action.waiting_target_lease", agent_id, turn_index, {"action_id": action_id, "tool_name": tool_name})
# ── workflow checkpoint events ─────────────────────────────────────────────
@@ -78,6 +78,10 @@ class _InternalState:
def __init__(self, principal: ExecutionPrincipal) -> None:
self.user = _PrincipalUser(principal)
self.auth = None
# This marker is set only on the server-created request used by the
# MAF dispatcher. Gateway code uses it to distinguish trusted MAF
# execution context from user-supplied run configuration.
self.maf_internal = True
@dataclass
@@ -193,8 +193,6 @@ class ActionResponse(BaseModel):
agent_id: str | None = None
tool_name: str
logical_action_id: str | None = None
idempotency_key: str
target_key: str | None = None
risk_level: str
status: str
arguments: dict[str, Any] | None = None
@@ -255,3 +253,7 @@ class HealthResponse(BaseModel):
default_write_policy: str
default_max_rounds: int
admin_diagnostic_access: bool
write_guard_enabled: bool
write_guard_ready: bool
action_secret_configured: bool
write_guard_status: str
@@ -79,6 +79,56 @@ class MafConfigurationError(RuntimeError):
"""
def get_maf_write_guard_readiness(*, app_config: Any | None = None) -> dict[str, Any]:
"""Return non-sensitive WriteGuard readiness information for MAF health.
A missing secret intentionally does not disable read-only MAF work:
WriteGuard fails closed before any external write can execute. ``ready``
is therefore the signal that the complete approval + encryption path is
usable, while ``enabled`` describes whether the middleware is mounted.
"""
import os
from deerflow.config.write_guard_config import get_write_guard_config
from deerflow.reflection import resolve_variable
wg = getattr(app_config, "write_guard", None) if app_config is not None else None
if wg is None:
wg = get_write_guard_config()
provider = getattr(wg, "provider", None)
provider_configured = bool(
provider
and getattr(provider, "policy_use", "")
and getattr(provider, "interceptor_use", "")
)
bridge_resolvable = False
if provider_configured:
try:
resolve_variable(provider.policy_use)
resolve_variable(provider.interceptor_use)
bridge_resolvable = True
except Exception: # noqa: BLE001 - health must remain safe and non-fatal
bridge_resolvable = False
secret_configured = bool((os.getenv("MAF_ACTION_SECRET_KEY") or "").strip())
if not getattr(wg, "enabled", False):
status = "disabled"
elif not provider_configured or not bridge_resolvable:
status = "provider_unavailable"
elif not secret_configured:
status = "missing_secret"
else:
status = "ready"
return {
"enabled": bool(getattr(wg, "enabled", False)),
"ready": status == "ready",
"secret_configured": secret_configured,
"status": status,
}
def validate_maf_prerequisites() -> None:
"""Fail loudly if MAF is enabled without its full safety stack in place.
@@ -86,14 +136,13 @@ def validate_maf_prerequisites() -> None:
1. ``write_guard.enabled`` is true (the approval guard is mounted);
2. ``write_guard.provider`` is complete (``policy_use`` + ``interceptor_use``);
3. the bridge factories actually resolve (the app-layer guard is importable);
4. ``MAF_ACTION_SECRET_KEY`` is set (action params are encrypted with it).
4. secret readiness is reported separately; a missing secret must not
disable read-only MAF work, while writes fail closed before execution.
Raises ``MafConfigurationError`` on the first unmet condition. Callers on
the startup path treat that as "force the feature off"; direct unit-test
construction of :class:`MafRoundtableService` does NOT call this.
"""
import os
from deerflow.config.write_guard_config import get_write_guard_config
from deerflow.reflection import resolve_variable
@@ -116,10 +165,6 @@ def validate_maf_prerequisites() -> None:
raise MafConfigurationError(
f"write_guard bridge factories are not resolvable: {exc}"
) from exc
if not (os.getenv("MAF_ACTION_SECRET_KEY") or "").strip():
raise MafConfigurationError(
"maf_roundtable.enabled=true requires the MAF_ACTION_SECRET_KEY env var"
)
# Session statuses that may be edited (config changes).
@@ -150,24 +195,10 @@ class MafRoundtableService:
# defaults when no config is injected (keeps unit tests lightweight).
self._config = config or MafRoundtableConfig()
# Startup validation: if the MAF feature is enabled, the
# ``MAF_ACTION_SECRET_KEY`` env var MUST be set — external-write
# action params are encrypted with it and there is no default key.
# A missing key forces the feature off (write paths then return 503)
# rather than running with a publicly-known key. This never aborts
# Gateway startup (the feature is experimental / opt-in).
if enabled:
import os
key = os.getenv("MAF_ACTION_SECRET_KEY", "")
if not key or not key.strip():
logger.error(
"[maf-roundtable] MAF_ACTION_SECRET_KEY is not set — "
"disabling the feature (write paths will return 503). "
"Set a 32+ char random string before enabling "
"maf_roundtable.enabled."
)
enabled = False
# A missing MAF_ACTION_SECRET_KEY does not disable read-only MAF
# operation. The action coordinator raises before creating or
# executing an external-write action, so writes fail closed until the
# deployment injects the secret through the environment.
self._enabled = enabled
@property
@@ -539,10 +570,17 @@ class MafRoundtableService:
async def list_actions(
self, *, run_id: str, user_id: str | None, is_admin: bool = False
) -> list[dict[str, Any]]:
from deerflow.persistence.maf_roundtable.actions import action_to_public_dict
run = await self.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
return await self._store.actions.list_actions(run_id=run["id"])
return [
action_to_public_dict(action)
for action in await self._store.actions.list_actions(run_id=run["id"])
]
async def get_action(self, *, action_id: str, user_id: str | None, is_admin: bool = False) -> dict[str, Any]:
from deerflow.persistence.maf_roundtable.actions import action_to_public_dict
action = await self._store.actions.get_action(action_id)
if action is None:
raise MafNotFoundError("action", action_id)
@@ -550,7 +588,7 @@ class MafRoundtableService:
session = await self._store.sessions.get_session(action["session_id"], user_id=scope)
if session is None:
raise MafNotFoundError("action", action_id)
return action
return action_to_public_dict(action)
# ── approvals ──────────────────────────────────────────────────────────
@@ -558,6 +596,8 @@ class MafRoundtableService:
self, *, run_id: str, user_id: str | None, is_admin: bool = False
) -> list[dict[str, Any]]:
run = await self.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
# Reads are observational. Expiry is performed by the dedicated
# scanner; decision/resume paths retain the fail-closed pre-CAS check.
return await self._store.approvals.list_approvals(run_id=run["id"])
async def decide_approval(
@@ -579,6 +619,11 @@ class MafRoundtableService:
if is_admin and await self._store.sessions.get_session(approval["session_id"], user_id=None) is not None:
raise MafForbiddenError("approval", approval_id)
raise MafNotFoundError("approval", approval_id)
# The timeout transition is server-owned and happens before the
# decision CAS, so an expired approval can never be revived by a late
# browser request.
if approval.get("run_id"):
await self._store.expire_due_approvals(run_id=approval["run_id"])
try:
updated = await self._store.approvals.decide(approval_id, decision=decision, decided_by=user_id)
except ValueError as exc:
@@ -113,6 +113,15 @@ class ToolExecutor:
)
return {"status": "failed", "error": f"action state machine error: {exc}"}
if claim == "target_busy":
logger.info("ToolExecutor: target lease is held for action %s", action_id)
return {"status": "waiting_target_lease", "error": "target lease is held by another action"}
if claim == "target_reconciliation_required":
logger.warning("ToolExecutor: expired target lease requires reconciliation for %s", action_id)
return {
"status": "unknown",
"error": "target lease expired during another action; reconciliation required",
}
if claim != "claimed":
existing = await self._coordinator._store.actions.get_action(action_id) # noqa: SLF001
if claim == "in_progress":
@@ -128,9 +137,8 @@ class ToolExecutor:
return {"status": claim, "action": existing}
if not approved_tool:
await self._coordinator.record_result(
action_id, status="failed",
result={"error": "no approved tool name — cannot execute"},
await self._record_result_or_log(
action_id, status="failed", result={"error": "no approved tool name — cannot execute"}
)
return {"status": "failed", "error": "no approved tool name"}
@@ -147,11 +155,14 @@ class ToolExecutor:
"ToolExecutor: action %s params unavailable (%s): %s",
action_id, exc.reason, safe_msg,
)
await self._coordinator.record_result(
persisted = await self._record_result_or_log(
action_id, status="failed",
result={"error": f"action secret unavailable: {exc.reason}"},
)
return {"status": "failed", "error": safe_msg}
return {
"status": "failed" if persisted else "unknown",
"error": safe_msg if persisted else "action result persistence failed",
}
# 3. Read the stored action record (authoritative turn_index + the
# run/session/agent/tool the harness will verify against).
@@ -191,34 +202,83 @@ class ToolExecutor:
"ToolExecutor: direct tool call '%s' raised: %s",
approved_tool, safe_msg,
)
await self._coordinator.record_result(
action_id, status="failed", result={"error": safe_msg},
# An exception can arrive after the remote request was accepted
# (network disconnect / timeout), so fail closed as ``unknown``.
persisted = await self._record_result_or_log(
action_id, status="unknown", result={"error": "tool_transport_exception"}
)
return {"status": "failed", "error": safe_msg}
return {
"status": "unknown",
"error": safe_msg if persisted else "action result persistence failed",
}
# 5. Record terminal result (state-machine constrained: only
# running → terminal; a lost race is a no-op).
outcome_status = outcome.get("status")
if outcome_status == "succeeded":
await self._coordinator.record_result(
action_id, status="succeeded",
result={"tool_result": outcome.get("result")},
tool_result = outcome.get("result")
external_object_id = (
str(tool_result.get("external_object_id"))
if isinstance(tool_result, dict)
and tool_result.get("external_object_id")
else None
)
return {"status": "succeeded", "tool_result": outcome.get("result")}
persisted = await self._record_result_or_log(
action_id, status="succeeded",
result={"tool_result": tool_result},
external_object_id=external_object_id,
)
if not persisted:
# The external side effect succeeded but its local terminal
# record did not. Never retry; the retained running lease is
# later reconciled to unknown by recovery.
return {"status": "unknown", "error": "action result persistence failed"}
return {"status": "succeeded", "tool_result": tool_result}
elif outcome_status == "unknown":
# Inconclusive external result (timeout / disconnect / gateway):
# record ``unknown`` — never auto-retry, a human must reconcile.
error = outcome.get("error", "inconclusive external result")
await self._coordinator.record_result(
persisted = await self._record_result_or_log(
action_id, status="unknown", result={"error": error},
)
return {"status": "unknown", "error": error}
return {"status": "unknown", "error": error if persisted else "action result persistence failed"}
else:
error = outcome.get("error", "tool execution failed")
await self._coordinator.record_result(
persisted = await self._record_result_or_log(
action_id, status="failed", result={"error": error},
)
return {"status": "failed", "error": error}
return {
"status": "failed" if persisted else "unknown",
"error": error if persisted else "action result persistence failed",
}
async def _record_result_or_log(
self,
action_id: str,
*,
status: str,
result: dict[str, Any],
external_object_id: str | None = None,
) -> bool:
"""Persist a terminal outcome without opening a retry path after I/O.
If this write fails after a real tool reports success, returning an
``unknown`` outcome lets the parent run stop for reconciliation while
the still-held lease prevents a replay of the external call.
"""
try:
await self._coordinator.record_result(
action_id,
status=status,
result=result,
external_object_id=external_object_id,
)
except Exception: # noqa: BLE001
logger.exception(
"ToolExecutor: could not persist terminal result for action %s", action_id
)
return False
return True
async def record_unknown(self, action_id: str, *, reason: str) -> None:
"""Record an ``unknown`` outcome (network timeout / inconclusive).
@@ -230,6 +290,6 @@ class ToolExecutor:
"ToolExecutor: action %s outcome unknown — %s (no auto-retry)",
action_id, reason,
)
await self._coordinator.record_result(
action_id, status="unknown", result={"reason": reason},
await self._record_result_or_log(
action_id, status="unknown", result={"reason": reason}
)
@@ -316,6 +316,10 @@ class WorkflowRunner:
logger.warning("Cannot resume run %s: no pending_approvals in checkpoint", run_id)
return run
# Expire due approvals before inspecting the checkpoint. This is a
# fail-closed server transition; a late HTTP approval cannot revive it.
await self._service.store.expire_due_approvals(run_id=run_id)
# Check if any approval is still pending — if so, don't resume yet.
any_pending = False
for pa in state.pending_approvals:
@@ -365,14 +369,15 @@ class WorkflowRunner:
from app.maf_roundtable.event_normalizer import make_action_rejected
# Conditional transition (waiting_approval → rejected) — never
# overwrite a terminal state via a generic update.
await self._service.store.actions.mark_rejected(action_id)
await self._adapter._emit_critical(run_id, make_action_rejected(
pa.get("agent_id", ""),
state.current_round,
action_id=action_id,
approval_id=approval_id,
decided_by=approval.get("decided_by", ""),
))
changed = await self._service.store.actions.mark_rejected(action_id)
if changed:
await self._adapter._emit_critical(run_id, make_action_rejected(
pa.get("agent_id", ""),
state.current_round,
action_id=action_id,
approval_id=approval_id,
decided_by=approval.get("decided_by", ""),
))
action_outcomes.append({"action_id": action_id, "status": "rejected"})
# Phase-3 P1-3: move the ledger row out of pending into the
# failed/unknown partition as rejected.
@@ -478,6 +483,37 @@ class WorkflowRunner:
# P0-3: determine parent run status from action outcomes.
statuses = {o.get("status") for o in action_outcomes}
# A target held by another approved write remains pending for an
# explicit later resume. It is never retried automatically.
if "waiting_target_lease" in statuses:
from app.maf_roundtable.event_normalizer import make_action_waiting_target_lease
state.status = "waiting_approval"
for outcome in action_outcomes:
if outcome.get("status") == "waiting_target_lease":
await self._adapter._emit_critical(
run_id,
make_action_waiting_target_lease(
next(
(str(pa.get("agent_id") or "") for pa in state.pending_approvals if pa.get("action_id") == outcome.get("action_id")),
"",
),
state.current_round,
action_id=str(outcome.get("action_id") or ""),
tool_name=str(outcome.get("tool_name") or ""),
),
)
await self._service.store.runs.update_run(run_id, {
"status": "waiting_approval",
"maf_checkpoint": state.to_dict(),
})
await self._service.store.sessions.update_session(
session_id,
user_id=self._user_id,
fields={"status": "waiting_approval", "fact_ledger": state.fact_ledger},
)
return await self._service.store.runs.get_run(run_id)
# While any action is still in_progress (another executor running it),
# the parent run stays running — never write finished_at, never finalize.
if "in_progress" in statuses:
@@ -572,6 +608,44 @@ class WorkflowRunner:
if session is None:
return run
expired_actions = await self._service.store.recover_expired_action_leases(run_id=run_id)
if expired_actions:
from app.maf_roundtable.event_normalizer import make_action_unknown
expired_ids = {action["id"] for action in expired_actions}
for action in expired_actions:
record_action_transition(
state.fact_ledger,
action_id=action["id"],
tool_name=action.get("tool_name", ""),
status="unknown",
agent_id=action.get("agent_id", ""),
)
await self._adapter._emit_critical(
run_id,
make_action_unknown(
action.get("agent_id", ""),
int(action.get("turn_index") or state.current_round),
action_id=action["id"],
reason_code="target_lease_expired_during_execution",
),
)
state.pending_approvals = [
item for item in state.pending_approvals
if item.get("action_id") not in expired_ids
]
state.status = "waiting_input"
await self._service.store.runs.update_run(run_id, {
"status": "waiting_input",
"maf_checkpoint": state.to_dict(),
})
await self._service.store.sessions.update_session(
state.session_id,
user_id=self._user_id,
fields={"status": "waiting_input", "fact_ledger": state.fact_ledger},
)
return await self._service.store.runs.get_run(run_id)
# Ensure threads still bound.
participants = await self._service.store.participants.list_participants(state.session_id)
thread_map = await self._adapter.ensure_threads(state.session_id, run_id, participants)
+15 -2
View File
@@ -558,13 +558,23 @@ task_deeplink:
# - 前端入口隐藏
# 详见 frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md
maf_roundtable:
enabled: false # 部署级特性开关;首期仅管理员/白名单可见
enabled: true # 启用独立 MAF 会商页面;不影响旧 roundtable 功能
default_mode: structured # structured(固定流程)| group_chat(动态群聊)
default_write_policy: confirm_high_risk # auto | confirm_high_risk | confirm_all_writes
default_max_rounds: 8 # 群聊模式轮次硬上限
admin_diagnostic_access: true # 管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写)
seat_timeout_seconds: 600 # 结构化流程单席位超时(秒)
approval_scan_interval_seconds: 10 # 审批主动过期扫描间隔(秒)
schema_repair_enabled: true # 席位 JSON 校验失败时允许一次定向修复
# 必须使用 MCP 实际发现到的运行时 tool name;不要改成 server 名或前缀猜测。
# 当前部署仅启用检索 MCP,因此这里只登记两个只读工具。
tool_metadata:
weknora-retrieval_weknora_list_knowledge_bases:
side_effect: read
risk_level: R0
weknora-retrieval_weknora_search:
side_effect: read
risk_level: R0
# ── WriteGuard 中间件配置 ────────────────────────────────────────────────────
# 与 maf_roundtable 联动:MAF 会商运行时,WriteGuard 中间件拦截外部写工具调用,
@@ -573,7 +583,10 @@ maf_roundtable:
# policy_use / interceptor_use 指向 app.maf_roundtable 的具体实现(通过
# resolve_variable 解析),保证 harness 层不导入 app.*。
write_guard:
enabled: false
# MAF-only WriteGuard is mounted in production. Legacy runs without
# maf_run_id pass through unchanged; a missing MAF_ACTION_SECRET_KEY makes
# external writes fail closed while read-only tools and skills remain usable.
enabled: true
provider:
policy_use: app.maf_roundtable.write_guard_bridge:make_policy
interceptor_use: app.maf_roundtable.write_guard_bridge:make_interceptor
@@ -17,15 +17,74 @@
from __future__ import annotations
import re
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# ── shared enums (config + API schemas) ───────────────────────────────────
MafMode = Literal["structured", "group_chat"]
MafWritePolicy = Literal["auto", "confirm_high_risk", "confirm_all_writes"]
MafParticipantRole = Literal["manager", "seat"]
MafToolSideEffect = Literal["read", "skill", "write"]
MafRiskLevel = Literal["R0", "R1", "R2", "R3", "R4"]
class MafToolMetadata(BaseModel):
"""Deployment-owned side-effect classification for one actual tool name.
This lives in the MAF configuration rather than in the browser request or
model output. The key in ``MafRoundtableConfig.tool_metadata`` must be the
exact runtime tool name (including the MCP server prefix when applicable).
"""
side_effect: MafToolSideEffect = Field(
description="Tool side effect class used by the MAF WriteGuard",
)
risk_level: MafRiskLevel = Field(
description="External action risk level used by MAF approval policy",
)
target_key_fields: list[str] = Field(
default_factory=list,
description=(
"仅服务端配置的、用于派生目标租约键的顶层参数字段。"
"参数值只参与稳定哈希,绝不直接写入 target_key。"
),
)
target_key_namespace: str = Field(
default="default",
min_length=1,
max_length=96,
description="服务端目标键命名空间;用于隔离不同租户/业务域。",
)
@field_validator("target_key_fields")
@classmethod
def _validate_target_key_fields(cls, fields: list[str]) -> list[str]:
"""Only allow explicit top-level server-owned argument names.
In particular, a model/client supplied ``target_key`` is never a
valid input. Nested paths are deliberately unsupported: a production
adapter must register the exact stable business-id fields it owns.
"""
valid = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
normalized: list[str] = []
for field in fields:
if not isinstance(field, str) or not valid.fullmatch(field):
raise ValueError("target_key_fields must be simple top-level argument names")
if field == "target_key":
raise ValueError("target_key must not be supplied as a target key input")
if field not in normalized:
normalized.append(field)
return normalized
@field_validator("target_key_namespace")
@classmethod
def _validate_target_key_namespace(cls, namespace: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,96}", namespace):
raise ValueError("target_key_namespace contains unsupported characters")
return namespace
class MafRoundtableConfig(BaseModel):
@@ -74,11 +133,39 @@ class MafRoundtableConfig(BaseModel):
default=True,
description="席位输出未通过 SeatTurnOutput 校验时是否允许一次定向格式修复",
)
# Server-trusted metadata keyed by the exact runtime tool name. Do not
# infer this from MCP server names or prefixes: the actual discovered name
# is what the capability snapshot and runtime WriteGuard see.
tool_metadata: dict[str, MafToolMetadata] = Field(
default_factory=dict,
description="MAF 专用工具副作用/风险元数据,键必须是实际运行时 tool name",
)
approval_timeout_seconds: int = Field(
default=3600,
ge=60,
le=604800,
description="外部写入审批有效期(秒);到期后自动拒绝,绝不执行。",
)
approval_scan_interval_seconds: int = Field(
default=10,
ge=1,
le=3600,
description="MAF approval expiry scan interval in seconds",
)
target_lease_seconds: int = Field(
default=900,
ge=30,
le=7200,
description="外部业务目标写入租约有效期(秒);过期运行中的动作转 unknown。",
)
__all__ = [
"MafMode",
"MafParticipantRole",
"MafRiskLevel",
"MafRoundtableConfig",
"MafToolMetadata",
"MafToolSideEffect",
"MafWritePolicy",
]
@@ -1,9 +1,9 @@
"""Microsoft Agent Framework (MAF) roundtable experiment persistence.
Six independent tables — **no foreign keys to the legacy ``roundtable_*``
Nine independent tables — **no foreign keys to the legacy ``roundtable_*``
tables** — so the existing multi-agent roundtable keeps its current behavior,
schema, and recovery semantics untouched. The MAF experiment is fully
isolated: it has its own session / participant / run / event / action /
isolated: it has its own session / participant / run / event / action / lease /
approval tables.
Boundary: this package lives in the harness layer (``deerflow.*``) and never
@@ -18,7 +18,8 @@ Tables (see the implementation plan §8):
- ``maf_roundtable_runs`` — one execution run of a session
- ``maf_roundtable_events`` — monotonic per-run event stream (``(run_id, seq)`` unique)
- ``maf_roundtable_actions`` — external write / tool actions (idempotency-keyed)
- ``maf_roundtable_approvals`` — pending human-approval requests
- ``maf_roundtable_action_leases`` — active target-write leases
- ``maf_roundtable_approvals`` — pending human-approval requests
- ``maf_roundtable_seat_runs`` — MAF↔DeerFlow run mapping (survives restart)
- ``maf_roundtable_action_secrets`` — encrypted actual execution params (server-only)
"""
@@ -32,6 +33,7 @@ from deerflow.persistence.maf_roundtable.actions import MafActionRepository
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
from deerflow.persistence.maf_roundtable.events import MafEventRepository
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionLeaseRow,
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
@@ -54,6 +56,7 @@ __all__ = [
"MafEventRepository",
"MafParticipantRepository",
"MafRoundtableActionRow",
"MafRoundtableActionLeaseRow",
"MafRoundtableActionSecretRow",
"MafRoundtableApprovalRow",
"MafRoundtableEventRow",
@@ -10,6 +10,7 @@ store does not re-redact.
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
@@ -50,6 +51,19 @@ def action_to_dict(row: MafRoundtableActionRow) -> dict[str, Any]:
}
def action_to_public_dict(action: Mapping[str, Any]) -> dict[str, Any]:
"""Return the allow-listed action representation exposed by MAF HTTP APIs.
Target lease keys and idempotency keys are persistence/coordinator
details. They must never cross the public boundary.
"""
public = dict(action)
public.pop("target_key", None)
public.pop("idempotency_key", None)
return public
# Back-compat alias for internal callers that used the old private name.
_to_dict = action_to_dict
@@ -1,9 +1,9 @@
"""ORM models for the MAF (Microsoft Agent Framework) roundtable experiment.
Six independent tables — **no foreign keys to the legacy ``roundtable_*``
Nine independent tables — **no foreign keys to the legacy ``roundtable_*``
tables** (``roundtable_jobs`` / ``roundtable_drafts`` /
``roundtable_task_drafts``). The MAF experiment is fully isolated: it has its
own session / participant / run / event / action / approval tables, keyed by
own session / participant / run / event / action / lease / approval tables, keyed by
its own surrogate ids, so the legacy multi-agent roundtable keeps its current
behavior, schema, and recovery semantics untouched.
@@ -14,6 +14,7 @@ Tables (see the implementation plan §8):
- ``maf_roundtable_runs`` — one execution run of a session
- ``maf_roundtable_events`` — monotonic per-run event stream (``(run_id, seq)`` unique)
- ``maf_roundtable_actions`` — external write / tool actions (idempotency-keyed)
- ``maf_roundtable_action_leases`` — active target-write leases (one per target key)
- ``maf_roundtable_approvals`` — pending human-approval requests
User isolation: ``maf_roundtable_sessions.user_id`` is the owner; all child
@@ -263,7 +264,42 @@ class MafRoundtableActionRow(Base):
)
# ── 6. approvals ──────────────────────────────────────────────────────────
# ── 6. target leases ──────────────────────────────────────────────────────
class MafRoundtableActionLeaseRow(Base):
"""One active, short-lived lease for a server-derived external target key.
The key is opaque (a stable SHA-256 digest) and is never sourced from a
browser/model field. ``target_key`` is the primary key, which makes the
database — not an in-process mutex — arbitrate concurrent writes across
workers and restarts. A lease belongs to exactly one action and is removed
with a known terminal result; an ``unknown`` result deliberately retains a
manual-reconciliation barrier.
"""
__tablename__ = "maf_roundtable_action_leases"
__table_args__ = (
UniqueConstraint("action_id", name="uq_maf_action_leases_action"),
)
target_key: Mapped[str] = mapped_column(String(255), primary_key=True)
action_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("maf_roundtable_actions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
lease_expires_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 7. approvals ──────────────────────────────────────────────────────────
class MafRoundtableApprovalRow(Base):
@@ -297,7 +333,7 @@ class MafRoundtableApprovalRow(Base):
)
# ── 7. seat runs (Phase 2: MAF↔DeerFlow run mapping) ─────────────────────
# ── 8. seat runs (Phase 2: MAF↔DeerFlow run mapping) ─────────────────────
class MafRoundtableSeatRunRow(Base):
@@ -370,7 +406,7 @@ class MafRoundtableSeatRunRow(Base):
)
# ── 8. action secrets (encrypted actual execution params, server-only) ────
# ── 9. action secrets (encrypted actual execution params, server-only) ────
class MafRoundtableActionSecretRow(Base):
@@ -403,6 +439,7 @@ class MafRoundtableActionSecretRow(Base):
__all__ = [
"MafRoundtableActionRow",
"MafRoundtableActionLeaseRow",
"MafRoundtableActionSecretRow",
"MafRoundtableApprovalRow",
"MafRoundtableEventRow",
@@ -14,19 +14,24 @@ another's transactions.
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from sqlalchemy import func, select, update
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.maf_roundtable.action_secrets import MafActionSecretRepository
from deerflow.persistence.maf_roundtable.actions import MafActionRepository, action_to_dict
from deerflow.persistence.maf_roundtable.actions import (
MafActionRepository,
MafActionStateMachineError,
action_to_dict,
)
from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository
from deerflow.persistence.maf_roundtable.events import MafEventRepository
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionLeaseRow,
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
@@ -315,6 +320,7 @@ class MafRoundtableStore:
approval_payload: dict[str, Any] | None,
audit_event_agent_id: str,
audit_event_turn_index: int,
approval_expires_at: datetime | None = None,
) -> dict[str, Any]:
"""Create an action + secret + approval + audit event in ONE transaction.
@@ -432,6 +438,7 @@ class MafRoundtableStore:
status="pending",
summary=approval_summary,
payload_redacted_json=approval_payload,
expires_at=approval_expires_at,
created_at=now,
updated_at=now,
))
@@ -482,10 +489,308 @@ class MafRoundtableStore:
return result
raise RuntimeError(
f"create_pending_action_bundle failed after {_MAX_BUNDLE_RETRIES} attempts "
f"for idempotency_key={idempotency_key}"
f"create_pending_action_bundle failed after {_MAX_BUNDLE_RETRIES} attempts"
) from last_error
async def claim_action_for_execution(self, action_id: str, *, lease_seconds: int) -> str:
"""Atomically claim an approved action and its external-target lease.
The action CAS and the target lease live in one transaction. A second
approved action for the same target receives ``target_busy`` and is not
executed. An expired lease owned by a still-running action is treated
as an ambiguous external outcome: the old action becomes ``unknown``
and the caller receives ``target_reconciliation_required`` rather than
being allowed to write over it.
"""
# Pre-Phase-5 actions have no server-derived target key. Preserve the
# original conditional UPDATE CAS for them; replacing it with a
# read-then-write transaction would weaken their existing cross-worker
# at-most-once guarantee on SQLite (where FOR UPDATE is a no-op).
existing = await self.actions.get_action(action_id)
if existing is None:
raise MafActionStateMachineError(f"action {action_id} not found during claim")
if not existing.get("target_key"):
return await self.actions.claim_for_execution(action_id)
now = _now()
expiry = now + timedelta(seconds=max(1, lease_seconds))
last_error: Exception | None = None
for _attempt in range(4):
try:
async with self._sf() as db:
async with db.begin():
action = (
await db.execute(
select(MafRoundtableActionRow)
.where(MafRoundtableActionRow.id == action_id)
.with_for_update()
)
).scalars().first()
if action is None:
raise MafActionStateMachineError(
f"action {action_id} not found during claim"
)
if action.status == "running":
return "in_progress"
if action.status in ("succeeded", "failed", "unknown", "rejected"):
return str(action.status)
if action.status != "approved":
raise MafActionStateMachineError(
f"action {action_id} cannot be claimed from state '{action.status}'"
)
# Legacy rows can be executed at most once with the old
# action CAS. All new actions receive a target key from
# ActionCoordinator; never manufacture one from client
# input here.
if action.target_key:
lease = (
await db.execute(
select(MafRoundtableActionLeaseRow)
.where(MafRoundtableActionLeaseRow.target_key == action.target_key)
.with_for_update()
)
).scalars().first()
if lease is not None and lease.action_id != action.id:
owner = (
await db.execute(
select(MafRoundtableActionRow)
.where(MafRoundtableActionRow.id == lease.action_id)
.with_for_update()
)
).scalars().first()
if owner is not None and owner.status == "unknown":
# Unknown means the remote side effect may
# have happened. Keep the lease as a
# durable manual-reconciliation barrier;
# another write to the same object must not
# silently leapfrog it.
return "target_reconciliation_required"
if lease.lease_expires_at > now:
return "target_busy"
if owner is not None and owner.status == "running":
owner.status = "unknown"
owner.result_redacted_json = {
"error_code": "target_lease_expired_during_execution"
}
owner.finished_at = now
owner.updated_at = now
# Retain the lease as an explicit manual
# reconciliation barrier. It is released
# only by a future audited reconciliation
# flow, never by automatic replay.
lease.lease_expires_at = now
lease.updated_at = now
return "target_reconciliation_required"
await db.delete(lease)
elif lease is not None and lease.action_id == action.id:
# This can only occur after a failed local
# terminal-result write. Do not treat it as a
# new execution opportunity.
return "in_progress"
db.add(MafRoundtableActionLeaseRow(
target_key=action.target_key,
action_id=action.id,
lease_expires_at=expiry,
created_at=now,
updated_at=now,
))
action.status = "running"
action.attempt_count = int(action.attempt_count or 0) + 1
action.started_at = now
action.updated_at = now
await db.flush()
return "claimed"
except IntegrityError as exc:
# The target-key primary key is the cross-worker arbitration
# point. On a collision retry and observe the winning lease.
last_error = exc
continue
raise RuntimeError(f"could not claim action lease for {action_id}") from last_error
async def record_action_terminal_result(
self,
action_id: str,
*,
status: str,
result: dict[str, Any] | None = None,
external_object_id: str | None = None,
) -> dict[str, Any] | None:
"""Persist a terminal result and release a known target lease atomically.
``unknown`` is intentionally different: its target lease remains as a
manual-reconciliation barrier because the remote side effect may have
completed even though the local process cannot prove it.
"""
if status not in ("succeeded", "failed", "unknown"):
raise ValueError(f"invalid action result status: {status}")
now = _now()
async with self._sf() as db:
async with db.begin():
action = (
await db.execute(
select(MafRoundtableActionRow)
.where(MafRoundtableActionRow.id == action_id)
.with_for_update()
)
).scalars().first()
if action is None or action.status != "running":
return None
action.status = status
action.result_redacted_json = result
action.external_object_id = external_object_id
action.finished_at = now
action.updated_at = now
if status != "unknown":
# A known success or known failure has a deterministic
# outcome, so the next approved action may acquire the
# target. ``unknown`` deliberately retains the barrier.
await db.execute(
delete(MafRoundtableActionLeaseRow).where(
MafRoundtableActionLeaseRow.action_id == action_id
)
)
await db.flush()
return action_to_dict(action)
async def expire_due_approvals(
self, *, run_id: str | None = None, now: datetime | None = None
) -> list[dict[str, Any]]:
"""Expire due approvals and reject unexecuted actions with audit events.
Expiry is deliberately a fail-closed transition: only an action still
waiting for approval becomes rejected; a later/running action is never
overwritten. The approval, action and critical event are committed in
one transaction so polling sees a coherent terminal decision.
"""
current = now or _now()
last_error: Exception | None = None
for _attempt in range(4):
try:
async with self._sf() as db:
async with db.begin():
stmt = select(MafRoundtableApprovalRow).where(
MafRoundtableApprovalRow.status == "pending",
MafRoundtableApprovalRow.expires_at.is_not(None),
MafRoundtableApprovalRow.expires_at <= current,
)
if run_id is not None:
stmt = stmt.where(MafRoundtableApprovalRow.run_id == run_id)
approvals = (await db.execute(stmt.with_for_update())).scalars().all()
if not approvals:
return []
max_seq_by_run: dict[str, int] = {}
expired: list[dict[str, Any]] = []
for approval in approvals:
approval.status = "expired"
approval.decided_by = "system:approval_timeout"
approval.decided_at = current
approval.updated_at = current
action = None
if approval.action_id:
action = (
await db.execute(
select(MafRoundtableActionRow)
.where(MafRoundtableActionRow.id == approval.action_id)
.with_for_update()
)
).scalars().first()
if action is not None and action.status == "waiting_approval":
action.status = "rejected"
action.finished_at = current
action.updated_at = current
if approval.run_id and action is not None:
seq = max_seq_by_run.get(approval.run_id)
if seq is None:
seq = int((
await db.execute(
select(func.max(MafRoundtableEventRow.seq)).where(
MafRoundtableEventRow.run_id == approval.run_id
)
)
).scalar_one() or 0)
seq += 1
max_seq_by_run[approval.run_id] = seq
db.add(MafRoundtableEventRow(
id=uuid4().hex,
session_id=approval.session_id,
run_id=approval.run_id,
seq=seq,
event_type="action.rejected",
agent_id=approval.requested_by_agent_id,
turn_index=action.turn_index,
source_event_id=f"approval-expired:{approval.id}",
payload_json={
"action_id": action.id,
"approval_id": approval.id,
"reason": "approval_expired",
},
created_at=current,
))
expired.append({
"id": approval.id,
"run_id": approval.run_id,
"action_id": approval.action_id,
})
await db.flush()
return expired
except IntegrityError as exc:
last_error = exc
continue
raise RuntimeError("could not persist expired MAF approvals") from last_error
async def recover_expired_action_leases(
self, *, run_id: str, now: datetime | None = None
) -> list[dict[str, Any]]:
"""Mark stale in-flight writes ``unknown`` during restart recovery.
No tool is re-run. A lease that survived a process death cannot prove
whether the remote side effect completed, so the only safe recovery is
an explicit human reconciliation path.
"""
current = now or _now()
async with self._sf() as db:
async with db.begin():
rows = (
await db.execute(
select(MafRoundtableActionLeaseRow, MafRoundtableActionRow)
.join(
MafRoundtableActionRow,
MafRoundtableActionLeaseRow.action_id == MafRoundtableActionRow.id,
)
.where(
MafRoundtableActionRow.run_id == run_id,
MafRoundtableActionLeaseRow.lease_expires_at <= current,
)
.with_for_update()
)
).all()
recovered: list[dict[str, Any]] = []
for lease, action in rows:
if action.status == "running":
action.status = "unknown"
action.result_redacted_json = {
"error_code": "target_lease_expired_during_execution"
}
action.finished_at = current
action.updated_at = current
recovered.append(action_to_dict(action))
# Keep a durable block for manual reconciliation;
# automatically deleting it would allow a later write
# to overtake an ambiguous external side effect.
lease.lease_expires_at = current
lease.updated_at = current
elif action.status != "unknown":
await db.delete(lease)
await db.flush()
return recovered
__all__ = [
"MafActionRepository",
@@ -37,6 +37,7 @@ passing all secret-derived params and the stored action record as plain data.
from __future__ import annotations
import json
import logging
import uuid
from typing import Any, TypedDict
@@ -477,6 +478,15 @@ def _interpret_tool_node_result(result: Any, tool_name: str) -> dict[str, Any]:
if _is_inconclusive_text(text):
return {"status": "unknown", "error": _ERROR_CODE_INCONCLUSIVE}
return {"status": "failed", "error": _ERROR_CODE_FAILED}
# LangChain may serialize a structured tool return into a JSON string
# inside ToolMessage.content. Recover only the safe, allow-listed
# fields so an external object id can be audited without exposing
# arbitrary tool output.
if isinstance(content, str):
try:
content = json.loads(content)
except (TypeError, ValueError):
pass
return {"status": "succeeded", "result": _safe_success_payload(content)}
return {"status": "succeeded", "result": {"executed": True}}
@@ -0,0 +1,77 @@
"""Test-only real external-write tool for MAF approval E2E tests.
This module is deliberately not registered in ``config.yaml``. Tests add the
tool to an isolated ``AppConfig`` and set ``MAF_E2E_OUTBOX_DIR`` to a temporary
directory. It is a real LangChain ``StructuredTool`` and therefore exercises
the same LangGraph ``ToolNode`` path as a configured production tool without
touching a business system.
"""
from __future__ import annotations
import json
import os
import re
import threading
from pathlib import Path
from langchain.tools import tool
_OPERATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$")
_TEST_BLOCK_STARTED: threading.Event | None = None
_TEST_BLOCK_RELEASE: threading.Event | None = None
def configure_test_block(
*, started: threading.Event | None = None, release: threading.Event | None = None
) -> None:
"""Install a test-only barrier for lease contention tests.
The hook is inert unless a test explicitly supplies both events. It keeps
the write inside the real synchronous ToolNode call while allowing the
second MAF run to observe the first target lease as ``running``.
"""
global _TEST_BLOCK_STARTED, _TEST_BLOCK_RELEASE
_TEST_BLOCK_STARTED = started
_TEST_BLOCK_RELEASE = release
def clear_test_block() -> None:
configure_test_block()
@tool("maf_test_outbox_write", parse_docstring=True)
def maf_test_outbox_write(operation_id: str, value: str) -> dict[str, object]:
"""Append one controlled write record to the test outbox.
Args:
operation_id: Stable idempotency identifier for the test action.
value: Test payload written only to the temporary outbox.
"""
outbox_dir = (os.getenv("MAF_E2E_OUTBOX_DIR") or "").strip()
if not outbox_dir:
raise RuntimeError("MAF_E2E_OUTBOX_DIR is not configured")
if not _OPERATION_ID.fullmatch(operation_id):
raise ValueError("invalid test outbox operation id")
if _TEST_BLOCK_STARTED is not None and _TEST_BLOCK_RELEASE is not None:
_TEST_BLOCK_STARTED.set()
_TEST_BLOCK_RELEASE.wait()
directory = Path(outbox_dir).resolve()
directory.mkdir(parents=True, exist_ok=True)
record_path = directory / f"{operation_id}.jsonl"
with record_path.open("a", encoding="utf-8", newline="") as record_file:
record_file.write(
json.dumps({"operation_id": operation_id, "value": value}, ensure_ascii=False)
+ "\n"
)
return {
"executed": True,
"external_object_id": operation_id,
"safe_summary": "test outbox write completed",
}
__all__ = ["clear_test_block", "configure_test_block", "maf_test_outbox_write"]
@@ -11,6 +11,7 @@ under ``confirm_high_risk``).
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
_DEFAULT_SIDE_EFFECT = "write"
@@ -42,15 +43,52 @@ def resolve_tool_metadata(
tool_name: str,
*,
config_extra: dict[str, Any] | None = None,
) -> dict[str, str]:
"""Return ``{name, tool_name, side_effect, risk_level}`` for one tool."""
maf_metadata: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Return trusted metadata for one tool.
Precedence is intentionally server-only and explicit:
1. MAF deployment metadata keyed by the exact runtime tool name;
2. existing server ``ToolConfig`` extras;
3. the curated built-in allow-list;
4. conservative ``write`` / ``R3`` fallback.
``maf_metadata`` is passed by the application while building a frozen
capability snapshot. It is never accepted from a session request or a
model message.
"""
side = _DEFAULT_SIDE_EFFECT
risk = _DEFAULT_RISK_LEVEL
if config_extra:
if config_extra.get("side_effect"):
side = str(config_extra["side_effect"])
if config_extra.get("risk_level"):
risk = str(config_extra["risk_level"])
target_key_fields: list[str] = []
target_key_namespace = "default"
registered = maf_metadata.get(tool_name) if maf_metadata else None
if registered is not None:
candidate_side = registered.get("side_effect") if isinstance(registered, Mapping) else getattr(registered, "side_effect", None)
candidate_risk = registered.get("risk_level") if isinstance(registered, Mapping) else getattr(registered, "risk_level", None)
if candidate_side in {"read", "skill", "write"}:
side = str(candidate_side)
if candidate_risk in {"R0", "R1", "R2", "R3", "R4"}:
risk = str(candidate_risk)
candidate_fields = registered.get("target_key_fields") if isinstance(registered, Mapping) else getattr(registered, "target_key_fields", None)
candidate_namespace = registered.get("target_key_namespace") if isinstance(registered, Mapping) else getattr(registered, "target_key_namespace", None)
if isinstance(candidate_fields, list) and all(isinstance(item, str) for item in candidate_fields):
target_key_fields = list(candidate_fields)
if isinstance(candidate_namespace, str) and candidate_namespace:
target_key_namespace = candidate_namespace
elif config_extra:
candidate_side = config_extra.get("side_effect")
candidate_risk = config_extra.get("risk_level")
if candidate_side in {"read", "skill", "write"}:
side = str(candidate_side)
if candidate_risk in {"R0", "R1", "R2", "R3", "R4"}:
risk = str(candidate_risk)
candidate_fields = config_extra.get("target_key_fields")
candidate_namespace = config_extra.get("target_key_namespace")
if isinstance(candidate_fields, list) and all(isinstance(item, str) for item in candidate_fields):
target_key_fields = list(candidate_fields)
if isinstance(candidate_namespace, str) and candidate_namespace:
target_key_namespace = candidate_namespace
elif tool_name in _BUILTIN_METADATA:
side, risk = _BUILTIN_METADATA[tool_name]
return {
@@ -58,4 +96,6 @@ def resolve_tool_metadata(
"tool_name": tool_name,
"side_effect": side,
"risk_level": risk,
"target_key_fields": target_key_fields,
"target_key_namespace": target_key_namespace,
}
@@ -0,0 +1,139 @@
"""Phase 5 migration for MAF external-write target leases.
Creates the additive ``maf_roundtable_action_leases`` table used to serialize
approved writes to the same server-derived external business target. Existing
action rows are intentionally not backfilled: old rows have no trusted target
key and retain the legacy action-level at-most-once behavior. New writes
receive an opaque target key from ``ActionCoordinator``.
Run this before deploying code that enables MAF external writes:
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase5.py --dry-run
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase5.py
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from typing import Any
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("migrate_maf_roundtable_phase5")
TABLE = "maf_roundtable_action_leases"
ACTION_INDEX = "ix_maf_action_leases_action_id"
EXPIRY_INDEX = "ix_maf_action_leases_lease_expires_at"
def _dialect_name(conn_or_dialect: Any) -> str:
return str(getattr(getattr(conn_or_dialect, "dialect", conn_or_dialect), "name", "sqlite")).lower()
def _timestamp_sql(conn_or_dialect: Any) -> str:
"""Return the timestamp type accepted by each supported database."""
dialect = _dialect_name(conn_or_dialect)
if dialect == "postgresql":
return "TIMESTAMP WITH TIME ZONE"
if dialect in {"mysql", "mariadb"}:
return "DATETIME(6)"
return "DATETIME"
def _create_lease_table_sql(conn_or_dialect: Any) -> str:
timestamp_type = _timestamp_sql(conn_or_dialect)
return f"""
CREATE TABLE {TABLE} (
target_key VARCHAR(255) NOT NULL PRIMARY KEY,
action_id VARCHAR(64) NOT NULL,
lease_expires_at {timestamp_type} NOT NULL,
created_at {timestamp_type} NOT NULL,
updated_at {timestamp_type} NOT NULL,
CONSTRAINT uq_maf_action_leases_action UNIQUE (action_id),
FOREIGN KEY (action_id) REFERENCES maf_roundtable_actions (id) ON DELETE CASCADE
)
"""
def _add_missing_columns(conn: Any, *, inspector: Any, execute: Any) -> None:
"""Complete a partially created table without rewriting existing data.
The migration is additive. A missing primary key or action foreign-key
column cannot be safely added to a populated table, so fail loudly rather
than creating a lease table that the arbiter could misinterpret.
"""
columns = {column["name"] for column in inspector.get_columns(TABLE)}
required = {"target_key", "action_id", "lease_expires_at", "created_at", "updated_at"}
missing = required - columns
if not missing:
return
if {"target_key", "action_id"} & missing:
raise RuntimeError(
f"{TABLE} is incomplete ({sorted(missing)}); recreate it with the deployment migration"
)
timestamp_type = _timestamp_sql(conn)
for column in ("lease_expires_at", "created_at", "updated_at"):
if column in missing:
execute(f"ALTER TABLE {TABLE} ADD COLUMN {column} {timestamp_type}")
async def _migrate(dry_run: bool) -> int:
from deerflow.config import get_app_config
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
await init_engine_from_config(get_app_config().database)
sf = get_session_factory()
if sf is None:
logger.error("Database backend is 'memory' — nothing to migrate.")
return 0
engine = sf.kw["bind"]
async with engine.begin() as conn:
await conn.run_sync(_migrate_sync, dry_run=dry_run)
await close_engine()
return 0
def _migrate_sync(conn: Any, *, dry_run: bool) -> None:
from sqlalchemy import inspect, text
inspector = inspect(conn)
def execute(sql: str) -> None:
if dry_run:
logger.info("[dry-run] would execute: %s", " ".join(sql.split()))
return
conn.execute(text(sql))
if TABLE not in inspector.get_table_names():
logger.info("Creating %s", TABLE)
execute(_create_lease_table_sql(conn))
if dry_run:
execute(f"CREATE INDEX {ACTION_INDEX} ON {TABLE} (action_id)")
execute(f"CREATE INDEX {EXPIRY_INDEX} ON {TABLE} (lease_expires_at)")
return
inspector = inspect(conn)
else:
logger.info("Table %s already exists", TABLE)
_add_missing_columns(conn, inspector=inspector, execute=execute)
if not dry_run:
inspector = inspect(conn)
indexes = {index.get("name") for index in inspector.get_indexes(TABLE)}
if ACTION_INDEX not in indexes:
execute(f"CREATE INDEX {ACTION_INDEX} ON {TABLE} (action_id)")
if EXPIRY_INDEX not in indexes:
execute(f"CREATE INDEX {EXPIRY_INDEX} ON {TABLE} (lease_expires_at)")
def main() -> int:
parser = argparse.ArgumentParser(description="Create MAF external-write target lease table")
parser.add_argument("--dry-run", action="store_true", help="Preview only; do not write")
return asyncio.run(_migrate(parser.parse_args().dry_run))
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,396 @@
"""Phase-5 target lease, timeout and ambiguous-result safety tests."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy import update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.app import _scan_maf_approval_expiry
from app.maf_roundtable.action_coordinator import (
ActionCoordinator,
derive_target_key,
encrypt_params,
)
from app.maf_roundtable.service import MafConflictError
from app.maf_roundtable.tool_executor import ToolExecutor
from app.maf_roundtable.workflow_executors import RoundtableState
from app.maf_roundtable.workflow_runner import WorkflowRunner
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
Base,
MafRoundtableActionLeaseRow,
MafRoundtableApprovalRow,
)
TEST_TOOL = "controlled_write"
def _snapshot() -> dict:
tool = {
"name": TEST_TOOL,
"side_effect": "write",
"risk_level": "R3",
"target_key_fields": ["tenant_id", "object_id"],
"target_key_namespace": "lease-test",
}
return {
"tools": [tool],
"agents": [{"agent_id": "seat-1", "tools": [tool]}],
}
@pytest_asyncio.fixture
async def store(tmp_path):
# A file-backed SQLite database gives concurrent sessions the same
# transaction/constraint semantics used by deployed SQLite installations.
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_action_leases.db'}", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
result = make_maf_roundtable_store(sf)
assert result is not None
yield result
await engine.dispose()
@pytest.fixture
def svc(store):
return make_maf_service(
store,
enabled=True,
config=MafRoundtableConfig(
enabled=True,
approval_timeout_seconds=60,
target_lease_seconds=30,
),
)
async def _run(store, svc) -> tuple[dict, dict]:
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
return session, run
async def _approved_action(
store, session: dict, run: dict, *, key: str, target_key: str, agent_id: str = "seat-1"
) -> dict:
return await store.actions.create_action({
"session_id": session["id"],
"run_id": run["id"],
"agent_id": agent_id,
"tool_name": TEST_TOOL,
"idempotency_key": key,
"target_key": target_key,
"risk_level": "R3",
"status": "approved",
})
def test_target_key_is_server_derived_and_opaque(monkeypatch) -> None:
snapshot = _snapshot()
original = {
"tenant_id": "tenant-a",
"object_id": "task-7",
"target_key": "browser-forged-value",
"secret": "do-not-persist",
}
same_target_different_noise = {
"tenant_id": "tenant-a",
"object_id": "task-7",
"target_key": "another-forged-value",
"secret": "changed",
}
target = derive_target_key(TEST_TOOL, original, snapshot, "seat-1")
assert target == derive_target_key(TEST_TOOL, same_target_different_noise, snapshot, "seat-1")
assert target.startswith("maf-target:v1:")
assert "tenant-a" not in target
assert "task-7" not in target
assert "browser-forged" not in target
nested = derive_target_key(
TEST_TOOL,
{"tenant_id": "tenant-a", "object_id": {"id": "task-7"}},
snapshot,
"seat-1",
)
nested_other = derive_target_key(
TEST_TOOL,
{"tenant_id": "tenant-a", "object_id": ["task-7"]},
snapshot,
"seat-1",
)
assert nested == nested_other
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "another-test-domain-secret")
assert target != derive_target_key(TEST_TOOL, original, snapshot, "seat-1")
@pytest.mark.asyncio
async def test_two_agents_same_target_are_serialized(store, svc):
"""The DB lease, not a process lock, blocks the second approved write."""
session, run = await _run(store, svc)
target_key = derive_target_key(
TEST_TOOL, {"tenant_id": "tenant-a", "object_id": "task-7"}, _snapshot(), "seat-1"
)
first = await _approved_action(store, session, run, key="lease-first", target_key=target_key)
second = await _approved_action(
store, session, run, key="lease-second", target_key=target_key, agent_id="seat-2"
)
first_claim, second_claim = await asyncio.gather(
store.claim_action_for_execution(first["id"], lease_seconds=30),
store.claim_action_for_execution(second["id"], lease_seconds=30),
)
assert sorted([first_claim, second_claim]) == ["claimed", "target_busy"]
winner = first if first_claim == "claimed" else second
waiting = second if winner["id"] == first["id"] else first
assert (await store.actions.get_action(waiting["id"]))["status"] == "approved"
assert await store.record_action_terminal_result(
winner["id"], status="succeeded", result={"safe": True}
)
assert await store.claim_action_for_execution(waiting["id"], lease_seconds=30) == "claimed"
@pytest.mark.asyncio
async def test_restart_recovers_expired_lease_as_unknown_without_replay(store, svc):
session, run = await _run(store, svc)
target_key = derive_target_key(
TEST_TOOL, {"tenant_id": "tenant-a", "object_id": "task-7"}, _snapshot(), "seat-1"
)
action = await _approved_action(store, session, run, key="lease-restart", target_key=target_key)
assert await store.claim_action_for_execution(action["id"], lease_seconds=1) == "claimed"
recovered = await store.recover_expired_action_leases(
run_id=run["id"], now=datetime.now(UTC) + timedelta(seconds=5)
)
assert [item["id"] for item in recovered] == [action["id"]]
stored = await store.actions.get_action(action["id"])
assert stored["status"] == "unknown"
assert stored["result"] == {"error_code": "target_lease_expired_during_execution"}
assert await store.claim_action_for_execution(action["id"], lease_seconds=30) == "unknown"
blocked = await _approved_action(
store,
session,
run,
key="lease-restart-blocked",
target_key=target_key,
agent_id="seat-2",
)
assert await store.claim_action_for_execution(blocked["id"], lease_seconds=30) == "target_reconciliation_required"
@pytest.mark.asyncio
async def test_approval_timeout_rejects_action_and_writes_audit_event(monkeypatch, store, svc):
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "test-only-lease-secret")
session, run = await _run(store, svc)
coordinator = ActionCoordinator(svc, user_id="u1")
actions = await coordinator.intercept_write_tool_calls(
run_id=run["id"],
session_id=session["id"],
agent_id="seat-1",
turn_index=1,
tool_calls=[{
"name": TEST_TOOL,
"id": "timeout-call",
"args": {"tenant_id": "tenant-a", "object_id": "task-7"},
}],
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
approval_id = actions[0]["approval_id"]
expired = await store.expire_due_approvals(
run_id=run["id"], now=datetime.now(UTC) + timedelta(minutes=2)
)
assert expired == [{"id": approval_id, "run_id": run["id"], "action_id": actions[0]["id"]}]
assert (await store.approvals.get_approval(approval_id))["status"] == "expired"
assert (await store.actions.get_action(actions[0]["id"]))["status"] == "rejected"
with pytest.raises(MafConflictError):
await svc.decide_approval(approval_id=approval_id, decision="approved", user_id="u1")
events = await store.events.list_events(run["id"])
assert any(
event["event_type"] == "action.rejected"
and event["payload"].get("reason") == "approval_expired"
for event in events
)
@pytest.mark.asyncio
async def test_external_success_with_local_result_failure_never_replays(monkeypatch, store, svc):
"""After external success, an unavailable DB result write becomes unknown."""
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "test-only-lease-secret")
session, run = await _run(store, svc)
target_key = derive_target_key(
TEST_TOOL, {"tenant_id": "tenant-a", "object_id": "task-7"}, _snapshot(), "seat-1"
)
action = await _approved_action(store, session, run, key="persist-failure", target_key=target_key)
await store.action_secrets.create(
action_id=action["id"], arguments_encrypted=encrypt_params({"tenant_id": "tenant-a", "object_id": "task-7"})
)
calls = 0
class Client:
async def execute_tool_directly(self, **_kwargs):
nonlocal calls
calls += 1
return {"status": "succeeded", "result": {"executed": True}}
adapter = SimpleNamespace(_client=Client())
coordinator = ActionCoordinator(svc, user_id="u1")
async def fail_terminal_persistence(*_args, **_kwargs):
raise RuntimeError("simulated local persistence outage")
monkeypatch.setattr(coordinator, "record_result", fail_terminal_persistence)
executor = ToolExecutor(adapter=adapter, action_coordinator=coordinator, user_id="u1")
first = await executor.execute(
action_id=action["id"],
session_id=session["id"],
run_id=run["id"],
agent_id="seat-1",
thread_id="thread-1",
approved_tool=TEST_TOOL,
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
second = await executor.execute(
action_id=action["id"],
session_id=session["id"],
run_id=run["id"],
agent_id="seat-1",
thread_id="thread-1",
approved_tool=TEST_TOOL,
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
assert first["status"] == "unknown"
assert second["status"] == "in_progress"
assert calls == 1
recovered = await store.recover_expired_action_leases(
run_id=run["id"], now=datetime.now(UTC) + timedelta(minutes=20)
)
assert [item["id"] for item in recovered] == [action["id"]]
assert (await store.actions.get_action(action["id"]))["status"] == "unknown"
@pytest.mark.asyncio
async def test_workflow_recovery_stops_on_expired_lease_without_tool_replay(store, svc):
"""The restart entry point converts a stale running lease to waiting_input."""
session, run = await _run(store, svc)
target_key = derive_target_key(
TEST_TOOL, {"tenant_id": "tenant-a", "object_id": "task-7"}, _snapshot(), "seat-1"
)
action = await _approved_action(store, session, run, key="runner-recovery", target_key=target_key)
assert await store.claim_action_for_execution(action["id"], lease_seconds=30) == "claimed"
async with store.session_factory() as db:
await db.execute(
update(MafRoundtableActionLeaseRow)
.where(MafRoundtableActionLeaseRow.action_id == action["id"])
.values(lease_expires_at=datetime.now(UTC) - timedelta(seconds=1))
)
await db.commit()
state = RoundtableState(
session_id=session["id"],
run_id=run["id"],
task_text="test",
mode="structured",
model_name=None,
reasoning_effort=None,
max_rounds=1,
write_policy="confirm_all_writes",
manager_agent_id=None,
seat_agent_ids=[],
thread_map={},
capability_snapshot=_snapshot(),
)
state.current_round = 1
state.status = "running"
state.pending_approvals = [{
"action_id": action["id"],
"approval_id": "already-approved",
"agent_id": "seat-1",
"tool_name": TEST_TOOL,
}]
await store.runs.update_run(run["id"], {"status": "running", "maf_checkpoint": state.to_dict()})
await store.sessions.update_session(session["id"], user_id="u1", fields={"status": "running"})
runner = WorkflowRunner(SimpleNamespace(), service=svc, user_id="u1")
recovered = await runner.recover(run["id"])
assert recovered is not None and recovered["status"] == "waiting_input"
stored = await store.actions.get_action(action["id"])
assert stored["status"] == "unknown"
assert (await store.sessions.get_session(session["id"], user_id="u1"))["status"] == "waiting_input"
@pytest.mark.asyncio
async def test_approval_expiry_scan_is_repeatable_and_get_is_observational(monkeypatch, store, svc):
"""A scan expires once; ordinary approval GETs never perform that mutation."""
session, run = await _run(store, svc)
coordinator = ActionCoordinator(svc, user_id="u1")
action = (
await coordinator.intercept_write_tool_calls(
run_id=run["id"],
session_id=session["id"],
agent_id="seat-1",
turn_index=1,
tool_calls=[{"name": TEST_TOOL, "id": "expiry-scan", "args": {
"tenant_id": "tenant-a", "object_id": "task-7", "value": "x"
}}],
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
)[0]
async with store.session_factory() as db:
await db.execute(
update(MafRoundtableApprovalRow)
.where(MafRoundtableApprovalRow.id == action["approval_id"])
.values(expires_at=datetime.now(UTC) - timedelta(seconds=1))
)
await db.commit()
# The normal read path must not mutate an expired pending row.
listed = await svc.list_approvals(run_id=run["id"], user_id="u1")
assert listed[0]["status"] == "pending"
class FakeDispatcher:
def __init__(self):
self.calls: list[tuple[str, str]] = []
async def resume(self, run_id: str, *, user_id: str):
self.calls.append((run_id, user_id))
return await store.runs.get_run(run_id)
fake = FakeDispatcher()
monkeypatch.setattr(
"app.maf_roundtable.dispatcher.get_dispatcher",
lambda _app: fake,
)
app = SimpleNamespace(
state=SimpleNamespace(maf_roundtable_service=svc),
)
concurrent = await asyncio.gather(
_scan_maf_approval_expiry(app),
_scan_maf_approval_expiry(app),
)
repeat_after_restart = await _scan_maf_approval_expiry(app)
assert sorted(concurrent, key=len) == [[], [run["id"]]]
assert repeat_after_restart == []
assert fake.calls == [(run["id"], "u1")]
assert (await store.approvals.get_approval(action["approval_id"]))["status"] == "expired"
assert (await store.actions.get_action(action["id"]))["status"] == "rejected"
@@ -50,7 +50,11 @@ CHANNEL_VALUES: dict[str, Any] = {
def app_config():
from deerflow.config import get_app_config
return get_app_config()
# The production config mounts WriteGuard. These low-level executor tests
# that do not register a MAF service intentionally exercise the harness
# path without the app-layer validator; the dedicated enabled tests below
# provide the real bridge/service wiring.
return get_app_config().model_copy(update={"write_guard": WriteGuardConfig(enabled=False)})
def _snapshot(action_tool: str) -> dict[str, Any]:
@@ -86,3 +86,33 @@ async def test_inaccessible_agent_rejected():
app_config=app_config,
agent_store=_DenyStore(),
)
def test_maf_tool_metadata_has_highest_server_priority():
metadata = resolve_tool_metadata(
"weknora-retrieval_weknora_search",
config_extra={"side_effect": "write", "risk_level": "R4"},
maf_metadata={"weknora-retrieval_weknora_search": {"side_effect": "read", "risk_level": "R0"}},
)
assert metadata["side_effect"] == "read"
assert metadata["risk_level"] == "R0"
def test_deployed_weknora_names_are_explicitly_read_only():
metadata = get_app_config().maf_roundtable.tool_metadata
assert metadata["weknora-retrieval_weknora_list_knowledge_bases"].side_effect == "read"
assert metadata["weknora-retrieval_weknora_list_knowledge_bases"].risk_level == "R0"
assert metadata["weknora-retrieval_weknora_search"].side_effect == "read"
assert metadata["weknora-retrieval_weknora_search"].risk_level == "R0"
def test_unknown_tool_metadata_remains_conservative():
metadata = resolve_tool_metadata(
"unregistered_external_tool",
config_extra={"side_effect": "invalid", "risk_level": "R99"},
)
assert metadata["side_effect"] == "write"
assert metadata["risk_level"] == "R3"
@@ -0,0 +1,89 @@
"""Acceptance coverage for the additive Phase-5 target-lease migration."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine
from deerflow.persistence.maf_roundtable.model import Base
def _migration_module():
script = Path(__file__).resolve().parent.parent / "scripts" / "migrate_maf_roundtable_phase5.py"
spec = importlib.util.spec_from_file_location("migrate_maf_roundtable_phase5", script)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.mark.asyncio
async def test_phase5_lease_migration_is_additive_and_idempotent():
module = _migration_module()
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.execute(text("DROP TABLE maf_roundtable_action_leases"))
async with engine.begin() as conn:
await conn.run_sync(module._migrate_sync, dry_run=False)
def check(sync_conn):
inspector = inspect(sync_conn)
assert "maf_roundtable_action_leases" in inspector.get_table_names()
columns = {column["name"] for column in inspector.get_columns("maf_roundtable_action_leases")}
assert {"target_key", "action_id", "lease_expires_at"} <= columns
indexes = {index["name"] for index in inspector.get_indexes("maf_roundtable_action_leases")}
assert {"ix_maf_action_leases_action_id", "ix_maf_action_leases_lease_expires_at"} <= indexes
async with engine.begin() as conn:
await conn.run_sync(check)
await conn.execute(text(f"DROP INDEX {module.ACTION_INDEX}"))
await conn.execute(text(f"DROP INDEX {module.EXPIRY_INDEX}"))
await conn.run_sync(module._migrate_sync, dry_run=False)
await conn.run_sync(check)
await conn.run_sync(module._migrate_sync, dry_run=True)
await engine.dispose()
@pytest.mark.asyncio
async def test_phase5_migration_dry_run_is_safe_when_table_is_absent():
module = _migration_module()
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(module._migrate_sync, dry_run=True)
tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
assert "maf_roundtable_action_leases" not in tables
await engine.dispose()
def test_phase5_ddl_uses_sqlite_datetime():
module = _migration_module()
ddl = module._create_lease_table_sql(SimpleDialect("sqlite"))
assert "lease_expires_at DATETIME NOT NULL" in ddl
assert "created_at DATETIME NOT NULL" in ddl
def test_phase5_ddl_uses_postgresql_timestamp_with_timezone():
module = _migration_module()
ddl = module._create_lease_table_sql(SimpleDialect("postgresql"))
assert "lease_expires_at TIMESTAMP WITH TIME ZONE NOT NULL" in ddl
assert "created_at TIMESTAMP WITH TIME ZONE NOT NULL" in ddl
assert "DATETIME" not in ddl
def test_phase5_ddl_uses_mysql_microsecond_datetime():
module = _migration_module()
ddl = module._create_lease_table_sql(SimpleDialect("mysql"))
assert "lease_expires_at DATETIME(6) NOT NULL" in ddl
assert "created_at DATETIME(6) NOT NULL" in ddl
class SimpleDialect:
def __init__(self, name: str) -> None:
self.name = name
@@ -0,0 +1,312 @@
"""Entry-level MAF Phase-5 approval and target-lease acceptance tests."""
from __future__ import annotations
import asyncio
import threading
from types import SimpleNamespace
import pytest
from fastapi import FastAPI, Request
from httpx import ASGITransport, AsyncClient
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langgraph.checkpoint.memory import InMemorySaver
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.maf_roundtable import router as maf_router
from app.maf_roundtable.dispatcher import get_dispatcher
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
from deerflow.config import get_app_config
from deerflow.config.maf_roundtable_config import MafRoundtableConfig, MafToolMetadata
from deerflow.config.tool_config import ToolConfig
from deerflow.config.write_guard_config import WriteGuardConfig, WriteGuardProviderConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.runtime import RunManager
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
TEST_TOOL = "maf_test_outbox_write"
class _User:
def __init__(self, user_id: str) -> None:
self.id = user_id
self.system_role = "user"
class _ThreadStore:
def __init__(self) -> None:
self.rows: dict[str, dict] = {}
async def create(self, thread_id, assistant_id=None, metadata=None):
self.rows[thread_id] = {
"thread_id": thread_id,
"assistant_id": assistant_id,
"metadata": metadata or {},
"status": "idle",
}
async def get(self, thread_id):
return self.rows.get(thread_id)
async def update_status(self, thread_id, status):
self.rows.setdefault(thread_id, {})["status"] = status
async def update_assistant_id(self, thread_id, assistant_id):
self.rows.setdefault(thread_id, {})["assistant_id"] = assistant_id
async def update_metadata(self, thread_id, metadata):
self.rows.setdefault(thread_id, {}).setdefault("metadata", {}).update(metadata)
async def update_display_name(self, thread_id, display_name):
self.rows.setdefault(thread_id, {})["display_name"] = display_name
class _DeterministicModel(BaseChatModel):
"""Deterministic model boundary: propose one write, then finish."""
calls: int = 0
@property
def _llm_type(self) -> str:
return "maf-phase5-deterministic"
def bind_tools(self, tools, **kwargs): # noqa: ARG002
return self
def _generate(self, messages, stop=None, run_manager=None, **kwargs): # noqa: ARG002
if self.calls == 0:
message = AIMessage(
content="",
tool_calls=[{
"name": TEST_TOOL,
"args": {"operation_id": "phase5-target", "value": "controlled"},
"id": "phase5-tool-call",
"type": "tool_call",
}],
)
else:
message = AIMessage(content="deterministic completion")
self.calls += 1
return ChatResult(generations=[ChatGeneration(message=message)])
def _app_config():
config = get_app_config().model_copy(deep=True)
config.tools = [
*config.tools,
ToolConfig(
name=TEST_TOOL,
group="maf-e2e",
use="deerflow.tools.builtins.maf_test_outbox:maf_test_outbox_write",
),
]
config.write_guard = WriteGuardConfig(
enabled=True,
provider=WriteGuardProviderConfig(
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
),
)
config.maf_roundtable = MafRoundtableConfig(
enabled=True,
default_write_policy="confirm_all_writes",
approval_timeout_seconds=300,
approval_scan_interval_seconds=1,
tool_metadata={
TEST_TOOL: MafToolMetadata(
side_effect="write",
risk_level="R2",
target_key_fields=["operation_id"],
target_key_namespace="phase5-e2e",
)
},
)
return config
class _VisibleAgentStore:
async def get_visible(self, agent_id: str, user_id: str): # noqa: ARG002
return {"id": agent_id, "user_id": user_id}
async def ensure_builtin(self, record): # noqa: ARG002
return None
def _install_test_app(app: FastAPI, service, config) -> None:
app.state.config = config
app.state.maf_roundtable_service = service
app.state.maf_auto_dispatch = True
app.state.checkpointer = InMemorySaver()
app.state.thread_store = _ThreadStore()
app.state.run_manager = RunManager()
app.state.stream_bridge = MemoryStreamBridge()
app.state.run_event_store = MemoryRunEventStore()
app.state.store = None
app.state.agent_store = _VisibleAgentStore()
register_write_guard_service(service)
app.include_router(maf_router)
async def _wait_dispatch(dispatcher, run_id: str) -> None:
task = dispatcher._tasks.get(run_id) # noqa: SLF001 - entry test waits on the real task
assert task is not None
await task
@pytest.fixture
def deterministic_agent(monkeypatch, tmp_path):
agent_config = SimpleNamespace(
tool_groups=["maf-e2e"],
id="seat-1",
description="MAF E2E test seat",
model=None,
skills=[],
subagent_enabled=False,
name="MAF E2E seat",
)
paths = (
"deerflow.config.agents_config.load_agent_config",
"app.maf_roundtable.capability_snapshot.load_agent_config",
"deerflow.agents.lead_agent.agent.load_agent_config",
)
for path in paths:
monkeypatch.setattr(path, lambda _agent_id, cfg=agent_config: cfg)
monkeypatch.setattr("deerflow.agents.lead_agent.agent.load_agent_soul", lambda _agent_id: "")
monkeypatch.setattr(
"deerflow.agents.lead_agent.agent.create_chat_model",
lambda **_kwargs: _DeterministicModel(),
)
config = _app_config()
config.title.enabled = False
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "phase5-entry-test-secret")
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
return config
async def _make_app(tmp_path, config):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_entry.db'}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
from app.maf_roundtable.service import MafRoundtableService
service = MafRoundtableService(
store,
enabled=True,
admin_diagnostic_access=False,
config=config.maf_roundtable,
agent_store=_VisibleAgentStore(),
app_config=config,
)
app = FastAPI()
_install_test_app(app, service, config)
@app.middleware("http")
async def test_auth(request: Request, call_next):
request.state.user = _User("u1")
return await call_next(request)
return app, service, engine
async def _create_waiting_run(client: AsyncClient, dispatcher, *, task_text: str, service=None) -> tuple[str, dict]:
session_response = await client.post(
"/api/maf-roundtable/sessions",
json={
"task_text": task_text,
"write_policy": "confirm_all_writes",
"participants": [{"agent_id": "seat-1", "role": "seat"}],
},
)
assert session_response.status_code == 200, session_response.text
session = session_response.json()
run_response = await client.post(
f"/api/maf-roundtable/sessions/{session['id']}/runs",
json={},
)
assert run_response.status_code == 200, run_response.text
run = run_response.json()
await _wait_dispatch(dispatcher, run["id"])
run_state = (await client.get(f"/api/maf-roundtable/runs/{run['id']}")).json()
if run_state["status"] != "waiting_approval" and service is not None:
actions = await service.store.actions.list_actions(run_id=run["id"])
raise AssertionError(f"unexpected run state: {run_state}; actions={actions}")
assert run_state["status"] == "waiting_approval"
return session["id"], run
@pytest.mark.asyncio
async def test_entry_http_approval_reaches_real_toolnode_once(tmp_path, deterministic_agent):
app, service, engine = await _make_app(tmp_path, deterministic_agent)
dispatcher = get_dispatcher(app)
try:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
session_id, run = await _create_waiting_run(client, dispatcher, task_text="entry approval", service=service)
approvals = (await client.get(f"/api/maf-roundtable/runs/{run['id']}/approvals")).json()["approvals"]
assert len(approvals) == 1
approval_response = await client.post(
f"/api/maf-roundtable/approvals/{approvals[0]['id']}/decision",
json={"decision": "approved"},
)
assert approval_response.status_code == 200, approval_response.text
action = (await client.get(f"/api/maf-roundtable/runs/{run['id']}/actions")).json()["actions"][0]
assert action["status"] == "succeeded"
outbox = tmp_path / "phase5-target.jsonl"
assert outbox.exists()
assert outbox.read_text(encoding="utf-8").count("\n") == 1
assert (await client.get(f"/api/maf-roundtable/sessions/{session_id}")).json()["status"] == "completed"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_entry_http_same_target_waits_for_explicit_resume(tmp_path, deterministic_agent):
app, service, engine = await _make_app(tmp_path, deterministic_agent)
dispatcher = get_dispatcher(app)
from deerflow.tools.builtins import maf_test_outbox
started = threading.Event()
release = threading.Event()
maf_test_outbox.configure_test_block(started=started, release=release)
try:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
_session_one, run_one = await _create_waiting_run(client, dispatcher, task_text="lease one", service=service)
approval_one = (await client.get(f"/api/maf-roundtable/runs/{run_one['id']}/approvals")).json()["approvals"][0]
first_decision = asyncio.create_task(client.post(
f"/api/maf-roundtable/approvals/{approval_one['id']}/decision",
json={"decision": "approved"},
))
assert await asyncio.to_thread(started.wait, 10)
_session_two, run_two = await _create_waiting_run(client, dispatcher, task_text="lease two", service=service)
approval_two = (await client.get(f"/api/maf-roundtable/runs/{run_two['id']}/approvals")).json()["approvals"][0]
second_decision = await client.post(
f"/api/maf-roundtable/approvals/{approval_two['id']}/decision",
json={"decision": "approved"},
)
assert second_decision.status_code == 200
assert (await client.get(f"/api/maf-roundtable/runs/{run_two['id']}")).json()["status"] == "waiting_approval"
second_action = (await client.get(f"/api/maf-roundtable/runs/{run_two['id']}/actions")).json()["actions"][0]
assert second_action["status"] == "approved"
release.set()
assert (await first_decision).status_code == 200
assert (await client.get(f"/api/maf-roundtable/runs/{run_one['id']}")).json()["status"] == "completed"
resumed = await client.post(f"/api/maf-roundtable/runs/{run_two['id']}/resume")
assert resumed.status_code == 200, resumed.text
assert (await client.get(f"/api/maf-roundtable/runs/{run_two['id']}")).json()["status"] == "completed"
outbox = tmp_path / "phase5-target.jsonl"
assert outbox.read_text(encoding="utf-8").count("\n") == 2
finally:
release.set()
maf_test_outbox.clear_test_block()
await engine.dispose()
@@ -0,0 +1,238 @@
"""MAF approval-to-real-tool E2E coverage using the test outbox tool.
The tool is a real LangChain StructuredTool and is loaded through the normal
``get_available_tools`` + LangGraph ``ToolNode`` path. It is intentionally
only added to this test's AppConfig; production ``config.yaml`` does not
register it.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
import pytest_asyncio
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.action_coordinator import ActionCoordinator
from app.maf_roundtable.tool_executor import ToolExecutor
from app.maf_roundtable.write_guard_bridge import (
MafWriteGuardInterceptor,
MafWriteGuardPolicy,
register_write_guard_service,
)
from deerflow.agents.middlewares.write_guard_middleware import WriteGuardMiddleware
from deerflow.config import get_app_config
from deerflow.config.tool_config import ToolConfig
from deerflow.config.write_guard_config import WriteGuardConfig, WriteGuardProviderConfig
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import Base
TEST_TOOL = "maf_test_outbox_write"
@pytest_asyncio.fixture
async def store():
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
result = make_maf_roundtable_store(sf)
assert result is not None
yield result
await engine.dispose()
@pytest.fixture
def svc(store):
result = make_maf_service(store, enabled=True, admin_diagnostic_access=True)
register_write_guard_service(result)
return result
def _app_config():
config = get_app_config().model_copy(deep=True)
config.tools = [
*config.tools,
ToolConfig(
name=TEST_TOOL,
group="maf-e2e",
use="deerflow.tools.builtins.maf_test_outbox:maf_test_outbox_write",
),
]
config.write_guard = WriteGuardConfig(
enabled=True,
provider=WriteGuardProviderConfig(
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
),
)
return config
def _snapshot():
tool = {"name": TEST_TOOL, "side_effect": "write", "risk_level": "R2"}
return {
"tools": [tool],
"agents": [{
"agent_id": "seat-1",
"allowed_tool_names": [TEST_TOOL],
"excluded_tools": [],
"tools": [tool],
}],
}
async def _make_pending_action(svc, store):
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coordinator = ActionCoordinator(svc, user_id="u1")
actions = await coordinator.intercept_write_tool_calls(
run_id=run["id"],
session_id=session["id"],
agent_id="seat-1",
turn_index=1,
tool_calls=[{
"name": TEST_TOOL,
"id": "call-outbox-1",
"args": {"operation_id": "op-1", "value": "controlled-value"},
}],
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
assert len(actions) == 1
return session, run, coordinator, actions[0]
@pytest.mark.asyncio
async def test_real_outbox_requires_approval_and_executes_once(monkeypatch, tmp_path, svc, store):
"""Pending action has no side effect; approved ToolNode call writes once."""
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
monkeypatch.setattr(
"deerflow.config.agents_config.load_agent_config",
lambda _agent_id: SimpleNamespace(tool_groups=["maf-e2e"], model=None),
)
session, run, coordinator, action = await _make_pending_action(svc, store)
outbox_file = tmp_path / "op-1.jsonl"
assert not outbox_file.exists(), "approval interception must not execute the tool"
assert await store.actions.mark_approved(action["id"]) is True
class Adapter:
class Client:
async def execute_tool_directly(self, **kwargs):
from deerflow.tools.approved_tool_executor import execute_approved_tool
return await execute_approved_tool(
app_config=_app_config(),
checkpointer=None,
store=None,
thread_id=kwargs["thread_id"],
user_id="u1",
session_id=kwargs["principal"].session_id,
run_id=kwargs["principal"].run_id,
agent_id=kwargs["agent_id"],
turn_index=kwargs["turn_index"],
model_name=None,
excluded_tools=[],
capability_snapshot=kwargs["capability_snapshot"],
write_policy=kwargs["write_policy"],
tool_name=kwargs["tool_name"],
params=kwargs["params"],
approved_action_id=kwargs["approved_action_id"],
action_record=kwargs["action_record"],
)
def __init__(self):
self._client = self.Client()
executor = ToolExecutor(
adapter=Adapter(),
action_coordinator=coordinator,
user_id="u1",
)
first = await executor.execute(
action_id=action["id"],
session_id=session["id"],
run_id=run["id"],
agent_id="seat-1",
thread_id="thread-1",
approved_tool=TEST_TOOL,
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
second = await executor.execute(
action_id=action["id"],
session_id=session["id"],
run_id=run["id"],
agent_id="seat-1",
thread_id="thread-1",
approved_tool=TEST_TOOL,
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
assert first["status"] == "succeeded"
assert second["status"] == "succeeded"
assert outbox_file.read_text(encoding="utf-8").count("\n") == 1
stored = await store.actions.get_action(action["id"])
assert stored["status"] == "succeeded"
assert stored["result"]["tool_result"]["external_object_id"] == "op-1"
@pytest.mark.asyncio
async def test_rejected_action_never_reaches_real_tool(monkeypatch, tmp_path, svc, store):
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
_session, _run, _coordinator, action = await _make_pending_action(svc, store)
assert await store.actions.mark_rejected(action["id"]) is True
assert not list(tmp_path.iterdir())
assert (await store.actions.get_action(action["id"]))["status"] == "rejected"
@pytest.mark.asyncio
async def test_missing_secret_fails_closed_before_action_creation(monkeypatch, tmp_path, svc, store):
monkeypatch.delenv("MAF_ACTION_SECRET_KEY", raising=False)
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
session = await svc.create_session(user_id="u1", body={"task_text": "test"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
middleware = WriteGuardMiddleware(
policy=MafWriteGuardPolicy(),
interceptor=MafWriteGuardInterceptor(),
)
called = 0
context = {
"maf_run_id": run["id"],
"maf_session_id": session["id"],
"maf_user_id": "u1",
"agent_id": "seat-1",
"turn_index": 1,
"write_policy": "confirm_all_writes",
"capability_snapshot": _snapshot(),
}
request = SimpleNamespace(
tool_call={
"name": TEST_TOOL,
"id": "call-missing-key",
"args": {"operation_id": "op-missing-key", "value": "must-not-run"},
},
runtime=SimpleNamespace(context=context),
)
async def handler(_request):
nonlocal called
called += 1
return {"executed": True}
result = await middleware.awrap_tool_call(request, handler)
envelope = json.loads(result.content)
assert envelope["status"] == "intercept_failed"
assert called == 0
assert not list(tmp_path.iterdir())
assert await store.actions.list_actions(run_id=run["id"]) == []
@@ -0,0 +1,223 @@
"""Real HTTP approval → dispatcher → runner → ToolNode/outbox acceptance test."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import FastAPI, Request
from httpx import ASGITransport, AsyncClient
from maf_test_helpers import make_maf_service
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.maf_roundtable import router as maf_router
from app.maf_roundtable.action_coordinator import ActionCoordinator
from app.maf_roundtable.dispatcher import get_dispatcher
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient
from app.maf_roundtable.workflow_executors import RoundtableState
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
from deerflow.config import get_app_config
from deerflow.config.maf_roundtable_config import MafRoundtableConfig
from deerflow.config.tool_config import ToolConfig
from deerflow.config.write_guard_config import WriteGuardConfig, WriteGuardProviderConfig
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.tools.approved_tool_executor import execute_approved_tool
TEST_TOOL = "maf_test_outbox_write"
class _User:
def __init__(self, user_id: str) -> None:
self.id = user_id
self.system_role = "user"
def _snapshot() -> dict:
tool = {
"name": TEST_TOOL,
"side_effect": "write",
"risk_level": "R2",
"target_key_fields": ["operation_id"],
"target_key_namespace": "maf-e2e",
}
return {
"tools": [tool],
"agents": [{
"agent_id": "seat-1",
"allowed_tool_names": [TEST_TOOL],
"excluded_tools": [],
"tools": [tool],
}],
}
def _app_config():
config = get_app_config().model_copy(deep=True)
config.tools = [
*config.tools,
ToolConfig(
name=TEST_TOOL,
group="maf-e2e",
use="deerflow.tools.builtins.maf_test_outbox:maf_test_outbox_write",
),
]
config.write_guard = WriteGuardConfig(
enabled=True,
provider=WriteGuardProviderConfig(
policy_use="app.maf_roundtable.write_guard_bridge:make_policy",
interceptor_use="app.maf_roundtable.write_guard_bridge:make_interceptor",
),
)
return config
@pytest.mark.asyncio
async def test_http_approval_dispatches_real_tool_once(monkeypatch, tmp_path):
"""A real route decision reaches the ToolNode exactly once, even on replay."""
monkeypatch.setenv("MAF_ACTION_SECRET_KEY", "test-only-http-e2e-secret")
monkeypatch.setenv("MAF_E2E_OUTBOX_DIR", str(tmp_path))
monkeypatch.setattr(
"deerflow.config.agents_config.load_agent_config",
lambda _agent_id: SimpleNamespace(tool_groups=["maf-e2e"], model=None),
)
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'maf_http_e2e.db'}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
assert store is not None
svc = make_maf_service(
store,
enabled=True,
config=MafRoundtableConfig(enabled=True, approval_timeout_seconds=300),
)
register_write_guard_service(svc)
app = FastAPI()
app.state.maf_roundtable_service = svc
app.include_router(maf_router)
@app.middleware("http")
async def test_auth(request: Request, call_next):
request.state.user = _User("u1")
return await call_next(request)
async def execute_directly(_client, **kwargs):
return await execute_approved_tool(
app_config=_app_config(),
checkpointer=None,
store=None,
thread_id=kwargs["thread_id"],
user_id=kwargs["principal"].user_id,
session_id=kwargs["principal"].session_id,
run_id=kwargs["principal"].run_id,
agent_id=kwargs["agent_id"],
turn_index=kwargs["turn_index"],
model_name=None,
excluded_tools=[],
capability_snapshot=kwargs["capability_snapshot"],
write_policy=kwargs["write_policy"],
tool_name=kwargs["tool_name"],
params=kwargs["params"],
approved_action_id=kwargs["approved_action_id"],
action_record=kwargs["action_record"],
)
async def no_model_finalizer(_adapter, state):
state.final_result = {"content": "http e2e final report"}
monkeypatch.setattr(DeerFlowRuntimeClient, "execute_tool_directly", execute_directly)
monkeypatch.setattr("app.maf_roundtable.workflow_runner.generate_final_report", no_model_finalizer)
session = await svc.create_session(user_id="u1", body={"task_text": "HTTP E2E"})
run = await svc.create_run(session_id=session["id"], user_id="u1")
coordinator = ActionCoordinator(svc, user_id="u1")
actions = await coordinator.intercept_write_tool_calls(
run_id=run["id"],
session_id=session["id"],
agent_id="seat-1",
turn_index=1,
tool_calls=[{
"name": TEST_TOOL,
"id": "http-outbox-call",
"args": {"operation_id": "http-op-1", "value": "controlled"},
}],
capability_snapshot=_snapshot(),
write_policy="confirm_all_writes",
)
action = actions[0]
state = RoundtableState(
session_id=session["id"],
run_id=run["id"],
task_text="HTTP E2E",
mode="structured",
model_name=None,
reasoning_effort=None,
max_rounds=1,
write_policy="confirm_all_writes",
manager_agent_id=None,
seat_agent_ids=["seat-1"],
thread_map={"seat-1": "thread-1"},
capability_snapshot=_snapshot(),
)
state.current_round = 1
state.status = "waiting_approval"
state.pending_approvals = [{
"action_id": action["id"],
"approval_id": action["approval_id"],
"agent_id": "seat-1",
"tool_name": TEST_TOOL,
}]
await store.runs.update_run(run["id"], {"status": "waiting_approval", "maf_checkpoint": state.to_dict()})
await store.sessions.update_session(
session["id"], user_id="u1", fields={"status": "waiting_approval"}
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get(f"/api/maf-roundtable/runs/{run['id']}/actions")
assert listed.status_code == 200
listed_action = listed.json()["actions"][0]
assert "target_key" not in listed_action
assert "idempotency_key" not in listed_action
fetched = await client.get(f"/api/maf-roundtable/actions/{action['id']}")
assert fetched.status_code == 200
fetched_action = fetched.json()
assert "target_key" not in fetched_action
assert "idempotency_key" not in fetched_action
# The lease arbiter still needs the private key in persistence.
stored_before_decision = await store.actions.get_action(action["id"])
assert stored_before_decision["target_key"]
assert stored_before_decision["idempotency_key"]
response = await client.post(
f"/api/maf-roundtable/approvals/{action['approval_id']}/decision",
json={"decision": "approved"},
)
assert response.status_code == 200
assert response.json()["status"] == "approved"
duplicate = await client.post(
f"/api/maf-roundtable/approvals/{action['approval_id']}/decision",
json={"decision": "approved"},
)
assert duplicate.status_code == 409
outbox = tmp_path / "http-op-1.jsonl"
assert outbox.read_text(encoding="utf-8").count("\n") == 1
stored = await store.actions.get_action(action["id"])
assert stored["status"] == "succeeded"
assert stored["attempt_count"] == 1
assert (await store.runs.get_run(run["id"]))["status"] == "completed"
# A process/retry replay after the terminal transition returns the stored
# run and never reaches ToolNode again.
replay = await get_dispatcher(app).resume(run["id"], user_id="u1")
assert replay is not None and replay["status"] == "completed"
assert outbox.read_text(encoding="utf-8").count("\n") == 1
await engine.dispose()
@@ -1,7 +1,7 @@
"""User-isolation + table-isolation tests for the MAF roundtable experiment (MAF-01-07).
These tests focus on the security boundary: a non-owner cannot read or mutate
another user's session / run / action / approval, and the six MAF tables have
another user's session / run / action / lease / approval, and the nine MAF tables have
no foreign-key relationship to the legacy ``roundtable_*`` tables (the
experiment is fully isolated —see implementation plan §2.3 "不改原有的工程定义).
"""
@@ -17,6 +17,7 @@ from app.maf_roundtable.service import MafNotFoundError
from deerflow.persistence.base import Base
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
MafRoundtableActionLeaseRow,
MafRoundtableActionRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
@@ -149,7 +150,7 @@ async def test_admin_diagnostic_access_off_keeps_owner_scope(store):
@pytest.mark.asyncio
async def test_maf_tables_isolated_from_legacy_roundtable(store):
"""The six MAF tables must exist and none of their FKs may target a legacy
"""The nine MAF tables must exist and none of their FKs may target a legacy
roundtable_* table (MAF-01-02 / 退出标净 MAF 表和旀roundtable 表完全无关联外键)."""
_, engine = store
async with engine.connect() as conn:
@@ -162,6 +163,7 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
"maf_roundtable_runs",
"maf_roundtable_events",
"maf_roundtable_actions",
"maf_roundtable_action_leases",
"maf_roundtable_approvals",
"maf_roundtable_seat_runs",
"maf_roundtable_action_secrets",
@@ -174,6 +176,7 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
MafRoundtableRunRow,
MafRoundtableEventRow,
MafRoundtableActionRow,
MafRoundtableActionLeaseRow,
MafRoundtableActionSecretRow,
MafRoundtableApprovalRow,
MafRoundtableSeatRunRow,
@@ -770,12 +770,9 @@ def test_validate_maf_prerequisites_passes_when_all_met(monkeypatch):
reset_write_guard_config()
def test_validate_maf_prerequisites_requires_secret_key(monkeypatch):
"""validate_maf_prerequisites raises when MAF_ACTION_SECRET_KEY is missing."""
from app.maf_roundtable.service import (
MafConfigurationError,
validate_maf_prerequisites,
)
def test_validate_maf_prerequisites_allows_read_only_without_secret(monkeypatch):
"""A missing key leaves reads available; writes fail closed at interception."""
from app.maf_roundtable.service import validate_maf_prerequisites
from deerflow.config.write_guard_config import (
load_write_guard_config_from_dict,
reset_write_guard_config,
@@ -790,8 +787,34 @@ def test_validate_maf_prerequisites_requires_secret_key(monkeypatch):
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
},
})
with pytest.raises(MafConfigurationError):
validate_maf_prerequisites()
validate_maf_prerequisites()
finally:
reset_write_guard_config()
def test_write_guard_health_reports_missing_secret(monkeypatch):
from app.maf_roundtable.service import get_maf_write_guard_readiness
from deerflow.config.write_guard_config import (
load_write_guard_config_from_dict,
reset_write_guard_config,
)
monkeypatch.delenv("MAF_ACTION_SECRET_KEY", raising=False)
try:
load_write_guard_config_from_dict({
"enabled": True,
"provider": {
"policy_use": "app.maf_roundtable.write_guard_bridge:make_policy",
"interceptor_use": "app.maf_roundtable.write_guard_bridge:make_interceptor",
},
})
readiness = get_maf_write_guard_readiness()
assert readiness == {
"enabled": True,
"ready": False,
"secret_configured": False,
"status": "missing_secret",
}
finally:
reset_write_guard_config()