From 99642fca6c13f7145effdfae24ee16c7aa53250d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 3 Aug 2026 12:09:47 +0800 Subject: [PATCH] feat(maf): conversation-driven MAF roundtable (R1/R2) + server-owned stage-3 dispatch Backend: - Conversation API at /api/maf-roundtable/conversations: create/list/detail, paginated messages, atomic send+run start, owner-only isolation, admin read-only diagnostic access - SSE real-time event stream (GET /conversations/{id}/stream) backed by ConversationEventHub + conversation_events store; multi-worker deployment doc - Deterministic server-owned stage-3 dispatch contracts (dispatch_contracts.py): routing resolved from trusted MAF deployment config instead of manager-chosen agent ids; config.yaml keeps stage-3 routing deployment-owned (default_manager_agent_id / seat ids / business_routes / max_seats_per_run) - Conversation persistence (conversations/messages stores) + v2 migration script - Tests: conversations, conversation stream, migration, phase3 conversation dispatch Frontend: - Vitest test infra for MAF (vitest.maf.config.ts + test:maf script) with component tests for ApprovalCard/RunHeader/SessionsPage and use-maf-events - Session form lib, flow-graph dialog / run-detail / sessions page updates, routes and nav tweaks - Docs: R0 legacy interaction baseline mapping, updated implementation plan Co-authored-by: Cursor --- .../docs/maf-r0-旧会商交互基线与组件映射.md | 198 ++ ...t-agent-framework-多智能体会商-实施计划.md | 1644 +++++------------ frontend-web/package.json | 7 +- .../workspace/workspace-nav-chat-list.tsx | 15 +- .../__tests__/MafApprovalCard.test.tsx | 143 ++ .../__tests__/MafRunHeader.test.tsx | 113 ++ .../__tests__/MafSessionsPage.test.tsx | 144 ++ .../__tests__/maf-roundtable.test.ts | 36 +- .../__tests__/use-maf-events.test.tsx | 89 + .../components/MafFlowGraphDialog.tsx | 39 +- .../src/maf-roundtable/lib/maf-flow-graph.ts | 76 +- .../src/maf-roundtable/lib/session-form.ts | 164 ++ .../maf-roundtable/pages/MafRunDetailPage.tsx | 29 +- .../maf-roundtable/pages/MafSessionsPage.tsx | 252 +-- frontend-web/src/pages/MafRoutes.tsx | 3 + frontend-web/vitest.maf.config.ts | 32 + .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 40 +- .../app/gateway/routers/maf_conversations.py | 341 ++++ .../app/gateway/routers/maf_roundtable.py | 3 + .../maf_roundtable/conversation_event_hub.py | 284 +++ .../maf_roundtable/conversation_schemas.py | 118 ++ .../maf_roundtable/conversation_service.py | 670 +++++++ .../maf_roundtable/deerflow_agent_adapter.py | 37 + .../app/maf_roundtable/dispatch_contracts.py | 268 +++ .../backend/app/maf_roundtable/dispatcher.py | 17 + .../backend/app/maf_roundtable/schemas.py | 9 + .../app/maf_roundtable/workflow_executors.py | 79 +- .../app/maf_roundtable/workflow_runner.py | 5 + offline-backend-20260512/backend/config.yaml | 6 + .../docs/maf-sse-multi-worker-deployment.md | 112 ++ .../deerflow/config/maf_roundtable_config.py | 34 + .../persistence/maf_roundtable/__init__.py | 12 + .../maf_roundtable/conversation_events.py | 102 + .../maf_roundtable/conversations.py | 200 ++ .../persistence/maf_roundtable/messages.py | 176 ++ .../persistence/maf_roundtable/model.py | 130 ++ .../persistence/maf_roundtable/store.py | 276 +++ .../harness/deerflow/tools/tool_metadata.py | 1 + .../migrate_maf_roundtable_conversation_v2.py | 367 ++++ .../tests/test_maf_conversation_migration.py | 190 ++ .../tests/test_maf_conversation_stream.py | 473 +++++ .../backend/tests/test_maf_conversations.py | 390 ++++ .../test_maf_phase3_conversation_dispatch.py | 286 +++ .../tests/test_maf_roundtable_isolation.py | 9 + .../backend/tests/test_maf_write_guard.py | 41 +- 46 files changed, 6358 insertions(+), 1306 deletions(-) create mode 100644 frontend-web/docs/maf-r0-旧会商交互基线与组件映射.md create mode 100644 frontend-web/src/maf-roundtable/__tests__/MafApprovalCard.test.tsx create mode 100644 frontend-web/src/maf-roundtable/__tests__/MafRunHeader.test.tsx create mode 100644 frontend-web/src/maf-roundtable/__tests__/MafSessionsPage.test.tsx create mode 100644 frontend-web/src/maf-roundtable/__tests__/use-maf-events.test.tsx create mode 100644 frontend-web/src/maf-roundtable/lib/session-form.ts create mode 100644 frontend-web/vitest.maf.config.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/conversation_schemas.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py create mode 100644 offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py create mode 100644 offline-backend-20260512/backend/docs/maf-sse-multi-worker-deployment.md create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversation_events.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversations.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/messages.py create mode 100644 offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_conversation_migration.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_conversation_stream.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_conversations.py create mode 100644 offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py diff --git a/frontend-web/docs/maf-r0-旧会商交互基线与组件映射.md b/frontend-web/docs/maf-r0-旧会商交互基线与组件映射.md new file mode 100644 index 00000000..9de475a7 --- /dev/null +++ b/frontend-web/docs/maf-r0-旧会商交互基线与组件映射.md @@ -0,0 +1,198 @@ +# MAF-R0 阶段 0 交付文档:旧会商交互基线与组件映射 + +> 状态:已完成 +> 更新时间:2026-08-03 + +--- + +## 1. 实验原型隐藏说明 + +### 已完成的操作 + +- `/page/maf/*` 路由(`MafSessionsPage` + `MafRunDetailPage`)已从正式菜单移除。 +- `workspace-nav-chat-list.tsx:199` 保留了墓碑注释:`{/* [MAF-R0] 实验原型已从正式菜单隐藏,仅保留路由供开发诊断:/page/maf */}` +- `MafRoutes.tsx` 文件头部标注为 `[MAF-R0 实验原型 — 仅供开发诊断,已从正式菜单移除]`。 +- 该原型仅可通过手动输入 URL `/page/maf` 访问,不在任何侧边栏、导航菜单或工作区入口中出现。 +- 停止在该原型上新增业务功能。 + +### 原型文件位置 + +| 文件 | 用途 | +|------|------| +| `src/pages/MafRoutes.tsx` | 路由定义(index → MafSessionsPage, runs/:runId → MafRunDetailPage) | +| `src/maf-roundtable/pages/MafSessionsPage.tsx` | 会话列表页(642 行) | +| `src/maf-roundtable/pages/MafRunDetailPage.tsx` | 单 run 工作区(195 行) | +| `src/maf-roundtable/` | 完整模块(33 文件:api/hooks/components/lib/tests) | +| `vitest.maf.config.ts` | MAF 专用测试配置 | +| `package.json` → `test:maf` | MAF 测试脚本 | + +--- + +## 2. 旧会商 8 类页面状态交互地图 + +旧会商入口:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(4376 行,三步合一)。 + +### 状态 1:空白 / 新建 + +- 触发:点击「新建会商」或首次进入。 +- 表现:中心区域显示欢迎语 + 业务链选择入口 + 底部输入框。左侧历史列表为空或显示已有草稿。 +- 关键组件:`RoundtablePlanningPage`(currentStep=1 初始态)、`Step1Panel`(空消息列表 + 输入栏)。 +- 数据:`useDraftPersistence` 创建新草稿记录。 + +### 状态 2:历史切换 + +- 触发:点击左侧草稿列表中的某一条。 +- 表现:加载该草稿的 Step1/Step2/Step3 快照,恢复到对应步骤。 +- 关键组件:`useRoundtableDrafts`(列表)、`useDraftPersistence`(加载 + hydrate)。 +- 数据:`GET /api/roundtable-drafts/{id}` 或 `GET /api/roundtable-task-drafts/{id}`。 + +### 状态 3:用户提问 + +- 触发:用户在 Step1 输入框输入任务文本并点击发送。 +- 表现:用户气泡立即插入,随后出现 `MessageLoadingPlaceholder`(spinner + 跳动点),等待 SSE 首帧。 +- 关键组件:`Step1Panel`(输入栏 → 发送)、`MessageBubble`(用户气泡)、`MessageLoadingPlaceholder`。 +- 数据:`POST /api/intent/init` → `POST /api/intent/stream`(SSE)。 + +### 状态 4:意图澄清 + +- 触发:总控/意图智能体判断信息不足,返回 `ask_clarification` 帧。 +- 表现:对话流中嵌入 `IntentClarificationCard`(单选/多选 + 自定义输入),用户选择后提交。 +- 关键组件:`IntentClarificationCard`(选项卡)、`Step1Panel`(澄清提交逻辑)。 +- 数据:SSE 帧 `phase=clarification`,用户回答后继续 `POST /api/intent/stream`。 + +### 状态 5:总控派活 + +- 触发:Step1 完成(INTENT_READY)→ 进入 Step2 → 总控输出派活计划。 +- 表现:总控气泡显示派活计划(谁做什么),`ParallelSeatStrip` 显示参与席位头像条(running/done/error 徽章)。 +- 关键组件:`Step2Panel`(派活叙事)、`ParallelSeatStrip`(席位条)、`MessageBubble`(总控气泡)。 +- 数据:`POST /api/multi-agent/init`(SSE,建立 thread map)→ `POST /api/multi-agent/run/stream`。 + +### 状态 6:席位发言 + +- 触发:总控派活后,各席位按序/并行执行。 +- 表现:每个席位的气泡流式出现(`StreamingCursor` 闪烁尾标),工具调用显示为 `MessageStepsCard`(ChainOfThought 折叠卡)。 +- 关键组件:`MessageBubble`(席位气泡 + avatar)、`StreamingCursor`、`MessageStepsCard`(工具步骤)、`DialogueSpeedMeter`(tok/s)。 +- 数据:SSE delta 帧按 message id 合并。 + +### 状态 7:审批 + +- 触发:席位执行外部写操作,WriteGuard 拦截并请求审批。 +- 表现:对话流中嵌入审批卡(工具名 + 风险等级 + 摘要),用户点击批准/拒绝。 +- 关键组件:MAF 原型中为 `MafApprovalCard`;旧会商中审批通过 `roundtable-jobs` 的 clarify-resume 机制。 +- 数据:`POST /api/maf-roundtable/approvals/{id}/decision`(MAF)或 `POST /api/roundtable-jobs/{id}/clarify-resume`(旧)。 + +### 状态 8:完成 / 失败恢复 + +- 触发:所有席位完成 + 总控收束 → Step3;或 SSE 错误帧 / 网络断开。 +- 表现(完成):Step3 显示最终报告(Markdown 总结 / HTML 看板 / 大屏),可追问。 +- 表现(失败):错误提示 + 恢复按钮;后台任务可挂起/恢复。 +- 关键组件:`Step3Panel` → `HighFidelityReport`(Report/Dashboard/Summary/FileBrowse 模式);失败时 `RoundtableJobNotifier`。 +- 数据:`useStep3Summary` / `useStep3Report` / `useStep3Dashboard`;失败恢复 via `POST /api/roundtable-jobs/{id}/resume`。 + +--- + +## 3. 旧组件 → 新 MAF view-model 映射表 + +| 旧组件 / hook | 新 MAF 对应 | 映射说明 | +|---|---|---| +| `RoundtablePlanningPage`(三步壳) | `MafRoundtableConversationPage` | 不再有步骤切换;单一连续时间线。 | +| `Step1Panel`(意图问答) | `ConversationTimeline` + `ClarificationCard` | 意图理解变为 `manager.intent` 事件卡。 | +| `Step2Panel`(研讨对话流) | `ConversationTimeline` + `SeatMessageBubble` | 席位发言由 SSE `agent.message.*` 驱动。 | +| `Step3Panel` / `HighFidelityReport` | `FinalResultCard` | 结论作为时间线中的结果卡。 | +| `MessageBubble` | `SeatMessageBubble` / 总控气泡 | 重写为 MAF view model;不复用旧 props。 | +| `MessageStepsCard` | `ToolProgressCard` | 工具过程由 `tool.started/progress/completed` 事件驱动。 | +| `IntentClarificationCard` | `ClarificationCard` | 由 `clarification.required` 事件触发。 | +| `ParallelSeatStrip` | `ParticipantStrip` | 状态改由 `manager.dispatch` + `seat.delivery.submitted` + `manager.review.decision` 驱动。 | +| `useStep1Intent` | 删除 | 意图理解完全服务端化。 | +| `useStep2Orchestration` | 删除 | 编排完全服务端化;前端只消费 SSE。 | +| `useDraftPersistence` | `use-maf-conversations` | 历史改为 conversation 列表。 | +| `useRoundtableDrafts` | `use-maf-conversations` | 同上。 | +| `useStep3Summary/Report/Dashboard` | 删除 | 结论由服务端生成,前端只渲染结果卡。 | +| `useAutoScroll` | 可复用(无业务依赖) | 纯 UI 滚动逻辑。 | +| `useDialogueSpeedMetrics` | 可复用(无业务依赖) | 纯 UI 速度指标。 | +| `MafApprovalCard`(原型) | `ApprovalMessageCard` | 重写为 conversation 时间线内卡片。 | +| `MafEventTimeline`(原型) | 删除 | 不再暴露 run/event 管理后台。 | +| `MafSeatStatusPanel`(原型) | `ParticipantStrip` | 合并到右侧席位条。 | + +--- + +## 4. 禁止复用的旧业务 hook / API / 状态清单 + +以下模块 **严禁** 被新 MAF conversation 页面直接 import 或调用: + +### 4.1 旧会商 API 客户端(`src/roundtable-planning/api/`) + +| 文件 | 禁止原因 | +|------|----------| +| `multi-agent.ts` | 调用 `/api/multi-agent/*`,包含前端 thread map 和编排逻辑 | +| `intent.ts` | 调用 `/api/intent/*`,前端驱动意图流 | +| `drafts.ts` | 调用 `/api/roundtable-drafts`,旧草稿状态 | +| `task-drafts.ts` | 调用 `/api/roundtable-task-drafts`,旧任务草稿 | +| `roundtable-jobs.ts` | 调用 `/api/roundtable-jobs`,旧后台任务 | +| `chains.ts` | 调用 `/api/roundtable-chains`,旧业务链 CRUD | +| `recommend.ts` | 调用 `/api/recommend/stream`,旧推荐流 | +| `flow-extract.ts` | 调用 `/api/flow-extract/*`,旧流程图提取 | +| `ingest.ts` | 调用 `/api/flow-extract/ingest-eligibility` | +| `dashboard-sessions.ts` | 调用 `/api/dashboard-sessions` | +| `diagnostics-report.ts` | 调用 `/api/roundtable-diagnostics/report` | + +### 4.2 旧会商业务 Hook(`src/roundtable-planning/hooks/`) + +| 文件 | 禁止原因 | +|------|----------| +| `useStep1Intent.ts` | 前端驱动意图状态机 | +| `useStep2Orchestration.ts` | 前端编排循环(thread map、派活、轮次控制) | +| `useDraftPersistence.ts` | 旧草稿持久化逻辑 | +| `useRoundtableDrafts.ts` | 旧草稿列表 | +| `useStep3Summary.ts` | 前端驱动总结生成 | +| `useStep3Report.ts` | 前端驱动看板生成 | +| `useStep3Dashboard.ts` | 前端驱动大屏生成 | + +### 4.3 旧会商业务状态类型 + +- `Step1Snapshot`、`Step2Snapshot`、`Step2Dialogue`、`StubAiMessage`、`ParallelSeatView` +- `ThreadIdMap`、`ClarificationPayload`、`LeaderDispatch`、`StreamPhase` +- `DraftRecord`、`DraftMeta`、`DraftStep1Snapshot`、`DraftStep2Snapshot`、`DraftStep3Snapshot` +- `RoundtableJob`、`JobStatus`、`DispatchChainNode`、`StartJobPayload` +- `IntentSummary`、`IntentFileRef`、`IntentStreamPhase` + +### 4.4 MAF 原型中禁止继续扩展的模块 + +| 文件 | 禁止原因 | +|------|----------| +| `src/maf-roundtable/pages/MafSessionsPage.tsx` | 实验原型,不作为产品主界面 | +| `src/maf-roundtable/pages/MafRunDetailPage.tsx` | 实验原型,暴露 run/session 管理 | +| `src/maf-roundtable/hooks/use-maf-events.ts` | 原型轮询 hook | +| `src/maf-roundtable/api/sessions.ts` | 原型 session CRUD | +| `src/maf-roundtable/api/runs.ts` | 原型 run 管理 | +| `src/maf-roundtable/api/events.ts` | 原型事件轮询 | +| `src/maf-roundtable/lib/session-form.ts` | 原型表单逻辑 | + +### 4.5 允许参考但必须重写的视觉组件 + +以下组件的 **交互模式和信息架构** 可作为参考,但新 MAF 页面必须使用独立实现(MAF view model + tdesign-react),不得直接 import: + +- `MessageBubble.tsx`(气泡布局) +- `MessageStepsCard.tsx`(工具步骤折叠) +- `IntentClarificationCard.tsx`(澄清选项卡) +- `ParallelSeatStrip.tsx`(席位头像条) +- `ScrollToBottomButton.tsx`(回到底部按钮) +- `Avatars.tsx`(头像 SVG) + +### 4.6 可安全复用的无业务依赖工具 + +- `useAutoScroll.ts`(纯滚动逻辑) +- `useDialogueSpeedMetrics.ts`(纯速度指标) +- `src/lib/use-desensitize-subtree.ts`(DOM 脱敏,已标注多模块共享) +- `markdown-components.tsx`(markdown 渲染配置,需确认无旧状态依赖) + +--- + +## 5. 验收确认 + +- [x] 旧会商 `RoundtablePlanningPage` 无代码行为变化(未修改任何 `src/roundtable-planning/` 文件)。 +- [x] `/page/maf/*` 原型已从菜单隐藏,仅保留路由供开发诊断。 +- [x] 8 类页面状态已记录,含触发条件、表现、关键组件和数据流。 +- [x] 旧组件到新 MAF view-model 的映射表已完成。 +- [x] 禁止复用的 hook/API/状态清单已明确。 +- [x] 任何人可根据本文档判断新版是否「像原页面」。 diff --git a/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md b/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md index 07bda50d..b6be5e1e 100644 --- a/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md +++ b/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md @@ -1,1236 +1,608 @@ -# Microsoft Agent Framework 多智能体会商对照页实施计划 +# MAF 多智能体会商升级版实施计划(V2:保留原会商体验,替换底层编排) -> 文档状态:实施中;已按当前工作区代码重新盘点,阶段 5 安全闭环处于收口验收,阶段 4 动态会商仍未达到退出标准 +> 文档状态:**V2 重写,待按本计划重新实施** > 更新时间:2026-08-02 -> 目标版本:实验性独立功能,不替换现有多智能体会商 -> 建议页面名称:`MAF 会商(实验)` +> 本轮决策:原 `/page/maf/*` 的「会话列表 + run 详情 + 前端轮询」原型不再作为目标产品继续扩展。 +> 产品目标:新 MAF 会商是**原多智能体会商页面的底层升级版**,不是另造一套会商产品。 -## 0. 当前实施状态与前后端隔离决议 +--- -### 0.1 代码盘点结论(以 2026-08-02 当前工作区为准) +## 1. 本次重新确认的产品目标 -本节的状态以当前 `master-dev`、尚未提交的 MAF 改动和本次实际运行结果为准,不沿用同事口头的阶段编号或测试数量。`已实现`不等于`退出验收通过`;只有满足本阶段退出标准、测试证据完整且没有 P0/P1 缺口时才能进入下一阶段的正式验收。 +用户认可的是现有多智能体会商的视觉语言和交互链路:左侧历史会话、中心对话流、输入框发问、意图理解、总控派活、席位发言、过程卡片、澄清与审批、最终结论。 -| 阶段 | 当前状态 | 已有代码/证据 | 尚未满足的退出条件 | -|---|---|---|---| -| 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 测试、离线打包与灰度 | 未开始 | 无。 | 依赖可验收版本。 | +本次升级只替换以下“看不见的发动机”: -本次重新运行的证据: +- 旧的前端驱动编排、多个前端可见线程和旧会商作业,替换为 MAF Workflow + DeerFlow Agent Adapter; +- 浏览器定时轮询,替换为一条可恢复的 SSE 对话事件流; +- 用户手动创建/管理多个智能体线程,收敛为服务端按需创建和持久化的内部执行线程; +- 保留现有工具、技能、MCP、沙箱、WriteGuard、审批和审计能力,并让它们在原有会商体验中自然出现。 -- 后端安全闭环重点用例:`299 passed in 151.09s`(包含真实入口 ToolNode/outbox、能力快照、WriteGuard、审批过期与目标租约、旧 DeerFlow 回归)。 -- 后端编排重点用例:`40 passed in 5.46s`(真实 SDK structured/group_chat 路径、Agent Adapter、阶段 3 主持人流程)。 -- 前端 MAF 逻辑用例:`19 passed`;`pnpm build` 通过。 -- 全量 `test_maf_*.py` 已在无短时命令限制的终端完成,最终结果为 `299 passed in 151.09s`;PowerShell 下使用文档中的文件枚举方式展开测试文件。 +**不能接受的结果:** 新页面没有对话列表、没有输入框、让用户先创建 session/run、需要用户选择或理解多个内部线程、靠多个定时 GET 拼出实时效果,或把审批/流程图做成脱离聊天主链路的独立后台页。 -阶段 1 完成并不表示旧会商被替换。当前配置已打开新的 MAF 功能开关;前端入口由健康检查和权限控制。旧 DeerFlow、旧 roundtable、AI 写作不携带 `maf_run_id`,因此不进入 MAF WriteGuard 分支。 +### 1.1 必须保留的用户体验 -### 0.3 阶段 5:真实工具、技能与外部写入审批闭环(当前收口) +| 原会商能力 | 升级版要求 | +|---|---| +| 左侧会商历史、新建会商 | 保留。一个条目就是一个用户可见会商,不暴露 MAF session/run/thread。 | +| 中央连续对话流 | 保留。用户消息、总控消息、席位发言、工具进度、澄清、审批、最终报告按时间顺序呈现。 | +| 底部对话输入框 | 保留。用户可在首轮发起任务、在澄清时回答、在会商结束后追问/继续会商。 | +| 意图理解 | 保留并服务端化。先识别“可直接会商 / 需要澄清 / 只回答问题 / 需要工具或写入”。 | +| 总控智能体派活 | 保留。总控是唯一能选择席位、分派任务、收束轮次和生成结论的主体。 | +| 业务链、席位与过程可视化 | 保留其信息架构和交互,不复用旧业务状态;数据改由 MAF 事件驱动。 | +| 澄清、取消、恢复、审批 | 保留,并成为对话流中的卡片,而不是跳转到 run 管理页。 | -本阶段已补齐以下安全边界: +### 1.2 明确不保留的实现方式 -- `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` 和状态码,不返回密钥。 +- 不在浏览器端 `setInterval` 轮询 run、events、actions、approvals; +- 不让浏览器创建或维护 seat thread、manager thread、thread map; +- 不让用户先填写“经理、席位、模式、最大轮数”才能开始一次正常会商; +- 不把 `MafSessionsPage` / `MafRunDetailPage` 当作新产品主界面继续堆功能; +- 不调用旧的 `/api/roundtable-*`、`/api/multi-agent/*`、`roundtable_jobs`、旧草稿或旧会商状态; +- 不修改旧 `RoundtablePlanningPage` 的业务状态机、历史数据和对外行为。 -本轮框架代码已完成阶段 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` 共 `299 passed`;阶段 5 定向回归 `21 passed`;包含 `app/gateway/app.py` 在内的本轮修改范围 Ruff 检查通过;`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 新前端页面的强制隔离原则 - -后续前端必须新建一套 MAF 会商页面,仅调用新后台 `/api/maf-roundtable/*`;不得把新页面接到旧会商 API,也不得为了复用 UI 而修改旧会商的状态机、草稿、运行记录或路由行为。 - -允许复用通用 UI 基础组件(按钮、Dialog、Tabs、SSE 基础设施、鉴权请求客户端),但以下模块只能作为只读参考,禁止修改其业务代码或直接共用其业务状态: - -- 旧 `RoundtablePlanningPage` 及 `roundtable-planning/**`; -- `/api/roundtable-*`、`/api/multi-agent/*` 及其草稿/作业接口; -- 任务深链会商、岗位会商和现有多智能体会商的页面、store、hooks; -- `roundtable_jobs`、`roundtable_drafts`、`roundtable_task_drafts` 及旧会商事件流。 - -新页面的唯一数据源是 MAF 专用 API 与 MAF 专用表;新旧会商之间如需做效果对照,只能通过显式的只读对照记录或导出的指标关联,不能共享运行状态。 - -## 1. 结论 - -本项目可以接入 Microsoft Agent Framework(以下简称 MAF),新增一套独立的多智能体会商页面,并且保留真实的工具调用、技能调用、MCP、沙箱文件写入和外部系统写入能力。 - -本方案不把 Microsoft Samples 示例应用整体嵌入 DeerFlow,而是使用 MAF 的 Agent/Workflow/Group Chat 能力负责多智能体编排;每个业务席位的实际执行优先复用现有 DeerFlow Agent Runtime。这样能够同时满足以下目标: - -- 原有会商页面、API、任务、草稿、后台作业和数据库表保持原行为。 -- MAF 页面拥有独立路由、独立 API、独立会话和独立事件记录。 -- 现有 Agent 配置中的工具组、技能、MCP、模型、沙箱和用户权限继续生效。 -- 外部写入不是演示占位,而是正式执行能力。 -- 可以在同模型、同提示词、同任务和同能力条件下比较两种编排方式。 - -## 2. 范围与边界 - -### 2.1 本期必须实现 - -- 新页面:`/page/maf/*`(当前已落地为独立顶级菜单,不嵌入旧会商页)。 -- 两种 MAF 编排模式: - - `structured`:专家并行分析 → 交叉审查 → 主持人综合。 - - `group_chat`:主持人动态选择下一位发言人,多轮讨论后收敛。 -- 支持从现有 Agent 列表选择主持人和参会席位。 -- 支持现有模型配置,包括 OpenAI 兼容端点。 -- 支持每个席位使用其已有工具组和技能白名单。 -- 支持内置工具、MCP 工具、技能脚本、沙箱产物和外部系统写入。 -- 支持工具过程、技能过程、审批过程、会商发言和最终结果的实时事件流。 -- 支持后台执行、刷新恢复、断线续传、取消和失败重试。 -- 支持查看外部写入结果、调用参数摘要、执行人、执行时间和审计状态。 -- 支持与现有会商的结果指标进行人工或半自动对比。 - -### 2.2 本期不做 - -- 不替换现有 `RoundtablePlanningPage`。 -- 不迁移现有 `roundtable_jobs`、草稿或历史记录。 -- 不把 MAF 依赖放进 `deerflow.*` 核心 Harness 的 Agent 编排层。 -- 不在第一期制作自由拖线式工作流编辑器。 -- 不在生产业务对象上同时执行两套会商的重复写操作;真实写入对照使用 UAT、测试租户或隔离业务命名空间。 - -### 2.3 “不改原有”的工程定义 - -允许在公共入口增加受特性开关保护的路由注册和菜单入口,但不得改变旧功能的状态机、API 请求体、数据库表、任务恢复逻辑或页面交互。 - -以下旧链路必须保持兼容: - -- `/api/roundtable-*` -- `/api/multi-agent/*` -- `roundtable_jobs` -- `roundtable_drafts` -- `roundtable_task_drafts` -- `/page/strategy/qa/multi-agent-roundtable` -- 任务深链下的三个原会商入口 - -### 2.4 新前端与新后台的对接边界 - -新前端页面固定使用独立路由 `/page/maf/*`,并且只允许请求以下新接口前缀: - -```text -GET /api/maf-roundtable/health -POST /api/maf-roundtable/sessions -GET /api/maf-roundtable/sessions -GET /api/maf-roundtable/sessions/{session_id} -PUT /api/maf-roundtable/sessions/{session_id} -POST /api/maf-roundtable/sessions/{session_id}/runs -GET /api/maf-roundtable/sessions/{session_id}/runs -GET /api/maf-roundtable/runs/{run_id} -POST /api/maf-roundtable/runs/{run_id}/cancel -GET /api/maf-roundtable/runs/{run_id}/events -GET /api/maf-roundtable/runs/{run_id}/actions -GET /api/maf-roundtable/runs/{run_id}/approvals -POST /api/maf-roundtable/approvals/{approval_id}/decision -``` - -其中事件写入没有公开 HTTP 接口;只有后续后端 Workflow 执行器可以通过内部服务写入事件。前端不得使用 mock 事件、不得伪造工具、技能、审批或外部写入事件。 - -## 3. 核心架构决策 - -### 3.1 采用“MAF 编排 + DeerFlow 席位执行” - -MAF 负责: - -- 工作流拓扑。 -- 发言人选择。 -- 轮次与终止条件。 -- 群聊上下文同步。 -- 并行、审查和综合流程。 -- Workflow checkpoint 与恢复。 - -DeerFlow Agent Runtime 负责: - -- 读取 Agent 的 SOUL、模型、工具组和技能白名单。 -- 注入技能说明和运行上下文。 -- 调用内置工具、MCP、ACP、沙箱和子智能体工具。 -- 文件产物生成。 -- 用户身份、记忆、视觉、循环检测、工具错误处理等现有中间件。 -- 外部 API、外部业务系统和其他有副作用工具的实际执行。 - -不建议第一版把 LangChain `BaseTool` 逐个改造成 MAF 原生工具。现有工具中存在 `ToolRuntime`、线程、用户、沙箱和中间件依赖,直接包装容易产生“工具能调用,但身份或沙箱上下文丢失”的隐性问题。 - -第一版应实现一个 `DeerFlowAgentAdapter`:MAF 把一次席位发言交给适配器,适配器通过进程内 ASGI 调用现有 thread/run stream,完整消费 DeerFlow SSE,再转换成统一的 MAF 会商事件。 - -### 3.2 公平对照原则 - -要比较的是“多智能体编排方式”,而不是模型、提示词或工具实现差异。因此同一组对照实验必须冻结: - -- 模型与推理参数。 -- 任务正文、附件和补充材料。 -- 主持人和席位 Agent。 -- 每个 Agent 的 SOUL、技能和工具权限。 -- 最大轮次、单次超时和总预算。 -- 外部写入模式与目标环境。 -- 最终输出结构和验收规则。 - -### 3.3 新功能隔离结构 +## 2. 总体架构与边界 ```mermaid flowchart LR - UI["MAF 会商实验页"] --> API["/api/maf-roundtable"] - API --> SVC["MAF Roundtable Service"] - SVC --> WF["MAF Workflow / GroupChatBuilder"] - WF --> MGR["主持人 Agent"] - WF --> A1["席位 Agent Adapter A"] - WF --> A2["席位 Agent Adapter B"] - WF --> A3["席位 Agent Adapter C"] - A1 --> DF["DeerFlow Thread / Run Stream"] - A2 --> DF - A3 --> DF - DF --> CAP["工具 + 技能 + MCP + 沙箱 + 外部写入"] - SVC --> DB["独立 MAF 会商表"] - SVC --> EVT["事件流与审计"] + U[用户:会商对话页] -->|POST 新建/发送消息| API[MAF Conversation API] + API --> C[(MAF Conversation / Message)] + API --> D[MAF Dispatcher] + D --> W[MAF Workflow] + W --> M[总控:意图、派活、收束] + M --> S[服务端按需创建席位执行线程] + S --> A[DeerFlow Agent Adapter] + A --> T[工具 / 技能 / MCP / 沙箱] + T --> G[WriteGuard + 审批] + W --> E[(持久化会商事件)] + E --> H[SSE Hub:重放 + 实时推送] + H -->|一条 SSE 流| U ``` -## 4. 运行流程 +### 2.1 前后端职责 -### 4.1 创建会商 +| 层 | 职责 | 禁止事项 | +|---|---|---| +| 前端 | 呈现会商、发送用户消息、消费 SSE、执行用户明确点击的取消/审批/重试 | 编排席位、决定下一位发言者、创建内部线程、轮询事件。 | +| MAF Conversation Service | 会话聚合、消息持久化、运行创建、SSE 重放、所有权校验 | 让浏览器传入 user_id、工具能力快照、目标键或内部幂等键。 | +| MAF Workflow | 意图判断、总控派活、席位协作、交叉审查、结论、恢复 | 把模型输出直接当作可写外部系统的授权。 | +| DeerFlow Adapter | 为总控/席位提供既有模型、工具、技能、MCP、沙箱和用户上下文 | 从 `packages/harness/deerflow/**` 导入 `app.*`。 | +| WriteGuard | 风险识别、审批、目标租约、一次执行、审计 | 将密钥、target_key、idempotency_key 回传给浏览器或模型。 | -1. 用户进入 `MAF 会商(实验)`。 -2. 输入任务、选择模型、主持人、参会 Agent 和编排模式。 -3. 前端读取每个 Agent 当前可用的工具组和技能,并展示能力摘要。 -4. 用户选择外部写入策略: - - `auto`:按现有用户权限直接执行所有已授权写操作。 - - `confirm_high_risk`:普通写入自动执行,高风险写入等待确认。 - - `confirm_all_writes`:所有外部写入均等待确认。 -5. 后端创建 MAF session,并冻结本次 `capability_snapshot`。 -6. 后端为主持人和每个席位创建或绑定独立 DeerFlow thread。 +### 2.2 新旧隔离规则 -### 4.2 执行会商 +1. 旧会商继续运行,旧路由、旧 API、旧数据表、旧任务深链全部不变。 +2. 新页面独立使用 `/api/maf-roundtable/*` 和 `maf_roundtable_*` 表;只允许参考旧页面的布局、文案、交互和通用视觉规范。 +3. 旧 `roundtable-planning/**` 中的业务 hook、草稿、Job、SSE 消费器和状态类型不能被新页面直接 import。 +4. 若需要复用旧组件的视觉效果,必须复制/迁移为 `maf-roundtable/legacy-compatible/**` 中的新组件,替换为 MAF view model;或抽到无业务依赖的公共展示组件。旧页面不得因此改变行为。 +5. 新旧页面都只能使用仓库本地依赖;UI 基础组件统一采用 **`tdesign-react`**,不接远程 UI、CDN 或外部运行时。 -1. MAF 生成下一步调度决策。 -2. 被选中的席位收到:用户任务、完整或裁剪后的会商上下文、自己的角色要求和当前阶段目标。 -3. `DeerFlowAgentAdapter` 发起席位 run。 -4. 席位可真实调用工具、技能、MCP、沙箱和外部写入。 -5. 工具与技能事件实时写入 `maf_roundtable_events`,并推送给前端。 -6. 席位完成后,标准化为 `AgentResponse` 返回 MAF。 -7. MAF 决定下一席位、进入审查、请求用户确认或结束会商。 -8. 主持人生成结构化最终结论和执行记录。 +--- -### 4.3 暂停与恢复 +## 3. 用户可见的新版会商流程 -出现以下情况时进入 `waiting_approval` 或 `waiting_input`: +### 3.1 首次进入 -- 工具声明需要审批。 -- 外部写入策略要求审批。 -- Agent 主动请求澄清。 -- MAF 工作流触发人工参与节点。 +1. 用户点击独立菜单“MAF 会商(升级版)”,进入与旧会商相同层级的页面壳。 +2. 左侧显示自己的 MAF 会商历史和“新建会商”。新建只创建一个**用户可见会商**,不显示 session/run/thread 表单。 +3. 中心区域显示欢迎语、业务链/任务上下文入口和对话输入框。 +4. 用户输入任务后,前端立即插入本地的用户气泡,调用一次 `POST /messages`,然后消费同一会商的 SSE 流。 -恢复时必须从持久化 checkpoint 和最后一个事件序号继续,不允许重新执行已经成功的外部写入。 +### 3.2 一次会商轮次 -## 5. 编排模式设计 +1. SSE 推送“正在理解任务”事件;页面以旧会商的过程卡/总控气泡表现。 +2. 若信息不足,推送 `clarification.required`,页面显示可回复的澄清卡;用户回答后仍通过输入框或卡片提交。 +3. 若可以执行,总控输出“派活计划”;页面以旧的席位条、派活卡和消息气泡展示。 +4. 每个席位的可见发言、工具进度、技能过程、交叉审查和总控收束都写入同一条时间线。 +5. 出现外部写操作时,SSE 推送审批卡。用户批准/拒绝后,后端恢复工作流,SSE 继续推送;前端不手工轮询 run 状态。 +6. 最终结论、事实账本、执行动作和报告作为对话流中的结果卡呈现;用户可以继续在同一会商输入追问。 -### 5.1 固定流程 `structured` +### 3.3 “一个会商”与内部线程的关系 + +- 用户只认识 `conversation_id`;左侧历史、URL 和前端状态都以它为主键。 +- 一个 conversation 可以有多轮用户消息,每轮消息产生一个内部 `maf_run`。 +- `maf_session`、manager/seat thread、seat run 都是服务端内部执行身份。服务端使用既有 `ExecutionPrincipal` 绑定当前用户,并在真正需要某席位执行时懒创建/复用线程。 +- 浏览器只收到经过脱敏的消息和事件,不会收到内部 thread id、能力快照、目标键、幂等键、密钥或原始工具结果。 + +### 3.4 总控质量闸门:派活不是“发出即完成” + +当前旧会商的问题不是没有派活,而是席位只说一两句话、格式和证据都不合格时,总控仍把它当作完成并直接进入下一步。新版 MAF 必须把总控从“消息转发者”升级为**每项席位交付的验收责任人**。 + +每一个由总控发布的席位任务都必须有服务端持久化的 `task_contract`: + +| 字段 | 含义 | +|---|---| +| `objective` | 本席位要回答的具体问题,不能只写泛泛角色描述。 | +| `expected_deliverable` | 交付类型和结构,例如“风险清单 + 依据 + 优先级 + 建议”。 | +| `acceptance_criteria` | 由总控给出的可验收标准:覆盖范围、最少关键点、证据/工具依据、格式与约束。 | +| `required` | 是否为本阶段必需交付;必需项不能被静默跳过。 | +| `max_rework_attempts` | 同一席位最多返工次数,默认 **2**(初稿之外最多两次返工),服务端配置且写入快照。 | +| `fallback_policy` | 到达返工上限后的策略:改派、请求用户澄清,或带缺口受控推进。 | + +#### 3.4.1 席位交付与总控验收状态机 ```mermaid -flowchart LR - T["任务"] --> P["计划与分工"] - P --> E1["专家 A"] - P --> E2["专家 B"] - P --> E3["专家 C"] - E1 --> R["交叉审查"] - E2 --> R - E3 --> R - R --> F["主持人综合"] - F --> O["结论 + 行动 + 产物 + 外部写入记录"] +stateDiagram-v2 + [*] --> assigned + assigned --> delivering + delivering --> submitted + submitted --> manager_review + manager_review --> accepted: 达到合同标准 + manager_review --> rework: 不合格且未到返工上限 + rework --> delivering: 带明确退回意见 + manager_review --> reassigned: 达到上限且可改派 + reassigned --> delivering + manager_review --> exhausted: 达到上限且无合适改派 + exhausted --> controlled_gap: 记录缺口、风险与原因 + accepted --> [*] + controlled_gap --> [*] ``` -实现要求: +验收规则如下: -- 专家阶段允许并行。 -- 并行席位各用独立 DeerFlow thread 和独立沙箱。 -- 每个席位允许使用工具、技能和外部写入。 -- 并行写同一个外部对象时由 Action Coordinator 串行化,不通过禁用写工具规避冲突。 -- 专家输出必须通过 Pydantic/JSON Schema 校验。 -- 校验失败允许一次定向修复;再次失败则保留原文并标记 `schema_invalid`,不得无限重试。 -- 审查阶段显式检查证据、冲突、遗漏、工具失败和外部写入结果。 +1. 席位先提交结构化 `delivery`,至少包含结论、依据/证据、未解决项、自检结果;不能只输出“已完成”或一两句空泛结论。 +2. 进入 `manager_review` 前先执行确定性检查:空内容、最小有效长度、必须章节、必填字段、所需证据/工具引用、重复文本、错误 schema。确定性不通过时不允许直接 accepted。 +3. 总控随后根据 `task_contract` 做结构化审查,决策只能是 `accept`、`return_for_rework`、`reassign`、`ask_user`、`controlled_gap`,并必须写明 `reason` 和针对性修改要求。 +4. **总控的模型自评不是验收证据。** 席位自评分只作为参考;验收由确定性规则 + 总控审查 + 必要时交叉审查共同决定。 +5. 对同一席位,默认最多两次返工。每次打回都必须把“缺什么、怎样补、哪些原结论不可用”带入下一次任务上下文。 +6. 到达上限后绝不把不合格结果伪装成合格:总控应优先改派给一个合适备用席位(默认最多一次改派);仍无法满足时生成 `controlled_gap`,明确标记未覆盖问题、风险和对最终结论的影响,再受控进入下一阶段。 +7. 一个阶段的所有 `required` 任务必须处于 `accepted` 或 `controlled_gap` 等明确终态,阶段闸门才可通过。总控不能因为“轮数到了”或“已有部分回答”跳过未决必需项。 +8. 最终报告不得将 `controlled_gap` 的内容写成已验证事实;它必须在结论中的风险/待补信息部分可追溯显示。 -### 5.2 动态群聊 `group_chat` +这既满足“打回几次后不要死循环”的要求,也避免“超过次数就把垃圾答案强行推进”的错误行为。 -使用 MAF `GroupChatBuilder`,由主持人或确定性 selector 选择下一位发言人。 +#### 3.4.2 对话页必须可见的质量反馈 -实现要求: +- 总控派活卡展示任务目标、交付要求和当前第几次尝试; +- 席位提交后显示“总控审核中”,而不是立即标为完成; +- 打回时以总控消息卡显示具体退回理由和补充要求; +- 改派时显示原席位未通过和新席位承接范围,但不羞辱性展示内部评分; +- 受控降级时显示“存在未解决缺口”,并在最终结果卡中保留风险提示; +- 右侧席位条区分“执行中、待返工、已验收、已改派、存在缺口”,不能只显示“发过言”。 -- 默认最大 8 个席位回合,可配置但设置硬上限。 -- 默认最多连续选择同一席位 2 次。 -- 终止条件同时包含:主持人判定完成、结构化结果通过校验、最大回合到达、总超时到达。 -- 每个席位在发言前同步会商上下文,但长工具输出只传摘要与引用,不广播完整二进制或超长日志。 -- 工具执行结果必须进入共享事实账本,避免后续席位把未经执行的提议当作已执行事实。 -- 主持人也可以使用其 Agent 配置中已授权的工具和技能,不做框架级禁用。 +--- -### 5.3 共享事实账本 +## 4. API 与 SSE 契约(必须先定,前端不得猜测) -群聊文本不能作为外部动作成功与否的唯一依据。新增 `fact_ledger`,记录: +### 4.1 Conversation API -- 已确认事实。 -- 信息来源与证据引用。 -- 工具调用结果。 -- 已执行外部写入。 -- 等待确认的动作。 -- 失败或状态不确定的动作。 -- Agent 之间尚未解决的冲突。 +以下接口全部位于 `/api/maf-roundtable`,身份只从服务端鉴权上下文读取。 -主持人生成结论时必须以账本状态为准。 +| 接口 | 用途 | 前端调用时机 | +|---|---|---| +| `GET /conversations` | 左侧历史列表(分页) | 首次进入、用户显式刷新、创建/删除后失效刷新;**无定时轮询**。 | +| `POST /conversations` | 创建空白用户会商 | 点击“新建会商”。只接收 title、任务上下文等安全字段。 | +| `GET /conversations/{id}` | 获取会商快照、权限和当前状态 | 进入会商或浏览器刷新时。 | +| `GET /conversations/{id}/messages` | 获取历史消息分页 | 进入会商、向上滚动加载历史。 | +| `POST /conversations/{id}/messages` | 原子写入用户消息并启动 MAF run | 用户点击发送或提交澄清。返回 `message_id`、`run_id`、`conversation_id`。 | +| `POST /conversations/{id}/cancel` | 取消当前活跃 run | 用户显式点击取消。 | +| `POST /approvals/{id}/decision` | 批准/拒绝写入动作 | 审批卡点击;成功后由后端恢复 workflow。 | +| `POST /conversations/{id}/recover` | 显式恢复异常中断的活跃会商 | 仅在后端判定可恢复时展示。 | -## 6. 工具、技能与外部写入 +不再让页面以“创建 session → 创建 run → GET events 循环”的方式驱动会商。原有低层 session/run 接口可以作为内部诊断 API 暂时保留,但从用户页面和导航移除。 -### 6.1 工具接入 - -工具能力不在 MAF 页面统一裁剪。每个席位继承其现有 Agent 配置和当前登录用户权限,包括: - -- `config.yaml` 配置工具。 -- DeerFlow 内置工具。 -- MCP 工具。 -- ACP Agent 工具。 -- 文件、搜索、浏览器、图像、记忆和计划任务工具。 -- Agent 已获授权的子智能体工具。 - -能力快照至少包含: - -```json -{ - "agent_id": "example-agent", - "model_name": "configured-model", - "tool_groups": ["search", "business-api"], - "tools": [ - {"name": "search", "schema_hash": "...", "source": "builtin"}, - {"name": "update_task", "schema_hash": "...", "source": "mcp"} - ], - "skills": [ - {"name": "example-skill", "version": "...", "content_hash": "..."} - ] -} -``` - -快照用于审计和复现,不代表运行期间复制工具实例。 - -MAF 的副作用分类是服务端可信配置,不由上述请求体或模型生成的能力描述决定。部署配置使用实际运行时工具名作为键,例如: - -```yaml -maf_roundtable: - tool_metadata: - weknora-retrieval_weknora_search: - side_effect: read - risk_level: R0 -``` - -未登记工具保持保守的 `write/R3`,避免未知外部工具被误放行。技能可以继续使用,但技能触发的实际外部副作用必须落到已分类工具调用,不能绕过 WriteGuard。 - -### 6.2 技能接入 - -- 继续使用 Agent 当前技能白名单。 -- 默认 Agent 沿用现有“可见全部已启用技能”的规则。 -- `skill_list`、`skill_view`、`search_skills` 和技能脚本按当前配置生效。 -- 如果启用了技能演进,`skill_manage` 也允许执行,但其写入必须进入 MAF 审计事件。 -- 会商启动时记录技能内容哈希;会商中途技能被修改时,当前 run 继续使用启动快照,新 run 才读取新版本。 -- 技能脚本产生的文件必须归属当前席位 sandbox,并通过产物清单暴露给其他席位。 - -### 6.3 外部写入接入 - -外部写入是正式能力,不使用全局 `excluded_tools` 将其移除。为了在多智能体并发下正确执行,需要增加 Action Coordinator。 - -Action Coordinator 只负责执行一致性,不替代工具: - -- 生成幂等键:`session_id + run_id + agent_id + tool_name + logical_action_id`。 -- 同一业务对象的写操作按 `target_key` 加租约锁并串行执行。 -- 工具声明幂等时才允许自动网络重试。 -- 工具未声明幂等且响应状态不明时,标记 `unknown` 并进入人工核验,不盲目重放。 -- 支持工具返回 `external_object_id`、`before_digest`、`after_digest` 和可选补偿动作。 -- 认证凭据只在服务端解析,不进入模型消息、会商历史或前端事件正文。 -- 日志中的令牌、密码、Cookie、Authorization 和敏感字段必须脱敏。 - -建议写入风险级别: - -| 等级 | 示例 | `auto` | `confirm_high_risk` | -|---|---|---:|---:| -| R0 读取 | 查询、搜索、读取附件 | 自动 | 自动 | -| R1 本地写入 | 席位沙箱文件、草稿 | 自动 | 自动 | -| R2 可逆外部写入 | 更新测试任务、写入可回退记录 | 自动 | 自动 | -| R3 高影响写入 | 发送消息、生产数据变更、发布 | 自动,但要求用户显式选择 `auto` | 等待确认 | -| R4 破坏性操作 | 删除、覆盖且无补偿、批量变更 | 等待确认 | 等待确认 | - -这套分级不禁止外部写入,而是保证它在不同运行策略下可控、可恢复、可审计。 - -当前已完成的可执行验收工具是测试专用 `maf_test_outbox_write`(`R2/write`),副作用限制在 `MAF_E2E_OUTBOX_DIR` 指定的临时目录。生产验收还需要业务方明确目标系统、测试租户、最小权限凭证和幂等/核验接口;在此之前不得宣称已经完成生产业务写入接入。 - -### 6.4 沙箱与产物冲突 - -- 一个席位一个 thread,一个 thread 一个沙箱上下文。 -- 席位可正常使用写文件、脚本和产物工具。 -- 不允许多个席位直接共享同一可写目录。 -- 汇总阶段通过只读产物清单读取各席位交付。 -- 最终发布由主持人或指定发布席位写入会商级产物目录。 -- 同名产物使用 `agent_id/revision/path` 做唯一定位。 - -## 7. 后端目录与文件计划 - -建议新增: +### 4.2 SSE:唯一的实时通道 ```text -offline-backend-20260512/backend/ -├─ app/ -│ ├─ gateway/routers/ -│ │ └─ maf_roundtable.py -│ └─ maf_roundtable/ -│ ├─ __init__.py -│ ├─ schemas.py -│ ├─ service.py -│ ├─ workflow_factory.py -│ ├─ model_client.py -│ ├─ deerflow_agent_adapter.py -│ ├─ event_normalizer.py -│ ├─ context_builder.py -│ ├─ fact_ledger.py -│ ├─ action_coordinator.py -│ ├─ approval_service.py -│ └─ metrics.py -├─ packages/harness/deerflow/persistence/maf_roundtable/ -│ ├─ __init__.py -│ ├─ model.py -│ └─ sql.py -└─ tests/ - ├─ test_maf_roundtable_api.py - ├─ test_maf_roundtable_workflow.py - ├─ test_maf_agent_adapter.py - ├─ test_maf_action_coordinator.py - ├─ test_maf_approval_resume.py - └─ test_maf_roundtable_isolation.py +GET /api/maf-roundtable/conversations/{conversation_id}/stream?after_seq={last_seen_seq} +Accept: text/event-stream +Authorization: 与现有 fetch 客户端一致 ``` -边界规则: +- 前端在进入一个 conversation 后只建立**一条** fetch-reader SSE 连接;切换会商、卸载页面、权限失效时 Abort。 +- 浏览器不能用定时 GET 代替 SSE。连接断开后采用指数退避重连,并携带最后已确认的 `after_seq`;不产生全量重复气泡。 +- `after_seq` 是 conversation 维度、服务端单调递增的序号,不能复用每个 run 独立的 `seq`。 +- 首次连接先由 `GET messages` 加载历史,再从该历史的最大 `seq` 订阅 SSE;服务端也必须支持重放,避免“历史请求和订阅之间”丢事件。 +- SSE 使用 `fetch` + `ReadableStream` 解析,以复用项目鉴权头;响应必须带 `Content-Type: text/event-stream`、`Cache-Control: no-cache`、`X-Accel-Buffering: no`,并每 15 秒发送心跳。 +- 服务端负责等待和推送。单 worker 可用进程内 conversation event hub;生产多 worker 部署前必须接入 Redis Pub/Sub 或等价分布式广播,不能退回浏览器轮询。 -- `app.maf_roundtable` 可以导入 `deerflow.*`。 -- `deerflow.*` 不得导入 `app.*` 或 MAF SDK。 -- `deerflow.persistence.maf_roundtable` 只保存纯数据模型,不依赖 MAF SDK。 -- MAF SDK 只在 `app.maf_roundtable` 内加载。 -- `app.py` 只增加 router 注册和生命周期初始化,不改变旧 router。 +### 4.3 SSE 事件信封 -## 8. 数据模型 - -### 8.1 `maf_roundtable_sessions` - -建议字段: - -- `id` -- `user_id` -- `title` -- `task_text` -- `task_payload_json` -- `mode`:`structured | group_chat` -- `status`:`draft | running | waiting_approval | waiting_input | completed | failed | cancelled` -- `model_name` -- `reasoning_effort` -- `manager_agent_id` -- `max_rounds` -- `write_policy` -- `capability_snapshot_json` -- `fact_ledger_json` -- `final_result_json` -- `created_at` -- `updated_at` - -### 8.2 `maf_roundtable_participants` - -- `id` -- `session_id` -- `agent_id` -- `display_name` -- `role` -- `sort_order` -- `thread_id` -- `sandbox_id` -- `status` -- `last_turn_index` -- `capability_snapshot_json` - -### 8.3 `maf_roundtable_runs` - -- `id` -- `session_id` -- `status` -- `workflow_version` -- `maf_checkpoint_json` 或 checkpoint 引用 -- `last_event_seq` -- `started_at` -- `finished_at` -- `error_code` -- `error_message_safe` -- `token_usage_json` -- `latency_ms` - -### 8.4 `maf_roundtable_events` - -- `id` -- `session_id` -- `run_id` -- `seq` -- `event_type` -- `agent_id` -- `turn_index` -- `payload_json` -- `created_at` - -唯一约束:`(run_id, seq)`。 - -### 8.5 `maf_roundtable_actions` - -- `id` -- `session_id` -- `run_id` -- `agent_id` -- `tool_name` -- `logical_action_id` -- `idempotency_key` -- `target_key` -- `risk_level` -- `status` -- `arguments_redacted_json` -- `result_redacted_json` -- `external_object_id` -- `approval_id` -- `attempt_count` -- `started_at` -- `finished_at` - -唯一约束:`idempotency_key`。 - -### 8.6 `maf_roundtable_approvals` - -- `id` -- `session_id` -- `run_id` -- `action_id` -- `requested_by_agent_id` -- `status`:`pending | approved | rejected | expired` -- `summary` -- `payload_redacted_json` -- `decided_by` -- `decided_at` -- `expires_at` - -## 9. API 设计 - -统一前缀:`/api/maf-roundtable`。 - -### 9.0 契约状态与前端接入顺序 - -阶段 1 已实现的前端可用基线是会商 CRUD、启动/查询/取消 run、轮询补拉 `GET /runs/{run_id}/events`、动作查询、审批查询与审批决定,准确路径见 2.4。新前端第一版必须只调用这些已落地接口,并以事件轮询补拉作为恢复机制。 - -`/stream`、`/resume`、`/input`、对照接口、动作人工核验/补偿接口属于后续编排阶段契约;在对应后端任务验收前,前端不得预先调用、伪造或为其创建兼容旧会商的降级路径。接口新增必须保持在 `/api/maf-roundtable/*` 前缀内。 - -### 9.1 会话 - -- `POST /sessions`:创建会商。 -- `GET /sessions`:会商列表。 -- `GET /sessions/{session_id}`:会商详情。 -- `PUT /sessions/{session_id}`:修改未运行配置。 -- `DELETE /sessions/{session_id}`:删除草稿或归档记录。 - -### 9.2 执行 - -- `POST /sessions/{session_id}/runs`:启动新 run。 -- `GET /runs/{run_id}`:读取运行状态。 -- `GET /runs/{run_id}/events?after_seq=N`:断线补拉事件。 -- `GET /runs/{run_id}/stream?after_seq=N`:SSE 实时订阅。 -- `POST /runs/{run_id}/cancel`:取消。 -- `POST /runs/{run_id}/resume`:从 checkpoint 恢复。 -- `POST /runs/{run_id}/input`:提交澄清内容。 - -### 9.3 审批和动作 - -- `GET /runs/{run_id}/approvals`:待处理审批。 -- `POST /approvals/{approval_id}/decision`:批准或拒绝。 -- `GET /runs/{run_id}/actions`:外部写入和工具动作清单。 -- `GET /actions/{action_id}`:单次动作详情。 -- `POST /actions/{action_id}/reconcile`:状态不明动作的人工核验。 -- `POST /actions/{action_id}/compensate`:执行工具声明的补偿操作。 - -### 9.4 对比 - -- `POST /comparisons`:绑定一个 MAF run 与一个原会商 run。 -- `GET /comparisons/{comparison_id}`:获取对比指标。 - -### 9.5 统一事件格式 +所有事件统一为: ```json { "seq": 42, - "event": "tool.completed", - "session_id": "...", - "run_id": "...", - "agent_id": "risk-reviewer", - "turn_index": 3, - "timestamp": "2026-07-31T10:00:00Z", - "payload": { - "tool_name": "update_task", - "action_id": "...", - "status": "succeeded", - "summary": "已更新测试任务状态" - } + "event_id": "evt_...", + "conversation_id": "conv_...", + "run_id": "run_...", + "type": "agent.message.delta", + "occurred_at": "2026-08-02T12:00:00Z", + "data": {} } ``` -首期事件类型至少包括: +允许的首批 `type`: -- `run.started` -- `run.status_changed` -- `workflow.checkpointed` -- `manager.decision` -- `agent.turn_started` -- `agent.message.delta` -- `agent.message.completed` -- `skill.selected` -- `skill.started` -- `skill.completed` -- `tool.requested` -- `tool.started` -- `tool.progress` -- `tool.completed` -- `tool.failed` -- `action.waiting_approval` -- `action.approved` -- `action.rejected` -- `artifact.created` -- `fact.recorded` -- `run.completed` -- `run.failed` +| 类型 | 对话页表现 | +|---|---| +| `conversation.snapshot` | 首帧状态同步,不重复插入消息。 | +| `user.message.created` | 刷新/多标签同步时补齐用户气泡。 | +| `manager.intent` | 总控“正在理解/已理解”的过程卡。 | +| `clarification.required` / `clarification.resolved` | 澄清卡及其回答状态。 | +| `manager.dispatch` | 总控派活卡、席位条状态更新。 | +| `seat.delivery.submitted` | 席位提交交付,进入总控审核而非直接完成。 | +| `manager.review.decision` | 展示验收、打回、改派或受控降级及其安全摘要。 | +| `seat.rework.assigned` / `seat.reassigned` | 更新第几次返工、原/新席位和新的任务要求。 | +| `stage.quality_gate` | 阶段质量闸门通过、阻塞或带缺口推进。 | +| `agent.message.start` / `delta` / `complete` | 席位或总控流式气泡。 | +| `tool.started` / `tool.progress` / `tool.completed` | 现有步骤卡/工具过程,不显示敏感参数。 | +| `approval.required` / `approval.decided` | 嵌入对话流的审批卡。 | +| `fact.confirmed` / `review.completed` | 事实/交叉审查摘要卡。 | +| `run.status` / `run.error` / `run.completed` | 顶部状态、失败重试与最终结果。 | -## 10. 前端设计 +事件 `data` 必须先经过现有 MAF 安全规范化和二次展示脱敏;前端不负责猜测工具风险或修复敏感数据。 -### 10.0 独立前端模块、路由和状态 +--- -新增目录固定为 `frontend-web/src/maf-roundtable/`,其 API client 只能封装 `/api/maf-roundtable/*`。当前页面根路由为 `MafRoutes.tsx`,路由仅新增 `/page/maf/*`;不得替换、重命名、迁移或修改旧 `multi-agent-roundtable` 路由及其页面组件。 +## 5. 后端改造计划 -入口展示前先读取 `GET /api/maf-roundtable/health`:`enabled=false` 时隐藏入口并禁止创建会商;开启后页面必须明确展示“实验版 / 独立后端”标识。TanStack Query 的 key 使用独立命名空间,例如 `['maf-roundtable', 'sessions', ...]`,不得复用任何旧 `roundtable`、`draft` 或 `job` key。所有页面本地状态、事件 reducer、轮询/补拉游标、取消控制器和历史恢复逻辑也必须归属于 MAF 模块。 +### MAF-R0:冻结错误原型并建立旧会商基线 -前端只能消费新后台写入的事件;不得为联调重新开放公开 mock 写接口,也不得在浏览器端伪造工具、技能、审批或外部写入事件。 +**难度:D2(中低)|负责人:前端/测试各 1 人|前置:无** -建议新增: +- 将现有 `/page/maf/*` 标记为“实验原型”,从正式菜单移除;不得在其上继续新增业务功能。 +- 记录旧会商的页面状态:空会商、发送后、意图澄清、总控派活、席位流式发言、审批、完成、失败恢复、历史切换。 +- 为每种状态保存可复现的测试数据与截图基线;截图只作为视觉验收参考,不作为数据契约。 +- 输出旧组件到新 MAF view-model 的映射清单,禁止直接搬旧 hook。 + +**验收:** 旧会商无行为回归;团队对“复制体验、替换编排”有书面映射表。 + +### MAF-R1:Conversation 聚合与消息持久化 + +**难度:D5(高)|负责人:后端资深 1 人|前置:R0** + +新增/迁移 MAF 专用持久化模型: + +- `maf_roundtable_conversations`:用户可见会商,至少包含 id、owner_user_id、title、task_context、安全状态、active_run_id、created_at、updated_at; +- `maf_roundtable_messages`:用户消息、总控/席位消息、澄清/审批/结果卡的稳定历史表示; +- `maf_roundtable_conversation_events`:conversation 维度严格递增的 `seq`、event_id、run_id、type、安全 data、created_at; +- 现有 `maf_roundtable_sessions/runs/events/seat_runs/actions/approvals` 保留为底层执行与审计记录;conversation 与内部 session/run 显式关联。 + +要求: + +1. 发送用户消息、创建/占用 run、写入首个用户消息事件必须在清晰的事务边界内完成;失败不留“消息已显示但未运行”的孤儿状态。 +2. 消息历史必须可在刷新后重建对话;不能只依赖内存或 SSE 临时帧。 +3. 所有读写继续 owner-only;管理员诊断只读不扩大写权限。 +4. 新迁移必须 SQLite/PostgreSQL/MySQL 可重复执行;旧 MAF 原型数据可保留,不强制伪造为新对话历史。 + +**验收:** 会话创建、连续发消息、刷新恢复、并发发送冲突、跨用户隔离、迁移幂等都有 API 和存储测试。 + +### MAF-R2:服务端 SSE Hub 与断线续传 + +**难度:D5(高)|负责人:后端流式/并发经验者 1 人|前置:R1** + +- 新增 conversation stream 路由和 `ConversationEventHub`;Workflow/Dispatcher 的规范化事件落库后再发布,顺序是“先持久化、后通知”。 +- SSE 连接先按 `after_seq` 重放,再订阅实时事件;重放和订阅切换期间必须有序、不丢帧、不重复。 +- workflow 终态、审批等待、审批恢复、取消、失败重试都必须通过同一流通知。 +- 不允许每个 SSE 客户端创建数据库轮询循环。服务端可以阻塞等待 hub 通知,事件重放只在连接/重连时发生。 +- 写出单 worker 与多 worker 的实现边界;若部署配置多 worker,未接入分布式广播时健康检查必须明确告警并禁止该模式上线。 + +**验收:** 测试覆盖慢客户端、断线重连、`after_seq` 重放、同时打开两个标签、切换会商、取消、审批后恢复、进程内 hub 异常和终态自动关流。 + +### MAF-R3:总控意图理解与派活语义对齐 + +**难度:D5(高)|负责人:MAF/Agent 负责人 1 人|前置:R1,可与 R2 并行** + +在现有 MAF Workflow 上增加用户对话入口状态,而不是让前端预先填 manager/seat/run: + +1. 总控首先进行意图判断:普通问答、需要澄清、单席位任务、并行专家会商、链式业务会商、需要工具/写入。 +2. 总控产出受 schema 校验的派活计划;只有服务端可将计划转成实际席位执行。 +3. 每个计划项必须生成并冻结 `task_contract`(目标、交付结构、验收标准、必需性、返工上限和降级策略),席位的发言不等于交付完成。 +4. 席位完成后先做确定性完整性检查,再由总控做结构化验收;总控可接受、打回同一席位、改派、请求用户补充或记录受控缺口。所有决策、次数和理由持久化到 checkpoint 与审计事件。 +5. 默认初稿后最多两次同席位返工;到达上限优先改派一次,仍失败才以带风险标记的 `controlled_gap` 结束。严禁自动把不合格交付推进为合格事实。 +6. 阶段迁移需经过质量闸门:所有必需任务均有 `accepted` 或受控缺口终态;最终总结只能消费已验收事实,缺口必须显式传入风险章节。 +7. 席位集合来自业务链、可信能力快照和上下文;前端可呈现“建议/已参与席位”,但不能提交任意工具权限或 thread id。 +8. 总控每轮应吸收已确认事实、验收结果、退回原因和用户追问;弱模型无效派活、重复派活、无收敛时走确定性纠偏/终止策略。 +9. 已有 structured/group_chat、事实账本、交叉审查、审批 checkpoint、执行阶段身份均继续复用;只重构其输入输出为 conversation event。 + +**验收:** 用同一任务对照旧会商的意图澄清、总控派活、席位协作、打回/改派和收束结论;弱模型的短答、空答、错误 schema、缺证据、重复派活、非法席位、超轮次、返工上限和受控降级都有确定性保护测试。 + +#### R3 当前实施记录(2026-08-03) + +后端已先完成 R3 的服务端核心骨架,作为 R1/R2 Conversation API/SSE 的后续: + +- `app/maf_roundtable/dispatch_contracts.py` 提供确定性意图分类、服务端业务路线解析、schema 校验的 `MafDispatchPlan` 和弱模型防护;请求、模型输出都不能提供 agent id、thread id、tool metadata 或能力快照。 +- `MafRoundtableConfig` 增加 `default_manager_agent_id`、`default_seat_agent_ids`、`business_routes` 和 `max_seats_per_run`。仓库配置保持空路线;上线前必须由部署配置填入实际 agent catalog 中的精确 id,未知业务路线失败关闭。 +- 首条消息在同一事务内创建内部 session、服务端选定的 manager/seat participants 和冻结能力快照;澄清意图进入 `waiting_input`,不创建 run,用户补充信息后才允许重新 claim。 +- Conversation service 已接入现有 `MafRunDispatcher`/`WorkflowRunner`,持久化 `manager.intent`、`manager.dispatch` 和 run 状态桥接事件;现有结构化工作流的席位集合仍只来自服务端 participants,manager 文本不能扩席或改派非法席位。 +- V2 `POST /conversations/{id}/messages` 只返回 `message_id`、`conversation_id`、`seq`;`run_id` 仍是持久层/服务端内部关联字段,不返回给浏览器。 +- 新增 `tests/test_maf_phase3_conversation_dispatch.py`,覆盖未知业务路线、默认路线、澄清、完成状态桥接、重复/越权派发计划等确定性用例。R3 尚未整体验收完成:仍需完成新页面、完整真实 MAF Workflow E2E、质量门禁和与旧会商体验的浏览器对照验收。 + +### MAF-R4:将审批和外部写入嵌回对话主链路 + +**难度:D4(较高)|负责人:后端安全 + 前端各 1 人|前置:R2、R3** + +- 保留 Phase 5 的 WriteGuard、能力快照、HMAC 目标键、租约、加密参数、一次执行和审计;不得为了 SSE 改弱任何安全边界。 +- `approval.required` 只携带白名单展示字段和安全摘要。审批决定后由后端自动续跑,前端只等待同一 SSE 流中的新事件。 +- 拒绝、过期、执行失败、unknown 均形成清晰对话卡和可恢复状态;不得把“未知结果”伪装成成功。 +- 行为审计可在最终结果区展开查看,但不能替代对话中的关键用户反馈。 + +**验收:** HTTP → MAF → ToolNode/outbox → 审批 → SSE → 前端卡片 的端到端测试;确保 target_key、idempotency_key、internal_canary、密钥和原始工具结果从不进入消息/API/SSE。 + +--- + +## 6. 前端改造计划:复刻体验,替换状态源 + +### MAF-R5:新会商页面壳与历史列表 + +**难度:D4(较高)|负责人:熟悉旧会商的前端 1 人|前置:R0、R1** + +新建 `src/maf-roundtable/conversation/**`,建议结构: ```text -frontend-web/src/maf-roundtable/ -├─ api/ -│ └─ maf-roundtable.ts -├─ components/ -│ ├─ MafRoundtableSetup.tsx -│ ├─ ParticipantRail.tsx -│ ├─ DiscussionTimeline.tsx -│ ├─ AgentTurnCard.tsx -│ ├─ CapabilitySnapshot.tsx -│ ├─ ToolExecutionCard.tsx -│ ├─ SkillExecutionCard.tsx -│ ├─ ExternalActionCard.tsx -│ ├─ ApprovalCard.tsx -│ ├─ FactLedgerPanel.tsx -│ ├─ FinalDecisionPanel.tsx -│ └─ ComparisonPanel.tsx -├─ hooks/ -│ ├─ useMafRoundtableSession.ts -│ ├─ useMafRoundtableStream.ts -│ └─ useMafRoundtableActions.ts -├─ lib/ -│ ├─ event-reducer.ts -│ ├─ schemas.ts -│ └─ metrics.ts -└─ pages/ - └─ MafRoundtablePage.tsx +maf-roundtable/conversation/ + pages/MafRoundtableConversationPage.tsx + components/ + ConversationSidebar.tsx + ConversationTimeline.tsx + ConversationComposer.tsx + ManagerDispatchCard.tsx + SeatMessageBubble.tsx + ToolProgressCard.tsx + ClarificationCard.tsx + ApprovalMessageCard.tsx + FinalResultCard.tsx + ParticipantStrip.tsx + hooks/ + use-maf-conversations.ts + use-maf-conversation-stream.ts + use-maf-conversation-reducer.ts + api/conversations.ts + lib/event-reducer.ts + styles/maf-roundtable-legacy-compatible.css ``` -### 10.1 页面布局 +页面要求: -- 顶部:实验标识、模型、编排模式、轮次上限、写入策略、启动/暂停/取消。 -- 左侧:主持人与参会席位,展示运行状态、当前发言者、工具数和技能数。 -- 中部:统一时间线,依次展示发言、调度、工具、技能、审批和产物事件。 -- 右侧:事实账本、外部动作、产物和运行指标。 -- 底部或结果页:最终结论、共识、分歧、执行动作、失败动作和证据引用。 +- 左侧 `ConversationSidebar` 的层级、历史列表、新建、激活态、删除/重命名交互对齐旧会商; +- 中间 `ConversationTimeline` 使用连续消息流,保留旧会商的用户气泡、总控气泡、席位气泡和时间锚点; +- `ConversationComposer` 常驻底部;发送、停止、澄清回答、追问都在该入口完成; +- 右侧仅保留旧体验中真正有用的参与席位、当前阶段和业务链摘要,不再摆放 run/session 管理后台; +- 使用本地 `tdesign-react` 组件实现按钮、输入、Dialog、Tag、Tabs 等基础交互,同时通过 scoped CSS 对齐旧视觉; +- 视觉相似不等于 import 旧业务组件。旧组件仅作参考,数据类型、hook 和 API 必须是 MAF 专用实现。 -### 10.2 页面必须明确展示的内容 +**验收:** 不连接后端时,使用固定 MAF 事件样本可完整演示旧会商同等的 8 种关键状态;没有 session/run/thread 表单。 -- 当前使用的是 MAF,而不是原会商。 -- 当前编排模式及终止条件。 -- 每个席位真实启用的工具和技能。 -- 哪个 Agent 请求了外部写入。 -- 写入是否已执行、等待审批、失败或状态不明。 -- 最终报告中的“建议动作”和“已实际执行动作”必须分栏,避免混淆。 -- 刷新后从数据库事件恢复,不依赖 React 内存保存运行状态。 +### MAF-R6:SSE 客户端、事件 reducer 与刷新恢复 -## 11. 分步实施 +**难度:D5(高)|负责人:前端流式经验者 1 人|前置:R2、R5** -### 11.0 难度定义与分配原则 +- `use-maf-conversation-stream` 只维护一个连接、一个 cursor、一个 AbortController;不得含 `setInterval`、`refetchInterval` 或轮询 fallback。 +- reducer 以 `(conversation_id, seq)` 去重并排序,将原始事件转为稳定 view model;流式 delta 必须按 `message_id` 合并,不能用数组下标。 +- 切换会商时先 abort 旧流并增加 generation;旧 SSE 帧永远不能污染新会商。 +- 刷新时先加载消息历史,再以最后 seq 订阅;网络断开显示“正在重连”,重连成功不重复气泡。 +- 只有历史列表等非实时资源可在用户主动操作后调用 GET;不能靠后台刷新伪装实时性。 -本文使用 D1–D5 标记开发难度。难度表示任务对工程能力的要求,不代表工作量,也不直接等于最终代码评分。 +**验收:** 模拟 1 万条历史、跨页加载、慢旧流切换、新流先到、断线重连、重复帧、乱序帧、终态关流和多标签同步;所有案例必须组件级测试。 -| 难度 | 能力要求 | 典型任务 | 适合人员 | -|---|---|---|---| -| D1 入门 | 能按明确样例修改,影响面很小 | 文案、静态展示、简单配置 | 初级开发,在指导下完成 | -| D2 基础 | 能独立完成单模块 CRUD 或普通组件 | 路由、表单、简单 API、类型定义 | 初级偏上或一般开发 | -| D3 中等 | 能处理完整模块、异常和测试 | Store、指标、普通流式接口、SDK Spike | 中级开发 | -| D4 较难 | 涉及并发、状态恢复、跨层契约或安全 | SSE reducer、权限隔离、工作流、离线交付 | 中高级开发 | -| D5 核心 | 涉及框架适配、一致性、不可重复副作用 | Agent Adapter、checkpoint、幂等外部写入 | 高级开发或技术负责人 | +### MAF-R7:意图/派活/审批卡的旧体验兼容 -任务分配时遵守以下规则: +**难度:D4(较高)|负责人:前端业务负责人 1 人|前置:R3、R4、R6** -- 每个任务包只设一名主负责人,其他人通过评审协作,避免责任不清。 -- D4/D5 任务必须提交设计说明、测试证据和失败场景说明,不能只交代码。 -- 不建议让同一位同事同时负责“外部写入实现”和“该模块最终验收”,需要交叉评审。 -- 为了评价个人水平,每个人应使用独立分支和独立提交,避免多人混在一个提交中。 -- 公共入口文件由集成人统一合并;任务开发者不要各自反复修改 `app.py`、`StrategyRoutes.tsx` 和菜单文件。 -- 代码评价同时记录任务难度、独立完成度和返工次数,不能只比较总代码量。 +- 用旧 `IntentClarificationCard`、`MessageStepsCard`、`ParallelSeatStrip`、`MessageBubble` 的交互作为视觉和信息架构参照,重新实现 MAF 版本。 +- 总控派活计划必须先显示“将由谁做什么”,席位开始/结束、工具过程和交叉审查要回到原有对话叙事中。 +- 席位“发言结束”只能显示为“已提交,待总控审核”;只有收到 `manager.review.decision=accept` 才能显示“已验收”。打回次数、改派和缺口必须清楚可见。 +- 审批卡需要同步防双击锁、二次脱敏、拒绝/通过后的“等待工作流继续”状态;不可另外跳转到 action 审计页。 +- 最终结论卡要区分“建议动作”“审批中动作”“已执行动作”“失败/未知动作”。 +- 继续追问时,页面不新建多个用户可见线程;后端根据 conversation 上下文决定是否创建新 run 或继续恢复。 -### 阶段 0:依赖与模型连通性 Spike +**验收:** 产品人员可在不看技术术语的情况下完成“发起会商 → 澄清 → 观察派活 → 审批 → 查看结论 → 追问”全链路。 -阶段难度:**D3(中等)** -建议负责人:熟悉 Python 依赖、异步 SDK 和模型网关的后端开发 -前置依赖:无 -主要评价点:能否用最小代码验证关键假设,并识别 SDK/模型/离线环境的真实限制 +--- -任务: +## 7. 测试、上线与回滚 -- [ ] `MAF-00-01`(D2)确认并固定 MAF Python 包版本,禁止使用浮动版本。 -- [ ] `MAF-00-02`(D2)在独立临时环境安装,不先修改生产锁文件。 -- [ ] `MAF-00-03`(D3)使用当前一个 OpenAI Chat Completions 兼容模型跑通流式单 Agent。 -- [ ] `MAF-00-04`(D3)使用同一模型跑通两个 Agent 的最小 Group Chat。 -- [ ] `MAF-00-05`(D3)验证自定义 `base_url`、API key、超时和代理配置。 -- [ ] `MAF-00-06`(D4)验证 MAF 的 stream event、checkpoint、cancel 和 resume 接口。 -- [ ] `MAF-00-07`(D4)验证实现 `DeerFlowAgentAdapter` 所需的 Agent/ChatClient 扩展接口。 -- [ ] `MAF-00-08`(D3)收集 Linux CPython 3.12 所需 wheel,执行离线安装测试。 +### 7.1 必须新增的自动化测试 -退出标准: - -- 模型能流式返回。 -- 两个 Agent 能完成至少两轮群聊。 -- 能保存并恢复 checkpoint。 -- 离线环境可以仅使用 wheelhouse 安装。 -- 形成版本锁定记录;如果 SDK API 与 Samples 不一致,以锁定版本实际 API 为准。 - -### 阶段 1:独立后端骨架与持久化 - -阶段难度:**D4(较难)** -建议负责人:熟悉 FastAPI、SQLAlchemy、鉴权和事件流的中高级后端开发 -前置依赖:阶段 0 版本锁定 -主要评价点:数据隔离、事件顺序、权限边界、事务处理和可测试性 - -任务: - -- [ ] `MAF-01-01`(D2)新增 MAF router、service 目录和健康检查。 -- [ ] `MAF-01-02`(D3)新增六张独立数据表及 store。 -- [ ] `MAF-01-03`(D3)在 Gateway lifespan 中初始化 store,并处理初始化失败。 -- [ ] `MAF-01-04`(D2)增加 `maf_roundtable.enabled` 特性开关。 -- [ ] `MAF-01-05`(D3)实现会话 CRUD、参与者配置和 capability snapshot。 -- [ ] `MAF-01-06`(D4)实现单调递增的事件序号、并发写入和事件补拉 API。 -- [ ] `MAF-01-07`(D4)增加用户隔离、越权测试和管理员诊断权限。 - -退出标准: - -- 可以创建、读取、更新、归档 MAF 会话。 -- MAF 表和旧 roundtable 表完全无关联外键。 -- 关闭特性开关后不加载 MAF SDK,不显示入口。 -- `test_harness_boundary.py` 通过。 - -### 阶段 2:DeerFlow Agent Adapter - -阶段难度:**D5(核心)** -建议负责人:熟悉 LangGraph/Agent Runtime、SSE、异步取消和本项目中间件链的高级后端开发 -前置依赖:阶段 0、阶段 1 -主要评价点:是否真正保留工具/技能/身份/沙箱语义,事件转换是否完整,取消和异常是否泄漏资源 - -任务: - -- [ ] `MAF-02-01`(D3)为每个席位创建、绑定和恢复独立 DeerFlow thread。 -- [ ] `MAF-02-02`(D4)使用 `httpx.ASGITransport(app=request.app)` 进程内调用现有 API。 -- [ ] `MAF-02-03`(D4)注入当前用户 Authorization 和有效 user context,并证明不存在跨用户泄漏。 -- [ ] `MAF-02-04`(D3)透传 model、thinking、reasoning effort、agent id、subagent 等运行参数。 -- [ ] `MAF-02-05`(D3)保持 Agent 原工具组和技能规则,不传统一 `excluded_tools`。 -- [ ] `MAF-02-06`(D4)把 MAF 群聊上下文转换成有边界、可裁剪、可追踪的席位输入。 -- [ ] `MAF-02-07`(D5)解析 DeerFlow SSE,正确转换消息、工具、技能、产物、澄清和错误事件。 -- [ ] `MAF-02-08`(D4)将席位最终消息转换为 MAF 标准响应并保留 usage/metadata。 -- [ ] `MAF-02-09`(D5)实现 MAF 取消、超时、客户端断开时的 DeerFlow run 联动取消和资源回收。 - -退出标准: - -- 一个 MAF 席位能真实调用一个只读工具。 -- 能执行技能并生成沙箱产物。 -- 能调用一个测试环境外部写工具。 -- 前端/接口能看到完整事件顺序。 -- 原 Agent 单聊行为不受影响。 - -### 阶段 3:固定流程编排 - -阶段难度:**D4(较难)** -建议负责人:熟悉异步工作流、结构化输出和失败恢复的中高级后端开发 -前置依赖:阶段 2 -主要评价点:并行正确性、结果校验、部分失败处理、checkpoint 可恢复性 - -任务: - -- [ ] `MAF-03-01`(D4)建立任务规划、并行专家、交叉审查和最终综合节点。 -- [ ] `MAF-03-02`(D3)实现席位输出 Pydantic schema 和可读错误信息。 -- [ ] `MAF-03-03`(D3)实现一次定向格式修复及停止条件。 -- [ ] `MAF-03-04`(D4)建立共享事实账本及证据/动作状态约束。 -- [ ] `MAF-03-05`(D4)实现并行 barrier、部分失败、超时和降级综合。 -- [ ] `MAF-03-06`(D4)将 MAF checkpoint 持久化到 run。 -- [ ] `MAF-03-07`(D5)实现服务重启后的恢复,并证明已完成节点不会重复执行。 - -退出标准: - -- 三个席位可并行执行真实工具和技能。 -- 某一席位失败时,其余席位结果不丢失。 -- 主持人能区分建议动作与已执行动作。 -- 重启后不会重放已成功的外部动作。 - -### 阶段 4:动态 Group Chat - -阶段难度:**D5(核心)** -建议负责人:熟悉多智能体编排、上下文工程和终止状态机的高级开发 -前置依赖:阶段 2;建议先完成阶段 3 以复用事实账本和恢复机制 -主要评价点:弱模型条件下能否收敛、上下文是否准确、硬终止是否可靠 - -当前代码状态:`workflow_factory.py` 已为 `group_chat` 建了真实 MAF `WorkflowBuilder` 图,`CoordinatorExecutor` 会写 `manager.decision`,并有一条 SDK 路径测试。但它目前按 round-robin 让每个席位最多发言一次;这只是可运行的占位编排,不是本阶段承诺的动态多轮会商。 - -任务: - -- [ ] `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 的多轮(同一席位可在合规策略下再次发言)验收。 - -退出标准: - -- 可以观察每轮由谁发言以及选择原因。 -- 达成结论时提前结束。 -- 弱模型未正确发出结束信号时,硬终止条件仍能停止。 -- 工具结果不会因上下文裁剪而被误判为未执行或执行成功。 - -### 阶段 5:外部写入、审批与一致性 - -阶段难度:**D5(核心且高风险)** -建议负责人:熟悉分布式一致性、幂等、事务、审计和业务安全的高级后端开发/技术负责人 -前置依赖:阶段 1、阶段 2、阶段 3 的 checkpoint 机制 -主要评价点:不得重复写、不得越权、状态不明时不得盲目重试、所有动作必须可追踪 - -任务: - -- [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` 的真实业务系统验收。 - -退出标准: - -- 外部写入可以实际成功执行。 -- 重复事件、断线恢复和服务重启不会造成重复写入。 -- 并行 Agent 写同一目标时不会互相覆盖。 -- 高风险写入在相应策略下能暂停和恢复。 -- 每个外部动作可追溯到用户、会商、run、Agent 和工具调用。 -- `target_key`、内部幂等键和明文目标标识均不从公开 API、事件、日志或前端状态泄露;Phase-5 迁移能在项目支持的 SQLite、PostgreSQL、MySQL 上幂等执行。 -- 审批即使无人刷新页面也会在到期后被后台任务关闭,且完整工作流入口到 HTTP 决策、真实 ToolNode 的链路经一次性测试验证。 - -### 阶段 6:前端工作台 - -阶段难度:**D4(较难)** -建议负责人:熟悉 React、TanStack Query、轮询事件归并和复杂工作台交互的中高级前端开发 -前置依赖:阶段 1 已验收的 API 基线与正式事件契约;reducer 可使用本地 fixture 开发,但不得依赖公开 mock 事件接口;可与阶段 2–5 并行开发 -主要评价点:事件状态准确、刷新恢复、审批交互、复杂状态下 UI 不误导用户 - -组件与实现约束: - -- 新 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、阶段 5、阶段 6;指标接口可提前开发 -主要评价点:对照是否公平、指标是否可解释、是否能区分模型/工具/编排错误 - -任务: - -- [ ] `MAF-07-01`(D3)建立同任务、同模型、同能力的配置快照与一致性校验。 -- [ ] `MAF-07-02`(D2)支持绑定原会商 run 与 MAF run。 -- [ ] `MAF-07-03`(D3)采集完成率、格式通过率、轮次、时延、token 和工具成功率。 -- [ ] `MAF-07-04`(D4)建立模型错误、工具错误、编排错误、外部系统错误分类器。 -- [ ] `MAF-07-05`(D3)采集错误工具调用、重复写入、错误写入和人工介入次数。 -- [ ] `MAF-07-06`(D2)增加盲评表:正确性、完整性、可执行性、分歧处理和证据质量。 -- [ ] `MAF-07-07`(D3)准备不少于 10 个包含工具、技能和写入的真实业务测试任务。 - -对照环境规则: - -- 两套系统使用同一模型和能力快照。 -- 外部写入使用 UAT、测试租户或不同命名空间。 -- 不允许两套实验同时修改同一个生产业务对象。 -- 对比报告必须标注模型错误、工具错误、编排错误和外部系统错误,不能全部归因于框架。 - -### 阶段 8:测试、离线打包与灰度 - -阶段难度:**D4(较难)** -建议负责人:熟悉自动化测试、Linux 离线部署、故障注入和上线观测的测试开发/DevOps/后端开发 -前置依赖:各功能阶段完成;离线依赖验证可在阶段 0 后提前进行 -主要评价点:测试有效性、环境可复现、是否真正覆盖恢复和副作用故障 - -任务: - -- [ ] `MAF-08-01`(D4)补齐后端单元测试、API 集成测试和并发测试。 -- [ ] `MAF-08-02`(D3)完成前端 typecheck、组件测试和关键 E2E。 -- [ ] `MAF-08-03`(D3)跑现有 roundtable、position-roundtable 和 task deep-link 回归。 -- [ ] `MAF-08-04`(D4)构建 Linux CPython 3.12 多平台离线 wheel 集合。 -- [ ] `MAF-08-05`(D4)使用 `--no-index --find-links` 做全新环境安装与启动验证。 -- [ ] `MAF-08-06`(D3)配置管理员/白名单用户灰度和回退开关。 -- [ ] `MAF-08-07`(D4)建立失败率、恢复率、重复动作数、事件积压和数据库增长观测。 - -退出标准: - -- 旧会商回归全部通过。 -- 离线部署可重复完成。 -- 外部写入重复执行数为 0。 -- 所有写操作都有审计记录。 -- MAF 功能可通过开关独立关闭。 - -## 12. 测试矩阵 - -### 12.1 必测成功场景 - -- 单席位调用内置读取工具。 -- 多席位并行调用不同工具。 -- 席位发现并执行技能。 -- 技能生成文件并被主持人引用。 -- MCP 读取与 MCP 写入。 -- 外部写操作成功并返回业务对象 ID。 -- 主持人调用自身授权工具。 -- 中途澄清后继续。 -- 高风险操作审批后继续。 -- 服务重启后从 checkpoint 继续。 - -### 12.2 必测失败场景 - -- 模型超时、限流和返回空消息。 -- 弱模型不遵守输出 schema。 -- Agent 连续选择自己或无限讨论。 -- 工具参数错误。 -- 工具超时但外部系统可能已成功。 -- MCP 服务断开。 -- 技能脚本失败。 -- 用户在等待审批时取消。 -- SSE 断线、事件重复和乱序。 -- 两个 Agent 同时更新同一目标。 -- 用户 A 读取或恢复用户 B 的 session。 - -### 12.3 回归场景 - -- 原多智能体会商创建、运行、后台恢复和报告。 -- 岗位会商页面。 -- 任务深链三类业务会商。 -- 普通 Agent 单聊的工具、技能和产物。 -- MCP 初始化与工具搜索。 -- Harness → App import firewall。 - -## 13. 验收指标 - -### 13.1 硬性指标 - -- 外部写入重复执行:0。 -- 未授权跨用户读取:0。 -- 未记录的外部写入:0。 -- 旧会商接口契约变化:0。 -- 关闭特性开关后旧功能行为变化:0。 -- 每个 run 均可根据事件记录解释“谁在何时调用了什么”。 - -### 13.2 效果指标 - -- 结构化结果一次通过率。 -- 会商完成率。 -- 工具调用成功率。 -- 技能任务完成率。 -- 外部写入正确率。 -- 人工澄清次数。 -- 每任务总轮次、总时延和 token。 -- 最终结论中无依据断言数量。 -- 盲评胜率和评审一致性。 - -不预先设定“MAF 必须胜出”。如果 MAF 只在特定任务或特定模型上更好,应保留两种方案并按业务类型选择。 - -## 14. 风险与处理 - -| 风险 | 影响 | 处理 | -|---|---|---| -| MAF SDK API 仍快速变化 | 示例与实际版本不一致 | 阶段 0 固定版本;集中封装 SDK;业务层不直接依赖 SDK 类型 | -| 当前模型只部分兼容 OpenAI 协议 | 流式、tool call 或 structured output 失败 | 分别验证 Chat Completions、流式工具调用和 schema;必要时增加模型适配层 | -| 群聊广播导致上下文膨胀 | 成本高、弱模型更易偏航 | 工具输出摘要、事实账本、轮次硬上限和上下文预算 | -| 多 Agent 重复外部写入 | 业务数据污染 | 幂等键、目标锁、动作状态机、核验和补偿 | -| 直接包装 LangChain 工具丢失运行上下文 | 身份、沙箱或线程错误 | 第一版通过 DeerFlow AgentAdapter 执行,不逐个直包工具 | -| 后台恢复重放工具 | 重复副作用 | action 表先落意图,幂等执行,恢复时先查动作状态 | -| 技能运行时被更新 | 结果不可复现 | 会商启动冻结技能哈希和能力快照 | -| 两套系统对比污染生产数据 | 无法公平比较且可能产生副作用 | 使用 UAT/测试租户/隔离命名空间 | - -## 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_roundtable.enabled` 停止新的 MAF 写入;必要时再关闭 `write_guard.enabled`。若密钥或外部系统异常,保持 MAF 开启也会因失败关闭而拒绝写操作。旧 roundtable 不需要迁移或回滚。 -- MAF 独立表保留用于审计,不在代码回退时删除。 -- MAF SDK 使用延迟导入,关闭功能时不影响 Gateway 启动。 - -## 16. 推荐开发顺序与工作量级 - -完整项目的依赖顺序仍是阶段 0 → 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 个可演示里程碑交付: - -- 里程碑 A:后端命令/API 能完成两席位 MAF 会商,真实工具与技能可用。 -- 里程碑 B:页面可运行、可查看工具/技能/产物,外部写入可审批和恢复。 -- 里程碑 C:固定流程和 Group Chat 都可用,完成与原会商的正式对比报告。 - -### 16.1 推荐人员任务包 - -以下任务包适合分给不同同事。一个人可以领取多个连续任务包,但应避免多人同时拥有同一公共模块。 - -| 任务包 | 内容 | 综合难度 | 建议角色 | 可并行条件 | -|---|---|---:|---|---| -| WP-A | 阶段 0:SDK、模型和离线依赖 Spike | D3 | Python/模型网关开发 | 可立即开始 | -| WP-B | 阶段 1:数据表、Store、API、事件序号、鉴权 | D4 | 后端开发 | WP-A 固定版本后开始 | -| WP-C | 阶段 2:DeerFlow Agent Adapter | D5 | 高级后端/Agent Runtime | 依赖 WP-A、WP-B 契约 | -| WP-D | 阶段 3:固定流程、事实账本、恢复 | D4 | 工作流后端 | 依赖 WP-C,可先用 fake adapter | -| WP-E | 阶段 4:Group Chat 与上下文控制 | D5 | 多智能体高级开发 | 依赖 WP-C;与 WP-D 部分并行 | -| WP-F | 阶段 5:外部写入与一致性 | D5 | 高级后端/技术负责人 | 依赖 WP-B;与 WP-D/E 协同 | -| WP-G | 阶段 6:前端工作台 | D4 | 中高级前端 | WP-B 定稿事件契约后并行 | -| WP-H | 阶段 7:评测与错误分类 | D3 | 测试/算法/全栈 | 指标契约确定后并行 | -| WP-I | 阶段 8:故障测试、离线包、灰度 | D4 | 测试开发/DevOps | 持续介入,最后收口 | -| WP-J | 公共入口与最终集成 | D4 | 集成人/技术负责人 | 各包通过独立验收后 | - -建议让 WP-J 负责人统一修改公共入口、处理冲突和跑全量回归。其他负责人通过最小接口提交自己的模块,减少“合并冲突掩盖代码能力”的情况。 - -### 16.2 交付物要求 - -每个同事交付任务包时必须提供: - -- 独立分支或明确的 commit 列表。 -- 对应任务编号,例如 `MAF-02-07`。 -- 设计说明:关键接口、状态机、边界和取舍。 -- 代码改动清单。 -- 自动化测试及执行结果。 -- 至少一个成功案例和一个失败案例。 -- 已知限制与后续风险。 -- 若修改公共文件,说明修改原因和是否可能影响旧功能。 - -缺少测试或失败场景说明的 D4/D5 任务,不视为完整交付。 - -## 16A. 同事代码水平评价标准 - -后续可以把某位同事的分支、commit 或 diff 交给 Codex,按本节输出统一评价。评价不直接代替团队绩效,只衡量本任务中的工程表现。 - -### 16A.1 评价输入 - -至少提供: - -- 负责人姓名或代号。 -- 任务编号与难度,例如 `MAF-05-03 / D5`。 -- 分支名、commit SHA 或明确 diff。 -- 是否允许查看测试日志。 -- 哪些代码是他人预先提供的。 -- 是否有时间、环境或接口限制。 - -如果多人代码混在一个提交中,只能评价该提交的整体质量,无法可靠评价个人水平。 - -### 16A.2 统一评分(100 分) - -| 维度 | 分值 | 评价内容 | -|---|---:|---| -| 功能正确性 | 25 | 是否满足任务契约、成功/失败路径是否正确 | -| 架构与边界 | 15 | 模块职责、依赖方向、是否污染旧会商、是否遵守 Harness/App 防火墙 | -| 异常与状态处理 | 15 | 超时、取消、重试、恢复、并发、乱序和部分失败 | -| 安全与副作用 | 15 | 身份隔离、凭据脱敏、幂等、审批、外部写入一致性;不适用部分转入正确性 | -| 测试质量 | 15 | 测试是否能发现真实 bug,而非只覆盖 happy path | -| 可维护性 | 10 | 命名、类型、复杂度、注释、复用和可观测性 | -| 交付完整度 | 5 | 设计说明、运行证据、已知限制和提交整洁度 | - -### 16A.3 额外记录项 - -以下项目不直接混入 100 分,但必须随评价一起报告: - -- 任务难度:D1–D5。 -- 独立完成度:`独立完成 | 少量提示 | 多次指导 | 主要由他人修复`。 -- 首次验收结果:`通过 | 小改后通过 | 大改后通过 | 未通过`。 -- 关键缺陷等级:P0/P1/P2/P3 数量。 -- 返工轮次。 -- 是否能清楚解释自己的并发、恢复或安全设计。 - -### 16A.4 能力结论参考 - -必须把代码质量与任务难度结合判断: - -| 表现 | 参考结论 | +| 类别 | 核心用例 | |---|---| -| D1/D2 高分 | 基础扎实,但不足以单独证明复杂系统能力 | -| D3 85 分以上且独立完成 | 可独立负责普通完整模块 | -| D4 80 分以上且关键异常覆盖完整 | 具备中高级复杂功能交付能力 | -| D5 80 分以上、无 P0/P1、能解释取舍 | 具备核心模块或技术负责人候选能力 | -| 任意难度出现越权、重复外部写入或伪造测试 | 即使功能可演示,也不能判定为合格交付 | +| 后端事务 | 发消息/创建 run 原子性、并发发送、跨用户隔离、迁移幂等。 | +| SSE 服务端 | 重放、顺序、心跳、取消、断线、审批恢复、多订阅者、终态关流。 | +| Workflow | 意图澄清、总控派活、弱模型格式错误、重复派活、最大轮次、恢复。 | +| 质量闸门 | 短答/空答、缺少必填章节、无证据、总控打回、两次返工上限、一次改派、受控缺口、阶段阻塞和最终报告不把缺口伪装成事实。 | +| 安全 | SSE/API/历史消息不泄露敏感键和值;审批和一次写入仍保持现有 Phase 5 保证。 | +| 前端 reducer | delta 合并、去重、乱序、切换代次隔离、重连 cursor。 | +| 前端组件 | 输入发送、澄清、派活、审批锁、取消、失败恢复、历史切换、无轮询断言。 | +| E2E | 从用户输入到真实 MAF Workflow、ToolNode/outbox、审批、最终结果的 SSE 全链路。 | +| 回归 | 旧 `roundtable-planning`、旧 DeerFlow、AI 写作以及既有 MAF Phase 1–5 用例全部通过。 | -### 16A.5 Codex 评审输出模板 +### 7.2 人工视觉验收 -```markdown -# 代码评价:<负责人> / <任务编号> +在同一浏览器尺寸下对照旧会商,至少核对: -- 任务难度:D4 -- 代码得分:82/100 -- 独立完成度:少量提示 -- 首次验收:小改后通过 -- 建议能力档位:可独立负责普通复杂模块,核心一致性模块仍需评审 +1. 空白页和新建会商; +2. 左侧历史/当前会商切换; +3. 用户输入、流式总控消息、席位消息; +4. 意图澄清和总控派活; +5. 工具过程、审批、取消、失败恢复; +6. 最终结论和连续追问; +7. 窄屏滚动、长内容、断线重连和页面刷新。 -## 关键发现 +### 7.3 灰度与回滚 -1. [P1] ... -2. [P2] ... +- 新入口使用独立 feature flag,例如 `maf_roundtable.conversation_ui_enabled`;旧会商不受该开关影响。 +- 先仅向内部账号开放,记录连接数、重连率、事件延迟、工作流失败率、审批耗时和前端异常。 +- 出现问题时关闭新入口即可;不迁移或删除旧会商数据,也不需要回滚旧页面。 +- 当前错误原型可保留为仅开发者诊断路由,或在新版稳定后删除;它不应继续出现在用户菜单。 -## 分项评分 +--- -- 功能正确性:22/25 -- 架构与边界:13/15 -- 异常与状态处理:10/15 -- 安全与副作用:12/15 -- 测试质量:13/15 -- 可维护性:8/10 -- 交付完整度:4/5 +## 8. 分阶段任务分配、难度与交付顺序 -## 做得好的地方 +本节是分工的唯一依据。难度定义:D1=很低(局部文案/样式),D2=低(明确边界的小模块),D3=中(跨 2–3 个模块),D4=较高(跨前后端或需回归),D5=高(并发、状态机、安全或 Agent 行为,必须由有经验者负责)。 -... +### 阶段 0:冻结错误原型,提取旧会商交互基线(R0) -## 需要本人修正的地方 +**难度:D2|建议人员:1 名熟悉旧会商的前端 + 1 名测试|依赖:无** -... +具体要做: -## 复评条件 +1. 将现有 `/page/maf/*` 的 session/run 详情原型从正式菜单隐藏,标注为开发诊断原型,停止继续加功能。 +2. 逐一记录旧会商的 8 类页面状态:空白/新建、历史切换、用户提问、意图澄清、总控派活、席位发言、审批、完成/失败恢复。 +3. 给每种状态保留可复现 mock 数据、页面截图和操作步骤;梳理旧组件到新 MAF view-model 的映射。 +4. 明确哪些只能参考视觉和交互,哪些旧业务 hook/API/状态禁止复用。 -... -``` +交付物:旧会商交互地图、截图基线、组件映射表、被隐藏的原型入口说明。 -## 17. 开工前最终确认项 +验收:旧会商没有代码行为变化;任何人可以根据基线判断新版是否“像原页面”。 -- [ ] 首批灰度用户范围。 -- [ ] 默认写入策略采用 `confirm_high_risk` 还是 `auto`。 -- [ ] 第一批用于验收的外部写工具及其 UAT 地址。 -- [ ] 第一批主持人和席位 Agent。 -- [ ] 对照实验使用的模型与推理参数。 -- [ ] MAF 版本和离线 wheel 构建平台。 -- [ ] 原会商 run 与 MAF run 的绑定方式。 +### 阶段 1:建立用户可见 Conversation 与消息历史(R1) -## 18. 参考资料 +**难度:D5|建议人员:1 名资深后端|依赖:阶段 0** -- [Microsoft Agent Framework GitHub](https://github.com/microsoft/agent-framework) -- [MAF Group Chat orchestration](https://learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/group-chat) -- [MAF workflow orchestrations](https://learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/) -- [MAF 连接 OpenAI-compatible endpoints](https://learn.microsoft.com/en-us/agent-framework/integrations/openai-endpoints) +具体要做: + +1. 新增 `maf_roundtable_conversations`、`maf_roundtable_messages`、`maf_roundtable_conversation_events`,设计 owner、标题、消息、conversation 全局序号、活跃 run 和安全状态字段。 +2. 建立 conversation 与现有 MAF session/run/seat-run 的内部关联;用户只得到 conversation id,不能看到内部 thread id。 +3. 实现会商历史列表、创建空白会商、读取会商快照、读取消息分页、发送用户消息五类 API。 +4. 发送消息时,用户消息持久化、run 创建/占用和首个事件必须有明确事务边界;并发发送、重复请求、失败回滚都要处理。 +5. 完成 SQLite/PostgreSQL/MySQL 迁移与 owner-only 权限测试。 + +交付物:迁移脚本、repository/service/router、API schema、后端事务与隔离测试。 + +验收:刷新后能从消息历史恢复完整会商;跨用户不可读写;失败不会留下“已显示但没有实际运行”的孤儿消息。 + +### 阶段 2:服务端 SSE 对话流、事件 Hub 与断线续传(R2) + +**难度:D5|建议人员:1 名熟悉 FastAPI 异步流与并发的后端|依赖:阶段 1** + +具体要做: + +1. 实现 `GET /conversations/{id}/stream?after_seq=`,按 conversation 全局 seq 先重放、后订阅实时事件。 +2. 新建 `ConversationEventHub`;所有 workflow 事件先安全落库,再按顺序广播。禁止 SSE 客户端通过定时查库等待更新。 +3. SSE 实现心跳、连接取消、权限校验、终态关流、异常帧和断线后的 cursor 续传。 +4. 明确多 worker 部署方案:单 worker 可进程内广播;多 worker 必须 Redis Pub/Sub 或等价方案,未满足时不能灰度上线。 +5. 把 run 状态、总控派活、席位消息 delta、工具过程、审批、质量审核、最终结论全部映射为统一事件信封。 + +交付物:SSE router/hub、事件发布适配器、集成测试、部署说明。 + +验收:慢连接、断线重连、乱序/重复防护、多标签订阅、审批后续跑、取消和终态均不丢事件;前端不需要轮询任何实时资源。 + +### 阶段 3:总控意图理解、业务链选择与服务端派活(R3) + +**难度:D5|建议人员:1 名 MAF/Agent 工程师|依赖:阶段 1;可与阶段 2 并行** + +具体要做: + +1. 将旧会商的意图理解能力迁到 MAF 总控入口:识别直接问答、需澄清、单席位、并行会商、链式业务会商、工具/写入请求。 +2. 总控根据业务链、用户上下文、可信能力快照选择席位,生成 schema 校验的派活计划;前端只展示,不参与编排。 +3. Dispatcher 在服务端按需创建/复用 manager 和 seat 的内部执行线程,并由 `ExecutionPrincipal` 绑定当前用户。 +4. 对弱模型增加确定性保护:非法席位、空派活、重复派活、无效 schema、超轮次、无法收束必须被拒绝、纠偏或终止。 +5. 保持既有工具、技能、MCP、沙箱、事实账本、交叉审查和 checkpoint 恢复可用。 + +交付物:总控状态 schema、workflow executor、派活事件、弱模型测试矩阵。 + +验收:用户只输入任务,就能看到“意图理解 → 总控派活 → 席位执行”;浏览器没有 thread map 和派活决策代码。 + +### 阶段 4:席位任务合同、总控验收、打回与强制收束(R3-Q) + +**难度:D5|建议人员:1 名 Agent/MAF 工程师 + 1 名后端状态机工程师|依赖:阶段 3** + +具体要做: + +1. 总控发布任务时冻结 `task_contract`:具体目标、交付格式、验收标准、必需性、返工次数、改派与降级策略。 +2. 为每项席位任务持久化状态机:`assigned → delivering → submitted → manager_review → accepted/rework/reassigned/controlled_gap`。 +3. 席位提交后先做确定性质量检查:空答、过短、必填章节缺失、格式错、无证据、重复内容等均不能自动完成。 +4. 总控必须生成结构化验收决定和原因;不合格时打回并带精确补充要求。默认最多两次同席位返工,之后最多一次改派。 +5. 改派仍失败时只能产生 `controlled_gap`,标记风险、缺失项和最终报告影响;严禁伪装为成功。 +6. 增加阶段质量闸门:所有必需任务处于 `accepted` 或 `controlled_gap`,才允许进入交叉审查/下一阶段/最终总结。 +7. 将尝试次数、退回理由、验收结果和缺口写入 checkpoint、审计和 SSE。 + +交付物:任务合同模型、持久化表/迁移、质量状态机、总控审查 prompt/schema、事件和测试。 + +验收:席位只说一两句话、无依据或内容垃圾时会被明确打回;达到上限不会死循环,也不会被当作合格产出;最终报告可追溯所有缺口。 + +### 阶段 5:审批、WriteGuard 与外部动作嵌入对话流(R4) + +**难度:D4|建议人员:1 名安全后端 + 1 名前端|依赖:阶段 2、3、4** + +具体要做: + +1. 保留现有 Phase 5 的可信能力快照、WriteGuard、目标租约、HMAC、加密参数、一次执行与审计,不允许为新页面降低安全性。 +2. 将工具开始、审批请求、批准/拒绝、执行成功/失败/unknown 变成安全 SSE 事件和对话卡。 +3. 审批点击只调用明确决策 API;后端负责恢复 checkpoint/workflow,浏览器只等待 SSE。 +4. 安全展示统一二次脱敏,禁止 target_key、idempotency_key、internal_canary、密钥、凭据和原始工具结果进入 API、消息、SSE 或 DOM。 + +交付物:事件映射、审批消息卡契约、端到端 ToolNode/outbox 测试。 + +验收:一次真实测试写入能在对话里经历“申请 → 审批 → 继续 → 结果”;重复点击或断线都不会重复外部写入。 + +### 阶段 6:复刻旧会商页面壳、历史列表和输入框(R5) + +**难度:D4|建议人员:1 名熟悉旧会商代码的前端|依赖:阶段 0、1** + +具体要做: + +1. 新建 MAF 专用 conversation 页面、路由和独立 feature flag;不修改旧 `RoundtablePlanningPage`。 +2. 依据阶段 0 的映射,复刻左侧历史会话、新建会商、中心时间线、常驻输入框、参与席位区和业务链摘要的视觉与交互层级。 +3. 基础组件使用本地 `tdesign-react`,旧页面仅参考;需要的视觉组件在 MAF 目录重写或抽无业务依赖公共组件。 +4. 接上 Conversation API:创建、历史、切换、消息分页。此阶段不允许先做任何轮询实时逻辑。 +5. 删除新页面中暴露给用户的 manager/seat/max round/session/run 创建表单。 + +交付物:新页面壳、MAF 专用 view model、静态/接口 mock 组件测试、视觉对照截图。 + +验收:即使 SSE 未接通,用户也能看到与旧会商同类的历史、对话与输入体验;新旧业务状态零耦合。 + +### 阶段 7:前端 SSE 消费、对话 reducer 与过程交互(R6 + R7) + +**难度:D5|建议人员:1 名前端流式工程师 + 1 名前端业务工程师|依赖:阶段 2、4、5、6** + +具体要做: + +1. 实现单连接 `use-maf-conversation-stream`:fetch-reader SSE、cursor、AbortController、指数退避重连和会商切换 generation 隔离。 +2. 实现 event reducer:按 `conversation_id + seq` 去重排序;按 message_id 合并 delta;刷新后历史与 SSE 无缝衔接。 +3. 重写旧会商对应的 MAF 版组件:总控意图卡、派活卡、席位条、席位气泡、工具步骤、澄清卡、审批卡、质量审核/打回卡、结果卡。 +4. 所有卡片在时间线内叙事,不另开 run/session 管理后台;继续追问仍使用同一输入框。 +5. 页面必须显示待审核、已验收、返工次数、改派、受控缺口和恢复状态,不能把“发过言”显示为“已完成”。 +6. 代码审查和测试中检查不存在 `setInterval`、`refetchInterval` 或事件轮询 fallback。 + +交付物:SSE hook、reducer、对话组件、组件/API mock 测试、无轮询静态检查。 + +验收:切换会商、断线重连、审批续跑、慢旧流回包、长会话翻页均不污染、不丢消息、不重复;整个会商过程只依赖 SSE 实时更新。 + +### 阶段 8:端到端验收、灰度和回滚演练(R8) + +**难度:D4|建议人员:1 名测试/集成人 + 各阶段负责人配合|依赖:阶段 1–7** + +具体要做: + +1. 跑完后端事务/SSE/Workflow/质量闸门/WriteGuard 测试、前端 reducer/组件测试、旧会商与 DeerFlow 回归。 +2. 在真实浏览器按阶段 0 截图基线逐项对照,并完成用户任务:发起、澄清、派活、短答打回、两次返工、改派、审批、受控缺口、结论和追问。 +3. 内部账号灰度,观测 SSE 建连/重连、事件时延、派活/验收/返工次数、workflow 失败率、审批耗时和前端错误。 +4. 验证关闭 feature flag 后立即回到旧会商入口,旧数据和旧任务不受影响。 +5. 若需生产外部写入,补齐 UAT 系统、最小权限凭据、核验 API、补偿联系人和生产 `MAF_ACTION_SECRET_KEY`。 + +交付物:自动化结果、人工验收记录、灰度指标面板、回滚演练记录、上线清单。 + +验收:满足第 9 节全部条件后才允许标记开发完成。 + +### 8.1 可并行边界 + +- 阶段 2 与阶段 3 可在阶段 1 的 schema/API 定稿后并行,但二者必须共用同一事件信封。 +- 阶段 6 可在阶段 1 的 API mock 契约定稿后提前开始;不得自行发明事件字段。 +- 阶段 5 必须等待阶段 2、3、4 的安全事件与状态机定稿。 +- 阶段 7 必须等待阶段 2、4、6,不能以轮询临时替代 SSE。 +- 阶段 8 只做集成、验收和上线准备,不补写前面阶段缺失的核心设计。 + +建议主顺序:`阶段 0 → 阶段 1 → (阶段 2 + 阶段 3 + 阶段 6) → 阶段 4 → 阶段 5 → 阶段 7 → 阶段 8`。 + +--- + +## 9. 最终验收定义 + +只有同时满足以下条件,才可以说“本次 MAF 会商升级开发完成”: + +- 新页面在视觉和主交互上可被用户认作原多智能体会商的升级版; +- 页面具备历史会话、连续对话流、输入框、意图理解、总控派活、席位协作、澄清、审批、结果和追问; +- 每个必需席位任务都有任务合同、总控验收、有限返工和改派/受控缺口处理;席位短答或垃圾产出不能自动被当作完成; +- 浏览器没有会商事件/run/approval 的定时轮询;实时交互通过可重连 SSE 完成; +- 用户不需要创建或管理多个智能体线程,内部线程由服务端按用户身份安全创建和恢复; +- 新页面未调用旧会商业务 API/状态,旧会商零回归; +- 工具、技能、MCP、沙箱、审批、WriteGuard、一次写入和审计都仍可用; +- 自动化测试、SSE 故障测试、真实浏览器人工验收和旧功能回归全部通过; +- 真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议及生产 `MAF_ACTION_SECRET_KEY` 已按 Phase 5 前置条件完成。 + +在这之前,任何“会话/run 原型页面通过构建或轮询测试”的结果,都只能算技术验证,不算产品升级验收。 diff --git a/frontend-web/package.json b/frontend-web/package.json index fb189ccb..8accf72c 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -11,6 +11,7 @@ "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "preview": "vite preview", "typecheck": "tsc --noEmit", + "test:maf": "vitest run --config vitest.maf.config.ts", "check:chrome109-css": "node scripts/check-chrome109-css.mjs", "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs" }, @@ -106,19 +107,23 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.0.15", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/file-saver": "^2.0.7", "@types/gsap": "^3.0.0", "@types/node": "^20.14.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.4", + "jsdom": "^30.0.1", "lightningcss": "^1.30.2", "postcss": "^8.5.3", "tailwindcss": "^4.0.15", "tw-animate-css": "^1.4.0", "typescript": "^5.8.2", "vite": "^6.0.0", - "vite-plugin-source-identifier": "^1.1.2" + "vite-plugin-source-identifier": "^1.1.2", + "vitest": "^4.1.10" }, "packageManager": "pnpm@10.26.2" } diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 5b3887d2..90307231 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -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, Network, PenLine, Plus, SparklesIcon, Users } from "lucide-react"; +import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { SidebarCollapseButton } from "./workspace-header"; @@ -196,18 +196,7 @@ export function WorkspaceNavChatList({ - - - - - MAF 会商 - - - + {/* [MAF-R0] 实验原型已从正式菜单隐藏,仅保留路由供开发诊断:/page/maf */} ({ + decideMutate: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../api/approvals", () => ({ + // MafApprovalCard 只用 useMafApprovalActions + useMafApprovalActions: () => ({ + decide: { + mutate: (vars: unknown, opts?: { onSuccess?: () => void }) => { + mocks.decideMutate(vars); + // 同步触发成功回调,让组件进入 resuming 锁定态 + opts?.onSuccess?.(); + }, + isPending: false, + }, + }), +})); + +import { MafApprovalCard } from "../components/MafApprovalCard"; +import type { MafAction, MafApproval } from "../api/types"; + +function makeApproval(overrides: Partial = {}): MafApproval { + return { + id: "ap1", + session_id: "s1", + run_id: "r1", + action_id: "act1", + requested_by_agent_id: "agent-a", + status: "pending", + summary: "写入测试文件", + payload: { api_key: "secret-val", note: "可见说明" }, + decided_by: null, + decided_at: null, + expires_at: null, + created_at: null, + updated_at: null, + ...overrides, + }; +} + +function makeAction(overrides: Partial = {}): MafAction { + return { + id: "act1", + session_id: "s1", + run_id: "r1", + agent_id: "agent-a", + tool_name: "write_file", + logical_action_id: null, + risk_level: "R3", + status: "waiting_approval", + arguments: { target_key: "leak-me", path: "/tmp/x", normal: "visible-val" }, + result: { secret: "must-not-show-result" }, + external_object_id: null, + approval_id: "ap1", + attempt_count: 0, + started_at: null, + finished_at: null, + created_at: null, + updated_at: null, + ...overrides, + }; +} + +describe("MafApprovalCard", () => { + beforeEach(() => { + mocks.decideMutate.mockClear(); + }); + + it("连续双击「批准」mutate 只调用一次", async () => { + const user = userEvent.setup(); + render(); + + const approve = screen.getByRole("button", { name: "批准" }); + await user.click(approve); + await user.click(approve); + + expect(mocks.decideMutate).toHaveBeenCalledTimes(1); + expect(mocks.decideMutate).toHaveBeenCalledWith({ approvalId: "ap1", decision: "approved" }); + }); + + it("连续双击「拒绝」mutate 只调用一次", async () => { + const user = userEvent.setup(); + render(); + + const reject = screen.getByRole("button", { name: "拒绝" }); + await user.click(reject); + await user.click(reject); + + expect(mocks.decideMutate).toHaveBeenCalledTimes(1); + expect(mocks.decideMutate).toHaveBeenCalledWith({ approvalId: "ap1", decision: "rejected" }); + }); + + it("成功回调后批准与拒绝按钮都 disabled", async () => { + // TDesign Button 在 disabled 时会把 + )} {(isFocused || n.detail) && n.detail && (

{n.detail}

)} - {isFocused && (inc.length > 0 || out.length > 0) && ( -
+ {isFocused && hasRelations && ( +
{inc.length > 0 && (
上游: diff --git a/frontend-web/src/maf-roundtable/lib/maf-flow-graph.ts b/frontend-web/src/maf-roundtable/lib/maf-flow-graph.ts index 9a960f0d..b3196880 100644 --- a/frontend-web/src/maf-roundtable/lib/maf-flow-graph.ts +++ b/frontend-web/src/maf-roundtable/lib/maf-flow-graph.ts @@ -47,6 +47,8 @@ export interface MafFlowGraphData { title: string; nodes: MafFlowGraphNode[]; edges: MafFlowGraphEdge[]; + /** 是否因规模上限截断了节点/边/源文本(弱模型超大输出保护)。 */ + truncated?: boolean; } /** 节点类型 → 中文标签(图例 / 类型徽章)。 */ @@ -65,6 +67,21 @@ const VALID_TYPES = new Set(Object.keys(MAF_FLOW_NODE_TYPE_LABEL)); export const MAF_FLOW_FENCE_OPEN = "```flow-json"; +// ── 规模上限(弱模型超大输出保护)───────────────────────────────────────── + +const MAX_FLOW_SOURCE_CHARS = 100_000; +const MAX_FLOW_NODES = 200; +const MAX_FLOW_EDGES = 400; +const MAX_FLOW_LABEL_CHARS = 160; +const MAX_FLOW_DETAIL_CHARS = 2_000; +const MAX_FLOW_TITLE_CHARS = 160; + +/** 把文本截断到 max 字符(超出时追加 …),用于 node label / detail / graph title。 */ +function clampText(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, max)}…`; +} + // ── flow-json 解析(容错半截 JSON / 裸换行 / 尾逗号)───────────────────────── function asString(v: unknown): string { @@ -179,14 +196,20 @@ function collectObjects(text: string, key: string): Record[] { function toNode(raw: Record): MafFlowGraphNode | null { const id = asString(raw.id); - const label = asString(raw.label) || asString(raw.name); - if (!id || !label) return null; + const labelRaw = asString(raw.label) || asString(raw.name); + if (!id || !labelRaw) return null; const rawType = asString(raw.type).toLowerCase(); const type = (VALID_TYPES.has(rawType) ? rawType : "task") as MafFlowNodeType; - const detail = asString(raw.detail) || asString(raw.description) || undefined; + const detailRaw = asString(raw.detail) || asString(raw.description) || undefined; const group = asString(raw.group) || asString(raw.level) || asString(raw.tier) || asString(raw.layer) || undefined; - return { id, label, type, detail, group }; + return { + id, + label: clampText(labelRaw, MAX_FLOW_LABEL_CHARS), + type, + detail: detailRaw ? clampText(detailRaw, MAX_FLOW_DETAIL_CHARS) : undefined, + group, + }; } function edgesFromNode(raw: Record, nodeId: string): MafFlowGraphEdge[] { @@ -226,6 +249,12 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null { const close = body.indexOf("```"); if (close >= 0) body = body.slice(0, close); + let truncated = false; + if (body.length > MAX_FLOW_SOURCE_CHARS) { + body = body.slice(0, MAX_FLOW_SOURCE_CHARS); + truncated = true; + } + const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body); let title = ""; if (titleMatch) { @@ -235,6 +264,10 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null { title = titleMatch[1]; } } + if (title.length > MAX_FLOW_TITLE_CHARS) { + title = clampText(title, MAX_FLOW_TITLE_CHARS); + truncated = true; + } const rawNodes = collectObjects(body, '"nodes"'); const nodes: MafFlowGraphNode[] = []; @@ -253,15 +286,26 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null { } if (nodes.length === 0) return null; + if (nodes.length > MAX_FLOW_NODES) { + truncated = true; + } + const cappedNodes = nodes.slice(0, MAX_FLOW_NODES); + const cappedNodeIds = new Set(cappedNodes.map((n) => n.id)); + const edgeKeys = new Set(); const validEdges = edges.filter((e) => { - if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false; + if (!cappedNodeIds.has(e.from) || !cappedNodeIds.has(e.to) || e.from === e.to) return false; const key = `${e.from}->${e.to}`; if (edgeKeys.has(key)) return false; edgeKeys.add(key); return true; }); - return { title, nodes, edges: validEdges }; + let cappedEdges = validEdges; + if (validEdges.length > MAX_FLOW_EDGES) { + cappedEdges = validEdges.slice(0, MAX_FLOW_EDGES); + truncated = true; + } + return { title, nodes: cappedNodes, edges: cappedEdges, truncated }; } // ── confirmed_facts 证据图 ────────────────────────────────────────────────── @@ -332,7 +376,7 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow id: factId, label: truncate(fact.text), type: "task", - detail: fact.text, + detail: clampText(fact.text, MAX_FLOW_DETAIL_CHARS), group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认", }); @@ -349,7 +393,7 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow id: evId, label: truncate(refStr.replace(/^tool:/, ""), 24), type: evKind === "tool" ? "deliverable" : "role", - detail: refStr, + detail: clampText(refStr, MAX_FLOW_DETAIL_CHARS), group: evKind === "tool" ? "工具证据" : "外部证据", }); addEdge(ROOT_ID, evId); @@ -365,7 +409,21 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow return true; }); - return { title: "事实证据图", nodes, edges: dedupEdges }; + let truncated = false; + let cappedNodes = nodes; + let cappedEdges = dedupEdges; + if (nodes.length > MAX_FLOW_NODES) { + truncated = true; + cappedNodes = nodes.slice(0, MAX_FLOW_NODES); + const cappedNodeIds = new Set(cappedNodes.map((n) => n.id)); + cappedEdges = dedupEdges.filter((e) => cappedNodeIds.has(e.from) && cappedNodeIds.has(e.to)); + } + if (cappedEdges.length > MAX_FLOW_EDGES) { + truncated = true; + cappedEdges = cappedEdges.slice(0, MAX_FLOW_EDGES); + } + + return { title: "事实证据图", nodes: cappedNodes, edges: cappedEdges, truncated }; } /** diff --git a/frontend-web/src/maf-roundtable/lib/session-form.ts b/frontend-web/src/maf-roundtable/lib/session-form.ts new file mode 100644 index 00000000..085a630b --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/session-form.ts @@ -0,0 +1,164 @@ +/** + * MAF 会商创建表单的纯逻辑层。 + * + * 把「从 health 默认值初始化表单」「校验最大轮数」「经理互斥过滤席位」「构建只含改写项的 + * 请求体」等逻辑抽成纯函数,作为组件与测试的共同事实源。组件不得在内部复制这些逻辑, + * 测试必须 import 这里的真实函数,避免「实现坏掉、测试仍通过」。 + */ + +export interface HealthDefaults { + default_mode?: string; + default_write_policy?: string; + default_max_rounds?: number; +} + +export interface SessionFormState { + title: string; + taskText: string; + mode: string; + managerAgentId: string; + seatAgentIds: string[]; + modelName: string; + reasoningEffort: string; + /** 文本输入,空串表示「使用默认」。 */ + maxRounds: string; + writePolicy: string; + showAdvanced: boolean; +} + +/** + * 根据 health 返回的后端默认值创建一份干净的初始表单。 + * - mode 默认取 health.default_mode(兜底 structured); + * - writePolicy 默认取 health.default_write_policy(空串表示使用默认); + * - maxRounds 默认取 health.default_max_rounds 的字符串(空串表示使用默认)。 + * + * 注意:mode 即便来自 health 也只用作 UI 初始展示;提交时是否真的传回后端, + * 由 buildSessionOverrides 按「是否被改写」决定(见下)。 + */ +export function createInitialSessionForm( + health: HealthDefaults | null | undefined, +): SessionFormState { + return { + title: "", + taskText: "", + mode: health?.default_mode ?? "structured", + managerAgentId: "", + seatAgentIds: [], + modelName: "", + reasoningEffort: "", + maxRounds: + typeof health?.default_max_rounds === "number" + ? String(health.default_max_rounds) + : "", + writePolicy: health?.default_write_policy ?? "", + showAdvanced: false, + }; +} + +export interface MaxRoundsValidation { + valid: boolean; + value?: number; + error?: string; +} + +/** + * 校验最大轮数输入。空串视为有效(= 使用后端默认)。必须是 1–64 的整数。 + */ +export function validateMaxRounds(input: string): MaxRoundsValidation { + const trimmed = input.trim(); + if (!trimmed) return { valid: true }; + const n = Number(trimmed); + if (!Number.isInteger(n) || n < 1 || n > 64) { + return { valid: false, error: "最大轮数必须为 1–64 的整数" }; + } + return { valid: true, value: n }; +} + +/** + * 经理与席位互斥(后端参与者唯一约束):切换经理时,从已选席位中移除该 agent。 + */ +export function filterSeatsForManager(seats: string[], managerId: string): string[] { + if (!managerId) return seats; + return seats.filter((id) => id !== managerId); +} + +/** + * 切换某个席位的选择(toggle)。若该 agent 已是经理,则拒绝(返回原数组)。 + */ +export function toggleSeat( + seats: string[], + agentId: string, + managerAgentId: string, +): string[] { + if (agentId === managerAgentId) return seats; + return seats.includes(agentId) + ? seats.filter((id) => id !== agentId) + : [...seats, agentId]; +} + +/** + * 展示一行只读的后端默认摘要(高级项折叠时用)。值只来自 health,绝不硬编码。 + */ +export function defaultsSummaryLine(health: HealthDefaults | null | undefined): string { + if (!health) return "正在读取后端默认配置…"; + return `当前后端默认:${health.default_mode} | ${health.default_write_policy} | 最大 ${health.default_max_rounds} 轮`; +} + +export interface SessionOverrides { + mode?: "structured" | "group_chat"; + writePolicy?: "auto" | "confirm_high_risk" | "confirm_all_writes"; + maxRounds?: number; +} + +/** + * 只产出「用户相对后端默认确实改写了」的字段。 + * + * 设计:用户没改写 → 不传 → 后端决定(符合「最好不传对应字段」)。 + * 模式必须是 structured/group_chat 之一才算有效改写;写入策略必须是合法枚举值; + * 最大轮数走 validateMaxRounds 校验。 + */ +export function buildSessionOverrides( + form: Pick, + defaults: HealthDefaults | null | undefined, +): { overrides: SessionOverrides; maxRoundsError?: string } { + const result: SessionOverrides = {}; + let maxRoundsError: string | undefined; + + // 模式:仅当与后端默认不同且合法时传 + if (form.mode !== (defaults?.default_mode ?? "structured")) { + if (form.mode === "structured" || form.mode === "group_chat") { + result.mode = form.mode; + } + } + + // 写入策略:仅当与后端默认不同且合法时传 + const policyDefault = defaults?.default_write_policy ?? ""; + if (form.writePolicy !== policyDefault) { + if ( + form.writePolicy === "auto" || + form.writePolicy === "confirm_high_risk" || + form.writePolicy === "confirm_all_writes" + ) { + result.writePolicy = form.writePolicy; + } + // 空串 = 使用默认,不传 + } + + // 最大轮数:空串不传;非空走校验 + const roundsValidation = validateMaxRounds(form.maxRounds); + if (form.maxRounds.trim()) { + if (!roundsValidation.valid) { + maxRoundsError = roundsValidation.error; + } else if (roundsValidation.value !== undefined) { + const defaultRounds = + typeof defaults?.default_max_rounds === "number" + ? defaults.default_max_rounds + : null; + if (defaultRounds === null || roundsValidation.value !== defaultRounds) { + result.maxRounds = roundsValidation.value; + } + } + } + + return { overrides: result, maxRoundsError }; +} diff --git a/frontend-web/src/maf-roundtable/pages/MafRunDetailPage.tsx b/frontend-web/src/maf-roundtable/pages/MafRunDetailPage.tsx index f4969be2..202adecb 100644 --- a/frontend-web/src/maf-roundtable/pages/MafRunDetailPage.tsx +++ b/frontend-web/src/maf-roundtable/pages/MafRunDetailPage.tsx @@ -22,10 +22,13 @@ import { useDesensitizeSubtree } from "@/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 { canManualResume } from "../lib/run-resume"; import { foldEventsToTimeline } from "../lib/event-timeline"; +import { MafActionAuditPanel } from "../components/MafActionAuditPanel"; import { MafApprovalCard } from "../components/MafApprovalCard"; import { MafEventTimeline } from "../components/MafEventTimeline"; import { MafFactFlowPanel } from "../components/MafFactFlowPanel"; +import { MafFactLedgerPanel } from "../components/MafFactLedgerPanel"; import { MafHealthGate } from "../components/MafHealthGate"; import { MafRunHeader } from "../components/MafRunHeader"; import { MafSeatStatusPanel } from "../components/MafSeatStatusPanel"; @@ -49,6 +52,23 @@ function RunBody({ runId }: { runId: string }) { const pendingApprovals = (approvalsQuery.data?.approvals ?? []).filter( (a) => a.status === "pending", ); + // 手动恢复前置判定(见 lib/run-resume.ts): + // run 处于 waiting_approval && 审批已加载成功 && 无 pending 审批时才允许手动恢复。 + // 其余情况(有 pending、加载中、加载失败)一律禁止,避免 /resume 撞后端 409。 + const allowManualResume = canManualResume({ + runStatus: run?.status, + approvalsLoading: approvalsQuery.isLoading, + approvalsSuccess: approvalsQuery.isSuccess, + pendingCount: pendingApprovals.length, + }); + // 阻断恢复时的提示文案(让用户知道为什么不能点) + const resumeBlockedHint = approvalsQuery.isError + ? "无法确认审批状态,请稍后重试" + : pendingApprovals.length > 0 + ? `请先完成全部审批(剩余 ${pendingApprovals.length})` + : approvalsQuery.isLoading + ? "审批加载中…" + : "正在等待自动恢复…"; const actionsById = useMemo(() => { const m = new Map(); for (const a of actionsQuery.data?.actions ?? []) m.set(a.id, a); @@ -83,7 +103,11 @@ function RunBody({ runId }: { runId: string }) { return (
- +
@@ -101,6 +125,7 @@ function RunBody({ runId }: { runId: string }) { : undefined} /> + {/* 最终结论(MAF-06-07:区分建议动作与已执行动作) */} {showFinalResult && ( @@ -150,6 +175,8 @@ function RunBody({ runId }: { runId: string }) { /> )) )} + +
diff --git a/frontend-web/src/maf-roundtable/pages/MafSessionsPage.tsx b/frontend-web/src/maf-roundtable/pages/MafSessionsPage.tsx index 9dc282f7..e5df7a6a 100644 --- a/frontend-web/src/maf-roundtable/pages/MafSessionsPage.tsx +++ b/frontend-web/src/maf-roundtable/pages/MafSessionsPage.tsx @@ -9,7 +9,7 @@ * 可选高级项(模型/推理强度/最大轮数/写入策略)。智能体列表复用 @/core/agents。 */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Loader2, MessageSquarePlus, Play, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -30,6 +30,15 @@ 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 { + buildSessionOverrides, + createInitialSessionForm, + defaultsSummaryLine, + filterSeatsForManager, + toggleSeat, + validateMaxRounds, + type SessionFormState, +} from "../lib/session-form"; import { MafHealthGate } from "../components/MafHealthGate"; const STATUS_LABEL: Record = { @@ -89,9 +98,9 @@ function SessionsBody() { theme="default" variant="outline" size="small" + icon={} onClick={() => setCreateOpen(true)} > - icon={} 新建
@@ -206,10 +215,10 @@ function SessionDetail({ theme="default" variant="outline" size="small" + icon={} onClick={() => navigate(`/page/maf/runs/${liveRun.id}`)} > - icon={} - 已有会商进行中 + 已有会商进行中 )} } onClick={onStart} > - icon={} 启动会商
@@ -264,7 +273,7 @@ function SessionDetail({ ); } -function CreateSessionDialog({ +export function CreateSessionDialog({ open, onOpenChange, onCreated, @@ -277,76 +286,86 @@ function CreateSessionDialog({ 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([]); - // 高级项 - const [showAdvanced, setShowAdvanced] = useState(false); - const [modelName, setModelName] = useState(""); - const [reasoningEffort, setReasoningEffort] = useState(""); - const [maxRounds, setMaxRounds] = useState(""); - const [writePolicy, setWritePolicy] = useState(""); + // 表单状态:单一对象,由 createInitialSessionForm 根据 health 默认值初始化。 + const [form, setForm] = useState(() => + createInitialSessionForm(health.data), + ); + const [maxRoundsError, setMaxRoundsError] = useState(""); - // 默认 mode 跟随后端 default_mode(在 effect 中设置,避免 render 内 setState) + // 仅在弹窗「由关闭变打开」时重置表单(干净表单 + 从 health 重新取默认值)。 + // 不在 health.data 变化时重置——避免打开中途 health 刷新把用户已填内容冲掉。 + const prevOpenRef = useRef(false); useEffect(() => { - if (health.data?.default_mode) { - setMode(health.data.default_mode); + if (open && !prevOpenRef.current) { + setForm(createInitialSessionForm(health.data)); + setMaxRoundsError(""); } - }, [health.data?.default_mode]); + prevOpenRef.current = open; + }, [open, health.data]); - // 默认 write_policy 跟随后端 default_write_policy + // 切换经理时,自动从已选席位中移除(后端参与者唯一约束) useEffect(() => { - if (health.data?.default_write_policy) { - setWritePolicy(health.data.default_write_policy); - } - }, [health.data?.default_write_policy]); + setForm((prev) => { + const nextSeats = filterSeatsForManager( + prev.seatAgentIds, + prev.managerAgentId, + ); - // 切换经理时,自动从已选席位中移除(后端参与者唯一约束:同一 agent 不能同时是 manager 和 seat) - useEffect(() => { - if (managerAgentId) { - setSeatAgentIds((prev) => prev.filter((id) => id !== managerAgentId)); - } - }, [managerAgentId]); + return nextSeats.length === prev.seatAgentIds.length + ? prev + : { ...prev, seatAgentIds: nextSeats }; + }); + }, [form.managerAgentId]); - const toggleSeat = (agentId: string) => { - // 经理不能同时作为席位 - if (agentId === managerAgentId) return; - setSeatAgentIds((prev) => - prev.includes(agentId) - ? prev.filter((id) => id !== agentId) - : [...prev, agentId], - ); + const onToggleSeat = (agentId: string) => { + setForm((prev) => ({ + ...prev, + seatAgentIds: toggleSeat(prev.seatAgentIds, agentId, prev.managerAgentId), + })); }; + const patch = (p: Partial) => setForm((prev) => ({ ...prev, ...p })); + + const healthDefaults = health.data; + const healthReady = !!healthDefaults; + const onSubmit = () => { - // 前端校验 - if (!taskText.trim()) { + if (!healthReady) { + toast.error("后端默认配置尚未就绪,请稍后重试"); + return; + } + if (!form.taskText.trim()) { toast.error("请填写任务说明"); return; } - if (!managerAgentId) { + if (!form.managerAgentId) { toast.error("请选择经理智能体"); return; } - if (seatAgentIds.length === 0) { + if (form.seatAgentIds.length === 0) { toast.error("请至少选择一个席位智能体"); return; } + // 只产出「相对后端默认确实改写了」的高级字段;未改写 → 不传 → 后端决定。 + const { overrides, maxRoundsError: roundsErr } = buildSessionOverrides(form, healthDefaults); + if (roundsErr) { + setMaxRoundsError(roundsErr); + toast.error(roundsErr); + return; + } + setMaxRoundsError(""); + // 构建 participants const participants: ParticipantInput[] = []; - // 经理 - const managerAgent = agents.find((a) => a.id === managerAgentId); + const managerAgent = agents.find((a) => a.id === form.managerAgentId); participants.push({ - agent_id: managerAgentId, + agent_id: form.managerAgentId, display_name: managerAgent?.name ?? "", role: "manager", sort_order: 0, }); - // 席位 - seatAgentIds.forEach((agentId, idx) => { + form.seatAgentIds.forEach((agentId, idx) => { const agent = agents.find((a) => a.id === agentId); participants.push({ agent_id: agentId, @@ -357,49 +376,37 @@ function CreateSessionDialog({ }); const body: CreateSessionBody = { - title: title.trim(), - task_text: taskText.trim(), - mode: mode as "structured" | "group_chat", - manager_agent_id: managerAgentId, + title: form.title.trim(), + task_text: form.taskText.trim(), + manager_agent_id: form.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"]; + // 仅传被改写的模式 / 写入策略 / 最大轮数(来自 buildSessionOverrides) + if (overrides.mode) body.mode = overrides.mode; + if (overrides.writePolicy) body.write_policy = overrides.writePolicy; + if (overrides.maxRounds != null) body.max_rounds = overrides.maxRounds; + // 模型名 / 推理强度:非空才传 + if (form.modelName.trim()) body.model_name = form.modelName.trim(); + if (form.reasoningEffort.trim()) body.reasoning_effort = form.reasoningEffort.trim(); - 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}`), + sessionActions.create.mutate(body, { + onSuccess: (s) => { + toast.success("会话已创建"); + // 关闭弹窗;下次打开会由 open-effect 重新重置表单 + onOpenChange(false); + onCreated(s.id); }, - ); + onError: (e: Error) => toast.error(`创建失败:${e.message}`), + }); }; const agentOptions = agents.map((a) => ({ label: a.name, value: a.id })); + // 实时校验最大轮数(展示错误,不阻塞输入) + const liveMaxRoundsError = (() => { + if (!form.maxRounds.trim()) return ""; + const v = validateMaxRounds(form.maxRounds); + return v.valid ? "" : v.error ?? ""; + })(); return ( - 创建 + {healthReady ? "创建" : "加载后端默认…"}
} >
+ {/* health 尚未就绪:禁用创建并提示 */} + {!healthReady && ( +
+ + 正在获取后端默认配置,创建按钮暂不可点… +
+ )} + {/* 标题 */}
setTitle(val)} + value={form.title} + onChange={(val: string) => patch({ title: val })} placeholder="例如:某任务多视角研讨" />
@@ -440,8 +456,8 @@ function CreateSessionDialog({ 任务说明 * setTaskText(val)} + value={form.taskText} + onChange={(val: string) => patch({ taskText: val })} placeholder="描述本次会商要解决的问题、背景和目标(必填)" autosize={{ minRows: 3, maxRows: 8 }} /> @@ -451,8 +467,8 @@ function CreateSessionDialog({
setMode(String(val))} + value={form.mode} + onChange={(val) => patch({ mode: String(val) })} options={[ { label: "structured(规划→专家→评审→总结)", value: "structured" }, { label: "group_chat(协调员动态点名)", value: "group_chat" }, @@ -475,8 +491,8 @@ function CreateSessionDialog({
) : ( setManagerAgentId(String(val))} + value={form.managerAgentId} + onChange={(val) => patch({ managerAgentId: String(val) })} options={agentOptions} placeholder="请选择…" filterable @@ -507,20 +523,20 @@ function CreateSessionDialog({ {!agentsLoading && !agentsError && agents.map((a) => ( ))}
- {seatAgentIds.length > 0 && ( + {form.seatAgentIds.length > 0 && (
- 已选 {seatAgentIds.length} 个席位 + 已选 {form.seatAgentIds.length} 个席位
)} @@ -529,18 +545,24 @@ function CreateSessionDialog({
- {showAdvanced && ( + {/* 高级项未展开时,显示一行只读后端默认摘要(值只来自 health,绝不硬编码) */} + {!form.showAdvanced && ( +
+ {defaultsSummaryLine(healthDefaults)} +
+ )} + {form.showAdvanced && (
setModelName(val)} + value={form.modelName} + onChange={(val: string) => patch({ modelName: val })} placeholder="留空使用默认" size="small" /> @@ -548,8 +570,8 @@ function CreateSessionDialog({
setReasoningEffort(String(val))} + value={form.reasoningEffort} + onChange={(val) => patch({ reasoningEffort: String(val) })} size="small" options={[ { label: "留空使用默认", value: "" }, @@ -562,18 +584,24 @@ function CreateSessionDialog({
setMaxRounds(val)} + value={form.maxRounds} + onChange={(val: string) => patch({ maxRounds: val })} placeholder="留空使用默认" type="number" size="small" + status={liveMaxRoundsError ? "warning" : "default"} /> + {(liveMaxRoundsError || maxRoundsError) && ( +
+ {liveMaxRoundsError || maxRoundsError} +
+ )}
setWritePolicy(String(val))} + value={form.writePolicy} + onChange={(val) => patch({ writePolicy: String(val) })} size="small" options={[ { label: "默认", value: "" }, @@ -600,6 +628,7 @@ export function DeleteSessionButton({ sessionId }: { sessionId: string }) { variant="outline" size="small" loading={actions.remove.isPending} + icon={} onClick={() => { actions.remove.mutate(sessionId, { onSuccess: () => toast.success("已删除"), @@ -607,7 +636,6 @@ export function DeleteSessionButton({ sessionId }: { sessionId: string }) { }); }} > - icon={} 删除 ); diff --git a/frontend-web/src/pages/MafRoutes.tsx b/frontend-web/src/pages/MafRoutes.tsx index 7f1bcd08..44031ac1 100644 --- a/frontend-web/src/pages/MafRoutes.tsx +++ b/frontend-web/src/pages/MafRoutes.tsx @@ -1,5 +1,8 @@ /** + * [MAF-R0 实验原型 — 仅供开发诊断,已从正式菜单移除] + * * MAF 多智能体会商路由树(/page/maf/*)。 + * 本原型不再新增业务功能。新版会商将使用 /api/maf-roundtable/* + conversation 页面。 * * 仿 RoundtableRoutes:顶层包一层满高容器,内部 : * index → 会话列表 diff --git a/frontend-web/vitest.maf.config.ts b/frontend-web/vitest.maf.config.ts new file mode 100644 index 00000000..f708573d --- /dev/null +++ b/frontend-web/vitest.maf.config.ts @@ -0,0 +1,32 @@ +/** + * Vitest 配置 —— 仅跑 MAF 模块的真实挂载测试(jsdom + Testing Library)。 + * + * 设计: + * - environment=jsdom 让 React 组件可挂载; + * - 只 include `*.test.tsx`(组件挂载测试),现有 `*.test.ts`(node:test 纯函数) + * 保持原来的 esbuild + node --test 路径,互不干扰; + * - 复用项目 tsconfig 的 `@/*` → `./src/*` 别名; + * - 不引入 CDN / 在线服务,所有依赖本地安装并锁入 lockfile。 + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(here, "src"), + }, + }, + test: { + environment: "jsdom", + globals: true, + // 只跑 MAF 的组件挂载测试(.test.tsx);纯函数 .test.ts 仍走 esbuild+node --test + include: ["src/maf-roundtable/**/*.test.tsx"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 3eb666df..02d0eade 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -50,6 +50,7 @@ from app.gateway.routers import ( knowledge_ingest, light_apps, llm_metrics, + maf_conversations, maf_roundtable, mcp, memory, @@ -1612,6 +1613,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable app.include_router(maf_roundtable.router) + # MAF roundtable V2 Conversation API (user-visible layer) at /api/maf-roundtable/conversations + app.include_router(maf_conversations.router) + # Temporary compatibility bridge for the TaskCOP situation-overview frontend. app.include_router(taskcop_tasks.router) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 2163863c..390c4b65 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -319,8 +319,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: # abort Gateway startup (the feature is experimental / opt-in). from deerflow.persistence.maf_roundtable import make_maf_roundtable_store - app.state.maf_roundtable_store = make_maf_roundtable_store(sf) - try: + app.state.maf_roundtable_store = make_maf_roundtable_store(sf) + maf_enabled = False + try: from app.maf_roundtable import MafRoundtableService maf_cfg = getattr(config, "maf_roundtable", None) @@ -369,6 +370,32 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: logger.exception("[maf-roundtable] service initialization failed; feature stays off") app.state.maf_roundtable_service = None + # ── MAF roundtable V2 Conversation service + SSE event hub ── + try: + from app.maf_roundtable.conversation_event_hub import ConversationEventHub + + app.state.maf_conversation_event_hub = ConversationEventHub() + + if app.state.maf_roundtable_store is not None: + from app.maf_roundtable.conversation_service import MafConversationService + from app.maf_roundtable.dispatcher import get_dispatcher + + app.state.maf_conversation_service = MafConversationService( + app.state.maf_roundtable_store, + maf_config=getattr(config, "maf_roundtable", None), + event_hub=app.state.maf_conversation_event_hub, + agent_store=getattr(app.state, "agent_store", None), + app_config=config, + dispatcher=get_dispatcher(app), + enabled=maf_enabled, + ) + else: + app.state.maf_conversation_service = None + except Exception: # noqa: BLE001 + logger.exception("[maf-roundtable] conversation service init failed") + app.state.maf_conversation_service = None + app.state.maf_conversation_event_hub = None + from deerflow.persistence.roundtable_jobs import ( make_roundtable_job_store, ) @@ -458,6 +485,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: try: yield finally: + # P1: shut down the SSE event hub before closing the DB engine + # so active SSE streams are released cleanly. + hub = getattr(app.state, "maf_conversation_event_hub", None) + if hub is not None: + try: + hub.shutdown() + except Exception: # noqa: BLE001 + logger.warning("[maf-roundtable] hub shutdown failed", exc_info=True) + from deerflow.persistence.notifications import set_notification_store set_notification_store(None) diff --git a/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py b/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py new file mode 100644 index 00000000..6a753674 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/maf_conversations.py @@ -0,0 +1,341 @@ +"""HTTP router for MAF roundtable Conversation API at ``/api/maf-roundtable/conversations``. + +Phase 1 (R1) scope: +- POST /conversations — create blank user conversation +- GET /conversations — list current user's history +- GET /conversations/{id} — public conversation DTO +- GET /conversations/{id}/messages — paginated message history +- POST /conversations/{id}/messages — atomic send + run start + +Phase 2 (R2) adds: +- GET /conversations/{id}/stream — SSE real-time event stream + +User isolation: owner-only reads/writes. Admin diagnostic access is read-only. +Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids) +are never returned. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request, status +from fastapi.responses import StreamingResponse + +from app.maf_roundtable.conversation_event_hub import ConversationEventHub +from app.maf_roundtable.conversation_schemas import ( + ConversationListResponse, + ConversationResponse, + CreateConversationRequest, + MessageListResponse, + SendMessageRequest, + SendMessageResponse, +) +from app.maf_roundtable.conversation_service import ( + ConversationConflictError, + ConversationDisabledError, + ConversationNoSessionConfigError, + ConversationNotFoundError, + ConversationServiceError, + MafConversationService, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/maf-roundtable", tags=["maf-roundtable-conversations"]) + +_SSE_HEADERS = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", +} + + +def _get_conversation_service(request: Request) -> MafConversationService: + svc = getattr(request.app.state, "maf_conversation_service", None) + if svc is None: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF conversation service not available") + return svc + + +def _get_event_hub(request: Request) -> ConversationEventHub | None: + return getattr(request.app.state, "maf_conversation_event_hub", None) + + +def _user_ctx(request: Request) -> tuple[str, bool]: + """Match the main MAF router's authenticated-user convention. + + ``AuthMiddleware`` stamps the resolved account on ``request.state.user``; + it does not create ``user_id`` / ``is_admin`` attributes. Reading those + nonexistent attributes collapses every authenticated caller into the + fallback account and breaks owner isolation. + """ + user = getattr(request.state, "user", None) + if user is None: + return "default", False + return str(user.id), getattr(user, "system_role", None) == "admin" + + +# ── REST endpoints (Phase 1) ────────────────────────────────────────────── + + +@router.post("/conversations", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED) +async def create_conversation(body: CreateConversationRequest, request: Request): + svc = _get_conversation_service(request) + user_id, _ = _user_ctx(request) + try: + return await svc.create_conversation( + user_id=user_id, + title=body.title.strip(), + task_context=body.task_context, + ) + except ConversationDisabledError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="MAF roundtable feature is disabled", + ) + + +@router.get("/conversations", response_model=ConversationListResponse) +async def list_conversations( + request: Request, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +): + svc = _get_conversation_service(request) + user_id, _ = _user_ctx(request) + conversations, total = await svc.list_conversations(user_id=user_id, limit=limit, offset=offset) + return {"conversations": conversations, "total": total} + + +@router.get("/conversations/{conversation_id}", response_model=ConversationResponse) +async def get_conversation(conversation_id: str, request: Request): + svc = _get_conversation_service(request) + user_id, is_admin = _user_ctx(request) + try: + return await svc.get_conversation(conversation_id=conversation_id, user_id=user_id, is_admin=is_admin) + except ConversationNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + + +@router.get("/conversations/{conversation_id}/messages", response_model=MessageListResponse) +async def list_messages( + conversation_id: str, + request: Request, + before_seq: int | None = Query(default=None, ge=1), + limit: int = Query(default=50, ge=1, le=200), +): + svc = _get_conversation_service(request) + user_id, is_admin = _user_ctx(request) + try: + messages, last_stream_seq = await svc.list_messages( + conversation_id=conversation_id, user_id=user_id, is_admin=is_admin, + before_seq=before_seq, limit=limit, + ) + return {"messages": messages, "last_stream_seq": last_stream_seq} + except ConversationNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + + +@router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse, status_code=status.HTTP_201_CREATED) +async def send_message(conversation_id: str, body: SendMessageRequest, request: Request): + svc = _get_conversation_service(request) + user_id, _ = _user_ctx(request) + try: + return await svc.send_message(conversation_id=conversation_id, user_id=user_id, content=body.content) + except ConversationNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + except ConversationConflictError: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A run is already active") + except ConversationNoSessionConfigError: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No trusted session configuration") + except ConversationDisabledError: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF roundtable feature is disabled") + except ConversationServiceError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.message) + + +# ── SSE endpoint (Phase 2 / R2) ─────────────────────────────────────────── + + +# Event types that mark the end of a run's lifecycle. When the SSE stream +# encounters any of these in order, it sends the event and then closes the +# connection so the client doesn't sit idle receiving heartbeats. +_TERMINAL_EVENT_TYPES = frozenset({ + "run.completed", + "run.error", + "run.cancelled", +}) + + +def _format_sse_event(event: dict[str, Any]) -> str: + """Format one conversation event as an SSE frame per §4.3 contract. + + ``event:`` field carries the event type; ``data:`` carries the full + envelope; ``id:`` carries the event_id for Last-Event-ID reconnection. + """ + event_type = event.get("type", "message") + event_id = event.get("event_id") + payload = json.dumps(event, default=str, ensure_ascii=False) + parts = [f"event: {event_type}", f"data: {payload}"] + if event_id: + parts.append(f"id: {event_id}") + parts.append("") + parts.append("") + return "\n".join(parts) + + +def _format_heartbeat() -> str: + """SSE comment frame — keeps the connection alive without producing a + client-visible event.""" + return ": heartbeat\n\n" + + +def _is_terminal(event: dict[str, Any]) -> bool: + return event.get("type") in _TERMINAL_EVENT_TYPES + + +@router.get("/conversations/{conversation_id}/stream") +async def stream_conversation( + conversation_id: str, + request: Request, + after_seq: int = Query(default=0, ge=0), +): + """SSE stream for one conversation (§4.2). + + Single fetch-reader connection per conversation. The ordering is: + + 1. **Open subscription** (register before any DB query — P0-1 fix). + 2. **Replay** from DB: ``list_events(after_seq)``. + 3. **Stabilise**: re-query while the hub generation advanced during replay. + 4. **Snapshot** marker. + 5. **Real-time**: ``wait_for_change`` → query DB → yield events. + + On terminal events (``run.completed`` / ``run.error`` / ``run.cancelled``) + the stream sends the event and closes (P0-2 fix). + + Heartbeat every 15 s. Disconnect detection via ``request.is_disconnected()``. + """ + svc = _get_conversation_service(request) + hub = _get_event_hub(request) + user_id, is_admin = _user_ctx(request) + + if hub is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="SSE event hub not configured", + ) + + # Ownership check before opening the stream. + try: + await svc.get_conversation( + conversation_id=conversation_id, user_id=user_id, is_admin=is_admin, + ) + except ConversationNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + + store = svc._store # noqa: SLF001 — router needs raw event queries for streaming + + async def event_generator(): + # ── P0-1: Open subscription BEFORE replay ── + sub = hub.open_subscription(conversation_id) + last_seq = after_seq + try: + # ── Phase 1: Replay from DB ── + try: + replay_events = await store.conversation_events.list_events( + conversation_id, after_seq=last_seq, + ) + for evt in replay_events: + if await request.is_disconnected(): + return + yield _format_sse_event(evt) + last_seq = max(last_seq, int(evt.get("seq", last_seq))) + # P0-2: stop if replay already hit a terminal event. + if _is_terminal(evt): + return + except Exception: + logger.exception("SSE replay error conv=%s", conversation_id) + yield _format_sse_event({ + "seq": last_seq, + "event_id": f"replay_error_{last_seq}", + "conversation_id": conversation_id, + "type": "run.error", + "occurred_at": datetime.now(UTC).isoformat(), + "data": {"error": "replay_failed"}, + }) + return + + # ── P0-1: Stabilise — catch events published during replay ── + while sub.has_pending: + sub.mark_seen() + late_events = await store.conversation_events.list_events( + conversation_id, after_seq=last_seq, + ) + if not late_events: + break + for evt in late_events: + if await request.is_disconnected(): + return + yield _format_sse_event(evt) + last_seq = max(last_seq, int(evt.get("seq", last_seq))) + if _is_terminal(evt): + return + + # ── Send snapshot marker ── + snapshot = { + "seq": last_seq, + "event_id": f"snapshot_{conversation_id}_{last_seq}", + "conversation_id": conversation_id, + "type": "conversation.snapshot", + "occurred_at": datetime.now(UTC).isoformat(), + "data": {"last_stream_seq": last_seq}, + } + if not await request.is_disconnected(): + yield _format_sse_event(snapshot) + + # ── Phase 2: Real-time subscription ── + heartbeat_interval = hub.heartbeat_interval + while True: + if hub.is_closed: + return + if await request.is_disconnected(): + return + + changed = await sub.wait_for_change(timeout=heartbeat_interval) + if not changed: + # Timeout → heartbeat (or hub closed). + if hub.is_closed: + return + yield _format_heartbeat() + continue + + # Generation advanced — fetch new events from DB. + new_events = await store.conversation_events.list_events( + conversation_id, after_seq=last_seq, + ) + for evt in new_events: + if await request.is_disconnected(): + return + yield _format_sse_event(evt) + last_seq = max(last_seq, int(evt.get("seq", last_seq))) + # P0-2: terminal event → close stream after sending it. + if _is_terminal(evt): + return + except asyncio.CancelledError: + logger.debug("SSE stream cancelled conv=%s", conversation_id) + raise + except Exception: + logger.exception("SSE stream error conv=%s", conversation_id) + return + finally: + sub.close() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers=_SSE_HEADERS, + ) diff --git a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py index 591661d4..4abe9d9f 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/maf_roundtable.py @@ -131,6 +131,9 @@ async def get_health(request: Request) -> HealthResponse: write_guard_ready=readiness["ready"], action_secret_configured=readiness["secret_configured"], write_guard_status=readiness["status"], + sse_hub_type="in_memory", + sse_multi_worker=False, + sse_warning="Single-worker only. Multi-worker requires Redis Pub/Sub.", ) diff --git a/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py b/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py new file mode 100644 index 00000000..ff1d0e08 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/conversation_event_hub.py @@ -0,0 +1,284 @@ +"""Conversation-scoped in-process event hub for MAF roundtable SSE streaming. + +This hub mediates between the writers (store/service/workflow executor that +persist conversation events to the DB) and the SSE subscribers (the HTTP +stream endpoint). It is deliberately **notification-only**: the database is +the authoritative event store, and this hub merely signals "new events may be +available" so subscribers can re-query. This avoids duplicating the event log +in memory and guarantees that a reconnecting client can always replay from the +DB cursor (``after_seq``). + +Writer contract (§4.2 "先持久化、后通知"): + 1. Persist events to ``maf_roundtable_conversation_events`` (via + ``MafRoundtableStore.append_conversation_item``) inside a transaction. + 2. **After commit**, call ``await hub.publish(conversation_id)``. + +Subscriber contract (P0-1 race-free): + 1. ``sub = hub.open_subscription(conversation_id)`` — register **before** + any DB query so publishes during replay are captured. + 2. Replay from DB: ``list_events(after_seq=last_seq)``. + 3. **Stabilise**: if ``sub.current_generation > sub.seen_generation``, + re-query DB and repeat until stable (events arrived during replay). + 4. Send snapshot. + 5. ``await sub.wait_for_change(timeout=heartbeat)`` → on True, query DB + for new events; on False, send heartbeat. + + This eliminates the ``signal.clear()`` race: generation is monotonic and + never cleared, so a publish that happens between the DB read and the + ``wait()`` is always detected on the next stabilisation check. + +Multi-worker limitation: + Single-process, in-memory. Multi-worker requires Redis Pub/Sub. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + +_DEFAULT_HEARTBEAT_INTERVAL = 15.0 +_MAX_SUBSCRIBERS_PER_CONVERSATION = 64 + + +class _ConvState: + """Per-conversation notification state.""" + + __slots__ = ("generation", "condition", "subscriber_count") + + def __init__(self) -> None: + self.generation: int = 0 + self.condition: asyncio.Condition = asyncio.Condition() + self.subscriber_count: int = 0 + + +class Subscription: + """Explicit subscription handle — race-free notification reader. + + Created via :meth:`ConversationEventHub.open_subscription`. The caller + owns its lifecycle: call :meth:`close` when the SSE connection ends. + + Key invariant: ``seen_generation`` is monotonic — it is never reset. + A publish that races ahead of ``wait_for_change`` simply increments + ``current_generation``, and the next ``wait_for_change`` returns + immediately (no lost wakeup). + """ + + __slots__ = ("_hub", "_conv_id", "_seen_gen", "_closed") + + def __init__(self, hub: ConversationEventHub, conversation_id: str, gen_at_open: int) -> None: + self._hub = hub + self._conv_id = conversation_id + self._seen_gen = gen_at_open + self._closed = False + + @property + def conversation_id(self) -> str: + return self._conv_id + + @property + def current_generation(self) -> int: + """Latest generation known to the hub for this conversation.""" + return self._hub._get_generation(self._conv_id) + + @property + def seen_generation(self) -> int: + """Generation as of the last successful stabilisation / wait.""" + return self._seen_gen + + @property + def has_pending(self) -> bool: + """True when the hub generation is ahead of what we've seen.""" + return self.current_generation > self._seen_gen + + def mark_seen(self) -> None: + """Advance ``seen_generation`` to the hub's current generation.""" + self._seen_gen = self.current_generation + + def mark_seen_if_higher(self, gen: int) -> None: + if gen > self._seen_gen: + self._seen_gen = gen + + async def wait_for_change(self, *, timeout: float) -> bool: + """Block until ``current_generation > seen_generation`` or timeout. + + Returns ``True`` if the generation advanced (new events *might* be + available — caller must query DB to confirm). Returns ``False`` on + timeout (caller should send a heartbeat). + """ + state = self._hub._convs.get(self._conv_id) + if state is None: + return False + async with state.condition: + if state.generation > self._seen_gen: + self._seen_gen = state.generation + return True + try: + await asyncio.wait_for(state.condition.wait(), timeout=timeout) + except TimeoutError: + return False + if state.generation > self._seen_gen: + self._seen_gen = state.generation + return True + return False + + def close(self) -> None: + """Release the subscription. Safe to call multiple times.""" + if not self._closed: + self._closed = True + self._hub._release_subscription(self._conv_id) + + def __enter__(self) -> Subscription: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +class ConversationEventHub: + """In-process notification hub keyed by ``conversation_id``. + + Not thread-safe; intended for a single asyncio event loop (one worker). + """ + + def __init__(self, *, heartbeat_interval: float = _DEFAULT_HEARTBEAT_INTERVAL) -> None: + self._heartbeat_interval = heartbeat_interval + self._convs: dict[str, _ConvState] = {} + self._closed = False + + @property + def heartbeat_interval(self) -> float: + return self._heartbeat_interval + + @property + def total_subscribers(self) -> int: + return sum(s.subscriber_count for s in self._convs.values()) + + @property + def is_closed(self) -> bool: + return self._closed + + def conversation_subscriber_count(self, conversation_id: str) -> int: + state = self._convs.get(conversation_id) + return state.subscriber_count if state else 0 + + def _get_generation(self, conversation_id: str) -> int: + state = self._convs.get(conversation_id) + return state.generation if state else 0 + + def open_subscription(self, conversation_id: str) -> Subscription: + """Register a new subscriber **before** any DB replay. + + Returns a :class:`Subscription` whose ``seen_generation`` is the hub's + current generation at registration time. Any ``publish()`` after this + call will be detectable via ``sub.has_pending``. + """ + if self._closed: + raise RuntimeError("ConversationEventHub is shut down") + state = self._convs.get(conversation_id) + if state is None: + state = _ConvState() + self._convs[conversation_id] = state + if state.subscriber_count >= _MAX_SUBSCRIBERS_PER_CONVERSATION: + raise RuntimeError( + f"Too many SSE subscribers for conversation {conversation_id} " + f"(max {_MAX_SUBSCRIBERS_PER_CONVERSATION})" + ) + state.subscriber_count += 1 + logger.debug( + "SSE subscribe conv=%s gen=%d (subs=%d)", + conversation_id, state.generation, state.subscriber_count, + ) + return Subscription(self, conversation_id, state.generation) + + def _release_subscription(self, conversation_id: str) -> None: + state = self._convs.get(conversation_id) + if state is None: + return + state.subscriber_count -= 1 + logger.debug( + "SSE unsubscribe conv=%s (subs=%d)", + conversation_id, max(0, state.subscriber_count), + ) + if state.subscriber_count <= 0: + self._convs.pop(conversation_id, None) + + async def publish(self, conversation_id: str) -> None: + """Signal that new events have been persisted for a conversation. + + Must be called **after** the events are committed to the DB, never + before. Atomically increments the per-conversation generation counter + and wakes all waiting subscribers. Safe to call when no subscribers + are active (the next subscriber's stabilisation check will see the + incremented generation). + """ + if self._closed: + return + state = self._convs.get(conversation_id) + if state is None: + # No active subscribers — nothing to wake. The generation is not + # tracked for conversations with zero subscribers; a future + # open_subscription starts fresh at 0, which is correct because + # there was nobody to notify. + return + async with state.condition: + state.generation += 1 + state.condition.notify_all() + + async def wait_for_change( + self, conversation_id: str, seen_gen: int, *, timeout: float + ) -> int: + """Convenience: wait until generation > ``seen_gen``. + + Returns the new generation (> ``seen_gen``) on change, or ``seen_gen`` + unchanged on timeout. Prefer using a :class:`Subscription` for the + full lifecycle. + """ + state = self._convs.get(conversation_id) + if state is None: + return seen_gen + async with state.condition: + if state.generation > seen_gen: + return state.generation + try: + await asyncio.wait_for(state.condition.wait(), timeout=timeout) + except TimeoutError: + return seen_gen + return state.generation + + def shutdown(self) -> None: + """Signal all subscribers to stop (server shutdown). + + Wakes every waiter; their ``wait_for_change`` returns ``True`` but + the caller's outer loop checks ``hub.is_closed`` and exits. + """ + self._closed = True + for state in self._convs.values(): + state.generation += 1 + # Wake all waiters — we can't use the condition here because + # shutdown is sync. The next wait_for_change call will see + # the incremented generation and return immediately. + # For a truly sync wake we'd need to schedule the notify; + # but since shutdown is called from the lifespan teardown + # (async context), callers should use: + # async with state.condition: state.condition.notify_all() + # Instead we just bump the generation; waiters time out on the + # next heartbeat cycle at worst (15s). For immediate wake, + # use `await hub.aclose()` below. + + +async def hub_aclose(hub: ConversationEventHub) -> None: + """Async shutdown that immediately wakes all subscribers.""" + hub._closed = True + for state in list(hub._convs.values()): + async with state.condition: + state.generation += 1 + state.condition.notify_all() + + +__all__ = ["ConversationEventHub", "Subscription", "hub_aclose"] diff --git a/offline-backend-20260512/backend/app/maf_roundtable/conversation_schemas.py b/offline-backend-20260512/backend/app/maf_roundtable/conversation_schemas.py new file mode 100644 index 00000000..bf4e407f --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/conversation_schemas.py @@ -0,0 +1,118 @@ +"""Pydantic schemas for the MAF roundtable Conversation API (V2). + +These are the public wire contracts for ``/api/maf-roundtable/conversations``. +Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids, +capability snapshots, idempotency keys) are NEVER exposed here. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +_TASK_CONTEXT_SAFE_KEYS = frozenset({"business_code", "task_id", "task_name", "chain_id", "chain_name"}) + + +class _StrictRequestModel(BaseModel): + """Reject unknown request members instead of silently ignoring spoofed input.""" + + model_config = ConfigDict(extra="forbid") + + +class CreateConversationRequest(_StrictRequestModel): + title: str = Field(default="", max_length=255) + task_context: dict[str, Any] | None = None + + @model_validator(mode="before") + @classmethod + def _reject_internal_fields(cls, values: Any) -> Any: + if isinstance(values, dict): + forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id", + "thread_id", "capability_snapshot", "id"} + found = forbidden & set(values.keys()) + if found: + raise ValueError(f"Forbidden fields: {sorted(found)}") + return values + + @field_validator("task_context") + @classmethod + def _validate_task_context(cls, value: dict[str, Any] | None) -> dict[str, str] | None: + if value is None: + return None + unexpected = set(value) - _TASK_CONTEXT_SAFE_KEYS + if unexpected: + raise ValueError(f"Unsupported task_context fields: {sorted(unexpected)}") + cleaned: dict[str, str] = {} + for key, raw in value.items(): + if not isinstance(raw, (str, int)) or isinstance(raw, bool): + raise ValueError(f"task_context.{key} must be a string or integer") + rendered = str(raw).strip() + if not rendered: + raise ValueError(f"task_context.{key} cannot be blank") + if len(rendered) > 255: + raise ValueError(f"task_context.{key} exceeds 255 characters") + cleaned[key] = rendered + return cleaned + + +class SendMessageRequest(_StrictRequestModel): + content: str = Field(min_length=1, max_length=20000) + + @model_validator(mode="before") + @classmethod + def _reject_internal_fields(cls, values: Any) -> Any: + if isinstance(values, dict): + forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id", + "thread_id", "capability_snapshot", "idempotency_key"} + found = forbidden & set(values.keys()) + if found: + raise ValueError(f"Forbidden fields: {sorted(found)}") + return values + + @model_validator(mode="after") + def _strip_content(self) -> SendMessageRequest: + stripped = self.content.strip() + if not stripped: + raise ValueError("content cannot be empty or whitespace-only") + self.content = stripped + return self + + +class ConversationResponse(BaseModel): + id: str + title: str + task_context: dict[str, Any] | None = None + safe_status: str + created_at: str | None = None + updated_at: str | None = None + last_stream_seq: int = 0 + + +class ConversationListResponse(BaseModel): + conversations: list[ConversationResponse] + total: int + + +class MessageResponse(BaseModel): + id: str + conversation_id: str + seq: int + message_kind: str + role: str + agent_id: str | None = None + message_ref: str | None = None + content: str + metadata: dict[str, Any] | None = None + created_at: str | None = None + + +class MessageListResponse(BaseModel): + messages: list[MessageResponse] + last_stream_seq: int + + +class SendMessageResponse(BaseModel): + message_id: str + conversation_id: str + seq: int diff --git a/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py b/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py new file mode 100644 index 00000000..eb379dc1 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/conversation_service.py @@ -0,0 +1,670 @@ +"""Conversation service for the MAF roundtable V2 (App layer). + +Orchestrates the harness-layer store with business rules that belong to the +application layer: session blueprint construction, ownership enforcement, +public DTO projection, and fail-closed behaviour when configuration is missing. + +Blueprint generation lives here (not in the harness) because it depends on +deployment config (``MafRoundtableConfig``) which is an app-layer concern. +The harness ``MafRoundtableStore`` never imports ``app.*``. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from app.maf_roundtable.dispatch_contracts import ( + DispatchConfigurationError, + MafDispatchCatalog, + MafDispatchPlan, + MafIntentDecision, + resolve_intent, +) +from deerflow.persistence.maf_roundtable.conversations import ( + conversation_public_to_dict, +) +from deerflow.persistence.maf_roundtable.messages import message_public_to_dict +from deerflow.persistence.maf_roundtable.model import ( + MafRoundtableConversationRow, + MafRoundtableMessageRow, + MafRoundtableRunRow, +) +from deerflow.persistence.maf_roundtable.store import MafRoundtableStore + +if TYPE_CHECKING: + from app.maf_roundtable.conversation_event_hub import ConversationEventHub + +logger = logging.getLogger(__name__) + + +class ConversationServiceError(Exception): + def __init__(self, code: str, message: str, status_code: int = 400): + self.code = code + self.message = message + self.status_code = status_code + super().__init__(message) + + +class ConversationNotFoundError(ConversationServiceError): + def __init__(self) -> None: + super().__init__("not_found", "Conversation not found", 404) + + +class ConversationConflictError(ConversationServiceError): + def __init__(self) -> None: + super().__init__("conflict", "A run is already active for this conversation", 409) + + +class ConversationNoSessionConfigError(ConversationServiceError): + def __init__(self) -> None: + super().__init__( + "no_session_config", + "Cannot start a run: no trusted session configuration available", + 503, + ) + + +class ConversationDisabledError(ConversationServiceError): + def __init__(self) -> None: + super().__init__("disabled", "MAF roundtable feature is disabled", 503) + + +class ConversationDispatchError(ConversationServiceError): + def __init__(self, message: str = "MAF dispatch is not configured") -> None: + super().__init__("dispatch_unavailable", message, 503) + + +class ConversationRuntimeUnavailableError(ConversationServiceError): + def __init__(self) -> None: + super().__init__( + "runtime_unavailable", + "MAF workflow runtime is not available", + 503, + ) + + +class MafConversationService: + """App-layer service for user-visible MAF conversations.""" + + def __init__( + self, + store: MafRoundtableStore, + *, + maf_config: Any = None, + event_hub: ConversationEventHub | None = None, + agent_store: Any | None = None, + app_config: Any | None = None, + dispatcher: Any | None = None, + enabled: bool | None = None, + ) -> None: + self._store = store + self._maf_config = maf_config + self._hub = event_hub + self._agent_store = agent_store + self._app_config = app_config + self._dispatcher = dispatcher + self._enabled_override = enabled + self._watchers: dict[str, asyncio.Task[None]] = {} + + @property + def _enabled(self) -> bool: + """Mutating conversation APIs obey the same feature flag as MAF runs.""" + if self._enabled_override is not None: + return self._enabled_override + return bool(getattr(self._maf_config, "enabled", False)) + + def _read_scope_user(self, *, user_id: str, is_admin: bool) -> str | None: + """Keep administrator diagnostics read-only and config-gated.""" + if is_admin and bool(getattr(self._maf_config, "admin_diagnostic_access", False)): + return None + return user_id + + async def _build_dispatch_blueprint( + self, + *, + user_id: str, + title: str, + content: str, + task_context: dict[str, Any] | None, + ) -> tuple[dict[str, Any] | None, MafIntentDecision, MafDispatchPlan | None]: + cfg = self._maf_config + if cfg is None: + raise ConversationDispatchError("MAF session configuration is unavailable") + + intent = resolve_intent(content, task_context) + plan: MafDispatchPlan | None = None + participants: list[dict[str, Any]] = [] + capability_snapshot: dict[str, Any] | None = None + + if intent.intent_type != "clarification": + try: + plan = MafDispatchCatalog(cfg).build_plan( + intent=intent, + task_context=task_context, + ) + except DispatchConfigurationError as exc: + raise ConversationDispatchError(str(exc)) from exc + + from app.maf_roundtable.capability_snapshot import ( + AgentNotAccessibleError, + InvalidExcludedToolsError, + build_trusted_capability_snapshot, + seat_snapshot_for_agent, + ) + + participants = MafDispatchCatalog.participants(plan) + if self._app_config is not None: + try: + capability_snapshot = await build_trusted_capability_snapshot( + user_id=user_id, + participants=participants, + app_config=self._app_config, + agent_store=self._agent_store, + session_model_name=getattr(cfg, "default_model_name", None), + session_reasoning_effort=getattr(cfg, "default_reasoning_effort", None), + ) + except AgentNotAccessibleError as exc: + raise ConversationDispatchError( + f"configured MAF agent is not accessible: {exc.agent_id}" + ) from exc + except InvalidExcludedToolsError as exc: + raise ConversationDispatchError( + f"configured MAF agent has invalid excluded tools: {exc.agent_id}" + ) from exc + + for participant in participants: + participant["capability_snapshot"] = seat_snapshot_for_agent( + capability_snapshot, str(participant["agent_id"]) + ) + + payload = { + "maf_v2": { + "intent_type": intent.intent_type, + "reason_code": intent.reason_code, + "business_code": (task_context or {}).get("business_code"), + "dispatch_plan": plan.model_dump(mode="json") if plan is not None else None, + } + } + blueprint = { + "title": title, + "task_text": content, + "mode": getattr(plan, "mode", None) or getattr(cfg, "default_mode", "structured"), + "model_name": getattr(cfg, "default_model_name", None), + "reasoning_effort": getattr(cfg, "default_reasoning_effort", None), + "manager_agent_id": ( + plan.manager_agent_id if plan is not None + else getattr(cfg, "default_manager_agent_id", None) + ), + "max_rounds": getattr(plan, "max_rounds", None) or getattr(cfg, "default_max_rounds", 8), + "write_policy": getattr(cfg, "default_write_policy", "confirm_high_risk"), + "capability_snapshot": capability_snapshot, + "task_payload": payload, + "participants": participants, + "start_run": intent.intent_type != "clarification", + } + return blueprint, intent, plan + + async def create_conversation( + self, *, user_id: str, title: str = "", task_context: dict[str, Any] | None = None + ) -> dict[str, Any]: + if not self._enabled: + raise ConversationDisabledError() + row_data = await self._store.conversations.create_conversation({ + "owner_user_id": user_id, + "title": title, + "task_context": task_context, + }) + conv_row = await self._get_conv_row(row_data["id"]) + if conv_row is None: + raise ConversationNotFoundError() + return conversation_public_to_dict(conv_row) + + async def list_conversations( + self, *, user_id: str, limit: int = 50, offset: int = 0 + ) -> tuple[list[dict[str, Any]], int]: + rows = await self._store.conversations.list_conversations( + user_id=user_id, limit=limit, offset=offset + ) + total = await self._store.conversations.count_conversations(user_id=user_id) + public = [] + for r in rows: + conv_row = await self._get_conv_row(r["id"]) + if conv_row is not None: + public.append(conversation_public_to_dict(conv_row)) + return public, total + + async def get_conversation(self, *, conversation_id: str, user_id: str, is_admin: bool = False) -> dict[str, Any]: + effective_user = self._read_scope_user(user_id=user_id, is_admin=is_admin) + row = await self._store.conversations.get_conversation(conversation_id, user_id=effective_user) + if row is None: + raise ConversationNotFoundError() + conv_row = await self._get_conv_row(conversation_id) + if conv_row is None: + raise ConversationNotFoundError() + return conversation_public_to_dict(conv_row) + + async def list_messages( + self, *, conversation_id: str, user_id: str, is_admin: bool = False, + before_seq: int | None = None, limit: int = 50, + ) -> tuple[list[dict[str, Any]], int]: + effective_user = self._read_scope_user(user_id=user_id, is_admin=is_admin) + conv = await self._store.conversations.get_conversation(conversation_id, user_id=effective_user) + if conv is None: + raise ConversationNotFoundError() + + rows = await self._store.messages.list_messages_desc( + conversation_id, limit=limit, before_seq=before_seq + ) + public = [] + for r in rows: + msg_row = await self._get_msg_row(r["id"]) + if msg_row is not None: + public.append(message_public_to_dict(msg_row)) + return public, int(conv.get("last_stream_seq", 0)) + + async def send_message( + self, *, conversation_id: str, user_id: str, content: str + ) -> dict[str, Any]: + if not self._enabled: + raise ConversationDisabledError() + conv = await self._store.conversations.get_conversation(conversation_id, user_id=user_id) + if conv is None: + raise ConversationNotFoundError() + + try: + blueprint, intent, plan = await self._build_dispatch_blueprint( + user_id=user_id, + title=conv.get("title", ""), + content=content, + task_context=conv.get("task_context"), + ) + except ConversationServiceError: + raise + + if blueprint is None: + raise ConversationNoSessionConfigError() + + # Do not create a durable running record if the MAF SDK is missing. + # Unit tests may inject a small fake dispatcher without this optional + # readiness method. + if intent.intent_type != "clarification" and self._dispatcher is not None: + check_sdk = getattr(self._dispatcher, "check_sdk_available", None) + if callable(check_sdk): + try: + check_sdk() + except Exception as exc: # noqa: BLE001 - safe public error + logger.warning("MAF runtime unavailable before dispatch: %s", type(exc).__name__) + raise ConversationRuntimeUnavailableError() from exc + + result = await self._store.send_message_and_start_run( + conversation_id=conversation_id, + owner_user_id=user_id, + content=content, + session_blueprint=blueprint, + ) + + if result is None: + raise ConversationNotFoundError() + if result.get("conflict"): + raise ConversationConflictError() + if result.get("no_session_config"): + raise ConversationNoSessionConfigError() + + await self._publish_conversation_event( + conversation_id=conversation_id, + owner_user_id=user_id, + event_type="manager.intent", + event_data={ + "intent_type": intent.intent_type, + "confidence": intent.confidence, + "reason_code": intent.reason_code, + }, + ) + + if intent.intent_type == "clarification": + await self._publish_conversation_event( + conversation_id=conversation_id, + owner_user_id=user_id, + event_type="clarification.required", + event_data={ + "prompt": intent.clarification_prompt, + "reason_code": intent.reason_code, + }, + ) + else: + if plan is None or result.get("run") is None: + raise ConversationDispatchError("dispatch plan did not produce a run") + await self._publish_conversation_event( + conversation_id=conversation_id, + owner_user_id=user_id, + event_type="manager.dispatch", + event_data={ + "intent_type": plan.intent_type, + "mode": plan.mode, + "business_code": plan.business_code, + "manager_agent_id": plan.manager_agent_id, + "seat_agent_ids": list(plan.seat_agent_ids), + "seat_count": len(plan.seat_agent_ids), + }, + ) + await self._dispatch_run( + conversation_id=conversation_id, + user_id=user_id, + run=result["run"], + ) + + return { + "message_id": result["message"]["id"] if result.get("message") else "", + "conversation_id": conversation_id, + "seq": result.get("seq", 0), + } + + async def _publish_conversation_event( + self, + *, + conversation_id: str, + owner_user_id: str, + event_type: str, + event_data: dict[str, Any], + source_event_id: str | None = None, + ) -> bool: + """Persist first, then notify; no internal run/thread ids are used. + + P0-4: when ``source_event_id`` is provided, dedup is enforced — if an + event with the same ``(conversation_id, source_event_id)`` already + exists, the method returns ``False`` (no duplicate written). This + makes projection idempotent across retries and restarts. + + Returns ``True`` if a new event was written, ``False`` if deduped. + """ + from sqlalchemy import select + + async with self._store.session_factory() as db: + async with db.begin(): + conv = ( + await db.execute( + select(MafRoundtableConversationRow) + .where(MafRoundtableConversationRow.id == conversation_id) + .with_for_update() + ) + ).scalars().first() + if conv is None or conv.owner_user_id != owner_user_id: + return False + + # P0-4: dedup check — skip if already projected. + if source_event_id is not None: + from deerflow.persistence.maf_roundtable.model import ( + MafRoundtableConversationEventRow, + ) + + existing = ( + await db.execute( + select(MafRoundtableConversationEventRow.id).where( + MafRoundtableConversationEventRow.conversation_id == conversation_id, + MafRoundtableConversationEventRow.source_event_id == source_event_id, + ).limit(1) + ) + ).first() + if existing is not None: + return False + + await self._store.append_conversation_item( + db, + conversation=conv, + event_type=event_type, + event_data=event_data, + run_id=None, + source_event_id=source_event_id, + ) + if self._hub is not None: + try: + await self._hub.publish(conversation_id) + except Exception: # noqa: BLE001 + logger.exception("hub publish failed conv=%s", conversation_id) + return True + + async def bridge_workflow_event(self, *, run_id: str, event: dict[str, Any]) -> None: + """Project a persisted per-run event onto the V2 conversation stream. + + The per-run event ledger remains the audit source of truth. This + projection is deliberately lossy: only user-visible lifecycle fields + are copied, while tool arguments, tool-call ids, thread ids, run ids, + and raw exception bodies are discarded before the SSE event is saved. + """ + from sqlalchemy import select + + async with self._store.session_factory() as db: + row = ( + await db.execute( + select(MafRoundtableConversationRow) + .join( + MafRoundtableRunRow, + MafRoundtableConversationRow.internal_session_id + == MafRoundtableRunRow.session_id, + ) + .where(MafRoundtableRunRow.id == run_id) + ) + ).scalars().first() + if row is None: + return + conversation_id = row.id + owner_user_id = row.owner_user_id + + source_type = str(event.get("event_type") or "") + mapped_type = { + "agent.turn_started": "agent.message.start", + "agent.message.completed": "agent.message.complete", + "action.waiting_approval": "approval.required", + "action.approved": "approval.decided", + "action.rejected": "approval.decided", + "run.started": "run.status", + "run.failed": "run.error", + "run.cancelled": "run.cancelled", + "manager.decision": "manager.dispatch", + "workflow.checkpointed": "run.status", + "action.waiting_target_lease": "tool.progress", + "action.unknown": "tool.failed", + }.get(source_type, source_type) + + allowed_types = { + "agent.message.start", "agent.message.delta", "agent.message.complete", + "seat.delivery.submitted", "manager.dispatch", "manager.review.decision", + "tool.requested", "tool.started", "tool.progress", "tool.completed", "tool.failed", + "skill.selected", "skill.started", "skill.completed", "skill.failed", + "artifact.created", "approval.required", "approval.decided", + "fact.confirmed", "review.completed", "run.status", "run.error", + "run.cancelled", "run.completed", + } + if mapped_type not in allowed_types: + return + + payload = event.get("payload") + if not isinstance(payload, dict): + payload = {} + safe_keys = { + "agent.message.start": {"thread_id": False}, + "agent.message.delta": {"text": True}, + "agent.message.complete": {"content": True, "usage": True, "metadata": False}, + "tool.requested": {"tool_name": True}, + "tool.started": {"tool_name": True, "node": True}, + "tool.progress": {"tool_name": True, "status": True}, + "tool.completed": {"tool_name": True, "status": True, "content_preview": True}, + "tool.failed": {"tool_name": True, "status": True, "error_code": True}, + "skill.selected": {"skill_name": True, "message": True}, + "skill.started": {"skill_name": True, "message": True}, + "skill.completed": {"skill_name": True, "result_preview": True}, + "skill.failed": {"skill_name": True, "error": True}, + "artifact.created": {"artifact_type": True, "title": True}, + "approval.required": {"tool_name": True, "summary": True}, + "approval.decided": {"decided_by": True}, + "run.status": {"checkpoint_id": True, "reason": True, "status": True}, + "run.error": {"error_code": True, "message": True}, + "run.cancelled": {}, + "run.completed": {"status": True}, + "manager.dispatch": {"next_speaker": True, "reason": True}, + "manager.review.decision": {"decision": True, "reason": True, "quality": True}, + "fact.confirmed": {"fact_id": True, "summary": True}, + "review.completed": {"summary": True, "decision": True}, + "seat.delivery.submitted": {"content": True, "schema_status": True}, + }.get(mapped_type, {}) + data: dict[str, Any] = { + key: payload[key] + for key in safe_keys + if key in payload and safe_keys[key] + } + if event.get("agent_id"): + data["agent_id"] = event["agent_id"] + if event.get("turn_index") is not None: + data["turn_index"] = event["turn_index"] + + # P0-4: use the source audit event's stable id for dedup. + source_evt_id = event.get("id") or event.get("source_event_id") + + await self._publish_conversation_event( + conversation_id=conversation_id, + owner_user_id=owner_user_id, + event_type=mapped_type, + event_data=data, + source_event_id=source_evt_id, + ) + if source_type == "agent.message.completed": + # A completed structured seat turn is also a delivery milestone; + # this gives the timeline a stable gate separate from raw text. + delivery_data = { + key: payload[key] + for key in ("content", "schema_status") + if key in payload + } + if event.get("agent_id"): + delivery_data["agent_id"] = event["agent_id"] + await self._publish_conversation_event( + conversation_id=conversation_id, + owner_user_id=owner_user_id, + event_type="seat.delivery.submitted", + event_data=delivery_data, + source_event_id=f"{source_evt_id}:delivery" if source_evt_id else None, + ) + + async def _dispatch_run(self, *, conversation_id: str, user_id: str, run: dict[str, Any]) -> None: + if self._dispatcher is None: + # R1 unit callers can still exercise persistence without starting + # the optional SDK runtime. The production dependency wiring always + # supplies a dispatcher. + return + session = await self._store.sessions.get_session(run["session_id"], user_id=user_id) + if session is None: + raise ConversationDispatchError("internal MAF session could not be loaded") + try: + await self._dispatcher.dispatch(session, run, user_id=user_id) + except Exception as exc: # noqa: BLE001 - reconcile durable state + logger.exception("MAF dispatch failed run=%s", run["id"]) + await self._mark_dispatch_failed( + conversation_id=conversation_id, + user_id=user_id, + run_id=run["id"], + error_code=type(exc).__name__[:64], + ) + raise ConversationRuntimeUnavailableError() from exc + + wait_for_run = getattr(self._dispatcher, "wait_for_run", None) + if callable(wait_for_run): + watcher = asyncio.create_task( + self._watch_run( + conversation_id=conversation_id, + user_id=user_id, + run_id=run["id"], + wait_for_run=wait_for_run, + ) + ) + self._watchers[run["id"]] = watcher + watcher.add_done_callback(lambda _task, rid=run["id"]: self._watchers.pop(rid, None)) + + async def _mark_dispatch_failed( + self, *, conversation_id: str, user_id: str, run_id: str, error_code: str + ) -> None: + await self._store.runs.update_run(run_id, { + "status": "failed", + "finished_at": datetime.now(UTC), + "error_code": error_code, + "error_message_safe": "MAF workflow dispatch failed", + }) + run = await self._store.runs.get_run(run_id) + if run is not None: + await self._store.sessions.update_session( + run["session_id"], user_id=user_id, fields={"status": "failed"} + ) + await self._store.conversations.set_active_run(conversation_id, None, "failed") + await self._publish_conversation_event( + conversation_id=conversation_id, + owner_user_id=user_id, + event_type="run.error", + event_data={"status": "failed", "error_code": error_code}, + ) + + async def _watch_run( + self, + *, + conversation_id: str, + user_id: str, + run_id: str, + wait_for_run: Any, + ) -> None: + try: + await wait_for_run(run_id) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("MAF conversation watcher failed run=%s", run_id) + return + + run = await self._store.runs.get_run(run_id) + if run is None: + return + status_value = str(run.get("status") or "failed") + terminal = status_value in {"completed", "failed", "cancelled"} + await self._store.conversations.set_active_run( + conversation_id, + None if terminal else run_id, + status_value, + ) + event_type = { + "completed": "run.completed", + "failed": "run.error", + "cancelled": "run.cancelled", + }.get(status_value, "run.status") + data: dict[str, Any] = {"status": status_value} + if run.get("error_code"): + data["error_code"] = run["error_code"] + await self._publish_conversation_event( + conversation_id=conversation_id, + owner_user_id=user_id, + event_type=event_type, + event_data=data, + ) + + async def _get_conv_row(self, conversation_id: str) -> MafRoundtableConversationRow | None: + from sqlalchemy import select + async with self._store.session_factory() as session: + row = ( + await session.execute( + select(MafRoundtableConversationRow).where( + MafRoundtableConversationRow.id == conversation_id + ) + ) + ).scalars().first() + return row + + async def _get_msg_row(self, message_id: str) -> MafRoundtableMessageRow | None: + from sqlalchemy import select + async with self._store.session_factory() as session: + row = ( + await session.execute( + select(MafRoundtableMessageRow).where( + MafRoundtableMessageRow.id == message_id + ) + ) + ).scalars().first() + return row diff --git a/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py b/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py index bec3cac8..b5c8c748 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/deerflow_agent_adapter.py @@ -488,6 +488,7 @@ class DeerFlowAgentAdapter: raise AuditEventError( f"Critical audit event '{event.get('event_type')}' failed to persist: {exc}" ) from exc + await self._bridge_conversation_event(run_id, event) async def _emit(self, run_id: str, event: dict[str, Any]) -> None: """Emit a non-critical event (agent.message.delta). Failure degrades gracefully. @@ -518,6 +519,42 @@ class DeerFlowAgentAdapter: ) except Exception: logger.warning("non-critical event %s failed for run %s (degraded)", event_type, run_id, exc_info=True) + return + await self._bridge_conversation_event(run_id, event) + + async def _bridge_conversation_event(self, run_id: str, event: dict[str, Any]) -> None: + """Best-effort projection into the V2 conversation event stream. + + The per-run audit write above remains authoritative. A projection + failure must not cause a tool to execute twice or turn a completed + seat run into a false runtime failure; reconnect/recovery can rebuild + the conversation view from the audit ledger later. + + P0-3: the entire ``app.state`` resolution is best-effort. If any link + is missing (no app, no state, no conversation service), the projection + is silently skipped — audit, recovery, cancel, lease, and tool + execution safety are never affected. + """ + try: + app = getattr(self._client, "app", None) + state = getattr(app, "state", None) + conversation_service = getattr(state, "maf_conversation_service", None) + except Exception: # noqa: BLE001 + return + if conversation_service is None: + return + bridge = getattr(conversation_service, "bridge_workflow_event", None) + if not callable(bridge): + return + try: + await bridge(run_id=run_id, event=event) + except Exception: # noqa: BLE001 - audit already persisted + logger.warning( + "conversation event projection failed for run %s (%s)", + run_id, + event.get("event_type"), + exc_info=True, + ) # ── internal helpers ──────────────────────────────────────────────────── diff --git a/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py b/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py new file mode 100644 index 00000000..b9caaed5 --- /dev/null +++ b/offline-backend-20260512/backend/app/maf_roundtable/dispatch_contracts.py @@ -0,0 +1,268 @@ +"""Deterministic, server-owned contracts for MAF Stage 3 dispatch. + +The manager may describe work, but it does not get to choose arbitrary agent +ids, threads, tools, or capabilities. Routing is resolved from the trusted +MAF deployment configuration and the small, validated task-context allowlist. +This module is intentionally independent of the MAF SDK so the policy can be +tested with a weak/fake model and before any workflow is launched. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from deerflow.config.maf_roundtable_config import MafMode + +MafIntentType = Literal[ + "direct_answer", + "clarification", + "single_seat", + "parallel_consultation", + "chain_consultation", + "tool_write", +] + + +class DispatchConfigurationError(RuntimeError): + """The deployment has not supplied a complete trusted route.""" + + +class InvalidDispatchPlanError(ValueError): + """A proposed or constructed plan violates the dispatch contract.""" + + +class MafIntentDecision(BaseModel): + model_config = ConfigDict(extra="forbid") + + intent_type: MafIntentType + confidence: float = Field(ge=0.0, le=1.0) + reason_code: str = Field(min_length=1, max_length=64) + clarification_prompt: str | None = Field(default=None, max_length=1000) + + @model_validator(mode="after") + def _clarification_shape(self) -> MafIntentDecision: + if self.intent_type == "clarification" and not self.clarification_prompt: + raise ValueError("clarification intent requires clarification_prompt") + if self.intent_type != "clarification" and self.clarification_prompt is not None: + raise ValueError("only clarification intent may include clarification_prompt") + return self + + +class MafDispatchAssignment(BaseModel): + model_config = ConfigDict(extra="forbid") + + agent_id: str = Field(min_length=1, max_length=64) + role: Literal["seat"] = "seat" + sort_order: int = Field(ge=1, le=64) + + +class MafDispatchPlan(BaseModel): + """Validated plan containing only server-selected participants.""" + + model_config = ConfigDict(extra="forbid") + + manager_agent_id: str = Field(min_length=1, max_length=64) + seat_agent_ids: list[str] = Field(default_factory=list, max_length=64) + assignments: list[MafDispatchAssignment] = Field(default_factory=list, max_length=64) + mode: MafMode = "structured" + intent_type: MafIntentType + business_code: str | None = Field(default=None, max_length=255) + max_rounds: int = Field(ge=1, le=64) + + @model_validator(mode="after") + def _validate_shape(self) -> MafDispatchPlan: + if self.manager_agent_id in self.seat_agent_ids: + raise ValueError("manager agent cannot also be a seat") + if len(set(self.seat_agent_ids)) != len(self.seat_agent_ids): + raise ValueError("duplicate seat agent") + assignment_ids = [assignment.agent_id for assignment in self.assignments] + if assignment_ids != self.seat_agent_ids: + raise ValueError("assignments must exactly match seat_agent_ids") + if [a.sort_order for a in self.assignments] != list(range(1, len(self.assignments) + 1)): + raise ValueError("assignments must have contiguous sort_order") + if self.intent_type == "clarification": + raise ValueError("clarification cannot be dispatched") + if self.intent_type != "direct_answer" and not self.seat_agent_ids: + raise ValueError("this intent requires at least one configured seat") + return self + + +def resolve_intent(content: str, task_context: dict[str, Any] | None = None) -> MafIntentDecision: + """Resolve the common Stage 3 intents deterministically. + + A production model can later supply a *description* for the intent, but + this policy remains the final validator. The fallback deliberately uses + conservative keywords and never extracts agent/tool ids from user text. + """ + + text = " ".join(str(content or "").strip().split()) + lowered = text.casefold() + if len(text) < 4 or lowered in {"?", "?", "不清楚", "不明确", "再说清楚"}: + return MafIntentDecision( + intent_type="clarification", + confidence=0.99, + reason_code="insufficient_task_detail", + clarification_prompt="请补充希望解决的具体问题、目标和必要的业务背景。", + ) + + write_markers = ( + "写入", "发布", "创建工单", "更新任务", "提交", "删除", "write", "publish", "create ticket", + ) + chain_markers = ("业务链", "链式", "逐阶段", "先…再", "先...再", "chain") + parallel_markers = ("多专家", "多角度", "并行", "同时分析", "比较", "parallel", "multi-agent") + single_markers = ("请某个专家", "单个席位", "单专家", "single seat", "指定专家") + + if any(marker in lowered for marker in write_markers): + intent_type: MafIntentType = "tool_write" + reason_code = "write_request" + elif any(marker in lowered for marker in chain_markers): + intent_type = "chain_consultation" + reason_code = "chain_request" + elif any(marker in lowered for marker in parallel_markers): + intent_type = "parallel_consultation" + reason_code = "parallel_request" + elif any(marker in lowered for marker in single_markers): + intent_type = "single_seat" + reason_code = "single_seat_request" + else: + intent_type = "direct_answer" + reason_code = "default_direct_answer" + + return MafIntentDecision( + intent_type=intent_type, + confidence=0.72 if task_context else 0.68, + reason_code=reason_code, + ) + + +def _read(value: Any, name: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(name, default) + return getattr(value, name, default) + + +class MafDispatchCatalog: + """Resolve routes from deployment config, never from a request body.""" + + def __init__(self, maf_config: Any) -> None: + self._config = maf_config + + def _route(self, business_code: str | None) -> tuple[str | None, list[str], MafMode | None]: + routes = _read(self._config, "business_routes", {}) or {} + if business_code: + route = routes.get(business_code) + if route is None: + # A supplied business code must match an explicitly configured + # route. Falling back here would silently cross business + # boundaries. + return None, [], None + return ( + _read(route, "manager_agent_id"), + list(_read(route, "seat_agent_ids", []) or []), + _read(route, "mode"), + ) + return ( + _read(self._config, "default_manager_agent_id"), + list(_read(self._config, "default_seat_agent_ids", []) or []), + None, + ) + + def build_plan( + self, + *, + intent: MafIntentDecision, + task_context: dict[str, Any] | None, + ) -> MafDispatchPlan: + business_code = str((task_context or {}).get("business_code") or "").strip() or None + manager_id, route_seats, route_mode = self._route(business_code) + if not manager_id: + raise DispatchConfigurationError( + "no trusted MAF manager route is configured for this conversation" + ) + + # The server owns the route. Intent only changes how many configured + # seats participate; it never adds an id. + if intent.intent_type in {"single_seat", "tool_write", "direct_answer"}: + seat_ids = route_seats[:1] + else: + seat_ids = route_seats[:] + + max_seats = int(_read(self._config, "max_seats_per_run", 16) or 16) + if len(seat_ids) > max_seats: + raise DispatchConfigurationError("configured MAF route exceeds max_seats_per_run") + + try: + plan = MafDispatchPlan( + manager_agent_id=str(manager_id).strip(), + seat_agent_ids=[str(agent_id).strip() for agent_id in seat_ids], + assignments=[ + MafDispatchAssignment(agent_id=str(agent_id).strip(), sort_order=index) + for index, agent_id in enumerate(seat_ids, start=1) + ], + mode=route_mode or _read(self._config, "default_mode", "structured"), + intent_type=intent.intent_type, + business_code=business_code, + max_rounds=int(_read(self._config, "default_max_rounds", 8) or 8), + ) + except (TypeError, ValueError) as exc: + raise DispatchConfigurationError("invalid trusted MAF dispatch route") from exc + + trusted_ids = {str(manager_id), *(str(agent_id) for agent_id in route_seats)} + validate_dispatch_plan(plan, trusted_agent_ids=trusted_ids, max_seats=max_seats) + return plan + + @staticmethod + def participants(plan: MafDispatchPlan) -> list[dict[str, Any]]: + """Return persistence records; thread ids are intentionally absent.""" + return [ + { + "agent_id": plan.manager_agent_id, + "role": "manager", + "sort_order": 0, + "display_name": "", + }, + *[ + { + "agent_id": assignment.agent_id, + "role": "seat", + "sort_order": assignment.sort_order, + "display_name": "", + } + for assignment in plan.assignments + ], + ] + + +def validate_dispatch_plan( + plan: MafDispatchPlan, + *, + trusted_agent_ids: set[str], + max_seats: int, +) -> None: + """Final allowlist check used before persistence and workflow launch.""" + + if plan.manager_agent_id not in trusted_agent_ids: + raise InvalidDispatchPlanError("manager agent is outside the trusted route") + if len(plan.seat_agent_ids) > max_seats: + raise InvalidDispatchPlanError("dispatch plan exceeds the seat limit") + if any(agent_id not in trusted_agent_ids for agent_id in plan.seat_agent_ids): + raise InvalidDispatchPlanError("seat agent is outside the trusted route") + if len(set(plan.seat_agent_ids)) != len(plan.seat_agent_ids): + raise InvalidDispatchPlanError("dispatch plan contains duplicate seats") + if plan.manager_agent_id in plan.seat_agent_ids: + raise InvalidDispatchPlanError("manager cannot be dispatched as a seat") + + +__all__ = [ + "DispatchConfigurationError", + "InvalidDispatchPlanError", + "MafDispatchAssignment", + "MafDispatchPlan", + "MafDispatchCatalog", + "MafIntentDecision", + "MafIntentType", + "resolve_intent", + "validate_dispatch_plan", +] diff --git a/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py b/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py index 1fa5b8f9..32fcd4f7 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/dispatcher.py @@ -71,6 +71,23 @@ class MafRunDispatcher: self._tasks[run_id] = task task.add_done_callback(lambda t, rid=run_id: self._tasks.pop(rid, None)) + async def wait_for_run(self, run_id: str) -> None: + """Wait for an in-process run without exposing its task to callers. + + Conversation V2 uses this only for a server-side status bridge. The + persisted run remains authoritative, so a missing task (for example + after a worker restart) simply returns and lets recovery own the run. + """ + task = self._tasks.get(run_id) + if task is None: + return + try: + await asyncio.shield(task) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - runner already persisted failure + return + async def _execute(self, runner: Any, session: dict[str, Any], run: dict[str, Any], run_id: str) -> None: """Execute the workflow, catching all exceptions (they're already persisted by the runner).""" try: diff --git a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py index 4c014f02..d51f9d35 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/schemas.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/schemas.py @@ -245,6 +245,12 @@ class HealthResponse(BaseModel): """Health / feature-flag probe. The frontend reads ``enabled`` to decide whether to show the MAF entry. ``store_available`` is false when no DB is configured (memory mode) — the router then returns 503 on writes. + + SSE fields (P1): ``sse_hub_type`` reports the hub implementation + (``in_memory`` or future ``redis``); ``sse_multi_worker`` is ``False`` + until Redis Pub/Sub is wired; ``sse_warning`` carries a human-readable + advisory. When multi-worker is unsupported, production deployments must + use a single worker or fail-closed. """ enabled: bool @@ -257,3 +263,6 @@ class HealthResponse(BaseModel): write_guard_ready: bool action_secret_configured: bool write_guard_status: str + sse_hub_type: str = "in_memory" + sse_multi_worker: bool = False + sse_warning: str = "Single-worker only. Multi-worker requires Redis Pub/Sub." diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py index 01b2f7cc..b1fd6df9 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_executors.py @@ -74,6 +74,8 @@ class RoundtableState: capability_snapshot: dict[str, Any] | None = None, seat_timeout_seconds: float = 600.0, schema_repair_enabled: bool = True, + intent_type: str | None = None, + dispatch_plan: dict[str, Any] | None = None, ) -> None: self.session_id = session_id self.run_id = run_id @@ -89,6 +91,11 @@ class RoundtableState: self.capability_snapshot = capability_snapshot self.seat_timeout_seconds = seat_timeout_seconds self.schema_repair_enabled = schema_repair_enabled + # Stage 3 server-validated routing metadata. The workflow may use + # this for diagnostics/checkpoints, but it never accepts a new agent + # id from manager model output. + self.intent_type = intent_type + self.dispatch_plan = dispatch_plan self.current_round = 0 self.turn_results: list[dict[str, Any]] = [] @@ -128,6 +135,8 @@ class RoundtableState: "capability_snapshot": self.capability_snapshot, "seat_timeout_seconds": self.seat_timeout_seconds, "schema_repair_enabled": self.schema_repair_enabled, + "intent_type": self.intent_type, + "dispatch_plan": self.dispatch_plan, "current_round": self.current_round, "turn_results": self.turn_results, "fact_ledger": self.fact_ledger, @@ -159,6 +168,8 @@ class RoundtableState: capability_snapshot=d.get("capability_snapshot"), seat_timeout_seconds=float(d.get("seat_timeout_seconds") or 600.0), schema_repair_enabled=bool(d.get("schema_repair_enabled", True)), + intent_type=d.get("intent_type"), + dispatch_plan=d.get("dispatch_plan"), ) state.current_round = d.get("current_round", 0) state.turn_results = d.get("turn_results", []) @@ -531,12 +542,20 @@ class SeatExecutor(Executor): a for a in turn_actions if a.get("tool_name") == tc_name and not a.get("logical_action_id") ] - if len(hits) != 1: + if len(hits) > 1: raise RuntimeError( f"seat {self._agent_id} turn {self._state.current_round}: " f"pending tool call '{tc_name}' (id={tc_id or ''}) matched " f"{len(hits)} waiting actions (expected exactly 1) — refusing to guess" ) + if len(hits) == 0: + logger.warning( + "seat %s turn %s: pending tool call '%s' (id=%s) classified as " + "needing approval but matched 0 waiting actions — tool may be " + "unclassified or executed as read by WriteGuard; skipping", + self._agent_id, self._state.current_round, tc_name, tc_id or "", + ) + continue matched.append(hits[0]) # Merge into pending_approvals (multi-seat). @@ -686,12 +705,45 @@ class CrossReviewExecutor(Executor): if failed_seats: review["gaps"] = [f"席位失败: {', '.join(failed_seats)}"] + # The quality gate is server-owned. A manager may recommend acceptance, + # but gaps/failures/schema degradation always become a controlled gap + # and are carried into the final result instead of being labelled as a + # verified fact. + has_quality_gap = bool( + review.get("partial_failure") + or review.get("failed_seats") + or review.get("gaps") + or review.get("conflicts") + or review.get("tool_failures") + or review.get("schema_status") == "schema_invalid" + ) + review["decision"] = "controlled_gap" if has_quality_gap else "accept" + review["reason"] = ( + "required evidence or seat output is incomplete" + if has_quality_gap + else "required seat deliveries passed deterministic checks" + ) self._state.cross_review_result = review # Surface unresolved conflicts from review notes. for c in review.get("conflicts") or []: if c and c not in self._state.fact_ledger["conflicts"]: self._state.fact_ledger["conflicts"].append(c) + await self._adapter._emit_critical(self._state.run_id, { + "event_type": "manager.review.decision", + "agent_id": self._state.manager_agent_id or "", + "turn_index": self._state.current_round, + "payload": { + "decision": review["decision"], + "reason": review["reason"], + "quality": { + "failed_seat_count": len(review.get("failed_seats") or []), + "gap_count": len(review.get("gaps") or []), + "conflict_count": len(review.get("conflicts") or []), + }, + }, + }) + self._state.mark_completed("cross_review") self._state.stage = "approval" await ctx.send_message({"decision": "reviewed", "review": review}) @@ -937,16 +989,13 @@ class ApprovalGateExecutor(Executor): def _check_needs_approval(self) -> bool: if self._state.write_policy == "auto": return False - if self._state.pending_approvals: - return True - for result in self._state.turn_results: - for tc in result.get("tool_calls") or []: - name = tc.get("name", "") - if not name: - continue - if tool_needs_approval(name, self._state.write_policy, self._state.capability_snapshot): - return True - return False + # pending_approvals is the authoritative source: it is populated by + # SeatExecutor._capture_pending_approvals which only adds tool calls + # that WriteGuard actually intercepted (i.e. have a database action). + # Tools that were classified as needing approval but had 0 matching + # actions (unclassified/hallucinated/read tools) are intentionally + # skipped and must NOT re-trigger the gate here. + return bool(self._state.pending_approvals) # ── Finalizer ─────────────────────────────────────────────────────────────── @@ -1006,6 +1055,10 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st "agent_id": manager_id, "partial_failure": state.partial_failure, "degraded": state.partial_failure, + "quality_decision": ( + (state.cross_review_result or {}).get("decision") + or ("controlled_gap" if state.partial_failure else "accept") + ), } else: state.final_result = { @@ -1014,6 +1067,10 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st "note": "no_manager_thread", "partial_failure": state.partial_failure, "degraded": state.partial_failure, + "quality_decision": ( + (state.cross_review_result or {}).get("decision") + or ("controlled_gap" if state.partial_failure else "accept") + ), } await adapter._emit_critical(state.run_id, make_run_completed(manager_id, state.current_round + 1, { diff --git a/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py b/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py index a6dd1149..7a394833 100644 --- a/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py +++ b/offline-backend-20260512/backend/app/maf_roundtable/workflow_runner.py @@ -96,6 +96,9 @@ class WorkflowRunner: seat_agents = [p["agent_id"] for p in participants if p.get("role") != "manager"] manager_id = session.get("manager_agent_id") seat_timeout, schema_repair = self._orchestration_settings() + task_payload = session.get("task_payload") or {} + maf_v2_payload = task_payload.get("maf_v2") if isinstance(task_payload, dict) else {} + maf_v2_payload = maf_v2_payload if isinstance(maf_v2_payload, dict) else {} state = RoundtableState( session_id=session_id, @@ -112,6 +115,8 @@ class WorkflowRunner: capability_snapshot=session.get("capability_snapshot"), seat_timeout_seconds=seat_timeout, schema_repair_enabled=schema_repair, + intent_type=maf_v2_payload.get("intent_type"), + dispatch_plan=maf_v2_payload.get("dispatch_plan"), ) workflow = self._build_workflow(state) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index cb4a3e81..1af343cc 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -562,6 +562,12 @@ 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 # 群聊模式轮次硬上限 + # Stage 3 routing is deployment-owned. Keep these empty in the repository; + # production must inject exact agent ids from its catalog. + default_manager_agent_id: null + default_seat_agent_ids: [] + business_routes: {} + max_seats_per_run: 16 admin_diagnostic_access: true # 管理员是否可跨用户读取所有 MAF 会商用于诊断(仅读,不含写) seat_timeout_seconds: 600 # 结构化流程单席位超时(秒) approval_scan_interval_seconds: 30 # 审批主动过期扫描间隔(秒) diff --git a/offline-backend-20260512/backend/docs/maf-sse-multi-worker-deployment.md b/offline-backend-20260512/backend/docs/maf-sse-multi-worker-deployment.md new file mode 100644 index 00000000..09cccdeb --- /dev/null +++ b/offline-backend-20260512/backend/docs/maf-sse-multi-worker-deployment.md @@ -0,0 +1,112 @@ +# MAF 会商 SSE 多 Worker 部署说明 + +> 状态:Phase 2 (R2) — 单 Worker 已就绪,多 Worker 需补 Redis Pub/Sub +> 更新时间:2026-08-03 + +--- + +## 1. 当前架构(单 Worker) + +`ConversationEventHub`(`app/maf_roundtable/conversation_event_hub.py`)是一个 +**进程内** 的 `asyncio.Event` 通知器: + +- 每个 worker 进程有自己独立的 hub 实例(`app.state.maf_conversation_event_hub`) +- 事件写入者(`ConversationService.send_message`)在事务提交后调用 `hub.publish()` +- SSE 订阅者通过 `hub.subscribe()` 注册一个 `asyncio.Event`,被 `publish()` 唤醒后 + 从数据库查询最新事件(通知-only,不缓存事件内容) +- 心跳:15 秒超时 → `: heartbeat\n\n` SSE 注释帧 + +**适用场景:** `uvicorn --workers 1` / `gunicorn -w 1`,开发环境、小规模灰度。 + +--- + +## 2. 多 Worker 限制 + +当使用 `--workers 2` 或更多 worker 时: + +| 问题 | 原因 | 影响 | +|------|------|------| +| **跨 Worker 不通知** | Worker A 写入事件后 `hub.publish()` 只通知 A 自己的订阅者;Worker B 的 SSE 客户端收不到实时推送 | SSE 流"卡住"直到客户端超时重连,重连后从 DB 重放才能补到事件 | +| **无跨进程共享状态** | `asyncio.Event` 是进程内对象,不能跨进程通信 | 需要 Redis Pub/Sub 或等价中间件 | + +**在接入 Redis Pub/Sub 之前,禁止多 Worker 灰度上线。** + +--- + +## 3. 健康检查建议 + +建议在 `GET /api/maf-roundtable/health` 中增加: + +```json +{ + "sse_hub_type": "in_memory", + "sse_multi_worker": false, + "sse_warning": "Single-worker only. Multi-worker requires Redis Pub/Sub." +} +``` + +当前版本已通过 `sse_multi_worker: false` 明确标注不支持多 Worker。 +部署脚本应检查此项,在多 Worker 模式下发出告警。 + +--- + +## 4. Redis Pub/Sub 接入方案(后续阶段) + +当需要多 Worker 时,替换 `ConversationEventHub` 为 Redis-backed 实现: + +### 4.1 接口不变 + +```python +class ConversationEventHub(abc.ABC): + async def publish(self, conversation_id: str) -> None: ... + async def subscribe(self, conversation_id: str, ...) -> AsyncIterator[bool]: ... +``` + +### 4.2 Redis 实现 + +```python +class RedisConversationEventHub(ConversationEventHub): + def __init__(self, redis: Redis, *, heartbeat_interval: float = 15.0): + self._redis = redis + self._heartbeat_interval = heartbeat_interval + + async def publish(self, conversation_id: str) -> None: + await self._redis.publish(f"maf:conv:{conversation_id}", "1") + + async def subscribe(self, conversation_id: str, ...): + pubsub = self._redis.pubsub() + await pubsub.subscribe(f"maf:conv:{conversation_id}") + try: + while True: + msg = await asyncio.wait_for( + pubsub.get_message(ignore_subscribe_messages=True, timeout=...), + timeout=self._heartbeat_interval, + ) + if msg is None: + yield False # heartbeat + else: + yield True # new events might be available + finally: + await pubsub.unsubscribe(f"maf:conv:{conversation_id}") +``` + +### 4.3 选择逻辑 + +```python +# deps.py +if config.database and config.database.redis_url: + hub = RedisConversationEventHub(create_redis(config.database.redis_url)) +else: + hub = ConversationEventHub() # in-memory, single-worker +``` + +--- + +## 5. 部署检查清单 + +- [ ] 确认 `uvicorn --workers 1`(或等价单进程配置) +- [ ] 确认 SSE 端点 `GET /api/maf-roundtable/conversations/{id}/stream` 可访问 +- [ ] 确认 nginx / 反向代理未缓冲 SSE(`X-Accel-Buffering: no` 生效) +- [ ] 确认心跳间隔 15s 足够长不会误触发代理超时(proxy_read_timeout 应 ≥ 60s) +- [ ] 确认 `after_seq` 重连不产生重复气泡(前端 reducer 按 seq 去重) +- [ ] 多 Worker 上线前:接入 Redis Pub/Sub 并更新 `sse_multi_worker: true` diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py index 9bdfd551..00530708 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/maf_roundtable_config.py @@ -87,6 +87,32 @@ class MafToolMetadata(BaseModel): return namespace +class MafBusinessRoute(BaseModel): + """Server-owned route for one MAF business chain. + + The browser and the model never submit these agent ids. Operators must + configure the exact ids discovered in the deployment's agent catalog. + Keeping the route in the MAF config also makes a session reproducible: the + selected route is frozen into its trusted capability snapshot at launch. + """ + + manager_agent_id: str = Field(min_length=1, max_length=64) + seat_agent_ids: list[str] = Field(default_factory=list, max_length=64) + mode: MafMode | None = None + + @field_validator("seat_agent_ids") + @classmethod + def _validate_seat_agent_ids(cls, ids: list[str]) -> list[str]: + normalized: list[str] = [] + for agent_id in ids: + value = str(agent_id).strip() + if not value or len(value) > 64: + raise ValueError("seat_agent_ids must contain non-empty agent ids") + if value not in normalized: + normalized.append(value) + return normalized + + class MafRoundtableConfig(BaseModel): """Microsoft Agent Framework 多智能体会商实验功能开关。 @@ -116,6 +142,13 @@ class MafRoundtableConfig(BaseModel): le=64, description="默认最大轮次上限(群聊模式硬上限,避免弱模型无限讨论)", ) + # Stage 3: server-owned routing. These remain empty in the repository; + # production deployments must inject exact agent ids in their deployment + # configuration instead of guessing from names or client input. + default_manager_agent_id: str | None = Field(default=None, max_length=64) + default_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64) + business_routes: dict[str, MafBusinessRoute] = Field(default_factory=dict) + max_seats_per_run: int = Field(default=16, ge=1, le=64) # 管理员诊断是否可见所有用户的会商(true=管理员跨用户读,false=管理员也只能看自己的)。 admin_diagnostic_access: bool = Field( default=True, @@ -162,6 +195,7 @@ class MafRoundtableConfig(BaseModel): __all__ = [ "MafMode", + "MafBusinessRoute", "MafParticipantRole", "MafRiskLevel", "MafRoundtableConfig", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py index 95d4205e..26555235 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/__init__.py @@ -31,13 +31,19 @@ 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 from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository +from deerflow.persistence.maf_roundtable.conversation_events import MafConversationEventRepository +from deerflow.persistence.maf_roundtable.conversations import MafConversationRepository from deerflow.persistence.maf_roundtable.events import MafEventRepository +from deerflow.persistence.maf_roundtable.messages import MafMessageRepository from deerflow.persistence.maf_roundtable.model import ( MafRoundtableActionLeaseRow, MafRoundtableActionRow, MafRoundtableActionSecretRow, MafRoundtableApprovalRow, + MafRoundtableConversationEventRow, + MafRoundtableConversationRow, MafRoundtableEventRow, + MafRoundtableMessageRow, MafRoundtableParticipantRow, MafRoundtableRunRow, MafRoundtableSeatRunRow, @@ -53,13 +59,19 @@ __all__ = [ "MafActionRepository", "MafActionSecretRepository", "MafApprovalRepository", + "MafConversationEventRepository", + "MafConversationRepository", "MafEventRepository", + "MafMessageRepository", "MafParticipantRepository", "MafRoundtableActionRow", "MafRoundtableActionLeaseRow", "MafRoundtableActionSecretRow", "MafRoundtableApprovalRow", + "MafRoundtableConversationEventRow", + "MafRoundtableConversationRow", "MafRoundtableEventRow", + "MafRoundtableMessageRow", "MafRoundtableParticipantRow", "MafRoundtableRunRow", "MafRoundtableSeatRunRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversation_events.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversation_events.py new file mode 100644 index 00000000..3fb43ca8 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversation_events.py @@ -0,0 +1,102 @@ +"""Conversation-event repository for the MAF roundtable V2 (SSE stream). + +Unlike ``maf_roundtable_events`` (per-run seq), this table uses a +**conversation-global** monotonic ``seq`` so the frontend needs only one cursor +(``after_seq``) regardless of how many internal runs a conversation spawns. +The SSE hub (§4.2) replays from this table on connect/reconnect, then +subscribes to real-time notifications. + +Seq allocation mirrors ``maf_roundtable_events``: ``SELECT MAX(seq) + 1`` with +``IntegrityError`` retry, portable across sqlite / postgres / mysql. + +The writer order is "persist first, then notify" — the hub never broadcasts an +event that is not already durable here, so a reconnecting client can always +replay anything it missed. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationEventRow + + +def _now() -> datetime: + return datetime.now(UTC) + + +def conversation_event_to_dict(row: MafRoundtableConversationEventRow) -> dict[str, Any]: + """Serialise a conversation-event ORM row to the §4.3 SSE envelope dict.""" + return { + "seq": row.seq, + "event_id": row.id, + "conversation_id": row.conversation_id, + "run_id": row.run_id, + "source_event_id": getattr(row, "source_event_id", None), + "type": row.event_type, + "occurred_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, + "data": row.data_json or {}, + } + + +class MafConversationEventRepository: + """Conversation-global monotonic event stream backing the SSE channel.""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def append_event( + self, + *, + conversation_id: str, + event_type: str, + run_id: str | None = None, + data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Persist one event with a monotonic conversation-global seq. + + Concurrent appends are safe: each attempts ``MAX(seq)+1`` and retries + on the ``(conversation_id, seq)`` unique violation. Returns the §4.3 + envelope dict. + """ + del conversation_id, event_type, run_id, data + raise RuntimeError( + "Conversation event writes must use " + "MafRoundtableStore.append_conversation_item" + ) + + async def list_events( + self, conversation_id: str, *, after_seq: int = 0, limit: int = 1000 + ) -> list[dict[str, Any]]: + """Replay: events with ``seq > after_seq`` in ascending order. + + Used by the SSE hub on connect/reconnect to catch the client up before + switching to the real-time subscription. + """ + stmt = ( + select(MafRoundtableConversationEventRow) + .where( + MafRoundtableConversationEventRow.conversation_id == conversation_id, + MafRoundtableConversationEventRow.seq > after_seq, + ) + .order_by(MafRoundtableConversationEventRow.seq.asc()) + .limit(limit) + ) + async with self._sf() as session: + rows = (await session.execute(stmt)).scalars().all() + return [conversation_event_to_dict(r) for r in rows] + + async def latest_seq(self, conversation_id: str) -> int: + async with self._sf() as session: + max_seq = ( + await session.execute( + select(func.max(MafRoundtableConversationEventRow.seq)).where( + MafRoundtableConversationEventRow.conversation_id == conversation_id + ) + ) + ).scalar_one() + return int(max_seq or 0) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversations.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversations.py new file mode 100644 index 00000000..5121f8f7 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/conversations.py @@ -0,0 +1,200 @@ +"""Conversation repository for the MAF roundtable V2 (user-visible aggregation). + +A conversation is the **only** entity the frontend knows about. Owner-scoped +by ``owner_user_id``: every read / update / delete checks ownership. Admins +bypass the owner filter when ``admin_diagnostic_access`` is true (handled in +the service / router layer, not here). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow + + +def _now() -> datetime: + return datetime.now(UTC) + + +def conversation_internal_to_dict(row: MafRoundtableConversationRow) -> dict[str, Any]: + """Internal serialisation — includes all columns. Never expose to the browser.""" + return { + "id": row.id, + "owner_user_id": row.owner_user_id, + "title": row.title, + "task_context": row.task_context_json, + "safe_status": row.safe_status, + "active_run_id": row.active_run_id, + "internal_session_id": row.internal_session_id, + "last_stream_seq": row.last_stream_seq, + "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, + "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at, + } + + +_TASK_CONTEXT_SAFE_KEYS = frozenset({"business_code", "task_id", "task_name", "chain_id", "chain_name"}) + + +def conversation_public_to_dict(row: MafRoundtableConversationRow) -> dict[str, Any]: + """Public DTO — only fields safe for the browser. No owner, session, run, or thread ids.""" + raw_ctx = row.task_context_json + safe_ctx: dict[str, Any] | None = None + if isinstance(raw_ctx, dict): + safe_ctx = {k: v for k, v in raw_ctx.items() if k in _TASK_CONTEXT_SAFE_KEYS} + return { + "id": row.id, + "title": row.title, + "task_context": safe_ctx, + "safe_status": row.safe_status, + "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, + "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at, + "last_stream_seq": row.last_stream_seq, + } + + +class MafConversationRepository: + """Owner-scoped CRUD for MAF roundtable conversations.""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def create_conversation(self, data: dict[str, Any]) -> dict[str, Any]: + """Create a new conversation. ``data`` must include ``owner_user_id``.""" + now = _now() + row = MafRoundtableConversationRow( + id=data.get("id") or uuid4().hex, + owner_user_id=str(data["owner_user_id"]), + title=str(data.get("title") or ""), + task_context_json=data.get("task_context"), + safe_status="idle", + active_run_id=None, + internal_session_id=None, + last_stream_seq=0, + created_at=now, + updated_at=now, + ) + async with self._sf() as session: + session.add(row) + await session.commit() + return conversation_internal_to_dict(row) + + async def get_conversation( + self, conversation_id: str, *, user_id: str | None = "REQUIRED" + ) -> dict[str, Any] | None: + """Read one conversation. + + ``user_id`` scoping: + - ``"REQUIRED"`` (default): enforce owner scope. + - ``None``: bypass owner scope (admin / internal path). + """ + async with self._sf() as session: + stmt = select(MafRoundtableConversationRow).where( + MafRoundtableConversationRow.id == conversation_id + ).limit(1) + row = (await session.execute(stmt)).scalars().first() + if row is None: + return None + if user_id is not None and row.owner_user_id != user_id: + return None + return conversation_internal_to_dict(row) + + async def list_conversations( + self, + *, + user_id: str | None = None, + safe_status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[dict[str, Any]]: + """List conversations, newest-first. + + ``user_id=None`` lists across all users (admin / diagnostic). + """ + stmt = select(MafRoundtableConversationRow) + if user_id is not None: + stmt = stmt.where(MafRoundtableConversationRow.owner_user_id == user_id) + if safe_status is not None: + stmt = stmt.where(MafRoundtableConversationRow.safe_status == safe_status) + stmt = stmt.order_by(MafRoundtableConversationRow.updated_at.desc()).limit(limit).offset(offset) + async with self._sf() as session: + rows = (await session.execute(stmt)).scalars().all() + return [conversation_internal_to_dict(r) for r in rows] + + async def count_conversations( + self, *, user_id: str | None = None, safe_status: str | None = None + ) -> int: + stmt = select(func.count()).select_from(MafRoundtableConversationRow) + if user_id is not None: + stmt = stmt.where(MafRoundtableConversationRow.owner_user_id == user_id) + if safe_status is not None: + stmt = stmt.where(MafRoundtableConversationRow.safe_status == safe_status) + async with self._sf() as session: + return int((await session.execute(stmt)).scalar_one()) + + async def update_conversation( + self, conversation_id: str, *, user_id: str | None, fields: dict[str, Any] + ) -> dict[str, Any] | None: + """Partial update. Returns ``None`` if missing or wrong owner.""" + async with self._sf() as session: + stmt = select(MafRoundtableConversationRow).where( + MafRoundtableConversationRow.id == conversation_id + ).limit(1) + row = (await session.execute(stmt)).scalars().first() + if row is None: + return None + if user_id is not None and row.owner_user_id != user_id: + return None + col_map = {"task_context": "task_context_json"} + for key in ("title", "task_context", "safe_status", "active_run_id", + "internal_session_id", "last_stream_seq"): + if key in fields: + col = col_map.get(key, key) + setattr(row, col, fields[key]) + row.updated_at = _now() + await session.commit() + return conversation_internal_to_dict(row) + + async def delete_conversation(self, conversation_id: str, *, user_id: str | None) -> bool: + """Delete a conversation (children via FK CASCADE).""" + async with self._sf() as session: + stmt = select(MafRoundtableConversationRow).where( + MafRoundtableConversationRow.id == conversation_id + ).limit(1) + row = (await session.execute(stmt)).scalars().first() + if row is None: + return False + if user_id is not None and row.owner_user_id != user_id: + return False + await session.execute( + delete(MafRoundtableConversationRow).where( + MafRoundtableConversationRow.id == conversation_id + ) + ) + await session.commit() + return True + + async def set_status(self, conversation_id: str, safe_status: str) -> None: + """Internal: update only the safe_status column.""" + async with self._sf() as session: + await session.execute( + update(MafRoundtableConversationRow) + .where(MafRoundtableConversationRow.id == conversation_id) + .values(safe_status=safe_status, updated_at=_now()) + ) + await session.commit() + + async def set_active_run(self, conversation_id: str, run_id: str | None, safe_status: str) -> None: + """Internal: update active_run_id + safe_status together.""" + async with self._sf() as session: + await session.execute( + update(MafRoundtableConversationRow) + .where(MafRoundtableConversationRow.id == conversation_id) + .values(active_run_id=run_id, safe_status=safe_status, updated_at=_now()) + ) + await session.commit() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/messages.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/messages.py new file mode 100644 index 00000000..f55b5d92 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/messages.py @@ -0,0 +1,176 @@ +"""Message repository for the MAF roundtable V2 (stable conversation history). + +Every user-visible bubble (user / manager / seat / system / clarification / +approval / result / tool_progress) has exactly one row here. ``seq`` is a +conversation-global monotonic counter (unique per conversation) allocated via +``SELECT MAX(seq) + 1`` with ``IntegrityError`` retry — the same portable +approach as ``maf_roundtable_events`` (works on sqlite / postgres / mysql). + +The message history is the durable record the frontend rebuilds the timeline +from on refresh; the SSE conversation-event stream (§4.3) is the real-time +channel. Both share the conversation-global seq space so a refresh can resume +the SSE cursor from the last persisted message/event seq. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.maf_roundtable.model import MafRoundtableMessageRow + + +def _now() -> datetime: + return datetime.now(UTC) + + +def message_to_dict(row: MafRoundtableMessageRow) -> dict[str, Any]: + """Internal serialisation — includes all columns. Never expose to the browser.""" + return { + "id": row.id, + "conversation_id": row.conversation_id, + "seq": row.seq, + "message_kind": row.message_kind, + "role": row.role, + "agent_id": row.agent_id, + "run_id": row.run_id, + "message_ref": row.message_ref, + "content": row.content, + "metadata": row.metadata_json, + "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, + } + + +_METADATA_WHITELIST_BY_KIND: dict[str, frozenset[str]] = { + "user": frozenset(), + "manager": frozenset({"intent_type", "dispatch_plan_summary"}), + "seat": frozenset({"seat_name", "delivery_status", "attempt_number"}), + "system": frozenset({"reason"}), + "clarification": frozenset({"clarification_id", "options", "allow_multiple", "status"}), + "approval": frozenset({"approval_id", "tool_name", "risk_level", "summary", "status"}), + "result": frozenset({"result_type", "has_controlled_gap", "gap_summary"}), + "tool_progress": frozenset({"tool_name", "progress_pct", "status"}), +} + + +def message_public_to_dict(row: MafRoundtableMessageRow) -> dict[str, Any]: + """Public DTO — only display-safe fields. No run_id, no unfiltered metadata.""" + raw_meta = row.metadata_json + safe_meta: dict[str, Any] | None = None + if isinstance(raw_meta, dict): + allowed = _METADATA_WHITELIST_BY_KIND.get(row.message_kind, frozenset()) + safe_meta = {k: v for k, v in raw_meta.items() if k in allowed} or None + return { + "id": row.id, + "conversation_id": row.conversation_id, + "seq": row.seq, + "message_kind": row.message_kind, + "role": row.role, + "agent_id": row.agent_id, + "message_ref": row.message_ref, + "content": row.content, + "metadata": safe_meta, + "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, + } + + +class MafMessageRepository: + """Conversation-scoped message history with a monotonic per-conversation seq.""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def append_message( + self, + *, + conversation_id: str, + message_kind: str, + role: str, + content: str = "", + agent_id: str | None = None, + run_id: str | None = None, + message_ref: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Reject an unsafe standalone write. + + Message and event writes must use + :meth:`MafRoundtableStore.append_conversation_item` inside one + transaction. Separate ``MAX(seq) + 1`` allocators would violate the + conversation's single SSE cursor. + """ + del conversation_id, message_kind, role, content, agent_id, run_id, message_ref, metadata + raise RuntimeError( + "Conversation message writes must use " + "MafRoundtableStore.append_conversation_item" + ) + + async def list_messages( + self, + conversation_id: str, + *, + after_seq: int = 0, + before_seq: int | None = None, + limit: int = 50, + ) -> list[dict[str, Any]]: + """Paginated message history in ascending seq order. + + ``after_seq`` / ``before_seq`` implement cursor paging: the frontend + loads the latest page on entry (``after_seq=0`` + reverse via + :meth:`list_messages_desc`) and older pages on scroll-up. + """ + stmt = ( + select(MafRoundtableMessageRow) + .where( + MafRoundtableMessageRow.conversation_id == conversation_id, + MafRoundtableMessageRow.seq > after_seq, + ) + ) + if before_seq is not None: + stmt = stmt.where(MafRoundtableMessageRow.seq < before_seq) + stmt = stmt.order_by(MafRoundtableMessageRow.seq.asc()).limit(limit) + async with self._sf() as session: + rows = (await session.execute(stmt)).scalars().all() + return [message_to_dict(r) for r in rows] + + async def list_messages_desc( + self, conversation_id: str, *, limit: int = 50, before_seq: int | None = None + ) -> list[dict[str, Any]]: + """Latest ``limit`` messages, newest-first (for initial page load). + + Returned newest-first; callers that want chronological order reverse + the list. ``before_seq`` loads the next older page. + """ + stmt = select(MafRoundtableMessageRow).where( + MafRoundtableMessageRow.conversation_id == conversation_id + ) + if before_seq is not None: + stmt = stmt.where(MafRoundtableMessageRow.seq < before_seq) + stmt = stmt.order_by(MafRoundtableMessageRow.seq.desc()).limit(limit) + async with self._sf() as session: + rows = (await session.execute(stmt)).scalars().all() + return [message_to_dict(r) for r in rows] + + async def latest_seq(self, conversation_id: str) -> int: + async with self._sf() as session: + max_seq = ( + await session.execute( + select(func.max(MafRoundtableMessageRow.seq)).where( + MafRoundtableMessageRow.conversation_id == conversation_id + ) + ) + ).scalar_one() + return int(max_seq or 0) + + async def count_messages(self, conversation_id: str) -> int: + async with self._sf() as session: + return int(( + await session.execute( + select(func.count()).select_from(MafRoundtableMessageRow).where( + MafRoundtableMessageRow.conversation_id == conversation_id + ) + ) + ).scalar_one()) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py index 06b663fd..00df93f8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/model.py @@ -437,12 +437,142 @@ class MafRoundtableActionSecretRow(Base): created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now) +# ── 10. conversations (V2: user-visible conversation aggregation) ───────── + + +class MafRoundtableConversationRow(Base): + """One user-visible MAF roundtable conversation. + + This is the **only** entity the frontend knows about. A conversation can + span multiple user messages, each of which may trigger an internal + ``maf_roundtable_runs`` execution. The user never sees session/run/thread + ids — only ``conversation_id``. + + ``active_run_id`` points to the currently executing internal run (nullable + when idle). ``safe_status`` is a coarse user-facing status derived from + the active run's state (idle / running / waiting_input / waiting_approval + / completed / failed / cancelled). + """ + + __tablename__ = "maf_roundtable_conversations" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), default="") + task_context_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True) + # idle | running | waiting_input | waiting_approval | completed | failed | cancelled + safe_status: Mapped[str] = mapped_column(String(32), default="idle", index=True) + active_run_id: Mapped[str | None] = mapped_column( + String(64), + ForeignKey("maf_roundtable_runs.id", ondelete="SET NULL"), + nullable=True, + ) + # Internal session this conversation is linked to (lazy-created on first message). + internal_session_id: Mapped[str | None] = mapped_column( + String(64), + ForeignKey("maf_roundtable_sessions.id", ondelete="SET NULL"), + nullable=True, + ) + last_stream_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now) + updated_at: Mapped[datetime] = mapped_column( + BeijingDateTime(), nullable=False, default=_now, onupdate=_now + ) + + +# ── 11. conversation messages ───────────────────────────────────────────── + + +class MafRoundtableMessageRow(Base): + """Stable message history for a conversation. + + Every user-visible bubble (user message, manager message, seat message, + clarification card, approval card, result card) has exactly one row here. + ``seq`` is a conversation-global monotonic counter (unique per conversation) + used for SSE cursor and ordering. ``message_kind`` classifies the bubble + type for the frontend reducer. + + ``run_id`` links the message to the internal run that produced it (nullable + for user messages that haven't started a run yet). ``agent_id`` identifies + the speaking agent (null for user messages). + """ + + __tablename__ = "maf_roundtable_messages" + __table_args__ = ( + UniqueConstraint("conversation_id", "seq", name="uq_maf_messages_conv_seq"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + conversation_id: Mapped[str] = mapped_column( + String(64), ForeignKey("maf_roundtable_conversations.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + seq: Mapped[int] = mapped_column(Integer, nullable=False) + # user | manager | seat | system | clarification | approval | result | tool_progress + message_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="user") + role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") + agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + # Stable message identity for SSE delta merging (e.g. LangGraph message id). + message_ref: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True) + content: Mapped[str] = mapped_column(PortableLongText, default="") + metadata_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True) + created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True) + + +# ── 12. conversation events (SSE stream, conversation-global seq) ───────── + + +class MafRoundtableConversationEventRow(Base): + """Conversation-dimension event stream for SSE. + + Unlike ``maf_roundtable_events`` (per-run seq), this table uses a + **conversation-global** monotonic ``seq`` so the frontend only needs one + cursor (``after_seq``) regardless of how many internal runs happen. + The SSE hub replays from this table on connect/reconnect, then subscribes + to real-time notifications. + + ``event_type`` follows the §4.3 contract (conversation.snapshot, + user.message.created, manager.intent, clarification.required, etc.). + ``data_json`` is the already-sanitized event payload safe for the browser. + """ + + __tablename__ = "maf_roundtable_conversation_events" + __table_args__ = ( + UniqueConstraint("conversation_id", "seq", name="uq_maf_conv_events_conv_seq"), + # P0-4: dedup key for projection idempotency. The source audit event's + # stable id (e.g. LangGraph event id / tool_call id) prevents duplicate + # conversation events when the bridge retries or the adapter re-projects + # after a restart. NULL source_event_ids are not deduped (SQL treats + # NULLs as distinct in UNIQUE constraints). + UniqueConstraint("conversation_id", "source_event_id", name="uq_maf_conv_events_conv_source"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + conversation_id: Mapped[str] = mapped_column( + String(64), ForeignKey("maf_roundtable_conversations.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + seq: Mapped[int] = mapped_column(Integer, nullable=False) + run_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # Stable upstream id from the source audit event (maf_roundtable_events.id + # or the LangGraph source_event_id). Used for projection dedup via the + # (conversation_id, source_event_id) unique constraint. + source_event_id: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True) + event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + data_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True) + created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True) + + __all__ = [ "MafRoundtableActionRow", "MafRoundtableActionLeaseRow", "MafRoundtableActionSecretRow", "MafRoundtableApprovalRow", + "MafRoundtableConversationEventRow", + "MafRoundtableConversationRow", "MafRoundtableEventRow", + "MafRoundtableMessageRow", "MafRoundtableParticipantRow", "MafRoundtableRunRow", "MafRoundtableSeatRunRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py index ee262895..842f1346 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/maf_roundtable/store.py @@ -29,13 +29,20 @@ from deerflow.persistence.maf_roundtable.actions import ( action_to_dict, ) from deerflow.persistence.maf_roundtable.approvals import MafApprovalRepository +from deerflow.persistence.maf_roundtable.conversation_events import MafConversationEventRepository +from deerflow.persistence.maf_roundtable.conversations import MafConversationRepository from deerflow.persistence.maf_roundtable.events import MafEventRepository +from deerflow.persistence.maf_roundtable.messages import MafMessageRepository from deerflow.persistence.maf_roundtable.model import ( MafRoundtableActionLeaseRow, MafRoundtableActionRow, MafRoundtableActionSecretRow, MafRoundtableApprovalRow, + MafRoundtableConversationEventRow, + MafRoundtableConversationRow, MafRoundtableEventRow, + MafRoundtableMessageRow, + MafRoundtableParticipantRow, MafRoundtableRunRow, MafRoundtableSessionRow, ) @@ -74,6 +81,10 @@ class MafRoundtableStore: self.approvals = MafApprovalRepository(session_factory) self.seat_runs = MafSeatRunRepository(session_factory) self.action_secrets = MafActionSecretRepository(session_factory) + # V2 conversation aggregation (user-visible layer). + self.conversations = MafConversationRepository(session_factory) + self.messages = MafMessageRepository(session_factory) + self.conversation_events = MafConversationEventRepository(session_factory) @property def session_factory(self) -> async_sessionmaker[AsyncSession]: @@ -791,12 +802,277 @@ class MafRoundtableStore: await db.flush() return recovered + # ── V2 conversation aggregation: unified seq allocation ───────────────── + + async def append_conversation_item( + self, + db: AsyncSession, + *, + conversation: MafRoundtableConversationRow, + event_type: str, + event_data: dict[str, Any], + message: dict[str, Any] | None = None, + run_id: str | None = None, + source_event_id: str | None = None, + ) -> dict[str, Any]: + """Allocate the next conversation-global seq and persist message + event atomically. + + MUST be called inside an existing transaction (``db.begin()``). The caller + is responsible for locking the conversation row (``with_for_update``). + + 1. ``conversation.last_stream_seq += 1`` (CAS via the row lock). + 2. If ``message`` is provided, write it at the same seq. + 3. Write the conversation event at the same seq. + 4. Flush (no commit — caller controls the transaction boundary). + + ``source_event_id`` (P0-4): stable upstream id for projection dedup. + If the same source_event_id was already projected for this conversation, + the caller should check and skip before calling this method. + + Returns ``{"seq": int, "message_id": str | None, "event_id": str}``. + """ + now = _now() + next_seq = int(conversation.last_stream_seq or 0) + 1 + conversation.last_stream_seq = next_seq + + message_id: str | None = None + if message is not None: + message_id = message.get("id") or uuid4().hex + db.add(MafRoundtableMessageRow( + id=message_id, + conversation_id=conversation.id, + seq=next_seq, + message_kind=message.get("message_kind", "user"), + role=message.get("role", "user"), + agent_id=message.get("agent_id"), + run_id=message.get("run_id") or run_id, + message_ref=message.get("message_ref"), + content=message.get("content", ""), + metadata_json=message.get("metadata"), + created_at=now, + )) + + event_id = uuid4().hex + db.add(MafRoundtableConversationEventRow( + id=event_id, + conversation_id=conversation.id, + seq=next_seq, + run_id=run_id, + source_event_id=source_event_id, + event_type=event_type, + data_json=event_data, + created_at=now, + )) + + await db.flush() + return {"seq": next_seq, "message_id": message_id, "event_id": event_id} + + # ── V2 conversation aggregation: atomic send-message + run launch ─────── + + async def send_message_and_start_run( + self, + *, + conversation_id: str, + owner_user_id: str, + content: str, + message_metadata: dict[str, Any] | None = None, + workflow_version: str | None = None, + session_blueprint: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + """Atomically persist a user message, ensure internal session, and start a run. + + Single ``AsyncSession`` + ``begin()`` so all writes share one transaction + — a mid-way failure rolls everything back (MAF-R1 requirement 1): + + 1. Load the conversation with ``FOR UPDATE``; verify ownership. + 2. Reject when an active run is already in flight → ``{"conflict": True}``. + 3. If no internal session exists, create one from ``session_blueprint``. + 4. Create a ``pending`` run linked to the real internal session. + 5. Allocate the next conversation-global seq via ``append_conversation_item`` + (message + event share the same seq). + 6. Flip conversation to ``safe_status='running'`` + ``active_run_id``. + + ``session_blueprint`` is provided by the App layer (ConversationService). + If the conversation has no internal session and no blueprint is given, + the method fails closed with ``{"no_session_config": True}``. + + Returns ``{"message": {...}, "run": {...}, "conversation_id": ...}`` on + success, ``{"conflict": True}`` when a run is already active, + ``{"no_session_config": True}`` when session cannot be created, or + ``None`` when the conversation is missing / not owned. + """ + now = _now() + try: + async with self._sf() as db: + async with db.begin(): + conv = ( + await db.execute( + select(MafRoundtableConversationRow) + .where(MafRoundtableConversationRow.id == conversation_id) + .with_for_update() + ) + ).scalars().first() + if conv is None or conv.owner_user_id != owner_user_id: + return None + + # ``waiting_input`` with no active run is a clarification + # turn. It is intentionally writable so the user can + # answer the clarification; approval/workflow waits keep + # their active run and remain protected by the CAS. + if conv.safe_status in _ACTIVE_SESSION_STATUSES and not ( + conv.safe_status == "waiting_input" and conv.active_run_id is None + ): + return {"conflict": True} + + # 3. Ensure internal session exists. + start_run = True if session_blueprint is None else bool( + session_blueprint.get("start_run", True) + ) + participant_blueprint = list( + (session_blueprint or {}).get("participants") or [] + ) + if conv.internal_session_id is None: + if session_blueprint is None: + return {"no_session_config": True} + session_row = MafRoundtableSessionRow( + id=uuid4().hex, + user_id=owner_user_id, + title=session_blueprint.get("title") or conv.title or "", + task_text=session_blueprint.get("task_text") or content, + task_payload_json=session_blueprint.get("task_payload"), + mode=session_blueprint.get("mode", "structured"), + status="running" if start_run else "waiting_input", + model_name=session_blueprint.get("model_name"), + reasoning_effort=session_blueprint.get("reasoning_effort"), + manager_agent_id=session_blueprint.get("manager_agent_id"), + max_rounds=session_blueprint.get("max_rounds", 8), + write_policy=session_blueprint.get("write_policy", "confirm_high_risk"), + capability_snapshot_json=session_blueprint.get("capability_snapshot"), + created_at=now, + updated_at=now, + ) + db.add(session_row) + await db.flush() + conv.internal_session_id = session_row.id + else: + session_row = ( + await db.execute( + select(MafRoundtableSessionRow).where( + MafRoundtableSessionRow.id == conv.internal_session_id + ) + ) + ).scalars().first() + if session_row is None or session_row.user_id != owner_user_id: + # Never repair a corrupted private link by guessing + # an id or attaching another user's session. + return {"no_session_config": True} + session_row.status = "running" if start_run else "waiting_input" + session_row.updated_at = now + + # Participants are selected by the App-layer trusted + # dispatch catalog and inserted in this same transaction. + # A clarification may create the session before a route is + # selected; the first real dispatch fills that empty set. + existing_participants = ( + await db.execute( + select(MafRoundtableParticipantRow.id).where( + MafRoundtableParticipantRow.session_id == session_row.id + ) + ) + ).scalars().all() + if not existing_participants and participant_blueprint: + seen_agents: set[str] = set() + for participant in participant_blueprint: + agent_id = str(participant.get("agent_id") or "").strip() + if not agent_id or agent_id in seen_agents: + continue + seen_agents.add(agent_id) + db.add(MafRoundtableParticipantRow( + id=uuid4().hex, + session_id=session_row.id, + agent_id=agent_id, + display_name=str(participant.get("display_name") or ""), + role=str(participant.get("role") or "seat"), + sort_order=int(participant.get("sort_order", 0)), + thread_id=None, + sandbox_id=None, + status="idle", + last_turn_index=None, + capability_snapshot_json=participant.get("capability_snapshot"), + created_at=now, + updated_at=now, + )) + await db.flush() + + # 4. Create pending run linked to the real session. + run_row: MafRoundtableRunRow | None = None + if start_run: + run_row = MafRoundtableRunRow( + id=uuid4().hex, + session_id=conv.internal_session_id, + status="pending", + workflow_version=workflow_version, + started_at=None, + created_at=now, + updated_at=now, + ) + db.add(run_row) + await db.flush() + + # 5. Allocate unified seq: message + event share the same seq. + item_result = await self.append_conversation_item( + db, + conversation=conv, + event_type="user.message.created", + event_data={"content": content}, + message={ + "message_kind": "user", + "role": "user", + "content": content, + "metadata": message_metadata, + # Conversation messages are public and must not + # carry the internal run id. + "run_id": None, + }, + run_id=None, + ) + + # 6. Claim the conversation. + conv.safe_status = "running" if start_run else "waiting_input" + conv.active_run_id = run_row.id if run_row is not None else None + conv.updated_at = now + await db.flush() + + from deerflow.persistence.maf_roundtable.conversations import conversation_internal_to_dict + from deerflow.persistence.maf_roundtable.messages import message_to_dict + + msg_row = ( + await db.execute( + select(MafRoundtableMessageRow).where( + MafRoundtableMessageRow.id == item_result["message_id"] + ) + ) + ).scalars().first() + + return { + "message": message_to_dict(msg_row) if msg_row else None, + "run": run_to_dict(run_row) if run_row is not None else None, + "conversation": conversation_internal_to_dict(conv), + "conversation_id": conversation_id, + "seq": item_result["seq"], + } + except IntegrityError: + return {"conflict": True} + __all__ = [ "MafActionRepository", "MafActionSecretRepository", "MafApprovalRepository", + "MafConversationEventRepository", + "MafConversationRepository", "MafEventRepository", + "MafMessageRepository", "MafParticipantRepository", "MafRoundtableStore", "MafRunRepository", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py index 976852c8..fe7f71d0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tool_metadata.py @@ -30,6 +30,7 @@ _BUILTIN_METADATA: dict[str, tuple[str, str]] = { "grep": ("read", "R0"), "glob": ("read", "R0"), "ls": ("read", "R0"), + "read_peer_delivery": ("read", "R0"), "invoke_skill": ("skill", "R1"), "skill_search": ("skill", "R1"), "task": ("read", "R2"), diff --git a/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py b/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py new file mode 100644 index 00000000..e2576a16 --- /dev/null +++ b/offline-backend-20260512/backend/scripts/migrate_maf_roundtable_conversation_v2.py @@ -0,0 +1,367 @@ +"""V2 migration for MAF roundtable conversation tables. + +Creates the three user-visible conversation tables: +- maf_roundtable_conversations +- maf_roundtable_messages +- maf_roundtable_conversation_events + +Idempotent: safe to run multiple times on SQLite / PostgreSQL / MySQL. +Does NOT modify or migrate existing MAF session/run/event data. + +Usage: + PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_conversation_v2.py --dry-run + PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_conversation_v2.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_conversation_v2") + +CONVERSATIONS_TABLE = "maf_roundtable_conversations" +MESSAGES_TABLE = "maf_roundtable_messages" +EVENTS_TABLE = "maf_roundtable_conversation_events" + + +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(dialect: str) -> str: + if dialect == "postgresql": + return "TIMESTAMP WITH TIME ZONE" + if dialect in {"mysql", "mariadb"}: + return "DATETIME(6)" + return "DATETIME" + + +def _json_sql(dialect: str) -> str: + if dialect == "postgresql": + return "JSONB" + if dialect in {"mysql", "mariadb"}: + return "JSON" + return "TEXT" + + +def _longtext_sql(dialect: str) -> str: + if dialect == "postgresql": + return "TEXT" + if dialect in {"mysql", "mariadb"}: + return "LONGTEXT" + return "TEXT" + + +def _conversations_ddl(dialect: str) -> str: + ts = _timestamp_sql(dialect) + js = _json_sql(dialect) + return f""" + CREATE TABLE {CONVERSATIONS_TABLE} ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + owner_user_id VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL DEFAULT '', + task_context_json {js}, + safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', + active_run_id VARCHAR(64), + internal_session_id VARCHAR(64), + last_stream_seq INTEGER NOT NULL DEFAULT 0, + created_at {ts} NOT NULL, + updated_at {ts} NOT NULL, + FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE SET NULL, + FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE SET NULL + ) + """ + + +def _messages_ddl(dialect: str) -> str: + ts = _timestamp_sql(dialect) + js = _json_sql(dialect) + lt = _longtext_sql(dialect) + return f""" + CREATE TABLE {MESSAGES_TABLE} ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + conversation_id VARCHAR(64) NOT NULL, + seq INTEGER NOT NULL, + message_kind VARCHAR(32) NOT NULL DEFAULT 'user', + role VARCHAR(16) NOT NULL DEFAULT 'user', + agent_id VARCHAR(64), + run_id VARCHAR(64), + message_ref VARCHAR(191), + content {lt} NOT NULL DEFAULT '', + metadata_json {js}, + created_at {ts} NOT NULL, + CONSTRAINT uq_maf_messages_conv_seq UNIQUE (conversation_id, seq), + FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE + ) + """ + + +def _events_ddl(dialect: str) -> str: + ts = _timestamp_sql(dialect) + js = _json_sql(dialect) + return f""" + CREATE TABLE {EVENTS_TABLE} ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + conversation_id VARCHAR(64) NOT NULL, + seq INTEGER NOT NULL, + run_id VARCHAR(64), + source_event_id VARCHAR(191), + event_type VARCHAR(64) NOT NULL, + data_json {js}, + created_at {ts} NOT NULL, + CONSTRAINT uq_maf_conv_events_conv_seq UNIQUE (conversation_id, seq), + CONSTRAINT uq_maf_conv_events_conv_source UNIQUE (conversation_id, source_event_id), + FOREIGN KEY (conversation_id) REFERENCES {CONVERSATIONS_TABLE} (id) ON DELETE CASCADE + ) + """ + + +def _indexes_ddl(dialect: str) -> list[str]: + stmts: list[str] = [] + stmts.append(f"CREATE INDEX ix_maf_conv_owner_updated ON {CONVERSATIONS_TABLE} (owner_user_id, updated_at)") + stmts.append(f"CREATE INDEX ix_maf_conv_safe_status ON {CONVERSATIONS_TABLE} (safe_status)") + stmts.append(f"CREATE INDEX ix_maf_msg_conv_seq ON {MESSAGES_TABLE} (conversation_id, seq)") + stmts.append(f"CREATE INDEX ix_maf_msg_created ON {MESSAGES_TABLE} (created_at)") + stmts.append(f"CREATE INDEX ix_maf_msg_run ON {MESSAGES_TABLE} (run_id)") + stmts.append(f"CREATE INDEX ix_maf_msg_ref ON {MESSAGES_TABLE} (message_ref)") + stmts.append(f"CREATE INDEX ix_maf_cevt_conv_seq ON {EVENTS_TABLE} (conversation_id, seq)") + stmts.append(f"CREATE INDEX ix_maf_cevt_type ON {EVENTS_TABLE} (event_type)") + stmts.append(f"CREATE INDEX ix_maf_cevt_created ON {EVENTS_TABLE} (created_at)") + stmts.append(f"CREATE INDEX ix_maf_cevt_source ON {EVENTS_TABLE} (source_event_id)") + return stmts + + +def validate_schema(inspector: Any) -> list[str]: + """Return contract violations found by SQLAlchemy inspector.""" + issues: list[str] = [] + tables = inspector.get_table_names() + + for tbl in (CONVERSATIONS_TABLE, MESSAGES_TABLE, EVENTS_TABLE): + if tbl not in tables: + issues.append(f"missing table: {tbl}") + + if CONVERSATIONS_TABLE in tables: + cols = {c["name"] for c in inspector.get_columns(CONVERSATIONS_TABLE)} + for required in ("id", "owner_user_id", "title", "safe_status", "last_stream_seq", "created_at", "updated_at"): + if required not in cols: + issues.append(f"{CONVERSATIONS_TABLE}: missing column {required}") + if "last_message_seq" in cols: + issues.append(f"{CONVERSATIONS_TABLE}: deprecated column last_message_seq still present") + fks = inspector.get_foreign_keys(CONVERSATIONS_TABLE) + fk_columns = { + (tuple(fk.get("constrained_columns") or ()), fk.get("referred_table")) + for fk in fks + } + if (("active_run_id",), "maf_roundtable_runs") not in fk_columns: + issues.append(f"{CONVERSATIONS_TABLE}: missing FK(active_run_id -> maf_roundtable_runs)") + if (("internal_session_id",), "maf_roundtable_sessions") not in fk_columns: + issues.append(f"{CONVERSATIONS_TABLE}: missing FK(internal_session_id -> maf_roundtable_sessions)") + + if MESSAGES_TABLE in tables: + cols = {c["name"] for c in inspector.get_columns(MESSAGES_TABLE)} + for required in ("id", "conversation_id", "seq", "message_kind", "role", "content", "created_at"): + if required not in cols: + issues.append(f"{MESSAGES_TABLE}: missing column {required}") + uqs = inspector.get_unique_constraints(MESSAGES_TABLE) + uq_cols = [set(u.get("column_names", [])) for u in uqs] + if {"conversation_id", "seq"} not in uq_cols: + issues.append(f"{MESSAGES_TABLE}: missing UNIQUE(conversation_id, seq)") + + if EVENTS_TABLE in tables: + cols = {c["name"] for c in inspector.get_columns(EVENTS_TABLE)} + for required in ("id", "conversation_id", "seq", "event_type", "created_at", "source_event_id"): + if required not in cols: + issues.append(f"{EVENTS_TABLE}: missing column {required}") + uqs = inspector.get_unique_constraints(EVENTS_TABLE) + uq_cols = [set(u.get("column_names", [])) for u in uqs] + if {"conversation_id", "seq"} not in uq_cols: + issues.append(f"{EVENTS_TABLE}: missing UNIQUE(conversation_id, seq)") + if {"conversation_id", "source_event_id"} not in uq_cols: + issues.append(f"{EVENTS_TABLE}: missing UNIQUE(conversation_id, source_event_id)") + + return issues + + +def _missing_conversation_link_foreign_keys(inspector: Any) -> list[str]: + """Return missing private-link FKs on an already-created conversation table.""" + if CONVERSATIONS_TABLE not in inspector.get_table_names(): + return [] + foreign_keys = { + (tuple(fk.get("constrained_columns") or ()), fk.get("referred_table")) + for fk in inspector.get_foreign_keys(CONVERSATIONS_TABLE) + } + missing: list[str] = [] + if (("active_run_id",), "maf_roundtable_runs") not in foreign_keys: + missing.append("active_run_id -> maf_roundtable_runs") + if (("internal_session_id",), "maf_roundtable_sessions") not in foreign_keys: + missing.append("internal_session_id -> maf_roundtable_sessions") + return missing + + +async def run_migration(database_url: str, *, dry_run: bool = False) -> None: + from sqlalchemy import inspect, text + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.pool import NullPool + + # A migration is a one-shot operation. NullPool makes every early + # failure close its SQLite connection as the transaction context exits, + # rather than retaining it until the process exits. + engine = create_async_engine(database_url, poolclass=NullPool) + async with engine.begin() as conn: + dialect = _dialect_name(conn) + logger.info("Dialect: %s", dialect) + + def _get_table_names(sync_conn: Any) -> list[str]: + return inspect(sync_conn).get_table_names() + + existing = set(await conn.run_sync(_get_table_names)) + + def _missing_links(sync_conn: Any) -> list[str]: + return _missing_conversation_link_foreign_keys(inspect(sync_conn)) + + missing_links = await conn.run_sync(_missing_links) + if missing_links: + if dialect in {"sqlite"}: + # SQLite cannot add a foreign-key constraint in place. These + # V2 tables have not been released as a data source yet, so an + # empty partial install can be rebuilt safely. Never delete + # a non-empty conversation history merely to improve a schema. + tables_to_rebuild = [EVENTS_TABLE, MESSAGES_TABLE, CONVERSATIONS_TABLE] + non_empty: list[str] = [] + for table in tables_to_rebuild: + if table not in existing: + continue + count = int((await conn.execute(text(f"SELECT COUNT(*) FROM {table}"))).scalar_one()) + if count: + non_empty.append(table) + if non_empty: + # ``run_migration`` normally disposes below the transaction + # block. This explicit failure is intentionally raised + # earlier, so release the aiosqlite pool before bubbling it + # to callers/tests. + await engine.dispose() + raise RuntimeError( + "MAF Conversation V2 schema is missing foreign keys " + f"({', '.join(missing_links)}) but has data in " + f"{', '.join(non_empty)}. Back up and migrate it explicitly; " + "this script refuses to delete conversation history." + ) + logger.warning( + "Rebuilding empty partial V2 tables to add missing foreign keys: %s", + ", ".join(missing_links), + ) + if not dry_run: + for table in tables_to_rebuild: + if table in existing: + await conn.execute(text(f"DROP TABLE {table}")) + existing.difference_update(tables_to_rebuild) + else: + # PostgreSQL and MySQL/MariaDB can add the missing constraints + # in place. The database validates existing rows and fails + # closed if a dangling private link exists. + if "active_run_id -> maf_roundtable_runs" in missing_links: + sql = ( + f"ALTER TABLE {CONVERSATIONS_TABLE} " + "ADD CONSTRAINT fk_maf_conv_active_run " + "FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs (id) " + "ON DELETE SET NULL" + ) + if dry_run: + logger.info("[DRY RUN] Would execute: %s", sql) + else: + await conn.execute(text(sql)) + if "internal_session_id -> maf_roundtable_sessions" in missing_links: + sql = ( + f"ALTER TABLE {CONVERSATIONS_TABLE} " + "ADD CONSTRAINT fk_maf_conv_internal_session " + "FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions (id) " + "ON DELETE SET NULL" + ) + if dry_run: + logger.info("[DRY RUN] Would execute: %s", sql) + else: + await conn.execute(text(sql)) + + ddls: list[str] = [] + if CONVERSATIONS_TABLE not in existing: + ddls.append(_conversations_ddl(dialect)) + logger.info("Will create: %s", CONVERSATIONS_TABLE) + else: + logger.info("Already exists: %s", CONVERSATIONS_TABLE) + + if MESSAGES_TABLE not in existing: + ddls.append(_messages_ddl(dialect)) + logger.info("Will create: %s", MESSAGES_TABLE) + else: + logger.info("Already exists: %s", MESSAGES_TABLE) + + if EVENTS_TABLE not in existing: + ddls.append(_events_ddl(dialect)) + logger.info("Will create: %s", EVENTS_TABLE) + else: + logger.info("Already exists: %s", EVENTS_TABLE) + + for idx_sql in _indexes_ddl(dialect): + ddls.append(idx_sql) + + if dry_run: + logger.info("[DRY RUN] Would execute %d statements:", len(ddls)) + for ddl in ddls: + logger.info(" %s", " ".join(ddl.split())[:120]) + else: + for ddl in ddls: + try: + await conn.execute(text(ddl)) + except Exception as exc: + if "already exists" in str(exc).lower() or "duplicate" in str(exc).lower(): + continue + raise + logger.info("Executed %d statements successfully.", len(ddls)) + + def _validate(sync_conn: Any) -> list[str]: + return validate_schema(inspect(sync_conn)) + + issues = await conn.run_sync(_validate) + if issues: + logger.error("Schema validation FAILED:") + for issue in issues: + logger.error(" - %s", issue) + sys.exit(1) + else: + logger.info("Schema validation passed.") + + await engine.dispose() + + +def main() -> None: + parser = argparse.ArgumentParser(description="MAF roundtable conversation V2 migration") + parser.add_argument("--database-url", default=None, help="Async database URL (default: from config)") + parser.add_argument("--dry-run", action="store_true", help="Print DDL without executing") + args = parser.parse_args() + + database_url = args.database_url + if database_url is None: + from pathlib import Path + + import yaml + + config_path = Path(__file__).resolve().parent.parent / "config.yaml" + if config_path.exists(): + with open(config_path) as f: + cfg = yaml.safe_load(f) or {} + db_cfg = cfg.get("database", {}) + database_url = db_cfg.get("url", "sqlite+aiosqlite:///./data/deerflow.db") + else: + database_url = "sqlite+aiosqlite:///./data/deerflow.db" + + logger.info("Database: %s", database_url.split("?")[0]) + asyncio.run(run_migration(database_url, dry_run=args.dry_run)) + + +if __name__ == "__main__": + main() diff --git a/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py b/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py new file mode 100644 index 00000000..9085f4d3 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_conversation_migration.py @@ -0,0 +1,190 @@ +"""Tests for the MAF roundtable conversation V2 migration script. + +Covers: +- SQLite real migration idempotency (run twice, no error). +- Schema validation passes after migration. +- Deprecated last_message_seq column is not present. +- PostgreSQL / MySQL DDL assertions (dialect-specific SQL generation). +- Existing tables are not destroyed by re-running. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import inspect, text +from sqlalchemy.ext.asyncio import create_async_engine + +from scripts.migrate_maf_roundtable_conversation_v2 import ( + CONVERSATIONS_TABLE, + EVENTS_TABLE, + MESSAGES_TABLE, + _conversations_ddl, + _events_ddl, + _indexes_ddl, + _messages_ddl, + run_migration, + validate_schema, +) + + +@pytest.mark.asyncio +async def test_sqlite_migration_creates_tables(tmp_path): + db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig.db'}" + await run_migration(db_url) + + engine = create_async_engine(db_url) + async with engine.begin() as conn: + def _check(sync_conn): + insp = inspect(sync_conn) + tables = insp.get_table_names() + issues = validate_schema(insp) + return tables, issues + + tables, issues = await conn.run_sync(_check) + assert CONVERSATIONS_TABLE in tables + assert MESSAGES_TABLE in tables + assert EVENTS_TABLE in tables + assert issues == [], f"Schema issues: {issues}" + await engine.dispose() + + +@pytest.mark.asyncio +async def test_sqlite_migration_idempotent(tmp_path): + db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig2.db'}" + await run_migration(db_url) + await run_migration(db_url) + + engine = create_async_engine(db_url) + async with engine.begin() as conn: + def _check(sync_conn): + return validate_schema(inspect(sync_conn)) + + issues = await conn.run_sync(_check) + assert issues == [], f"Schema issues after 2nd run: {issues}" + await engine.dispose() + + +@pytest.mark.asyncio +async def test_no_deprecated_last_message_seq(tmp_path): + db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig3.db'}" + await run_migration(db_url) + + engine = create_async_engine(db_url) + async with engine.begin() as conn: + def _check(sync_conn): + insp = inspect(sync_conn) + return {c["name"] for c in insp.get_columns(CONVERSATIONS_TABLE)} + + cols = await conn.run_sync(_check) + assert "last_stream_seq" in cols + assert "last_message_seq" not in cols + await engine.dispose() + + +@pytest.mark.asyncio +async def test_existing_data_preserved(tmp_path): + db_url = f"sqlite+aiosqlite:///{tmp_path / 'mig4.db'}" + await run_migration(db_url) + + engine = create_async_engine(db_url) + async with engine.begin() as conn: + await conn.execute(text( + f"INSERT INTO {CONVERSATIONS_TABLE} (id, owner_user_id, title, safe_status, last_stream_seq, created_at, updated_at) " + "VALUES ('c1', 'u1', 'keep', 'idle', 0, '2026-01-01', '2026-01-01')" + )) + + await run_migration(db_url) + + async with engine.begin() as conn: + row = (await conn.execute(text(f"SELECT id, title FROM {CONVERSATIONS_TABLE} WHERE id='c1'"))).first() + assert row is not None + assert row[1] == "keep" + await engine.dispose() + + +@pytest.mark.asyncio +async def test_empty_partial_conversation_table_is_rebuilt_with_foreign_keys(tmp_path): + db_url = f"sqlite+aiosqlite:///{tmp_path / 'partial.db'}" + engine = create_async_engine(db_url) + async with engine.begin() as conn: + # Represents the first uncommitted V2 draft: the table exists but its + # private run/session links were not constrained. + await conn.execute(text( + f"CREATE TABLE {CONVERSATIONS_TABLE} (" + "id VARCHAR(64) PRIMARY KEY, owner_user_id VARCHAR(64) NOT NULL, " + "title VARCHAR(255) NOT NULL DEFAULT '', task_context_json TEXT, " + "safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', active_run_id VARCHAR(64), " + "internal_session_id VARCHAR(64), last_stream_seq INTEGER NOT NULL DEFAULT 0, " + "created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)" + )) + await engine.dispose() + + await run_migration(db_url) + + engine = create_async_engine(db_url) + async with engine.begin() as conn: + def _check(sync_conn): + return validate_schema(inspect(sync_conn)) + + assert await conn.run_sync(_check) == [] + await engine.dispose() + + +@pytest.mark.asyncio +async def test_nonempty_partial_conversation_table_fails_closed(tmp_path): + db_url = f"sqlite+aiosqlite:///{tmp_path / 'partial-data.db'}" + engine = create_async_engine(db_url) + async with engine.begin() as conn: + await conn.execute(text( + f"CREATE TABLE {CONVERSATIONS_TABLE} (" + "id VARCHAR(64) PRIMARY KEY, owner_user_id VARCHAR(64) NOT NULL, " + "title VARCHAR(255) NOT NULL DEFAULT '', safe_status VARCHAR(32) NOT NULL DEFAULT 'idle', " + "last_stream_seq INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)" + )) + await conn.execute(text( + f"INSERT INTO {CONVERSATIONS_TABLE} (id, owner_user_id, title, safe_status, last_stream_seq, created_at, updated_at) " + "VALUES ('c1', 'u1', 'keep', 'idle', 0, '2026-01-01', '2026-01-01')" + )) + await engine.dispose() + + with pytest.raises(RuntimeError, match="refuses to delete conversation history"): + await run_migration(db_url) + + +def test_postgresql_ddl_generation(): + ddl = _conversations_ddl("postgresql") + assert "TIMESTAMP WITH TIME ZONE" in ddl + assert "JSONB" in ddl + assert CONVERSATIONS_TABLE in ddl + assert "FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs" in ddl + assert "FOREIGN KEY (internal_session_id) REFERENCES maf_roundtable_sessions" in ddl + + msg_ddl = _messages_ddl("postgresql") + assert "ON DELETE CASCADE" in msg_ddl + assert "UNIQUE (conversation_id, seq)" in msg_ddl + + evt_ddl = _events_ddl("postgresql") + assert "UNIQUE (conversation_id, seq)" in evt_ddl + + +def test_mysql_ddl_generation(): + ddl = _conversations_ddl("mysql") + assert "DATETIME(6)" in ddl + assert "JSON" in ddl + assert "FOREIGN KEY (active_run_id) REFERENCES maf_roundtable_runs" in ddl + + msg_ddl = _messages_ddl("mysql") + assert "LONGTEXT" in msg_ddl + assert "ON DELETE CASCADE" in msg_ddl + + +def test_sqlite_ddl_generation(): + ddl = _conversations_ddl("sqlite") + assert "DATETIME" in ddl + assert "TEXT" in ddl + + indexes = _indexes_ddl("sqlite") + assert len(indexes) >= 8 + assert any("ix_maf_conv_owner_updated" in i for i in indexes) + assert any("ix_maf_msg_conv_seq" in i for i in indexes) + assert any("ix_maf_cevt_conv_seq" in i for i in indexes) diff --git a/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py b/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py new file mode 100644 index 00000000..8ae7c9e8 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_conversation_stream.py @@ -0,0 +1,473 @@ +"""Tests for MAF roundtable V2 Conversation SSE streaming (Phase 2 / R2 + P0 fixes). + +Covers: +- ConversationEventHub: generation counter, publish/subscribe, multi-subscriber. +- P0-1: race-free replay — events published during replay are not lost. +- P0-2: terminal events auto-close the stream. +- P0-4: projection dedup via source_event_id. +- SSE replay: events persisted before connect are delivered. +- after_seq cursor: reconnect resumes without duplicates. +- Heartbeat: idle connection produces ``: heartbeat`` frames. +- Concurrent subscribers: fan-out to multiple listeners. +- Ownership: non-owner gets 404 on the stream endpoint. +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.maf_roundtable.conversation_event_hub import ConversationEventHub +from app.maf_roundtable.conversation_service import ( + ConversationNotFoundError, + MafConversationService, +) +from deerflow.persistence.base import Base +from deerflow.persistence.maf_roundtable import make_maf_roundtable_store +from deerflow.persistence.maf_roundtable.model import ( + MafRoundtableConversationRow, +) +from deerflow.persistence.maf_roundtable.store import MafRoundtableStore + + +class FakeMafConfig: + enabled = True + default_mode = "structured" + default_model_name = "test-model" + default_reasoning_effort = None + default_manager_agent_id = "mgr-agent" + default_max_rounds = 8 + default_write_policy = "confirm_high_risk" + admin_diagnostic_access = True + + +@pytest_asyncio.fixture +async def store(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'stream.db'}") + async with engine.begin() as conn: + import deerflow.persistence.maf_roundtable.model # noqa: F401 + + await conn.run_sync(Base.metadata.create_all) + sf = async_sessionmaker(engine, expire_on_commit=False) + yield make_maf_roundtable_store(sf) + await engine.dispose() + + +@pytest_asyncio.fixture +async def hub(): + h = ConversationEventHub(heartbeat_interval=0.3) + yield h + h.shutdown() + + +@pytest_asyncio.fixture +async def svc(store, hub): + return MafConversationService(store, maf_config=FakeMafConfig(), event_hub=hub) + + +# ── ConversationEventHub unit tests ─────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_hub_publish_notifies_subscriber(hub): + sub = hub.open_subscription("c1") + assert not sub.has_pending + + await hub.publish("c1") + assert sub.has_pending + + changed = await sub.wait_for_change(timeout=2.0) + assert changed + sub.close() + + +@pytest.mark.asyncio +async def test_hub_heartbeat_on_idle(hub): + sub = hub.open_subscription("c2") + changed = await sub.wait_for_change(timeout=0.2) + assert not changed + sub.close() + + +@pytest.mark.asyncio +async def test_hub_multi_subscriber_fanout(hub): + sub_a = hub.open_subscription("c3") + sub_b = hub.open_subscription("c3") + + await hub.publish("c3") + + assert await sub_a.wait_for_change(timeout=2.0) + assert await sub_b.wait_for_change(timeout=2.0) + + sub_a.close() + sub_b.close() + assert hub.conversation_subscriber_count("c3") == 0 + + +@pytest.mark.asyncio +async def test_hub_publish_no_subscribers_is_noop(hub): + await hub.publish("c_noone") + assert hub.conversation_subscriber_count("c_noone") == 0 + + +@pytest.mark.asyncio +async def test_hub_unsubscribe_cleans_up(hub): + sub = hub.open_subscription("c4") + assert hub.conversation_subscriber_count("c4") == 1 + sub.close() + assert hub.conversation_subscriber_count("c4") == 0 + + +@pytest.mark.asyncio +async def test_hub_shutdown_stops_subscribers(): + h = ConversationEventHub(heartbeat_interval=0.2) + sub = h.open_subscription("c_shutdown") + h.shutdown() + assert h.is_closed + # After shutdown, generation should have advanced. + assert sub.has_pending or sub.current_generation > sub.seen_generation + sub.close() + + +# ── P0-1: Race-free replay ──────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_p0_1_event_during_replay_is_not_lost(svc, store, hub): + """Event published between list_events() and subscription must be caught + by the stabilisation loop.""" + conv = await svc.create_conversation(user_id="u1", title="Race") + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg1") + await _reset_conv_to_idle(store, conv["id"]) + + # Simulate: subscribe, replay, then a publish sneaks in before stabilise. + sub = hub.open_subscription(conv["id"]) + last_seq = 0 + + # Phase 1: initial replay + events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq) + for evt in events: + last_seq = max(last_seq, evt["seq"]) + + # Event sneaks in here (between replay and stabilise) + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="sneaky") + await hub.publish(conv["id"]) + + # Stabilise: should detect the pending generation + assert sub.has_pending + sub.mark_seen() + late_events = await store.conversation_events.list_events(conv["id"], after_seq=last_seq) + assert len(late_events) >= 1 # The sneaky event was caught + for evt in late_events: + last_seq = max(last_seq, evt["seq"]) + + sub.close() + + +@pytest.mark.asyncio +async def test_p0_1_subscribe_before_replay_catches_concurrent_publish(svc, store, hub): + """Subscribe must be opened BEFORE any DB query so a publish during replay + is detected via generation delta.""" + conv = await svc.create_conversation(user_id="u1", title="SubBefore") + + sub = hub.open_subscription(conv["id"]) + + # No events yet + events = await store.conversation_events.list_events(conv["id"], after_seq=0) + assert len(events) == 0 + + # Publish arrives + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first") + await hub.publish(conv["id"]) + + # Stabilisation detects it + assert sub.has_pending + sub.mark_seen() + events = await store.conversation_events.list_events(conv["id"], after_seq=0) + assert len(events) >= 1 + + sub.close() + + +# ── P0-2: Terminal events auto-close stream ────────────────────────────── + + +@pytest.mark.asyncio +async def test_p0_2_replay_terminal_event_closes_stream(): + from app.gateway.routers.maf_conversations import _TERMINAL_EVENT_TYPES, _is_terminal + + assert "run.completed" in _TERMINAL_EVENT_TYPES + assert "run.error" in _TERMINAL_EVENT_TYPES + assert "run.cancelled" in _TERMINAL_EVENT_TYPES + + assert _is_terminal({"type": "run.completed"}) + assert _is_terminal({"type": "run.error"}) + assert _is_terminal({"type": "run.cancelled"}) + assert not _is_terminal({"type": "agent.message.delta"}) + assert not _is_terminal({"type": "user.message.created"}) + + +@pytest.mark.asyncio +async def test_p0_2_terminal_event_in_replay_stops_generator(store): + """If the replay phase hits a terminal event, the generator should stop.""" + from app.gateway.routers.maf_conversations import _is_terminal + + # Simulate a conversation with events ending in run.completed + events = [ + {"seq": 1, "type": "user.message.created"}, + {"seq": 2, "type": "agent.message.delta"}, + {"seq": 3, "type": "run.completed"}, + {"seq": 4, "type": "should_not_reach"}, + ] + + found_terminal = False + for evt in events: + if _is_terminal(evt): + found_terminal = True + break + assert found_terminal + # Verify the event after terminal would not be reached + terminal_idx = next(i for i, e in enumerate(events) if _is_terminal(e)) + assert events[terminal_idx]["type"] == "run.completed" + + +# ── P0-4: Projection dedup ──────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_p0_4_dedup_same_source_event_id(svc, store): + """Projecting the same source_event_id twice should not create a duplicate.""" + conv = await svc.create_conversation(user_id="u1", title="Dedup") + + result1 = await svc._publish_conversation_event( + conversation_id=conv["id"], + owner_user_id="u1", + event_type="tool.started", + event_data={"tool_name": "search"}, + source_event_id="evt_src_001", + ) + assert result1 is True + + result2 = await svc._publish_conversation_event( + conversation_id=conv["id"], + owner_user_id="u1", + event_type="tool.started", + event_data={"tool_name": "search"}, + source_event_id="evt_src_001", + ) + assert result2 is False # Deduped + + events = await store.conversation_events.list_events(conv["id"], after_seq=0) + tool_events = [e for e in events if e["type"] == "tool.started"] + assert len(tool_events) == 1 + + +@pytest.mark.asyncio +async def test_p0_4_different_source_event_ids_both_written(svc, store): + conv = await svc.create_conversation(user_id="u1", title="MultiSrc") + + await svc._publish_conversation_event( + conversation_id=conv["id"], owner_user_id="u1", + event_type="tool.started", event_data={"tool_name": "a"}, + source_event_id="src_1", + ) + await svc._publish_conversation_event( + conversation_id=conv["id"], owner_user_id="u1", + event_type="tool.started", event_data={"tool_name": "b"}, + source_event_id="src_2", + ) + + events = await store.conversation_events.list_events(conv["id"], after_seq=0) + tool_events = [e for e in events if e["type"] == "tool.started"] + assert len(tool_events) == 2 + + +@pytest.mark.asyncio +async def test_p0_4_null_source_event_id_not_deduped(svc, store): + """Events with no source_event_id should never be deduped.""" + conv = await svc.create_conversation(user_id="u1", title="NoSrc") + + await svc._publish_conversation_event( + conversation_id=conv["id"], owner_user_id="u1", + event_type="manager.dispatch", event_data={"reason": "x"}, + source_event_id=None, + ) + await svc._publish_conversation_event( + conversation_id=conv["id"], owner_user_id="u1", + event_type="manager.dispatch", event_data={"reason": "y"}, + source_event_id=None, + ) + + events = await store.conversation_events.list_events(conv["id"], after_seq=0) + assert len(events) == 2 + + +# ── SSE replay + cursor + real-time integration ────────────────────────── + + +async def _reset_conv_to_idle(store: MafRoundtableStore, conv_id: str): + async with store.session_factory() as session: + row = await session.get(MafRoundtableConversationRow, conv_id) + if row: + row.safe_status = "idle" + row.active_run_id = None + await session.commit() + + +@pytest.mark.asyncio +async def test_sse_replay_delivers_past_events(svc, store): + conv = await svc.create_conversation(user_id="u1", title="Replay") + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg1") + await _reset_conv_to_idle(store, conv["id"]) + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="msg2") + + events = await store.conversation_events.list_events(conv["id"], after_seq=0) + assert len(events) >= 2 + types = [e["type"] for e in events] + assert types.count("user.message.created") == 2 + seqs = [e["seq"] for e in events] + assert seqs == sorted(seqs) + + +@pytest.mark.asyncio +async def test_sse_after_seq_prevents_duplicates(svc, store): + conv = await svc.create_conversation(user_id="u1", title="Cursor") + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first") + await _reset_conv_to_idle(store, conv["id"]) + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="second") + + all_events = await store.conversation_events.list_events(conv["id"], after_seq=0) + last_seq = all_events[0]["seq"] + + replay = await store.conversation_events.list_events(conv["id"], after_seq=last_seq) + assert all(e["seq"] > last_seq for e in replay) + assert len(replay) == len(all_events) - 1 + + +@pytest.mark.asyncio +async def test_real_time_event_pushed_after_subscribe(svc, store, hub): + conv = await svc.create_conversation(user_id="u1", title="RT") + + received_events: list[dict] = [] + + async def stream_consumer(): + sub = hub.open_subscription(conv["id"]) + last_seq = 0 + + # Stabilise (no events yet) + while sub.has_pending: + sub.mark_seen() + + # Wait for change + changed = await sub.wait_for_change(timeout=5.0) + if changed: + new_evts = await store.conversation_events.list_events(conv["id"], after_seq=last_seq) + for evt in new_evts: + received_events.append(evt) + last_seq = max(last_seq, evt["seq"]) + sub.close() + + task = asyncio.create_task(stream_consumer()) + await asyncio.sleep(0.1) + + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="live msg") + + await asyncio.wait_for(task, timeout=5.0) + types = [e["type"] for e in received_events] + assert "user.message.created" in types + + +@pytest.mark.asyncio +async def test_concurrent_subscribers_both_receive(svc, store, hub): + conv = await svc.create_conversation(user_id="u1", title="Fanout") + + buf_a: list[dict] = [] + buf_b: list[dict] = [] + + async def consume(buf): + sub = hub.open_subscription(conv["id"]) + last_seq = 0 + changed = await sub.wait_for_change(timeout=5.0) + if changed: + new_evts = await store.conversation_events.list_events(conv["id"], after_seq=last_seq) + for evt in new_evts: + buf.append(evt) + last_seq = max(last_seq, evt["seq"]) + sub.close() + + ta = asyncio.create_task(consume(buf_a)) + tb = asyncio.create_task(consume(buf_b)) + await asyncio.sleep(0.15) + + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="broadcast") + + await asyncio.wait_for(asyncio.gather(ta, tb), timeout=5.0) + assert len(buf_a) >= 1 + assert len(buf_b) >= 1 + assert buf_a[0]["seq"] == buf_b[0]["seq"] + + +@pytest.mark.asyncio +async def test_reconnect_after_seq_resume(svc, store): + conv = await svc.create_conversation(user_id="u1", title="Reconnect") + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="batch1") + await _reset_conv_to_idle(store, conv["id"]) + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="batch2") + + all_events = await store.conversation_events.list_events(conv["id"], after_seq=0) + midpoint = all_events[len(all_events) // 2]["seq"] + + replay = await store.conversation_events.list_events(conv["id"], after_seq=midpoint) + assert all(e["seq"] > midpoint for e in replay) + + +# ── SSE frame format ────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_sse_frame_format(): + from app.gateway.routers.maf_conversations import _format_heartbeat, _format_sse_event + + frame = _format_sse_event({ + "seq": 42, + "event_id": "evt_abc", + "conversation_id": "conv_1", + "run_id": "run_1", + "type": "user.message.created", + "occurred_at": "2026-08-03T00:00:00Z", + "data": {"content": "hi"}, + }) + assert "event: user.message.created" in frame + assert "data: " in frame + assert "id: evt_abc" in frame + assert frame.endswith("\n\n") + + parsed = None + for line in frame.splitlines(): + if line.startswith("data: "): + parsed = json.loads(line[len("data: "):]) + break + assert parsed is not None + assert parsed["seq"] == 42 + assert parsed["type"] == "user.message.created" + + hb = _format_heartbeat() + assert hb == ": heartbeat\n\n" + + +# ── Ownership ──────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_ownership_check_before_stream(svc): + conv = await svc.create_conversation(user_id="u1", title="OwnerOnly") + + fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="u1") + assert fetched["id"] == conv["id"] + + with pytest.raises(ConversationNotFoundError): + await svc.get_conversation(conversation_id=conv["id"], user_id="u2") diff --git a/offline-backend-20260512/backend/tests/test_maf_conversations.py b/offline-backend-20260512/backend/tests/test_maf_conversations.py new file mode 100644 index 00000000..2f752f87 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_conversations.py @@ -0,0 +1,390 @@ +"""Tests for the MAF roundtable V2 Conversation API (Phase 1 / R1). + +Covers: +- Create, list, detail, message pagination, user isolation. +- Public API responses never leak internal_session_id, active_run_id, run_id, + owner_user_id, thread ids, or secret-class fields. +- First message creates a real internal session; run FK is valid. +- Transaction rollback: any flush failure rolls back message + run + event + status. +- Concurrent send: only one succeeds, the other gets 409; no duplicate runs. +- Mixed tool-events and messages: last_stream_seq is contiguous and usable as SSE cursor. +- Empty / overlong / forged-internal-field content → 422. +- Non-owner cannot read/write; admin cannot write another user's conversation. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from sqlalchemy import inspect, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.gateway.routers.maf_conversations import _user_ctx +from app.maf_roundtable.conversation_schemas import ( + CreateConversationRequest, + SendMessageRequest, +) +from app.maf_roundtable.conversation_service import ( + ConversationConflictError, + ConversationDisabledError, + ConversationNotFoundError, + MafConversationService, +) +from deerflow.persistence.base import Base +from deerflow.persistence.maf_roundtable import make_maf_roundtable_store +from deerflow.persistence.maf_roundtable.model import ( + MafRoundtableConversationRow, + MafRoundtableMessageRow, + MafRoundtableRunRow, + MafRoundtableSessionRow, +) + + +class FakeMafConfig: + enabled = True + default_mode = "structured" + default_model_name = "test-model" + default_reasoning_effort = None + default_manager_agent_id = "mgr-agent" + default_max_rounds = 8 + default_write_policy = "confirm_high_risk" + admin_diagnostic_access = True + + +@pytest_asyncio.fixture +async def store(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'conv.db'}") + async with engine.begin() as conn: + import deerflow.persistence.maf_roundtable.model # noqa: F401 + + await conn.run_sync(Base.metadata.create_all) + sf = async_sessionmaker(engine, expire_on_commit=False) + yield make_maf_roundtable_store(sf) + await engine.dispose() + + +@pytest_asyncio.fixture +async def svc(store): + return MafConversationService(store, maf_config=FakeMafConfig()) + + +FORBIDDEN_FIELDS = { + "owner_user_id", "internal_session_id", "active_run_id", + "thread_id", "capability_snapshot", "idempotency_key", + "target_key", "internal_canary", +} + + +@pytest.mark.asyncio +async def test_create_and_get_conversation(svc): + conv = await svc.create_conversation(user_id="u1", title="Test", task_context={"business_code": "3Q"}) + assert conv["id"] + assert conv["title"] == "Test" + assert conv["safe_status"] == "idle" + assert conv["last_stream_seq"] == 0 + assert conv["task_context"] == {"business_code": "3Q"} + for field in FORBIDDEN_FIELDS: + assert field not in conv, f"Leaked field: {field}" + + fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="u1") + assert fetched["id"] == conv["id"] + for field in FORBIDDEN_FIELDS: + assert field not in fetched + + +@pytest.mark.asyncio +async def test_list_conversations_owner_scoped(svc): + await svc.create_conversation(user_id="u1", title="A") + await svc.create_conversation(user_id="u1", title="B") + await svc.create_conversation(user_id="u2", title="C") + + list_u1, total_u1 = await svc.list_conversations(user_id="u1") + assert total_u1 == 2 + assert all(c["id"] for c in list_u1) + + list_u2, total_u2 = await svc.list_conversations(user_id="u2") + assert total_u2 == 1 + + +@pytest.mark.asyncio +async def test_get_conversation_wrong_owner_returns_404(svc): + conv = await svc.create_conversation(user_id="u1", title="Private") + with pytest.raises(ConversationNotFoundError): + await svc.get_conversation(conversation_id=conv["id"], user_id="u2") + + +@pytest.mark.asyncio +async def test_admin_can_read_but_not_write(svc): + conv = await svc.create_conversation(user_id="u1", title="AdminRead") + fetched = await svc.get_conversation(conversation_id=conv["id"], user_id="admin", is_admin=True) + assert fetched["id"] == conv["id"] + + +@pytest.mark.asyncio +async def test_send_message_creates_real_session_and_run(svc, store): + conv = await svc.create_conversation(user_id="u1", title="SendTest") + result = await svc.send_message(conversation_id=conv["id"], user_id="u1", content="Hello world") + + assert result["message_id"] + assert result["conversation_id"] == conv["id"] + assert result["seq"] == 1 + + async with store.session_factory() as session: + conv_row = (await session.execute( + select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"]) + )).scalars().first() + assert conv_row is not None + assert conv_row.internal_session_id is not None + assert conv_row.safe_status == "running" + assert conv_row.active_run_id is not None + # Stage 3 persists the manager.intent and manager.dispatch events in + # the same conversation-global cursor as the user message. + assert conv_row.last_stream_seq == 3 + + session_row = (await session.execute( + select(MafRoundtableSessionRow).where(MafRoundtableSessionRow.id == conv_row.internal_session_id) + )).scalars().first() + assert session_row is not None + assert session_row.user_id == "u1" + + run_row = (await session.execute( + select(MafRoundtableRunRow).where(MafRoundtableRunRow.id == conv_row.active_run_id) + )).scalars().first() + assert run_row is not None + assert run_row.session_id == conv_row.internal_session_id + + +@pytest.mark.asyncio +async def test_send_message_wrong_owner_404(svc): + conv = await svc.create_conversation(user_id="u1", title="X") + with pytest.raises(ConversationNotFoundError): + await svc.send_message(conversation_id=conv["id"], user_id="u2", content="hack") + + +@pytest.mark.asyncio +async def test_concurrent_send_only_one_succeeds(svc, store): + conv = await svc.create_conversation(user_id="u1", title="Race") + + async def try_send(content: str): + try: + r = await svc.send_message(conversation_id=conv["id"], user_id="u1", content=content) + return ("ok", r) + except ConversationConflictError: + return ("conflict", None) + + results = await asyncio.gather(try_send("msg1"), try_send("msg2")) + statuses = [r[0] for r in results] + assert statuses.count("ok") == 1 + assert statuses.count("conflict") == 1 + + async with store.session_factory() as session: + runs = (await session.execute( + select(MafRoundtableRunRow).join( + MafRoundtableSessionRow, + MafRoundtableRunRow.session_id == MafRoundtableSessionRow.id, + ).where(MafRoundtableSessionRow.user_id == "u1") + )).scalars().all() + assert len(runs) == 1 + + +@pytest.mark.asyncio +async def test_message_public_dto_no_run_id(svc, store): + conv = await svc.create_conversation(user_id="u1", title="PubDTO") + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="test content") + + messages, last_seq = await svc.list_messages(conversation_id=conv["id"], user_id="u1") + assert len(messages) == 1 + msg = messages[0] + assert "run_id" not in msg + assert msg["content"] == "test content" + assert msg["message_kind"] == "user" + assert last_seq == 3 + + +@pytest.mark.asyncio +async def test_last_stream_seq_contiguous(svc, store): + conv = await svc.create_conversation(user_id="u1", title="SeqTest") + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first") + + async with store.session_factory() as session: + conv_row = (await session.execute( + select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"]) + )).scalars().first() + conv_row.safe_status = "idle" + conv_row.active_run_id = None + await session.commit() + + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="second") + + async with store.session_factory() as session: + conv_row = (await session.execute( + select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"]) + )).scalars().first() + assert conv_row.last_stream_seq == 6 + + msgs = (await session.execute( + select(MafRoundtableMessageRow).where( + MafRoundtableMessageRow.conversation_id == conv["id"] + ).order_by(MafRoundtableMessageRow.seq) + )).scalars().all() + seqs = [m.seq for m in msgs] + assert seqs == [1, 4] + + +@pytest.mark.asyncio +async def test_schema_rejects_forbidden_fields(): + with pytest.raises(Exception): + CreateConversationRequest.model_validate({"title": "x", "owner_user_id": "hacker"}) + + with pytest.raises(Exception): + CreateConversationRequest.model_validate({"title": "x", "internal_session_id": "s1"}) + + with pytest.raises(Exception): + SendMessageRequest.model_validate({"content": "hi", "run_id": "r1"}) + + with pytest.raises(Exception): + SendMessageRequest.model_validate({"content": "hi", "thread_id": "t1"}) + + +@pytest.mark.asyncio +async def test_schema_rejects_empty_and_overlong_content(): + with pytest.raises(Exception): + SendMessageRequest.model_validate({"content": ""}) + + with pytest.raises(Exception): + SendMessageRequest.model_validate({"content": " "}) + + with pytest.raises(Exception): + SendMessageRequest.model_validate({"content": "x" * 20001}) + + +@pytest.mark.asyncio +async def test_task_context_whitelist(svc): + conv = await svc.create_conversation( + user_id="u1", title="Ctx", + task_context={"business_code": "6BF", "secret_key": "LEAKED", "task_name": "T1"}, + ) + assert conv["task_context"] == {"business_code": "6BF", "task_name": "T1"} + assert "secret_key" not in (conv["task_context"] or {}) + + +@pytest.mark.asyncio +async def test_task_context_rejects_unknown_or_nested_values(): + with pytest.raises(Exception): + CreateConversationRequest.model_validate({"task_context": {"secret_key": "nope"}}) + with pytest.raises(Exception): + CreateConversationRequest.model_validate({"task_context": {"task_id": {"secret": "nope"}}}) + with pytest.raises(Exception): + CreateConversationRequest.model_validate({"title": "x", "session_id": "forged"}) + + +@pytest.mark.asyncio +async def test_disabled_feature_rejects_conversation_writes(store): + disabled = MafConversationService(store, maf_config=SimpleNamespace(enabled=False)) + with pytest.raises(ConversationDisabledError): + await disabled.create_conversation(user_id="u1", title="disabled") + + +def test_router_uses_authenticated_user_object_for_owner_scope(): + user = SimpleNamespace(id="real-user", system_role="admin") + request = SimpleNamespace(state=SimpleNamespace(user=user)) + assert _user_ctx(request) == ("real-user", True) + + +@pytest.mark.asyncio +async def test_admin_cross_user_read_respects_diagnostic_config(store): + owner_service = MafConversationService(store, maf_config=FakeMafConfig()) + conv = await owner_service.create_conversation(user_id="u1", title="Private") + no_diagnostics = MafConversationService( + store, + maf_config=SimpleNamespace(enabled=True, admin_diagnostic_access=False), + ) + with pytest.raises(ConversationNotFoundError): + await no_diagnostics.get_conversation( + conversation_id=conv["id"], user_id="admin", is_admin=True + ) + + +@pytest.mark.asyncio +async def test_conversation_internal_links_have_real_foreign_keys(store): + async with store.session_factory() as session: + def _fks(sync_session): + inspector = inspect(sync_session.connection()) + return { + (tuple(fk["constrained_columns"]), fk["referred_table"]) + for fk in inspector.get_foreign_keys("maf_roundtable_conversations") + } + + foreign_keys = await session.run_sync(_fks) + assert (("active_run_id",), "maf_roundtable_runs") in foreign_keys + assert (("internal_session_id",), "maf_roundtable_sessions") in foreign_keys + + +@pytest.mark.asyncio +async def test_event_only_item_advances_the_same_sse_cursor(svc, store): + conv = await svc.create_conversation(user_id="u1", title="Unified cursor") + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="first") + + async with store.session_factory() as session: + async with session.begin(): + row = ( + await session.execute( + select(MafRoundtableConversationRow) + .where(MafRoundtableConversationRow.id == conv["id"]) + .with_for_update() + ) + ).scalars().one() + item = await store.append_conversation_item( + session, + conversation=row, + event_type="manager.intent", + event_data={"status": "understood"}, + ) + assert item["seq"] == 4 + + messages, last_stream_seq = await svc.list_messages(conversation_id=conv["id"], user_id="u1") + assert [message["seq"] for message in messages] == [1] + assert last_stream_seq == 4 + events = await store.conversation_events.list_events(conv["id"]) + assert [event["seq"] for event in events] == [1, 2, 3, 4] + + +@pytest.mark.asyncio +async def test_standalone_repositories_cannot_create_a_second_cursor(svc, store): + conv = await svc.create_conversation(user_id="u1", title="No split cursor") + with pytest.raises(RuntimeError, match="append_conversation_item"): + await store.messages.append_message( + conversation_id=conv["id"], message_kind="manager", role="assistant", content="unsafe" + ) + with pytest.raises(RuntimeError, match="append_conversation_item"): + await store.conversation_events.append_event( + conversation_id=conv["id"], event_type="manager.intent" + ) + + +@pytest.mark.asyncio +async def test_atomic_send_rolls_back_on_item_write_failure(svc, store, monkeypatch): + conv = await svc.create_conversation(user_id="u1", title="Rollback") + + async def _fail_append(*args, **kwargs): + raise RuntimeError("simulated item write failure") + + monkeypatch.setattr(store, "append_conversation_item", _fail_append) + with pytest.raises(RuntimeError, match="simulated item write failure"): + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="must roll back") + + async with store.session_factory() as session: + refreshed = ( + await session.execute( + select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv["id"]) + ) + ).scalars().one() + assert refreshed.safe_status == "idle" + assert refreshed.internal_session_id is None + assert refreshed.active_run_id is None + assert refreshed.last_stream_seq == 0 + assert (await session.execute(select(MafRoundtableMessageRow))).scalars().all() == [] + assert (await session.execute(select(MafRoundtableRunRow))).scalars().all() == [] diff --git a/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py b/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py new file mode 100644 index 00000000..5e828a4b --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_maf_phase3_conversation_dispatch.py @@ -0,0 +1,286 @@ +"""Deterministic Stage 3 tests for V2 conversation dispatch.""" + +from __future__ import annotations + +import asyncio + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.maf_roundtable.conversation_service import MafConversationService +from app.maf_roundtable.dispatch_contracts import ( + InvalidDispatchPlanError, + MafDispatchAssignment, + MafDispatchPlan, + MafIntentDecision, + resolve_intent, + validate_dispatch_plan, +) +from deerflow.persistence.base import Base +from deerflow.persistence.maf_roundtable import make_maf_roundtable_store +from deerflow.persistence.maf_roundtable.model import ( + MafRoundtableConversationRow, + MafRoundtableParticipantRow, + MafRoundtableRunRow, + MafRoundtableSessionRow, +) + + +class FakeMafConfig: + enabled = True + default_mode = "structured" + default_model_name = "test-model" + default_reasoning_effort = None + default_manager_agent_id = "manager-from-config" + default_seat_agent_ids = ["seat-a", "seat-b"] + business_routes = {} + max_seats_per_run = 4 + default_max_rounds = 8 + default_write_policy = "confirm_high_risk" + admin_diagnostic_access = True + + +class RecordingDispatcher: + def __init__(self) -> None: + self.calls: list[tuple[dict, dict, str]] = [] + + def check_sdk_available(self) -> None: + return + + async def dispatch(self, session: dict, run: dict, *, user_id: str) -> None: + self.calls.append((session, run, user_id)) + + +class CompletingDispatcher(RecordingDispatcher): + def __init__(self, store) -> None: + super().__init__() + self._store = store + self.ready = asyncio.Event() + + async def dispatch(self, session: dict, run: dict, *, user_id: str) -> None: + await super().dispatch(session, run, user_id=user_id) + await self._store.runs.update_run(run["id"], {"status": "completed"}) + await self._store.sessions.update_session( + session["id"], user_id=user_id, fields={"status": "completed"} + ) + self.ready.set() + + async def wait_for_run(self, run_id: str) -> None: + await self.ready.wait() + + +@pytest_asyncio.fixture +async def store(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'phase3.db'}") + async with engine.begin() as conn: + import deerflow.persistence.maf_roundtable.model # noqa: F401 + + await conn.run_sync(Base.metadata.create_all) + sf = async_sessionmaker(engine, expire_on_commit=False) + yield make_maf_roundtable_store(sf) + await engine.dispose() + + +@pytest.mark.asyncio +async def test_server_route_creates_participants_and_dispatches(store): + dispatcher = RecordingDispatcher() + svc = MafConversationService( + store, + maf_config=FakeMafConfig(), + dispatcher=dispatcher, + ) + conv = await svc.create_conversation( + user_id="u1", + title="Trusted route", + task_context={"business_code": "6BF"}, + ) + + # The unknown business code is rejected rather than falling back across + # business boundaries. + with pytest.raises(Exception) as exc_info: + await svc.send_message( + conversation_id=conv["id"], user_id="u1", content="请多专家并行分析这个任务" + ) + assert "route" in str(exc_info.value) + assert dispatcher.calls == [] + + # With no business code the configured default route is selected and the + # runtime sees only server-owned participants. + conv2 = await svc.create_conversation(user_id="u1", title="Default route") + await svc.send_message( + conversation_id=conv2["id"], user_id="u1", content="请多专家并行分析这个任务" + ) + assert len(dispatcher.calls) == 1 + session, run, owner = dispatcher.calls[0] + assert owner == "u1" + assert run["session_id"] == session["id"] + + async with store.session_factory() as db: + session_row = ( + await db.execute( + select(MafRoundtableSessionRow).where( + MafRoundtableSessionRow.id == session["id"] + ) + ) + ).scalars().one() + participants = ( + await db.execute( + select(MafRoundtableParticipantRow) + .where(MafRoundtableParticipantRow.session_id == session["id"]) + .order_by(MafRoundtableParticipantRow.sort_order) + ) + ).scalars().all() + assert session_row.task_payload_json["maf_v2"]["dispatch_plan"]["manager_agent_id"] == "manager-from-config" + assert [p.agent_id for p in participants] == ["manager-from-config", "seat-a", "seat-b"] + assert all(p.thread_id is None for p in participants) + + events = ( + await db.execute( + select(MafRoundtableConversationRow).where( + MafRoundtableConversationRow.id == conv2["id"] + ) + ) + ).scalars().one() + assert events.active_run_id == run["id"] + + +@pytest.mark.asyncio +async def test_clarification_does_not_create_or_dispatch_run(store): + dispatcher = RecordingDispatcher() + svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher) + conv = await svc.create_conversation(user_id="u1", title="Need detail") + + result = await svc.send_message(conversation_id=conv["id"], user_id="u1", content="不清楚") + assert result["message_id"] + assert dispatcher.calls == [] + + async with store.session_factory() as db: + conv_row = ( + await db.execute( + select(MafRoundtableConversationRow).where( + MafRoundtableConversationRow.id == conv["id"] + ) + ) + ).scalars().one() + assert conv_row.safe_status == "waiting_input" + assert conv_row.active_run_id is None + runs = ( + await db.execute(select(MafRoundtableRunRow)) + ).scalars().all() + assert runs == [] + + # A clarification reply is allowed to claim the idle waiting-input + # conversation and launch exactly one run. + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="请分析这个任务") + assert len(dispatcher.calls) == 1 + + +@pytest.mark.asyncio +async def test_dispatcher_completion_bridges_back_to_conversation(store): + dispatcher = CompletingDispatcher(store) + svc = MafConversationService(store, maf_config=FakeMafConfig(), dispatcher=dispatcher) + conv = await svc.create_conversation(user_id="u1", title="Completion") + + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="complete this task") + watcher_tasks = list(svc._watchers.values()) # noqa: SLF001 - lifecycle assertion + if watcher_tasks: + await asyncio.gather(*watcher_tasks) + + public = await svc.get_conversation(conversation_id=conv["id"], user_id="u1") + assert public["safe_status"] == "completed" + events = await store.conversation_events.list_events(conv["id"], after_seq=0) + assert [event["type"] for event in events][-1] == "run.completed" + assert all(event["run_id"] is None for event in events) + + +@pytest.mark.asyncio +async def test_workflow_projection_whitelists_user_visible_event_data(store): + svc = MafConversationService(store, maf_config=FakeMafConfig()) + conv = await svc.create_conversation(user_id="u1", title="Projection") + await svc.send_message(conversation_id=conv["id"], user_id="u1", content="complete this task") + internal = await store.conversations.get_conversation(conv["id"], user_id="u1") + run = await store.runs.list_runs(internal["internal_session_id"]) + + await svc.bridge_workflow_event( + run_id=run[0]["id"], + event={ + "event_type": "tool.requested", + "agent_id": "seat-a", + "turn_index": 1, + "payload": { + "tool_name": "safe_tool", + "args": {"secret": "must-not-cross"}, + "tool_call_id": "internal-call-id", + }, + }, + ) + await svc.bridge_workflow_event( + run_id=run[0]["id"], + event={ + "event_type": "agent.message.completed", + "agent_id": "seat-a", + "turn_index": 1, + "payload": { + "content": "answer", + "metadata": {"thread_id": "thread-secret", "deerflow_run_id": "df-secret"}, + }, + }, + ) + events = await store.conversation_events.list_events(conv["id"], after_seq=3) + tool = next(event for event in events if event["type"] == "tool.requested") + assert tool["data"] == {"tool_name": "safe_tool", "agent_id": "seat-a", "turn_index": 1} + complete = next(event for event in events if event["type"] == "agent.message.complete") + assert "metadata" not in complete["data"] + assert "thread-secret" not in str(complete) + assert any(event["type"] == "seat.delivery.submitted" for event in events) + + +def test_intent_resolution_is_deterministic_and_does_not_select_agents(): + assert resolve_intent("请多专家并行分析") .intent_type == "parallel_consultation" + assert resolve_intent("请发布这条内容").intent_type == "tool_write" + assert resolve_intent("不清楚").intent_type == "clarification" + assert resolve_intent("给我一个普通答案").intent_type == "direct_answer" + + +def test_weak_model_cannot_expand_or_duplicate_dispatch_plan(): + with pytest.raises(ValueError): + MafDispatchPlan( + manager_agent_id="manager", + seat_agent_ids=["seat-a", "seat-a"], + assignments=[ + MafDispatchAssignment(agent_id="seat-a", sort_order=1), + MafDispatchAssignment(agent_id="seat-a", sort_order=2), + ], + intent_type="parallel_consultation", + max_rounds=8, + ) + + plan = MafDispatchPlan( + manager_agent_id="manager", + seat_agent_ids=["seat-a"], + assignments=[MafDispatchAssignment(agent_id="seat-a", sort_order=1)], + intent_type="single_seat", + max_rounds=8, + ) + with pytest.raises(InvalidDispatchPlanError): + validate_dispatch_plan(plan, trusted_agent_ids={"manager", "seat-b"}, max_seats=4) + + with pytest.raises(ValueError, match="at least one configured seat"): + MafDispatchPlan( + manager_agent_id="manager", + seat_agent_ids=[], + assignments=[], + intent_type="parallel_consultation", + max_rounds=8, + ) + + +def test_clarification_plan_is_not_constructible(): + with pytest.raises(ValueError): + MafIntentDecision( + intent_type="clarification", + confidence=0.9, + reason_code="needs_detail", + ) diff --git a/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py b/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py index a629c06c..e6b37b1c 100644 --- a/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py +++ b/offline-backend-20260512/backend/tests/test_maf_roundtable_isolation.py @@ -21,7 +21,10 @@ from deerflow.persistence.maf_roundtable.model import ( MafRoundtableActionRow, MafRoundtableActionSecretRow, MafRoundtableApprovalRow, + MafRoundtableConversationEventRow, + MafRoundtableConversationRow, MafRoundtableEventRow, + MafRoundtableMessageRow, MafRoundtableParticipantRow, MafRoundtableRunRow, MafRoundtableSeatRunRow, @@ -167,6 +170,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store): "maf_roundtable_approvals", "maf_roundtable_seat_runs", "maf_roundtable_action_secrets", + "maf_roundtable_conversations", + "maf_roundtable_messages", + "maf_roundtable_conversation_events", } # Every FK defined on an MAF model must reference another maf_roundtable_* table. @@ -180,6 +186,9 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store): MafRoundtableActionSecretRow, MafRoundtableApprovalRow, MafRoundtableSeatRunRow, + MafRoundtableConversationRow, + MafRoundtableMessageRow, + MafRoundtableConversationEventRow, ] for model in maf_models: for fk in model.__table__.foreign_keys: diff --git a/offline-backend-20260512/backend/tests/test_maf_write_guard.py b/offline-backend-20260512/backend/tests/test_maf_write_guard.py index 3f115de5..d6b9cc11 100644 --- a/offline-backend-20260512/backend/tests/test_maf_write_guard.py +++ b/offline-backend-20260512/backend/tests/test_maf_write_guard.py @@ -903,9 +903,10 @@ async def test_seat_matching_scopes_by_turn_index(svc, store): @pytest.mark.asyncio -async def test_seat_matching_count_mismatch_fails(svc, store): - """P1b: a pending tool call that does not match EXACTLY one waiting action - (by tool_call_id) fails the turn —the executor never guesses.""" +async def test_seat_matching_zero_hits_warns_and_skips(svc, store): + """A pending tool call that matches 0 waiting actions (e.g. unclassified + tool, hallucinated tool name, or WriteGuard let it through as read) should + log a warning and skip — NOT crash the entire workflow.""" from app.maf_roundtable.workflow_executors import SeatExecutor session = await svc.create_session(user_id="u1", body={"task_text": "t"}) @@ -923,7 +924,39 @@ async def test_seat_matching_count_mismatch_fails(svc, store): executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1", action_coordinator=ActionCoordinator(svc, user_id="u1")) - with pytest.raises(RuntimeError, match="expected exactly 1"): + # Should NOT raise — the unmatched tool call is logged and skipped. + await executor.handle("msg", _FakeSeatCtx()) + # The unmatched call was not added to pending_approvals. + assert len(state.pending_approvals) == 0 + + +@pytest.mark.asyncio +async def test_seat_matching_multiple_hits_raises(svc, store): + """>1 match for a single tool call is a genuine data-integrity error — + the executor refuses to guess and raises RuntimeError.""" + from app.maf_roundtable.workflow_executors import SeatExecutor + + session = await svc.create_session(user_id="u1", body={"task_text": "t"}) + run = await svc.create_run(session_id=session["id"], user_id="u1") + + # Two waiting actions with the same logical_action_id for turn 1. + await store.actions.create_action({ + "session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1", + "tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1", + "idempotency_key": "dup1", "risk_level": "R3", "status": "waiting_approval", + }) + await store.actions.create_action({ + "session_id": session["id"], "run_id": run["id"], "agent_id": "seat-1", + "tool_name": "update_task", "turn_index": 1, "logical_action_id": "call-1", + "idempotency_key": "dup2", "risk_level": "R3", "status": "waiting_approval", + }) + + state = _seat_state(session["id"], run["id"]) + response = _FakeSeatResponse(tool_calls=[{"name": "update_task", "id": "call-1", "args": {}}]) + executor = SeatExecutor(_FakeSeatAdapter(store, response), state, "seat-1", + action_coordinator=ActionCoordinator(svc, user_id="u1")) + + with pytest.raises(RuntimeError, match="refusing to guess"): await executor.handle("msg", _FakeSeatCtx())