CopilotKit 与 Agno 集成实战:Shared State 双向读写(UI ↔ Agent)实现原理与完整验收指南
【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit
导读
在 CopilotKit 的 Agno 集成中,shared-state-read-write演示展示了 UI 与 Agent 之间同一份共享状态对象的双向读写:前端通过agent.setState(...)把偏好写入 Agent 状态,Agent 通过set_notes工具把笔记写回状态并由前端实时渲染。本文以 showcase/integrations/agno/qa/shared-state-read-write.md 这份 QA 测试计划为主体,逐条拆解其前置条件、测试步骤与预期结果,并结合 Agent 后端源码、演示页面源码 与 自定义 AGUI router 揭示底层实现原理。读完你将掌握:该演示的功能验收路径、每个可定位元素(testid)的含义、set_notes的"全量替换"契约、以及 CopilotKit 为什么需要为 Agno 定制StateSnapshotEvent才能实现 Agent→UI 的状态回流。
前置条件与运行环境
该 QA 计划针对的是已部署在 dashboard 主机上的演示环境,启动前需确认三个前提:
- 演示页面已部署并可访问:
/demos/shared-state-read-write路由需要能从 dashboard 主机正常加载。该页面由 showcase/integrations/agno/src/app/demos/shared-state-read-write/page.tsx 实现。 - Agent 后端健康:
/api/health返回正常,OPENAI_API_KEY已设置。健康探针在 src/app/api/copilotkit/route.ts 的GET分支实现,会返回agent_status与OPENAI_API_KEY是否设置的状态。 - Agno Agent 服务器暴露
/shared-state-rw/agui端点:这是关键端点——它不是 Agno 的通用/agui,而是由 src/agent_server.py 挂载的状态感知(state-aware)AGUI 端点。之所以需要独立端点,是因为 Agno 自带的 AGUI router 不向外发射StateSnapshotEvent,而该演示依赖这个事件完成 Agent→UI 的状态回写(详见"源码级原理"一节)。
前端运行时通过 src/app/api/copilotkit/route.ts 把shared-state-read-write这个 Agent 名称映射到HttpAgent({ url: \${AGENT_URL}/shared-state-rw/agui` }),即后端进程(默认http://localhost:8000`)上挂载的状态感知路由。
页面布局与可定位元素地图
QA 计划要求页面在 3 秒内渲染完成,左侧为两张卡片(偏好卡片 + 笔记卡片),右侧为CopilotSidebar聊天面板。各元素的data-testid与语义如下表:
| testid | 所属组件 | 语义 |
|---|---|---|
preferences-card | preferences-card.tsx | 偏好卡片,标题 "Your preferences" |
pref-name | 偏好卡片 | 姓名输入框,placeholder 为 "e.g. Atai" |
pref-tone | 偏好卡片 | 语气下拉(formal / casual / playful) |
pref-language | 偏好卡片 | 语言下拉(English / Spanish / French / German / Japanese) |
pref-state-json | 偏好卡片 | 实时 JSON 预览,同步展示preferences对象 |
notes-card | notes-card.tsx | 笔记卡片,源码中标题为 "Agent Scratch pad" |
notes-empty | 笔记卡片 | 笔记为空时的占位提示 |
notes-list | 笔记卡片 | 非空时的笔记列表容器 |
note-item | 笔记卡片 | 单条笔记条目 |
notes-clear-button | 笔记卡片 | 清空按钮,仅在笔记非空时渲染 |
兴趣选择采用 Badge 药丸按钮,可选项定义在INTEREST_OPTIONS(Cooking、Travel、Tech、Music、Sports、Books、Movies),选中态使用边框#BEC2FF、背景#BEC2FF1A。
需要注意:QA 文档中描述的笔记卡标题 "Agent notes" 与空态文案 "No notes yet. Ask the agent to remember something." 与当前源码存在细微出入——源码实际使用标题 "Agent Scratch pad",空态文案为 "the agent will make observations about you and note them here!"。执行验收时建议以当前仓库实际渲染文案为准,或同步更新 QA 文档。
聊天输入框的 placeholder 由 demo-layout.tsx 中的CopilotSidebar的labels.chatInputPlaceholder配置为 "Chat with the agent..."。三个建议药丸(suggestion pills)定义在 suggestions.ts,标题与消息原文如下:
- Greet me→ "Say hi and introduce yourself."
- Remember something→ "Remember that I prefer morning meetings and that I don't eat dairy."
- Plan a weekend→ "Suggest a weekend plan based on my interests."
基础功能测试
QA 计划的第一步验证页面骨架与最小交互闭环:
- 访问
/demos/shared-state-read-write,确认页面在 3 秒内渲染出左侧偏好 + 笔记卡片与右侧CopilotChat面板; - 确认
preferences-card可见且标题为 "Your preferences"; - 确认
notes-card可见,空态元素notes-empty渲染; - 确认聊天输入框 placeholder 为 "Chat with the agent...";
- 确认三个建议药丸按原文标题可见;
- 发送 "Hello",确认 10 秒内返回 assistant 文本回复。
页面结构上,CopilotKit组件通过runtimeUrl="/api/copilotkit"与agent="shared-state-read-write"建立连接,DemoContent通过useAgent订阅状态更新(详见下文),CopilotSidebar承载对话 UI。
专项一:UI 写入 → Agent 读取(preferences 通过agent.setState)
这是"读 + 写"的第一条方向:前端拥有preferences对象,把它写进 Agent 状态,Agent 每一轮都读取并据此调整回复。
操作与断言
- 在
pref-name输入 "Atai",断言pref-state-json同步更新并包含"name": "Atai"; - 将
pref-tone切换为formal,断言 JSON 预览反映"tone": "formal"; - 将
pref-language切换为Spanish,断言 JSON 预览反映"language": "Spanish"; - 点击
Cooking和Travel兴趣药丸,断言两者呈现选中样式(border#BEC2FF、bg#BEC2FF1A),且 JSON 预览的interests数组同时包含两项; - 发送 "What do you know about me?",断言 10 秒内回复引用姓名 "Atai"、formal 语气、西班牙语以及 Cooking/Travel 兴趣;
- 点击 "Plan a weekend" 药丸,断言回复针对所选兴趣量身定制。
实现原理
前端每次编辑都会触发handlePreferencesChange,调用agent.setState({ preferences: next, notes })(见 page.tsx),其中notes被透传以保留 Agent 已写入的内容。偏好卡片本身是纯受控表单,完全不知道 Agent 的存在,所有状态接线都在父级page.tsx一层完成——这是该演示刻意保持的解耦设计。
后端一侧,Agent 的定义见 src/agents/shared_state_read_write.py。关键点在于动态指令函数build_instructions:
def build_instructions(run_context: RunContext) -> str: base = dedent("""...""").strip() prefs_block = _format_preferences(getattr(run_context, "session_state", None) or {}) if prefs_block: return f"{prefs_block}\n\n{base}" return base该函数从run_context.session_state中读取preferences,并通过_format_preferences生成形如[shared-state-read-write] preferences:开头、包含 Name / Preferred tone / Preferred language / Interests 的偏好块,拼接到系统指令前。_format_preferences有一个防御性边界:若 dict 为真但没有任何可识别键,则返回空串而不是输出一个光秃秃的标题头(与 google-adk 参考实现中的 guard 保持一致)。
让"写入立即生效"的关键是 Agent 构造参数:
agent = Agent( model=OpenAIChat(id="gpt-4o-mini", timeout=120), tools=[set_notes], cache_callables=False, # 每轮重新求值 instructions instructions=build_instructions, tool_call_limit=5, )cache_callables=False使得build_instructions在每一轮都被重新求值,而不是在 Agent 构造时缓存一次——这正是 UI 写入的偏好能在下一轮就生效的根本原因。
专项二:Agent 写入 → UI 读取(notes 通过set_notes工具)
这是第二条方向:Agent 通过set_notes工具写session_state["notes"],前端订阅状态变化并实时重渲染笔记卡片。
操作与断言
- 点击 "Remember something" 药丸(实际发送 "Remember that I prefer morning meetings and that I don't eat dairy.");
- 15 秒内断言
notes-list出现,且包含至少 2 条note-item,分别提及 "morning meetings" 与 "dairy"; - 断言
notes-empty不再渲染; - 发送 "Also remember I live in Berlin.",15 秒内断言笔记列表增长(旧笔记保留、新笔记追加)。
set_notes的全量替换契约
QA 计划特别强调:后续调用时旧笔记必须完整保留,因为set_notes的契约是"整体替换数组而非追加"。Agent 的系统指令明确要求:当用户要求记住某事时,调用set_notes并传入完整的新列表(已有笔记 + 新笔记),每条笔记不超过 120 字符。
源码实现 shared_state_read_write.py 还包含一个健壮性处理:
def set_notes(run_context: RunContext, notes: list[str]) -> str: if run_context.session_state is None: run_context.session_state = {} # 容忍模型把早期轮次的杂散 dict/None 传进来, # 统一强转为字符串,避免流中途 AGUI 序列化失败崩溃。 cleaned = [str(n) for n in (notes or []) if n is not None] run_context.session_state["notes"] = cleaned return f"Notes updated. ({len(cleaned)} total)"set_notes直接就地修改run_context.session_state["notes"]为清洗后的列表,并返回更新条数——所有条目被强转为纯字符串,从实现上消除了序列化崩溃的隐患。
前端如何感知 Agent 的写入
前端通过useAgent订阅状态变更(见 page.tsx):
const { agent } = useAgent({ agentId: "shared-state-read-write", updates: [UseAgentUpdate.OnStateChanged], });useAgent({ updates: [OnStateChanged] })让组件对 Agent 的每次状态变更重渲染,state.notes变化会传导到NotesCard重新渲染列表。
专项三:UI 写回 Agent 创作的状态切片(清空笔记)
该演示还验证了同一字段上的"反向写回":UI 把 Agent 写出的笔记清空,写回 Agent 状态。
操作与断言
- 在有笔记的前提下,断言
notes-clear-button可见; - 点击 Clear 按钮,断言笔记列表消失、
notes-empty重新渲染; - 询问 "What do you remember about me?",断言 Agent 不再引用已清空的笔记——证明状态确实通过
agent.setState({ notes: [] })写回了 Agent。
源码中handleClearNotes的实现在 page.tsx:
const handleClearNotes = () => { agent.setState({ preferences, notes: [] } as RWAgentState); };注意preferences同样被透传保留,与handlePreferencesChange中透传notes形成对称:两侧写入都以"读取当前另一切片并原样带回"的方式避免互相覆盖。这是双向共享状态实现中最容易踩的坑——只写自己关心的切片会覆盖掉对方写的内容。
专项四:多轮状态持久化
操作与断言
- 将 tone 改为
playful并添加Music兴趣,发送 "Write me a one-line haiku greeting.",断言回复俏皮且引用音乐; - 追加发送 "Do it again in French.",断言回复仍为俏皮语气、切换为法语、并继续承认音乐兴趣——证明偏好跨轮持久化;
- 刷新页面,断言偏好重置为默认值(
tone: casual、language: English、空 interests、空 name),笔记也重置为空。
会话(session)语义
偏好跨轮持久的机理在于:后端在_run_agent_with_state_snapshot中调用agent.arun(..., session_id=thread_id, session_state=session_state)(见 agent_server.py),Agno 用同一个thread_id/session_id复用会话,session_state在轮次间得以保留。
而刷新页面后状态重置,是因为状态本身是按会话(per-session)存在的,初始值由页面加载时的useEffect一次性agent.setState({ preferences: INITIAL_PREFERENCES, notes: [] })播种(见 page.tsx),默认偏好为tone: "casual"、language: "English"、空 interests、空 name。刷新即重新播种,因此回到默认值。
错误处理与边界场景
- 空消息:发送空消息应为 no-op——不产生用户气泡,也不产生 assistant 回复;
- 无偏好兜底:取消全部兴趣并清空姓名后发送 "Who am I?",Agent 应正常回答而不崩溃——
_format_preferences在没有任何可识别键时返回空串,build_instructions因此跳过偏好块,直接返回基础指令; - 控制台洁净:以上所有流程中 DevTools Console 不应出现未捕获错误。
源码级原理:Agno 缺少的StateSnapshotEvent补丁
该演示能工作的关键,是后端 agent_server.py 中_run_agent_with_state_snapshot这个自定义 AGUI handler。源码注释明确说明了动机:
Agno 自带的 AGUI router(
agno.os.interfaces.agui)不会向客户端发射StateSnapshotEvent。这意味着通过工具修改session_state的变更,对订阅了useAgent({ updates: [OnStateChanged] })的 UI 是不可见的——往返链路是断的。
该 handler 复刻了agno.os.interfaces.agui.router.run_agent的默认行为,但做了三处关键调整:
- 压制内部流的
RUN_STARTED/RUN_FINISHED:自己先发射RunStartedEvent,避免重复; - 在
RunFinishedEvent之前插入StateSnapshotEvent:在 Agno 流结束后,通过agent.aget_session_state(session_id=thread_id)(回退到同步get_session_state)读回最终session_state,再发射StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=final_state),最后发射自己的RunFinishedEvent,让快照落在 run 窗口内; - 快照读取回退策略:读状态时若有异常则回退到内存中的
session_state,避免单个失败导致整轮崩溃。
正因为如此,前端useAgent才能在每轮结束后拿到state.notes的最新值。这一补丁在 PARITY_NOTES.md 中有完整记录:Agno 的 stock AGUI router 不发射状态事件,没有这个 shim,依赖set_notes/delegations的 Agent 侧状态写入对前端完全不可见。同款补丁还被subagents、gen-ui-agent等依赖 Agent 侧状态写入的演示复用(见 route.ts)。
自动化测试佐证
仓库内的 Playwright 测试 tests/e2e/shared-state-read-write.spec.ts 覆盖了本文的部分验收点,并记录了真实的回归教训:
- "Greet me" 药丸曾匹配到 feature-parity.json 中裸
userMessage: "hi"的夹具,回复了通用的 "Hi there! I'm your showcase assistant…" 开场白,而不是共享状态感知的问候;修复方式是添加更长子串匹配的夹具使其优先命中; - 同样,"Plan a weekend" 曾匹配到裸
"plan"夹具,返回泛泛的 5 步内容营销计划;修复后断言回复包含/interests panel/i。
E2E 测试还通过正向 + 负向断言双重锁定行为,例如断言助理消息包含/shared-state co-pilot/i且不包含通用开场白。这说明 QA 计划中的药丸测试并非只验证"有回复",而是验证"回复来自正确的状态感知路径"。
预期结果与验收标准汇总
QA 计划的最终验收标准:
- 页面 3 秒内加载,assistant 文本回复 10 秒内返回;
- 偏好写入在变更时同步反映到
pref-state-json; - Agent 创作的笔记在 "remember" 提示后 15 秒内出现在
notes-card,后续set_notes调用完整保留此前列表; - Clear 按钮完成 UI → Agent 状态的往返,Agent 下一轮即失去对已清空笔记的访问;
- 无布局破坏、无未捕获的控制台错误。
对照上述标准可以确认:该演示完整覆盖了双向共享状态的四个方向——UI 写偏好(Agent 读)、Agent 写笔记(UI 读)、UI 清空笔记(写回 Agent)、以及跨轮持久化与刷新重置。无论你是要在自己的 CopilotKit 集成中实现类似的双向共享状态,还是只想验证 Agno 后端是否实现了完整的状态契约,都可以直接复用本文的测试路径与源码参考。
【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考