【无标题】

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.pysrc/agent/orchestrator.pysrc/a2ui/render.pysrc/models.pysrc/graph.py、领域 Agent agent-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 与 supervisor src/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):

  1. a2ui.render(operations)createSurface / updateComponents / updateDataModel 序列化为 JSON 串:

    json 复制代码
    {"a2ui_operations": [...]}
  2. 该 JSON 串作为 ToolMessage 的 content 进入 state["messages"]

  3. 前端 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() 返回类型是 strtask.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 做了三件让卡片彻底消失的事:

  1. 拼接 ------ 多 Agent 文本拼成一坨
  2. LLM 二次改写 ------ 结构化数据(菜单/热力图/推荐)被压缩成自然语言
  3. 只发 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 卡片时,必须满足:

  1. 载体ToolMessage,其 content 是 JSON 串 {"a2ui_operations": [...]}
  2. operations 来源a2ui.render([create_surface, update_components, update_data_model]) 的原生返回值
  3. schema 解析load_card_schema(card_type);未命中 → 不发射卡片,回退文本
  4. 兜底过滤card_data == {"content": text} 视为「无结构化数据」,不发射卡片(见 §5.3)
  5. 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_typecard_data 同时出现或同时缺失
  • ok=Falsecard_type 必为 None
  • card_data == {"content": <text>} 视为兜底,发射阶段过滤(见 §5.3.3 / §5.5)
  • round 默认 0;replan 时新结果 round = replan_count(执行时的值),旧结果保留原 round 不变

为什么不新建 DomainReplyDispatchResult 已有 agent / content / error 字段覆盖 DomainReplytext / 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,DispatchResultdispatch_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_stepdispatcher.py:89_dispatch_step,import 时按需重命名避免与 graph.py 内的同名节点函数冲突)

5.3.4. _responddispatch_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. 紧急快通道与澄清分支

_emergencyneeds_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-98DomainReply 合并到 DispatchResult(字段一一对应:text→content、error 已有、card_type/card_data/trace_id 已扩展),消除「supervisor 内部两套等价类型」

5.6. 类型与配置注入合规

AGENTS.md §0.3 配置注入边界 + 明确类型红线:

  • DispatchResult 所有字段有明确类型,无 Any
  • dispatch() 签名不变(仍接收 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_idDispatchResult
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_typecard_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 退出码 0
  • uv run ruff check src tests && uv run mypy src 退出码 0
  • uv 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 契约变更(DomainReplyDispatchResult 是内部类型合并,不涉 state)
  • 涉及文件:src/agent/orchestrator.py
  • 验收:§7.1 后 3 项单测

8.3. 风险

风险 缓解
ToolMessage 追加进 messages 后,reflector 重规划时 LLM 可能误读 reflector 不读 messages,只读 state["results"] / dispatch_results(已验证),无影响
dispatch_resultsresults 双写不一致 单测 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 前端新建卡验证项
相关推荐
EXI-小洲2 小时前
MacOS 微服务网关双雄:Nacos + Higress 安装与 Dubbo 配置实战
macos·微服务·nacos·dubbo
星期一研究室4 小时前
用公式让表格自动完成90%重复工作!
微服务·产品·设计
重庆小透明1 天前
深入探寻微服务【第四篇微服务监控实战】
微服务·云原生·架构
重庆小透明1 天前
深入探寻微服务【第五篇微服务限流实战】
微服务·junit·架构
hhb_6181 天前
AI智能体调度微服务:高效任务分配方案
人工智能·微服务·架构
星期一研究室1 天前
一个Emoji让你的文档阅读量提升300%!
微服务·产品·设计
数智化管理手记1 天前
数据治理不规范?标准化数据治理如何搭建与落地?
微服务·云原生·架构
Java成神之路-2 天前
Spring Cloud 微服务契约先行:为什么 Controller 建议要实现 Feign 接口?
spring·spring cloud·微服务
霸道流氓气质2 天前
微服务架构中多实例消息抢占问题:技术解析与解决方案
微服务·云原生·架构