CopilotKit × AG2 双向共享状态实战:UI 与 Agent 如何读写同一个 State(Shared State Read + Write)
【免费下载链接】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 仓库中 AG2 集成 demo 的Shared State(Read + Write)功能展开,系统讲解"前端写、Agent 读;Agent 写、前端读"的双向共享状态机制。你将掌握useAgent()/agent.setState()的完整用法、后端基于 AG2ContextVariables+ReplyResult的状态回写原理,以及一份可复用的端到端验收清单(含错误处理与预期结果),可直接用于验证同类功能的正确性。
一、功能全景:什么是"双向共享状态"
Shared State 是 CopilotKit 中让UI 与 Agent 共享同一份状态对象的能力。在showcase/integrations/ag2中,这个 demo 的共享状态由两个字段组成(见 前端类型定义):
preferences:由 UI 通过agent.setState()写入(用户档案:名字、语气、语言、兴趣),Agent 在每轮回复前读取并据此调整行为;notes:由 Agent 通过set_notes工具写入(Agent 的"备忘清单"),UI 通过useAgent()订阅并实时渲染。
两个方向同时成立,就形成了闭环:
UI 写入 preferences ──▶ agent.setState() ──▶ 后端注入系统提示词 ──▶ Agent 回复适配 UI 读取 notes ◀── Agent 调用 set_notes ◀── ContextVariables 状态回传 ◀── useAgent 订阅官方说明(见 demo README)将其概括为三点:
- UI → agent:侧边栏表单(name、tone、language、interests)通过
agent.setState(...)写入state.preferences;后端中间件每轮读取并注入系统提示词。 - agent → UI:Agent 的
set_notes工具写入state.notes;侧边栏 notes 卡片在 Agent 更新时实时重渲染。 - Round-trip:在侧边栏编辑偏好,能立刻体现在 Agent 的下一轮回复中(语气、语言、直呼其名)。
二、前置条件(Prerequisites)
运行本功能前需满足以下条件(对应 QA 文档):
- Demo 已部署并可通过
/demos/shared-state-read-write访问; - Agent 后端健康,
GET /api/copilotkit返回agent_status: reachable; - 后端已配置
OPENAI_API_KEY; shared-state-read-writeAgent 已挂载在 FastAPI 服务器的/shared-state-read-write路径下(见 agent_server.py)。
后端 Agent 的挂载逻辑位于 shared_state_read_write.py 末尾:stream = AGUIStream(agent)构建 AG-UI 流式服务,随后shared_state_read_write_app.mount("", stream.build_asgi())将其挂载为独立 FastAPI 应用,再在agent_server.py中统一注册到主服务器。
三、页面结构与初始状态(Step 1:页面渲染)
3.1 页面骨架
页面由SharedStateReadWriteDemo组件构成(见 page.tsx):
<CopilotKit runtimeUrl="/api/copilotkit" agent="shared-state-read-write"> <DemoContent /> </CopilotKit>DemoContent内部通过DemoLayout(见 demo-layout.tsx)渲染两个卡片和一个聊天侧边栏:
| 区域 | 标识 | 说明 |
|---|---|---|
| Preferences 卡片 | data-testid="preferences-card" | 用户编辑偏好(Name / Tone / Language / Interests) |
| Agent notes 卡片 | data-testid="notes-card" | 展示 Agent 写入的备忘笔记 |
| 聊天侧边栏 | CopilotSidebar | agentId="shared-state-read-write",defaultOpen={true},占位文案 "Chat with the agent..." |
3.2 初始共享状态
INITIAL_PREFERENCES定义在 page.tsx:
const INITIAL_PREFERENCES: Preferences = { name: "", tone: "casual", language: "English", interests: [], };页面加载后,Preferences 卡片底部的"Shared state"JSON 面板(data-testid="pref-state-json")会展示与之一致的初始值;notes 卡片则显示空态文案 "No notes yet. Ask the agent to remember something."(前端组件实际文案见 notes-card.tsx,QA 文档记录的是空态语义,两者表达一致)。
值得注意的一个细节:DemoContent在挂载时通过useEffect调用一次agent.setState()播种初始状态(见 page.tsx),确保 Agent 在第一轮对话前就有preferences和notes可读——即使此时默认值全部为空。
四、UI → Agent:前端写入(Step 2)
4.1 写入入口:agent.setState()
前端把每次表单变更直接同步进共享状态(见 page.tsx):
const handlePreferencesChange = (next: Preferences) => { agent.setState({ preferences: next, notes, // 保留 Agent 已写入的内容 } as RWAgentState); };关键点:写入时必须携带完整的状态对象(preferences+notes),否则会覆盖掉 Agent 端写入的notes。这是双向共享状态最容易踩的坑。
PreferencesCard本身是纯受控组件(见 preferences-card.tsx),对 Agent 一无所知:每个输入变化只是调用onChange({ ...value, [key]: v }),真正与 Agent 状态对接的逻辑上移到了父级页面。这种分层让卡片可独立复用。
4.2 可编辑的偏好维度
- Name:文本输入(
data-testid="pref-name"),例如填入Atai; - Tone:下拉选择,取值为
formal/casual/playful(data-testid="pref-tone"); - Language:下拉选择,支持
English/Spanish/French/German/Japanese; - Interests:Badge 按钮组,预置选项见 INTEREST_OPTIONS:
Cooking、Travel、Tech、Music、Sports、Books、Movies,可多选/取消。
每次编辑后,"Shared state" JSON 面板立即更新——这验证了写入已即时进入共享状态。
4.3 后端如何消费 UI 的写入
UI 的写入并不会凭空生效。后端在 shared_state_read_write.py 中做了两件事:
① 定义与前端一致的数据契约(Pydantic 模型):
class Preferences(BaseModel): name: str = Field(default="", description="The user's preferred name") tone: str = Field(default="casual", ...) language: str = Field(default="English", ...) interests: List[str] = Field(default_factory=list, ...) class SharedSnapshot(BaseModel): preferences: Preferences = Field(default_factory=Preferences) notes: List[str] = Field(default_factory=list)② 提供get_current_preferences工具:工具注释明确要求 Agent "Always call this BEFORE answering"。其实现通过_load_snapshot()从ContextVariables中读取快照并返回 JSON(见 shared_state_read_write.py)。
③ 在系统提示词中强制读取:system_message写明 "Callget_current_preferencesBEFORE answering, every turn, and tailor your reply to those preferences",并要求 "Never repeat preferences back at the user verbatim — just adapt"(见 shared_state_read_write.py)。
正是这套"契约 + 工具 + 提示词约束"的组合,保证了 Agent 会用用户的名字、按指定语气、用指定语言回复,而不是简单复读 JSON。
4.4 验证方式
按 QA 文档输入示例:
- Name 填
Atai,Tone 选playful,Language 选Spanish,再勾选 2~3 个兴趣(如Cooking、Tech); - 发送"Greet me in one sentence.";
- 期望:Agent 用名字称呼你、语气俏皮、用西班牙语回复,且不是复读 JSON。
五、Agent → UI:后端写入,前端实时渲染(Step 3)
5.1 写入工具:set_notes
set_notes是 Agent 侧唯一的状态写入通道(见 shared_state_read_write.py):
@tool() async def set_notes(context_variables: ContextVariables, notes: List[str]) -> ReplyResult: snapshot = _load_snapshot(context_variables) cleaned = [str(n).strip() for n in notes if str(n).strip()] snapshot.notes = cleaned context_variables.update(snapshot.model_dump()) return ReplyResult( message=f"Notes updated. Total notes: {len(cleaned)}.", context_variables=context_variables, )三个实现要点:
- 整表替换而非 diff:工具注释强调 "Always pass the FULL notes list (existing + new) — not a diff"(每轮必须携带完整列表,防止遗漏历史条目);
- 状态回传靠
ReplyResult:工具把更新后的context_variables挂在ReplyResult上返回,AGUIStream 将其经 AG-UI 协议回传给前端,触发OnStateChanged更新; - 入参清洗:去空白、过滤空串,保证笔记列表的整洁。
5.2 前端订阅:useAgent({ updates: [OnStateChanged] })
前端通过订阅机制感知 Agent 的每次状态变更(见 page.tsx):
const { agent } = useAgent({ agentId: "shared-state-read-write", updates: [UseAgentUpdate.OnStateChanged], });只要 Agent 通过set_notes变更状态,该 hook 就会触发、组件重渲染,notes 卡片随之更新。NotesCard是纯展示组件(见 notes-card.tsx):空态显示占位文案,有数据时渲染带编号的data-testid="note-item"列表。
5.3 验证方式
- 发送"Remember that I prefer morning meetings and that I don't eat dairy.";
- 期望:notes 卡片从空态过渡为至少 2 条
note-item(对应上述两个事实),且在 Agent 轮次进行中/结束后实时更新; - 再发送"Also remember I work in Pacific time.";
- 期望:列表至少 3 条——证明 Agent 传回了完整列表而非仅新增项。
六、Round-trip 与清除(Step 4)
这一环节同时验证"读"与"写"两个方向在同一字段上的闭环(见 page.tsx):
const handleClearNotes = () => { agent.setState({ preferences, notes: [] } as RWAgentState); };操作步骤与预期:
- 点击 notes 卡片的Clear按钮(
data-testid="notes-clear-button",仅在有笔记时显示)→ notes 卡片立即回到空态; - 发送"What do you remember about me?"→ Agent 回答"没有任何记忆的笔记";
- 含义:UI 通过
agent.setState的清除操作,在 Agent 的下一轮被其看到——证明 UI 的写回对 Agent 可见。
这就是最完整的"Round-trip":UI 能写(偏好)、Agent 能写(笔记)、UI 能覆盖 Agent 的写入(Clear)、Agent 能在下一轮感知 UI 的覆盖。
七、错误处理与鲁棒性(Step 5)
QA 文档对异常路径的验收要求:
- 发送空消息 → 被优雅处理(不崩溃、不破坏 UI);
- 正常使用过程中无控制台报错。
与此对应,后端在状态读取层面也做了容错设计:_load_snapshot()采用"尽力加载"策略(见 shared_state_read_write.py):
- 若
ContextVariables中状态缺失或损坏,先尝试从preferences、notes两个独立槽位做部分恢复; - 单个槽位仍失败则回退到默认值,保证 Agent 在 UI 尚未调用
agent.setState的第一轮也能正常工作; - 同时记录 WARNING 日志,让静默损坏在服务端可见,而非悄悄降级。
八、预期结果(验收基线)
QA 文档最终定义了五条可量化验收标准:
| 验收项 | 期望 |
|---|---|
| 页面加载 | < 3 秒 |
| 偏好编辑 | 即时传播到 Agent 状态 |
| Agent 回复 | 明显适配偏好(名字、语气、语言) |
| Notes 卡片 | 反映每一次set_notes调用 |
| UI 清除笔记 | 在 Agent 下一轮被感知 |
九、端到端测试佐证
仓库为该功能提供了 Playwright 端到端测试(见 shared-state-read-write.spec.ts),覆盖:
- 挂载断言:"Your preferences" 与 "Agent Scratch pad" 两个面板均可见;
- 起始建议渲染:"Greet me"、"Remember something"、"Plan a weekend" 三个建议按钮可见;
- 回归用例(防夹具串扰):测试注释记录了历史上"Greet me"建议曾错误匹配
feature-parity.json中裸"hi"夹具、"Plan a weekend"曾匹配裸"plan"夹具的 bug。修复方式是添加子串更长的d5-all.json专用夹具使其优先命中——测试通过正反双断言(toContainText(/shared-state co-pilot/i)且not.toContainText(/showcase assistant.../))锁定行为。
这套测试可以作为你验证自己 Shared State 实现的参考模板:正向断言 + 负向断言组合,防止"碰巧命中通用夹具"造成的假阳性。
结语
Shared State(Read + Write)模式的核心价值在于把"用户画像"和"Agent 工作记忆"统一进一份状态:前端负责交互式编辑,后端负责每轮读取与回写,useAgent/agent.setState/ContextVariables/ReplyResult四者协作实现闭环。实践时牢记两个原则——写入携带完整状态、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
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考