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

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

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

No files matched your search

@@ -0,0 +1,301 @@
/**
* 把 MAF 原始事件流(按 seq 升序)折叠成时间线可读条目。
*
* 后端事件分类见 app/maf_roundtable/event_normalizer.py:
* agent.message.delta —— 流式 token 片段,需聚合为一条消息
* agent.message.completed —— 一轮完整回复(含 content)
* tool.* —— 工具生命周期,折叠为单条「调用 → 结果」
* action.waiting_approval —— 审批锚点(右侧审批卡片入口)
* run.* / skill.* —— 状态/生命周期条目
*
* 设计:**纯函数**,无 React、无副作用,便于单测。输入 events[](已去重升序),
* 输出 TimelineEntry[](时间线渲染顺序)。
*/
import type { MafEvent } from "../api/types";
export type TimelineEntryKind =
| "message" // 智能体消息(delta 聚合 或 completed)
| "tool" // 工具调用生命周期(requested→completed/failed 折叠)
| "skill" // 技能生命周期
| "approval" // 审批锚点(waiting/approved/rejected)
| "artifact" // 产出物
| "run" // run 生命周期(started/completed/failed/cancelled)
| "checkpoint" // 工作流检查点
| "turn"; // 席位轮次开始
export interface TimelineEntry {
/** 用于 React key,全局唯一。 */
key: string;
kind: TimelineEntryKind;
/** 原始 event_type(供组件按类型配色/图标)。 */
eventType: string;
/** 触发该条目的 agent_id(席位/智能体标识)。 */
agentId: string | null;
/** 轮次索引(便于分组)。 */
turnIndex: number | null;
seq: number;
createdAt: string | null;
/** 主标题(工具名 / 状态文案 / 席位名)。 */
title: string;
/** 正文(消息内容 / 工具结果摘要 / 错误信息)。 */
body?: string;
/** 关联的二级信息(action_id/approval_id/tool_call_id 等)。 */
meta?: Record<string, unknown> | null;
}
/** delta 聚合的分组键:(agent_id, turn_index) —— 同一席位同一轮的 delta 拼成一条。 */
function deltaGroupKey(ev: MafEvent): string {
return `delta:${ev.agent_id ?? ""}:${ev.turn_index ?? 0}`;
}
/**
* 折叠事件流为时间线条目。
*
* 聚合规则:
* 1. `agent.message.delta` 按 (agent,turn) 分组拼接 → 一条 message(取最大 seq 为排序键)。
* 若同组有 `agent.message.completed`,则用 completed 的完整 content 覆盖。
* 2. `tool.requested`/`tool.started`/`tool.completed`/`tool.failed` 按 tool_call_id 折叠
* 为一条 tool 条目(最终状态 + 名称)。
* 3. 其余每事件一条。
*/
export function foldEventsToTimeline(events: MafEvent[]): TimelineEntry[] {
const entries: TimelineEntry[] = [];
// delta 分组缓冲:groupKey → { texts[], lastSeq, agent, turn, createdAt }
const deltaBuf = new Map<
string,
{ texts: string[]; seq: number; ev: MafEvent; completed?: MafEvent }
>();
// tool 折叠:tool_call_id → entry(先建占位,后续更新)
const toolMap = new Map<string, TimelineEntry>();
for (const ev of events) {
const p = ev.payload ?? {};
switch (ev.event_type) {
case "agent.message.delta": {
const text = typeof p.text === "string" ? p.text : "";
if (!text) break;
const k = deltaGroupKey(ev);
const cur = deltaBuf.get(k);
if (cur) {
cur.texts.push(text);
if (ev.seq > cur.seq) {
cur.seq = ev.seq;
cur.ev = ev;
}
} else {
deltaBuf.set(k, { texts: [text], seq: ev.seq, ev });
}
break;
}
case "agent.message.completed": {
const k = deltaGroupKey(ev);
const cur = deltaBuf.get(k);
const content = typeof p.content === "string" ? p.content : "";
if (cur) {
cur.completed = ev;
if (content) cur.texts = [content]; // completed 的完整内容覆盖 delta 拼接
if (ev.seq > cur.seq) cur.seq = ev.seq;
} else {
deltaBuf.set(k, {
texts: content ? [content] : [],
seq: ev.seq,
ev,
completed: ev,
});
}
break;
}
case "tool.requested":
case "tool.started":
case "tool.completed":
case "tool.failed": {
const tcId = typeof p.tool_call_id === "string" ? p.tool_call_id : "";
const toolName = typeof p.tool_name === "string" ? p.tool_name : "";
const mapKey = tcId || `${ev.event_type}:${ev.seq}`;
const status =
ev.event_type === "tool.failed"
? "failed"
: ev.event_type === "tool.completed"
? "completed"
: ev.event_type === "tool.started"
? "started"
: "requested";
const existing = toolMap.get(mapKey);
if (existing) {
// 更新到最新状态(seq 更大者覆盖)
if (ev.seq >= existing.seq) {
existing.seq = ev.seq;
existing.title = toolName || existing.title;
existing.body =
typeof p.content_preview === "string"
? p.content_preview
: existing.body;
existing.meta = { ...existing.meta, ...p, status };
}
} else {
toolMap.set(mapKey, {
key: `tool:${mapKey}`,
kind: "tool",
eventType: ev.event_type,
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq: ev.seq,
createdAt: ev.created_at,
title: toolName || "工具调用",
body: typeof p.content_preview === "string" ? p.content_preview : undefined,
meta: { ...p, status },
});
}
break;
}
default: {
// 其它事件一一映射(含 approval/run/skill/artifact/checkpoint/turn)。
const entry = mapSingleEvent(ev);
if (entry) entries.push(entry);
}
}
}
// delta 缓冲落盘
for (const { texts, seq, ev, completed } of deltaBuf.values()) {
entries.push({
key: `msg:${deltaGroupKey(ev)}`,
kind: "message",
eventType: completed ? "agent.message.completed" : "agent.message.delta",
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq,
createdAt: ev.created_at,
title: ev.agent_id ?? "智能体",
body: texts.join("").trim() || undefined,
meta: completed?.payload,
});
}
// tool 折叠落盘
for (const entry of toolMap.values()) entries.push(entry);
// 按 seq 升序(聚合条目用其最大 seq,自然排到出现时刻之后)
entries.sort((a, b) => a.seq - b.seq);
return entries;
}
/** 把单个非聚合事件映射成 TimelineEntry。 */
function mapSingleEvent(ev: MafEvent): TimelineEntry | null {
const p = ev.payload ?? {};
const base = {
key: `${ev.event_type}:${ev.id}`,
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq: ev.seq,
createdAt: ev.created_at,
};
switch (ev.event_type) {
case "agent.turn_started":
return {
...base,
kind: "turn",
eventType: ev.event_type,
title: `${ev.agent_id ?? "席位"} 开始第 ${(ev.turn_index ?? 0) + 1} 轮`,
meta: p,
};
case "run.started":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商开始", meta: p };
case "run.completed":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商完成", meta: p };
case "run.failed":
return {
...base,
kind: "run",
eventType: ev.event_type,
title: "会商失败",
body: typeof p.message === "string" ? p.message : undefined,
meta: p,
};
case "run.cancelled":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商已取消", meta: p };
case "skill.selected":
case "skill.started":
case "skill.completed":
case "skill.failed": {
const skillName = typeof p.skill_name === "string" ? p.skill_name : "技能";
const phase = ev.event_type.split(".")[1];
const labelMap: Record<string, string> = {
selected: "选择",
started: "开始",
completed: "完成",
failed: "失败",
};
return {
...base,
kind: "skill",
eventType: ev.event_type,
title: `技能「${skillName}」${labelMap[phase] ?? phase}`,
body:
typeof p.result_preview === "string"
? p.result_preview
: typeof p.error === "string"
? p.error
: undefined,
meta: p,
};
}
case "artifact.created":
return {
...base,
kind: "artifact",
eventType: ev.event_type,
title: `产出:${typeof p.title === "string" ? p.title : typeof p.artifact_type === "string" ? p.artifact_type : "产物"}`,
meta: p,
};
case "action.waiting_approval":
case "action.approved":
case "action.rejected": {
const phase = ev.event_type.split(".")[1];
const labelMap: Record<string, string> = {
waiting_approval: "等待审批",
approved: "已批准",
rejected: "已拒绝",
};
return {
...base,
kind: "approval",
eventType: ev.event_type,
title: `写工具「${typeof p.tool_name === "string" ? p.tool_name : "未知"}」${labelMap[phase] ?? phase}`,
body: typeof p.summary === "string" ? p.summary : undefined,
meta: p,
};
}
case "workflow.checkpointed":
return {
...base,
kind: "checkpoint",
eventType: ev.event_type,
title: "工作流已检查点",
body: typeof p.reason === "string" ? p.reason : undefined,
meta: p,
};
default:
// 兜底:未知事件类型仍展示,避免遗漏。
return {
...base,
kind: "run",
eventType: ev.event_type,
title: ev.event_type,
meta: p,
};
}
}
/** 时间线条目的人类可读时间(HH:MM:SS)。null/无效返回空串。 */
export function shortTime(iso: string | null): string {
if (!iso) return "";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return "";
}
}
@@ -0,0 +1,139 @@
/**
* 把 MAF 事实账本 + 末轮结论转成 FlowGraphPanel 可渲染的 FlowGraphData。
*
* 两个数据源,优先级合并:
* 1. `final_result.content` 里的 ```flow-json``` 块(次源,结论流程图)—— 若 final_result
* 含 flow-json,用现成的 extractFlowGraph 解析,直接作为「结论流程图」返回(它已是
* 模型精心编排的层级图,比纯事实更有结构)。
* 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其
* evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable);
* 证据根用 start。
*
* 设计:**纯函数**,输入 checkpoint(宽松类型),输出 FlowGraphData | null。
* null 表示无任何可绘制数据,由组件显示空态。
*/
import {
extractFlowGraph,
type FlowGraphData,
type FlowGraphEdge,
type FlowGraphNode,
} from "@/roundtable-planning/lib/flow-graph";
import type { MafCheckpoint, MafFactLedger } from "../api/types";
/** 截断长文本用于节点 label(detail 保留全文)。 */
function truncate(text: string, max = 28): string {
const t = text.trim().replace(/\s+/g, " ");
return t.length <= max ? t : `${t.slice(0, max)}…`;
}
/**
* 尝试从 final_result.content 解析 flow-json 结论图。
* 有则优先返回(模型编排的结论图比纯事实更有结构)。
*/
export function extractConclusionFlow(checkpoint: MafCheckpoint | null): FlowGraphData | null {
const content = checkpoint?.final_result?.content;
if (!content || typeof content !== "string") return null;
const graph = extractFlowGraph(content);
return graph && graph.nodes.length > 0 ? graph : null;
}
/**
* 把 confirmed_facts + evidence + tool_results 编织成事实证据图。
*
* 布局:
* start(根) ──► evidence/tool(role/deliverable) ──► fact(task)
*
* 每个事实节点 group 标 confirmed_by(席位),detail 是全文 + evidence_refs。
*/
export function buildFactFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
const confirmed = ledger?.confirmed_facts ?? [];
if (confirmed.length === 0) return null;
const nodes: FlowGraphNode[] = [];
const edges: FlowGraphEdge[] = [];
const nodeIds = new Set<string>();
const addNode = (n: FlowGraphNode) => {
if (!nodeIds.has(n.id)) {
nodeIds.add(n.id);
nodes.push(n);
}
};
const addEdge = (from: string, to: string, label?: string) => {
if (from === to) return;
edges.push({ from, to, label });
};
// 证据索引:ref → 来源类型,便于给节点配 type。
const evidenceByText = new Map<string, "evidence" | "tool">();
for (const e of ledger?.evidence ?? []) {
const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : "";
if (ref) evidenceByText.set(ref, "evidence");
}
for (const tr of ledger?.tool_results ?? []) {
if (tr.status !== "completed") continue; // 只有成功的工具算证据
const name = tr.tool_name ?? "";
const turn = tr.turn_index;
const ref =
name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : "";
if (ref) evidenceByText.set(ref, "tool");
}
// 根节点
const ROOT_ID = "root";
addNode({ id: ROOT_ID, label: "已确认事实", type: "start" });
for (const fact of confirmed) {
const factId = `fact:${fact.candidate_id}`;
addNode({
id: factId,
label: truncate(fact.text),
type: "task",
detail: fact.text,
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
});
if ((fact.evidence_refs ?? []).length === 0) {
// 无证据引用的事实:直接连根(理论上 Phase-3 校验后不应出现,兜底)。
addEdge(ROOT_ID, factId);
continue;
}
for (const ref of fact.evidence_refs) {
const refStr = String(ref);
const evKind = evidenceByText.get(refStr) ?? "evidence";
const evId = `ev:${refStr}`;
// 证据节点(role=外部证据 / deliverable=工具产出)
addNode({
id: evId,
label: truncate(refStr.replace(/^tool:/, ""), 24),
type: evKind === "tool" ? "deliverable" : "role",
detail: refStr,
group: evKind === "tool" ? "工具证据" : "外部证据",
});
addEdge(ROOT_ID, evId);
addEdge(evId, factId);
}
}
// 边去重(同 from→to 只留一条)
const seen = new Set<string>();
const dedupEdges = edges.filter((e) => {
const k = `${e.from}->${e.to}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return { title: "事实证据图", nodes, edges: dedupEdges };
}
/**
* 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。
*/
export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
return extractConclusionFlow(checkpoint) ?? buildFactFlowGraph(checkpoint);
}