Supervisor-Agent --- A2A ↔ A2UI 透传设计(9_a2a_a2ui_pass_through.md)
输入 :<3_agent_design.md> §3.6 / §3.10 / §7 / §9、<6_interface_tool_design.md> §2、
src/nodes/dispatcher.py、src/agent/orchestrator.py、src/a2ui/render.py、src/models.py、src/graph.py、领域 Agentagent-canteen/agent/src/a2a/a2a.py+a2ui/contracts.py方法 :跨模式(workflow / agent)协议契约审计 + 透传链路设计
状态 :v1(设计稿,未实现;实现须先走
openspec-propose,见 §8)口径 :本文档专门解决「supervisor 经 A2A 调下游领域 Agent 时,如何完整接收并向前端透传 A2UI 卡片数据(
card_type+card_data)」。双模(workflow / agent)都必须考虑------这是跨模式的一致契约,不允许只在 agent 模式透传、workflow 模式静默丢弃。
1. 背景
1.1. A2UI 卡片透传契约(领域 Agent 侧已实现)
领域 Agent(以 agent-canteen 为参考实现)不直接发射 A2UI operations,而是把 card_type 标签 + card_data 结构化数据塞进 A2A JSON-RPC 响应的 task.metadata,交由 supervisor 按 card_type 在本地 schema catalog 中查 schema 并渲染。
参考 agent-canteen src/a2a/a2a.py:51-76:
python
def _build_task_response(session_id, text, *, card_type=None, card_data=None, ...):
metadata = {}
if card_type:
metadata["card_type"] = card_type
metadata["card_data"] = card_data or {"content": text}
return {"task": {"id": ..., "status": {...}, "artifacts": [], "metadata": metadata}}
A2A 响应规范形:
json
{
"result": {
"task": {
"id": "...",
"status": {"state": "completed", "message": {"parts": [{"text": "..."}]}},
"metadata": {
"card_type": "canteen_menu",
"card_data": {"canteen": "一食堂", "dishes": [...]}
}
}
}
}
契约要点:
card_type与 supervisorsrc/a2ui/schemas/<card_type>.json文件名 stem 一一对应card_data形状由领域 Agent 的src/a2ui/contracts.py定义(Pydantic 模型),与 schema 的dataModel路径绑定对齐card_type未设置 → 无卡片,supervisor 回退纯文本- 兜底反模式 :当领域 Agent 的
_resolve_card_data失败(工具缺失/异常/无映射),card_data退化为{"content": text}但card_type仍然设置 → 这是已知的契约裂缝(见 §5.1)
1.2. supervisor 侧的发射通道(CopilotKit / ag-ui-langgraph)
A2UI v0.9 的发射机制(来自 .venv/.../copilotkit/a2ui.py:79-95 + ag_ui_a2ui_toolkit/__init__.py:323-367):
-
a2ui.render(operations)把createSurface/updateComponents/updateDataModel序列化为 JSON 串:json{"a2ui_operations": [...]} -
该 JSON 串作为 ToolMessage 的 content 进入
state["messages"] -
前端 A2UI renderer 反向遍历 messages,只对 role=tool 的消息解析
a2ui_operations键,按 surfaceId 累积最新状态后渲染
关键约束 :a2ui_operations 必须出现在 ToolMessage content 中才会被前端识别。AIMessage content 里的同名键不会被渲染。
2. 现状审计
2.1. Agent 模式(src/agent/orchestrator.py):已透传,但有缺陷
接收侧 (orchestrator.py:152-162)✅:
python
task = data.get("result", {}).get("task", {}) or {}
parts = task.get("status", {}).get("message", {}).get("parts", []) or []
text = " ".join(p.get("text", "") for p in parts if isinstance(p, dict)) or "(empty response)"
meta = task.get("metadata", {}) or {}
return DomainReply(text=text, card_type=meta.get("card_type"), card_data=meta.get("card_data"))
发射侧 (单 agent,orchestrator.py:196-204)✅:
python
if reply.card_type and reply.card_data:
ops = render_card(reply.card_type, reply.card_data)
if ops is not None:
return ops # → ToolMessage content = {"a2ui_operations": [...]}
已知缺陷:
| # | 缺陷 | 位置 | 后果 |
|---|---|---|---|
| D-A1 | {"content": text} 兜底未过滤 |
orchestrator.py:198 | 领域 Agent 工具失败时仍走 render_card,schema 期望 zones/dishes,实际拿到 {"content": "..."} → 前端空卡片 / 绑定失败 |
| D-A2 | 并行同 card_type 多次 → surfaceId 冲突 | render.py:32 sid = f"card-{card_type}" |
第二张 canteen_menu 覆盖第一张 |
| D-A3 | 并行路径手工拼 envelope | orchestrator.py:281 json.dumps({"a2ui_operations": ..., "summary": ...}) |
多了 summary 字段,envelope 形状与 a2ui.render() 原生不同;前端按 parsed.get("a2ui_operations") 取键,键存在则可识别,但 schema 校验若严格则可能拒收 |
| D-A4 | 单 agent 路径依赖 render_card 返回 JSON 串被 CopilotKitMiddleware 包装为 ToolMessage |
orchestrator.py:200-201 | 实测可工作(@tool 返回值自动包 ToolMessage),但与并行路径的 envelope 形状不一致 → 双轨 |
2.2. Workflow 模式(src/graph.py + src/nodes/dispatcher.py):完全未透传 ❌
接收侧 (dispatcher.py:74-83)❌:
python
resp = await registry.a2a().send(url, msg, headers=headers or None)
raw = (resp.raw if resp else {}) or {}
parts = raw.get("task", {}).get("status", {}).get("message", {}).get("parts", [])
texts = [p.get("text", "") for p in parts if isinstance(p, dict)]
return " ".join(t for t in texts if t) or "(empty response)" # ← 纯 str,metadata 被丢弃
dispatch() 返回类型是 str,task.metadata.card_type / card_data 在这一步直接丢失。
累积侧 (graph.py:78-86)❌:
python
result = await dispatch(...) # str
results.append(result) # list[str]
state["results"] = results
SupervisorState.results: list[str](models.py:20)结构上没有放卡片元信息的位置。
发射侧 (graph.py:92-126)❌:
python
response = "\n".join(results) # 拼接纯文本
# LLM 改写为"一句话中文回复"
resp = await llm_chat.chat.completions.create(model=llm_model, messages=[...])
response = resp.choices[0].message.content
msgs.append(AIMessage(content=response)) # ← AIMessage,前端不识别 a2ui_operations
_respond 做了三件让卡片彻底消失的事:
- 拼接 ------ 多 Agent 文本拼成一坨
- LLM 二次改写 ------ 结构化数据(菜单/热力图/推荐)被压缩成自然语言
- 只发 AIMessage ------ 没有 ToolMessage,前端 A2UI renderer 不会扫描
结论 :workflow 模式下,无论领域 Agent 是否返回 card_type / card_data,前端永远拿不到卡片。这是当前最严重的链路缺口。
3. 设计目标
| 目标 | 说明 |
|---|---|
| G1 双模一致 | workflow 与 agent 模式都能完整透传 A2UI 卡片,行为可预期 |
| G2 契约单一 | 双模共用同一 DispatchResult 类型与同一 render_card 发射逻辑(agent 模式 DomainReply 合并到 DispatchResult) |
| G3 失败显式 | {"content": text} 兜底、schema 未命中、A2A 异常都有显式回退路径,不产生空卡片 |
| G4 surfaceId 稳定 | 多步 / 并行场景下 surfaceId 不冲突,同 card_type 多次出现可区分 |
| G5 不破坏现有行为 | reflector 仍读 state["results"]: list[str];旧测试不破 |
| G6 可观测 | 每次卡片发射 / 丢弃有 Langfuse trace(card_type / 是否回退 / 原因) |
4. A2A ↔ A2UI 契约定义(双模共享)
4.1. 接收契约
supervisor 接收领域 Agent 的 A2A 响应时,必须 从 result.task 中提取:
| 字段 | 路径 | 用途 |
|---|---|---|
text |
task.status.message.parts[].text 拼接 |
自然语言回复(CopilotChat 渲染) |
card_type |
task.metadata.card_type |
卡片类型,对应 src/a2ui/schemas/<card_type>.json |
card_data |
task.metadata.card_data |
卡片数据,形状由领域 Agent a2ui/contracts.py 定义 |
trace_id |
task.metadata.trace_id(可选) |
领域 Agent 的 Langfuse trace,跨 Agent 串联 |
提取结果统一封装为 DispatchResult(见 §5.1)。
4.2. 发射契约
supervisor 向前端发射 A2UI 卡片时,必须满足:
- 载体 :
ToolMessage,其content是 JSON 串{"a2ui_operations": [...]} - operations 来源 :
a2ui.render([create_surface, update_components, update_data_model])的原生返回值 - schema 解析 :
load_card_schema(card_type);未命中 → 不发射卡片,回退文本 - 兜底过滤 :
card_data == {"content": text}视为「无结构化数据」,不发射卡片(见 §5.3) - surfaceId 唯一:同一次图执行内,每张卡的 surfaceId 全局唯一(见 §5.4)
4.3. 错误回退矩阵
| 场景 | 回退行为 | 前端可见 |
|---|---|---|
card_type 缺失 |
纯文本(领域 Agent 文本) | 文本气泡 |
card_type 存在但 schema 未命中 |
[card_type={ct}]\n{text} 标记 + 文本 |
文本气泡(带类型标签) |
card_data == {"content": text} 兜底 |
纯文本 | 文本气泡 |
| A2A 调用失败 | A2A call to '{name}' failed: {exc} |
文本气泡(错误提示) |
| render_card 内部异常 | catch 后回退纯文本 + Langfuse warning | 文本气泡 |
5. 设计
5.1. 数据模型:扩展 DispatchResult(不新增类型)
models.py:27-33 已有的 DispatchResult 是 dispatcher → reflector 的结构化结果载体。本设计扩展 它加 4 个字段,承载 A2UI 透传与补偿式重规划所需的元信息,避免新增 DomainReply 造成双轨:
python
class DispatchResult(TypedDict, total=False):
agent: str
skill: str
group: int
ok: bool
content: str
error: str
card_type: str | None # 新增:卡片类型(对应 schemas/<card_type>.json)
card_data: dict | None # 新增:卡片数据(领域 Agent a2ui/contracts.py 定义形状)
trace_id: str | None # 新增:领域 Agent Langfuse trace(跨 Agent 串联)
round: int # 新增:所属重规划轮次(首轮=0,第一次 replan 后=1,依此类推);reflector replan 时不清空 dispatch_results,加 round 标记区分历史轮次,供 planner 第二轮做差集补偿
约束:
content永远非空(失败时填错误描述,与error同时设置)card_type与card_data同时出现或同时缺失ok=False时card_type必为 Nonecard_data == {"content": <text>}视为兜底,发射阶段过滤(见 §5.3.3 / §5.5)round默认 0;replan 时新结果round = replan_count(执行时的值),旧结果保留原round不变
为什么不新建 DomainReply :DispatchResult 已有 agent / content / error 字段覆盖 DomainReply 的 text / error 语义;dispatch_results: list[DispatchResult] state 字段也已存在且 reflector 已优先读(见 §5.2)。新建类型会造成「results + dispatch_results + domain_replies」三写并存,违反宪法 §4 极简。
5.2. SupervisorState 无新增字段(复用 dispatch_results)
models.py:36-54 已有 dispatch_results: list[DispatchResult] 字段。本设计零新增 state 字段 ------DispatchResult 扩展后,dispatch_results 自然承载卡片信息,_respond 直接读它。
reflector.py:79-84 已经优先读 dispatch_results(只有它为 None 时才退回 results),所以本设计不改变 reflector 行为。
results: list[str] 的去留 :graph.py:78-86 当前同时写 results 与(本应写但未写的)dispatch_results。本设计让 graph.py 改写 dispatch_results 后,results 退化为「无 dispatch_results 时的降级兜底」,先保留不删 (避免一次改太多;后续可单独提 change 清理 results)。
5.3. Workflow 模式改造
5.3.1. 预存不一致(先标注,本设计顺手对齐)
graph.py:72-86 的内联 _dispatch_step 当前直接调 dispatch() 存 str 到 results ,完全没用 dispatcher.py:89-117 已有的 _dispatch_step / dispatch_group(它们已经产生 DispatchResult)。这是预存的技术债------本设计顺手对齐:graph.py 改用 dispatcher._dispatch_step,DispatchResult 进 dispatch_results,results 退化为降级兜底。
5.3.2. dispatch() 返回结构化结果
src/nodes/dispatcher.py:20-86 当前返回 str,需改造为返回带卡片元信息的结构化对象。两种实现路径(任选其一,实现时定):
路径 1 :dispatch() 直接返回 DispatchResult
python
async def dispatch(registry, *, message, agent_name="", skill="") -> DispatchResult:
...
try:
resp = await registry.a2a().send(url, msg, headers=headers or None)
raw = (resp.raw if resp else {}) or {}
parts = raw.get("task", {}).get("status", {}).get("message", {}).get("parts", [])
text = " ".join(p.get("text", "") for p in parts if isinstance(p, dict)) or "(empty response)"
meta = raw.get("metadata", {}) or {}
return DispatchResult(
agent=agent_name, skill=skill, ok=True, content=text,
card_type=meta.get("card_type"), card_data=meta.get("card_data"),
trace_id=meta.get("trace_id"),
)
except Exception as exc:
logger.warning("A2A dispatch to %s failed: %s", target.get("name"), exc)
return DispatchResult(agent=agent_name, skill=skill, ok=False,
content=f"A2A call to '{target.get('name')}' failed: {exc}",
error=str(exc))
路径 2 :dispatch() 保持返回 str,在 dispatcher.py:89-111 的 _dispatch_step 包装层提取 metadata(需要 dispatch() 内部把 raw 暴露出来,或在 _dispatch_step 内直接调 registry.a2a().send)
推荐路径 1 ------单点改造,_dispatch_step 包装层无需重复 A2A 调用逻辑。
5.3.3. graph.py::_dispatch_step 改用 dispatcher._dispatch_step
graph.py:72-86 改造:
python
async def _dispatch_step(state: SupervisorState) -> SupervisorState:
plan = state.get("plan", [])
idx = state.get("step_index", 0)
if idx >= len(plan):
return state
step = plan[idx]
# 复用 dispatcher._dispatch_step,产生 DispatchResult(含 card_type/card_data)
result = await dispatcher_dispatch_step(registry, step)
dispatch_results = list(state.get("dispatch_results", []))
dispatch_results.append(result)
state["dispatch_results"] = dispatch_results
# results 退化降级兜底(reflector 优先读 dispatch_results,见 §5.2)
results = list(state.get("results", []))
results.append(result.get("content", ""))
state["results"] = results
state["step_index"] = idx + 1
return state
(dispatcher_dispatch_step 即 dispatcher.py:89 的 _dispatch_step,import 时按需重命名避免与 graph.py 内的同名节点函数冲突)
5.3.4. _respond 从 dispatch_results 读卡片并发射 A2UI
graph.py:91-126 改造:
python
import uuid # 顶部 import(graph.py 已有 json / logging)
@observe(name="supervisor.respond")
async def _respond(state: SupervisorState) -> SupervisorState:
query = state.get("query", "")
dispatch_results = state.get("dispatch_results", [])
results = state.get("results", [])
response = state.get("response", "")
# 1. 收集 a2ui_operations(UUID 保证 surfaceId 跨 turn 唯一,见 §5.4)
a2ui_ops: list[dict] = []
fallback_texts: list[str] = []
for r in dispatch_results:
ct, cd = r.get("card_type"), r.get("card_data")
text = r.get("content", "")
if ct and cd and cd != {"content": text}:
sid = f"card-{ct}-{uuid.uuid4().hex[:8]}"
ops = render_card(ct, cd, surface_id=sid)
if ops is not None:
a2ui_ops.extend(json.loads(ops)["a2ui_operations"])
else:
fallback_texts.append(f"[card_type={ct}]\n{text}")
else:
fallback_texts.append(text)
# 2. 拼接非卡片文本作为 LLM 输入
non_card_text = "\n".join(fallback_texts) or "\n".join(results) or "(暂无可用结果)"
# 3. LLM 改写(仅非卡片部分)
if llm_chat is not None:
try:
prompt = f"用户问题:{query}\n识别意图:{state.get('intent','未知')}\n执行结果:{non_card_text}\n请用中文简要回复用户(一句话即可)。"
resp = await llm_chat.chat.completions.create(model=llm_model, messages=[{"role":"user","content":prompt}])
response = resp.choices[0].message.content
except Exception as exc:
logger.warning("LLM respond failed: %s", exc)
response = non_card_text
else:
response = non_card_text
state["response"] = response
msgs = list(state.get("messages", []))
msgs.append(AIMessage(content=response))
# 4. 发射 A2UI:作为 ToolMessage 追加(前端 renderer 只扫 tool role,见 §1.2)
if a2ui_ops:
from langchain_core.messages import ToolMessage
msgs.append(ToolMessage(
content=json.dumps({"a2ui_operations": a2ui_ops}, ensure_ascii=False),
tool_call_id=f"a2ui-{uuid.uuid4().hex[:8]}",
))
state["messages"] = msgs
return state
关键设计决策:
- A2UI ops 作为 ToolMessage 追加到
messages,而非塞进 AIMessage content(前端 renderer 只扫 tool role,见 §1.2) - LLM 只改写非卡片文本,卡片数据原样下发,避免结构化信息被压缩
- surfaceId 用
card-{card_type}-{uuid4().hex[:8]}保证跨 turn 唯一(见 §5.4),无需 state 字段计数器(enumerate 索引跨 turn 会撞车,见 §5.4)
5.3.4. 紧急快通道与澄清分支
_emergency 与 needs_clarification 分支直接进 _respond,此时 dispatch_results 为空 → _respond 退化为纯文本输出 + 编排级卡片(emergency / clarify card,见 3_agent_design.md §7.2)。不冲突。
5.4. surfaceId 唯一性
规则 :card-{card_type}-{uuid4().hex[:8]} ------ UUID 后缀保证全局唯一,跨 turn 不撞车。
| 场景 | 旧规则(已否决) | 新规则 |
|---|---|---|
| 单 turn 单卡 | card-{card_type} |
card-{card_type}-{uuid} |
| 单 turn 多步同 card_type | card-{card_type}-{enumerate索引} |
card-{card_type}-{uuid}(每张独立) |
| 跨 turn 同 card_type | card-{card_type}-{i}(i 每 turn 从 0 起,撞车) |
card-{card_type}-{uuid}(永不撞车) |
为什么不用 enumerate 索引 :跨 turn 时 dispatch_results 被 reflector 重置、step_index 被 planner 重置,索引每 turn 从 0 起步。Turn 1 的 card-canteen_menu-0 与 Turn 2 的 card-canteen_menu-0 撞车,前端 find_prior_surface 把后者解释为对前者的更新而非新建,导致 Turn 1 的卡被覆盖。
为什么不用 thread_id + msg_seq:thread_id 已在 state,但 msg_seq 需要额外维护;UUID 实现成本最低且无状态。
牺牲的能力 :无法主动「刷新已存在的卡」(用户说"再查一次午餐菜单"时,会新建一张卡而非刷新旧卡)。此交互本设计未要求,后续需要时升级为方案 C(reflector 读 messages 取旧 surfaceId)。
render_card 增加可选 surface_id 参数(render.py:27 已有签名,调用方传入即可)。
5.5. Agent 模式硬化
agent 模式已有透传,本次补齐缺陷:
| 缺陷 | 修复 |
|---|---|
D-A1 {"content": text} 兜底 |
orchestrator.py:198 增加判断:cd != {"content": reply.text} 才走 render_card,否则回退文本 |
| D-A2 surfaceId 冲突 | orchestrator.py:274 并行路径改用 render_card(ct, cd, surface_id=f"card-{ct}-{uuid4().hex[:8]}")(见 §5.4) |
| D-A3 手工 envelope | orchestrator.py:281 改为:a2ui.render(operations=card_ops) 原生返回,不再手工拼 summary 字段;summary 通过 ToolMessage 之外的 AIMessage 承载 |
| D-A4 双轨 envelope | D-A3 修复后,单 agent 与并行路径统一走 a2ui.render() |
| D-A5 类型双轨 | orchestrator.py:94-98 的 DomainReply 合并到 DispatchResult(字段一一对应:text→content、error 已有、card_type/card_data/trace_id 已扩展),消除「supervisor 内部两套等价类型」 |
5.6. 类型与配置注入合规
按 AGENTS.md §0.3 配置注入边界 + 明确类型红线:
DispatchResult所有字段有明确类型,无Anydispatch()签名不变(仍接收registry+ keyword args),返回类型从str改为DispatchResult(签名变更,需 mypy 全量校验)_respond内部llm_chat/llm_model来自工厂层注入,符合「业务函数不接收配置对象」
6. 与现有文档的对齐
6.1. <3_agent_design.md> 需要同步更新
| 章节 | 更新内容 |
|---|---|
| §1.1 全局状态 Schema | DispatchResult 行补 card_type / card_data / trace_id 三个字段;dispatch_results 行说明改为「reflector 优先读;respond 也读(卡片透传)」 |
| §3.6 dispatch_step | A2A 调用第 5 步补「从 task.metadata 提取 card_type / card_data / trace_id,封装进 DispatchResult」;标注 graph.py::_dispatch_step 改用 dispatcher._dispatch_step(对齐预存不一致) |
| §3.8 respond | 新增「收集 dispatch_results 中带 card_type+card_data 的项 → render_card → 追加 ToolMessage(content={"a2ui_operations":[...]})」;明确 LLM 只改写非卡片文本 |
| §7 A2UI Card 设计 | §7.1 改为「supervisor 既渲染编排兜底卡片,也透传领域 Agent 卡片」;新增 §7.3「领域卡片透传契约」引用本文档 |
| §9.1 workflow 状态机 | responding 节点写入字段补 messages 中的 ToolMessage;dispatching 节点写入字段补 dispatch_results |
6.2. <6_interface_tool_design.md> 需要同步更新
§2 A2A 出站调用响应解析部分,补 task.metadata.card_type / card_data / trace_id 提取说明。
7. 测试与验收
7.1. 单测(确定性)
| 测试 | 覆盖点 |
|---|---|
test_dispatch_extracts_card_metadata |
dispatcher 从 mock A2A 响应正确提取 card_type / card_data / trace_id 到 DispatchResult |
test_dispatch_a2a_failure_returns_error_result |
A2A 异常时返回 DispatchResult(ok=False, error=...),不抛 |
test_dispatch_no_endpoint_returns_error_result |
无 agent / 无 endpoint 时返回 ok=False 的 DispatchResult |
test_dispatch_step_writes_dispatch_results |
graph.py::_dispatch_step 把 DispatchResult 追加到 dispatch_results(顺带验证预存不一致已对齐) |
test_respond_emits_tool_message_with_a2ui_ops |
_respond 在有 card_type+card_data 时追加 ToolMessage,content 含 a2ui_operations |
test_respond_filters_content_fallback |
card_data == {"content": text} 时不发射卡片,回退文本 |
test_respond_surface_id_unique_multi_step |
单 turn 多步同 card_type 时 surfaceId 不冲突(用 UUID) |
test_respond_surface_id_unique_across_turns |
跨 turn 同 card_type 时 surfaceId 不撞车(mock 两 turn,验证 UUID 不同) |
test_respond_emergency_no_card |
紧急快通道不发射领域卡片 |
test_orchestrator_parallel_surface_id_unique |
agent 模式并行同 card_type 多次时 surfaceId 不冲突 |
test_orchestrator_parallel_envelope_native |
并行路径 envelope 为 a2ui.render() 原生形,无 summary 字段 |
test_orchestrator_uses_dispatch_result_type |
agent 模式 DomainReply 已合并到 DispatchResult,无残留双轨 |
7.2. 契约测试
| 测试 | 覆盖点 |
|---|---|
test_a2a_response_contract |
对接 agent-canteen 真实响应,验证 task.metadata.card_type / card_data 解析 |
test_card_data_shape_matches_schema |
每个 card_type 的 card_data 形状与 schemas/<card_type>.json 的 dataModel 路径绑定一致 |
7.3. Eval(非确定性,AI 轨)
按 constitution §3,A2UI 透传本身是确定性的(无 LLM 介入),但 _respond 的 LLM 改写是非确定的。Eval 覆盖:
| Eval | 指标 | 阈值 |
|---|---|---|
evals/a2ui_pass_through/ |
卡片发射成功率(有 card_type 时前端实际渲染卡片的比例) | ≥ 95% |
evals/a2ui_pass_through/ |
非卡片文本保真度(LLM 改写后关键信息未丢失) | ≥ 90% |
7.4. 完成信号(AGENTS.md §4)
uv run pytest -q退出码 0uv run ruff check src tests && uv run mypy src退出码 0uv run python scripts/validate_a2a_contract.py退出码 0(若 script 已初始化)- 手动验证:workflow 模式调
agent-canteen「午餐菜单」→ 前端实际渲染canteen_menu卡片(非纯文本)
8. 实施计划
8.1. 流程约束
本设计改了 SupervisorState 契约 + dispatch() 返回签名 + _respond 行为 ,按 constitution §5 / §8 + AGENTS.md §7.4:
openspec-propose → 人类评审 → openspec-apply-change(TDD)
→ verify(§7.4 完成信号)
→ openspec-sync-specs
→ openspec-archive-change
禁止直接改代码。
8.2. change 拆分建议
建议拆为 2 个独立 change,避免一次 PR 过大:
change-1: workflow 模式 A2UI 透传 + DispatchResult 扩展
- specs delta:
supervisor-state(DispatchResult字段扩展;无新增 state 字段) - 涉及文件:
src/models.py/src/nodes/dispatcher.py/src/graph.py - 顺带对齐:graph.py::_dispatch_step 改用 dispatcher._dispatch_step(预存不一致)
- 验收:§7.1 前 8 项单测 + 手动验证
change-2: agent 模式 A2UI 硬化 + DomainReply 合并
- specs delta: 无 state 契约变更(
DomainReply→DispatchResult是内部类型合并,不涉 state) - 涉及文件:
src/agent/orchestrator.py - 验收:§7.1 后 3 项单测
8.3. 风险
| 风险 | 缓解 |
|---|---|
ToolMessage 追加进 messages 后,reflector 重规划时 LLM 可能误读 |
reflector 不读 messages,只读 state["results"] / dispatch_results(已验证),无影响 |
dispatch_results 与 results 双写不一致 |
单测 test_dispatch_step_writes_dispatch_results 校验 dispatch_results[i].content == results[i] |
| 前端 A2UI renderer 不识别 workflow 模式发射的 ToolMessage | 手动验证(§7.4 第 4 项);若不识别,回退方案:用 copilotkit state 字段透传(待验证 LangGraphAGUIAgent 是否扫描) |
| LLM 改写非卡片文本时丢失关键信息 | §7.3 Eval 阈值卡控 |
DomainReply 合并到 DispatchResult 影响 orchestrator 现有调用方 |
字段一一对应(text→content),mypy 全量校验 + 单测覆盖 |
9. 待验证项
| # | 待验证 | 验证方式 | 影响 |
|---|---|---|---|
| Q1 | LangGraphAGUIAgent 是否扫描 messages 中的 ToolMessage 并转发 a2ui_operations 给前端 |
手动跑 workflow 模式 + 浏览器 DevTools 看 SSE 事件 | 决定 §5.3.4 是否成立,若不成立需走 copilotkit state 字段通道 |
| Q2 | agent-canteen 之外的其他领域 Agent(transport / meeting / ...)是否也按 task.metadata.card_type/card_data 契约返回 |
跑 scripts/validate_a2a_contract.py + 抓包 |
决定契约是否需要在本设计文档外推广到所有领域 Agent |
| Q3 | render_card 在 schema 未命中时返回 None,调用方回退文本------是否需要 Langfuse 显式埋点 |
检查现有 render_card 实现 |
影响 §3 G6 可观测达标 |
| Q4 | 前端收到新 surfaceId(UUID)时是否真的新建卡,而非复用历史 surfaceId 卡片 | 手动跑两 turn 同 card_type 查询,DevTools 看 SSE 事件 / DOM | 验证 §5.4 UUID 方案确实避免了跨 turn 覆盖 |
10. 变更记录
| 版本 | 日期 | 变更 |
|---|---|---|
| v1 | 2026-08-15 | 初稿:双模 A2A ↔ A2UI 透传设计,识别 workflow 模式缺口与 agent 模式 4 项缺陷 |
| v2 | 2026-08-15 | 改方案 A:砍掉 DomainReply / domain_replies / card_surface_seq 三个新增,改为扩展 DispatchResult + 复用 dispatch_results;新增 D-A5 类型双轨合并;标注 graph.py::_dispatch_step 预存不一致 |
| v3 | 2026-08-15 | surfaceId 改用 UUID(card-{card_type}-{uuid4().hex[:8]}),修复 enumerate 索引跨 turn 撞车缺陷;新增 §5.4 跨 turn 唯一性论证;新增 Q4 前端新建卡验证项 |