commerce-agents.py
§ 00 · Source-level Observatory

commerce-agents 把购物流程做成可审计 Agent 蓝图

commerce-agents 不是一个泛化聊天样例,而是把购物 Agent 与商家 Agent 同时放进同一套公共运行时里。源码里用 sources/commerce-agents/commerce-common/commerce_common/turn.py:67、sources/commerce-agents/commerce-common/commerce_common/execution.py:214、sources/commerce-agents/shopping-agent/core/shopping_agent/executor.py:132、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:192 串起输入归约、工具执行、领域动作、审批门与测试夹具,适合观察 Anthropic 如何把 Claude Agent SDK 用在真实交易边界。

★ 497
GitHub Stars
15k+
LOC
600
Files
2026-08-31
Last commit
User / Merchantnatural language intent
→
common turn runtimehistory · streaming · tool closure
→
domain executorshopping cart · listings · campaigns
→
gatesprovenance · approval · follow-through
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

这个仓库的价值在于它把两个相反角色放到同一个工程框架里:买家侧 shopping agent 处理搜索、详情、购物车和订单,商家侧 merchant agent 处理库存、活动和上架草稿。公共层先在 sources/commerce-agents/commerce-common/commerce_common/turn.py:67、sources/commerce-agents/commerce-common/commerce_common/turn.py:124、sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:60、sources/commerce-agents/commerce-common/commerce_common/execution.py:184 抽掉对话历史、提示块、工具契约与执行入口,领域层再把这些能力收敛到明确的商业动作。

源码最值得看的是防线分布,而不是单个 prompt。输入先经过 fencing 与 prompt assembly,工具调用再经过 executor 的 schema/状态注入,最后由 gates 判断是否必须澄清、补证据或等待人工确认。这条链在 sources/commerce-agents/commerce-common/commerce_common/fencing.py:83、sources/commerce-agents/commerce-common/commerce_common/grounding.py:55、sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:102、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:163、sources/commerce-agents/commerce-common/tests/test_turn.py:147 都有测试或显式实现,因此它更像一份可复制的 agent 安全蓝图。

▸核心数据

架构模式
公共 turn/runtime + 双领域 executor + gate 后置约束
核心抽象
StreamedRound、BaseToolExecutor、MemoryFact、GroundingRule、shopping/merchant gates
性能/精度数据
仓库没有 benchmark;验证重心在回归测试和安全语义测试
下游依赖
Claude Agent SDK、FastMCP、pytest、示例 Web UI 和 domain fixture
本文导航 — 6 个章节依次:概述 → 架构图 → 核心模块 → 关键流程 → 测试与验证 → 风险。子页面在每个 H3 末尾以 深度阅读 → 入口展开。
§ 01 · ARCHITECTURE

双 Agent 蓝图与公共运行时

架构上它没有把购物和商家流程塞进一个巨大的 agent,而是先定义公共 turn、prompt、memory、fencing、grounding 和 executor,再由 shopping / merchant 两套子包注册领域工具。从 sources/commerce-agents/commerce-common/commerce_common/types.py:17、sources/commerce-agents/commerce-common/commerce_common/skills.py:17、sources/commerce-agents/shopping-agent/core/shopping_agent/prompt.py:145、sources/commerce-agents/merchant-agent/core/merchant_agent/prompt.py:188 可以看到这套分层让提示词规则、工具契约和可持久化事实分别演进。

▸系统拓扑

Client turnlatest user text + compact history
→
Prompt blocksstatic rules + dynamic context
→
Claude streamtool calls arrive incrementally
→
Executorvalidate args + inject state
→
Gate layerprove, clarify, approve, close

▸模块清单

公共包里的 turn 负责把 stream 转成可恢复的 round,execution 负责把工具名映射到处理器,fencing 和 grounding 负责让外部文本进 prompt 前先降权,memory 负责写入前过滤隐私与会话性事实。这些边界分别落在 sources/commerce-agents/commerce-common/commerce_common/turn.py:270、sources/commerce-agents/commerce-common/commerce_common/execution.py:106、sources/commerce-agents/commerce-common/commerce_common/fencing.py:99、sources/commerce-agents/commerce-common/commerce_common/grounding.py:72、sources/commerce-agents/commerce-common/commerce_common/memory.py:130、sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:43,而 shopping 与 merchant 只需要实现自己领域里的工具 handler 和 gate。

commerce_common.turn
维护最新用户输入、history compact、streamed tool calls、salvage 与 turn close。
commerce_common.execution
统一校验工具参数,把 session/context/cache/status 注入 handler。
shopping_agent / merchant_agent
分别实现面向买家与商家的领域工具、提示词和审批 gate。

▸关键数据结构

src/sources/commerce-agents/commerce-common/commerce_common/types.py · L17-48Python
  17 
  18 RecordT = TypeVar("RecordT")
  19 
  20 
  21 def remember(records: dict[str, RecordT], key: str, value: RecordT) -> None:
  22     records.pop(key, None)
  23     records[key] = value
  24     while len(records) > PROVENANCE_CAP:
  25         del records[next(iter(records))]
  26 
  27 
  28 class MemoryCategory(StrEnum):
  29     PREFERENCE = "preference"
  30     CONSTRAINT = "constraint"
  31     CONTEXT = "context"
  32 
  33 
  34 class MemoryFact(BaseModel):
  35     """One stored fact. The schema bounds the shape; ``validate_fact`` decides what a
  36     value may be about. Constraints are injected on every turn."""
  37 
  38     key: str = Field(max_length=64)
  39     value: str = Field(max_length=200)
  40     category: MemoryCategory = MemoryCategory.PREFERENCE
  41     updated_at: datetime | None = None
  42     # Recalled facts carry the session that wrote them, so the model reads them as a
  43     # past turn's claim and a poisoned session's writes stay traceable. The value is the
  44     # session's ``session_tag``, not the id: a fact is read back by other sessions of the
  45     # subject, and on the example hosts the id is also the request credential.
  46     source_session_id: str | None = Field(default=None, max_length=80)
  47 
  48 
深度阅读 · turn loop 与 streaming round
§ 02 · CORE MODULES

Turn、工具、记忆如何接起来

▸StreamedRound:边收边拼的工具调用账本

StreamedRound 把 Claude streaming 事件逐块聚合成文本、tool_use、tool_result 和 stop_reason,并在工具参数尚未完整时保留 partially parsed input。关键路径在 sources/commerce-agents/commerce-common/commerce_common/turn.py:248、sources/commerce-agents/commerce-common/commerce_common/turn.py:270、sources/commerce-agents/commerce-common/commerce_common/turn.py:326、sources/commerce-agents/commerce-common/commerce_common/turn.py:379、sources/commerce-agents/commerce-common/commerce_common/turn.py:401;这解释了为什么测试要覆盖 salvage、关闭 open tool_use,以及 stream 中断后的最小可恢复状态。

src/sources/commerce-agents/commerce-common/commerce_common/turn.py · L270-319Python
 270     def feed(self, raw: Any) -> StreamedTool | None:
 271         """Record one raw stream event; returns the tool it touched, if any."""
 272         raw_type = getattr(raw, "type", "")
 273         if raw_type in ("message_start", "message_delta"):
 274             # The stream's counts are cumulative, so the latest value of each replaces ours.
 275             counts = getattr(raw.message if raw_type == "message_start" else raw, "usage", None)
 276             for key in usage_totals():
 277                 if isinstance(value := getattr(counts, key, None), int):
 278                     setattr(self.usage, key, value)
 279             return None
 280         index = getattr(raw, "index", None)
 281         if not isinstance(index, int):
 282             return None
 283         if raw_type == "content_block_start":
 284             block = raw.content_block
 285             entry = dict(block.model_dump(exclude_none=True, exclude={"citations"}))
 286             kind = entry.get("type")
 287             # The streamed fields start empty here and grow from the deltas.
 288             if kind == "text":
 289                 entry["text"] = ""
 290             elif kind == "thinking":
 291                 entry["thinking"] = ""
 292             elif kind in ("tool_use", "server_tool_use"):
 293                 entry["input"] = {}
 294                 self.tools[index] = StreamedTool(
 295                     name=block.name, id=block.id, server=kind == "server_tool_use"
 296                 )
 297             self.blocks.append(entry)
 298             return self.tools.get(index)
 299         if raw_type == "content_block_delta":
 300             delta = raw.delta
 301             delta_type = getattr(delta, "type", "")
 302             if (tool := self.tools.get(index)) is not None:
 303                 if delta_type == "input_json_delta":
 304                     tool.buffer += delta.partial_json
 305                 return tool
 306             entry = self.blocks[index] if index < len(self.blocks) else {}
 307             if delta_type == "text_delta":
 308                 entry["text"] = entry.get("text", "") + delta.text
 309             elif delta_type == "thinking_delta":
 310                 entry["thinking"] = entry.get("thinking", "") + delta.thinking
 311             elif delta_type == "signature_delta":
 312                 entry["signature"] = delta.signature
 313             return None
 314         if raw_type == "content_block_stop" and (tool := self.tools.get(index)) is not None:
 315             tool.closed = True
 316             if tool.server and (args := tool.parsed()) is not None:
 317                 self.blocks[index]["input"] = args
 318             return tool
 319         return None

▸BaseToolExecutor:把工具表变成受控 dispatch

executor 没有让模型直接触达业务函数,而是先按 contract 校验参数,再把 session、context、cache、status 等运行态按 handler 声明注入。这套分发在 sources/commerce-agents/commerce-common/commerce_common/execution.py:54、sources/commerce-agents/commerce-common/commerce_common/execution.py:69、sources/commerce-agents/commerce-common/commerce_common/execution.py:106、sources/commerce-agents/commerce-common/commerce_common/execution.py:214、sources/commerce-agents/commerce-common/commerce_common/execution.py:245 形成了可测试的工具边界,领域 executor 只暴露表驱动 handler。

src/sources/commerce-agents/commerce-common/commerce_common/execution.py · L214-243Python
 214     async def execute(self, name: str, tool_input: dict[str, Any] | None) -> ToolOutcome:
 215         try:
 216             return await self.dispatch(name, dict(tool_input or {}))
 217         except InvalidArguments as invalid:
 218             return ToolOutcome.error(invalid_arguments_text(name, invalid.invalid))
 219         except Exception as error:  # a tool failure must not end the turn
 220             if (outcome := self.domain_error(error)) is not None:
 221                 return outcome
 222             logger.warning("tool %s failed and is reported as unavailable", name, exc_info=True)
 223             return ToolOutcome.error(self.unavailable_text.format(name=name))
 224 
 225     async def dispatch(self, name: str, tool_input: dict[str, Any]) -> ToolOutcome:
 226         """:meth:`execute` without the failure ladder: what the tool raised propagates,
 227         so a host prefetching a read can tell a failed tool from a result the tool wrote
 228         (a not-found line, a held call). The ``status`` line is dropped here, before any
 229         argument is validated or any handler runs; a tool the deployment's config leaves
 230         out of the tool list is unknown here too, whichever path calls it."""
 231         tool_input, _status = self.split_status(name, tool_input)
 232         if name in self._absent:
 233             return ToolOutcome.error(self.absent_text.format(name=name))
 234         if name == LOAD_SKILL:
 235             return self._load_skill(tool_input)
 236         if (spec := self.components.get(name) or self._extensions.get(name)) is not None:
 237             return await self._present(spec, tool_input)
 238         if (delegate := self._delegates.get(name)) is not None:
 239             return await self._run_delegate(delegate, tool_input)
 240         handler = self._handlers.get(name)
 241         if handler is None:
 242             return ToolOutcome.error(f"Unknown tool: {name}")
 243         return await handler(tool_input)
深度阅读 · grounding、fencing 与 memory
§ 03 · KEY FLOW

从用户话术到受控动作

关键流程可以读成两条:买家侧先查证商品与购物车状态,再允许加入或修改;商家侧先 stage 变更并生成确认文本,再由用户 apply 或 discard。购物路径落在 sources/commerce-agents/shopping-agent/core/shopping_agent/executor.py:132、sources/commerce-agents/shopping-agent/core/shopping_agent/executor.py:155、sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:86、sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:102,商家路径落在 sources/commerce-agents/merchant-agent/core/merchant_agent/executor.py:248、sources/commerce-agents/merchant-agent/core/merchant_agent/executor.py:265、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:192。

▸Shopping path:证据优先,再动购物车

search_productsquery + filters
→
get_product_detailsgrounded item evidence
→
cart gatelock + option validation
→
cart mutationadd/update/remove

▸Merchant path:stage first,apply later

listings/catalogread current business state
→
stage updatepending mutations
→
follow-through gaterequires explicit confirmation
→
apply/discardauditable outcome

▸Shopping gate 的真实代码边界

购物车变更不是只靠提示词提醒,代码会检查最近一轮是否已经证明了商品与选项;sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:102、sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:113、sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:126、sources/commerce-agents/shopping-agent/core/tests/test_executor.py:149 把 add/update/remove 前的证据和锁定关系落成了可读条件。

src/sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py · L102-130Python
 102 async def gated_add_to_cart(
 103     *,
 104     backend: StorefrontBackend,
 105     config: ShoppingAgentConfig,
 106     session: ShoppingSessionContext,
 107     state: ShoppingSessionState,
 108     product_id: str,
 109     quantity: int,
 110 ) -> ToolOutcome:
 111     if held := check_provenance(state, product_id) or check_options(state, product_id):
 112         return held
 113     requested = max(1, quantity)
 114     max_quantity = config.max_quantity_per_item
 115     async with _cart_lock(session):
 116         current = await backend.get_cart(session)
 117         existing = next((i for i in current.items if i.product_id == product_id), None)
 118         if existing is None and len(current.items) >= config.max_cart_lines:
 119             return ToolOutcome.error("The cart is full.")
 120         allowed = min(requested, max(0, max_quantity - (existing.quantity if existing else 0)))
 121         if allowed <= 0:
 122             return ToolOutcome.error(
 123                 f"This item is already at the per-item limit of {max_quantity}."
 124             )
 125         cart = await backend.add_to_cart(session, product_id, allowed)
 126     # The confirmation names the id only: titles are catalog text and stay inside fences.
 127     capped = f" (capped at the per-item limit of {max_quantity})" if allowed < requested else ""
 128     return _written(
 129         f"Added {product_id} x{allowed}{capped}. Cart now has {cart_summary(cart)}.", cart
 130     )

▸Merchant gate 的 staged-write 约束

商家侧更强调先暂存再确认,模型生成的 listing、campaign 或 promotion 变更必须经过 follow-through;sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:192、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:201、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:218、sources/commerce-agents/merchant-agent/core/tests/test_executor.py:217 显示生效动作被拆到了明确的 apply/discard 分支。

src/sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py · L192-215Python
 192 def check_apply_change(
 193     state: MerchantSessionState, config: MerchantAgentConfig, change_id: str
 194 ) -> ToolOutcome | None:
 195     """Provenance, then the guardrails under the current config, then the host's
 196     approval mark when the deployment requires one."""
 197     known = state.seen_changes.get(change_id)
 198     if known is None:
 199         return ToolOutcome.held(
 200             PROVENANCE_GATE,
 201             f"change_id {change_id} was not staged or listed in this session. Stage "
 202             "the change (or call get_pending_changes) first, preview it, and apply it "
 203             "only after the operator approves it.",
 204         )
 205     if violations := check_guardrails(known.kind, known.items, config):
 206         return ToolOutcome.held(GUARDRAIL_GATE, apply_guardrail_message(violations))
 207     if config.require_host_approval and change_id not in state.approved_change_ids:
 208         return ToolOutcome.held(
 209             APPROVAL_GATE,
 210             f"change {change_id} has not been approved through "
 211             f"{config.approval_surface}. Tell the operator it is staged and "
 212             f"waiting for their approval on {config.approval_surface} — "
 213             "approving it there is what applies it.",
 214         )
 215     return None
深度阅读 · commerce gates 与审批语义
§ 04 · TESTS & VALIDATION

测试把安全约束落成回归契约

测试不是只确认 happy path,它把 turn salvage、工具 schema、记忆过滤、购物车锁、staged write 和 prompt guardrail 都写成了行为契约。例如 sources/commerce-agents/commerce-common/tests/test_turn.py:42、sources/commerce-agents/commerce-common/tests/test_turn.py:106、sources/commerce-agents/shopping-agent/core/tests/test_executor.py:112、sources/commerce-agents/merchant-agent/core/tests/test_executor.py:130、sources/commerce-agents/commerce-common/tests/test_fencing.py:1 覆盖了运行时最容易被模型输出扰乱的边界。

▸测试覆盖

下面这个测试片段能代表项目风格:它不是 mock 一个“模型很听话”的响应,而是检查 executor 在订单、购物车和领域错误之间是否返回稳定 envelope。类似思路也出现在 sources/commerce-agents/shopping-agent/core/tests/test_executor.py:75、sources/commerce-agents/shopping-agent/core/tests/test_executor.py:99、sources/commerce-agents/merchant-agent/core/tests/test_executor.py:78、sources/commerce-agents/merchant-agent/core/tests/test_executor.py:186、sources/commerce-agents/commerce-common/tests/test_execution.py:1,这些测试让 agent 行为可回归,而不是只靠 prompt 文案。

tests/sources/commerce-agents/shopping-agent/core/tests/test_executor.py · L112-146Python
 112 async def test_update_and_remove_require_provenance_or_cart_membership(executor, backend):
 113     update = await executor.execute("update_cart_item", {"product_id": "p-100", "quantity": 2})
 114     assert update.blocked == "provenance"
 115     # The backend never sees the id; an upsert-style update would otherwise create the line.
 116     assert backend.cart_items == {}
 117     remove = await executor.execute("remove_from_cart", {"product_id": "p-100"})
 118     assert remove.blocked == "provenance"
 119 
 120     await executor.execute("search_products", {"query": "tent"})
 121     await executor.execute("add_to_cart", {"product_id": "p-100", "quantity": 1})
 122     update = await executor.execute("update_cart_item", {"product_id": "p-100", "quantity": 3})
 123     assert not update.is_error and update.blocked is None
 124     assert backend.cart_items["p-100"].quantity == 3
 125 
 126 
 127 async def test_cart_membership_alone_grants_update_and_remove(
 128     backend, config, skills, session, state
 129 ):
 130 
 131     backend.cart_items["p-200"] = CartItem(
 132         product_id="p-200", title="Two-Burner Camp Stove", price=64.5, quantity=2
 133     )
 134     executor = ShoppingToolExecutor(
 135         backend=backend,
 136         config=config,
 137         skills=skills,
 138         session=session,
 139         state=state,
 140         memory=build_memory(config, InMemoryMemoryStore()),
 141     )
 142     update = await executor.execute("update_cart_item", {"product_id": "p-200", "quantity": 4})
 143     assert not update.is_error and update.blocked is None
 144     remove = await executor.execute("remove_from_cart", {"product_id": "p-200"})
 145     assert not remove.is_error and remove.blocked is None
 146     assert backend.cart_items == {}

▸已知边界 / 失败模式

  • 流式输出中断时,必须通过 salvage_round 补齐可恢复消息;对应 sources/commerce-agents/commerce-common/commerce_common/turn.py:401。
  • 外部检索文本进入 prompt 前需要 fence 与特殊 token 清洗;对应 sources/commerce-agents/commerce-common/commerce_common/fencing.py:99。
  • 商家侧 staged mutation 不能被模型自行 apply;对应 sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:192。
§ 05 · HIGHLIGHTS

值得借鉴的工程取舍

流式工具调用可恢复
sources/commerce-agents/commerce-common/commerce_common/turn.py:379 记录 partial input,sources/commerce-agents/commerce-common/tests/test_turn.py:147 验证 salvage 后仍能关闭 round。
工具执行边界清晰
sources/commerce-agents/commerce-common/commerce_common/execution.py:214 的 dispatch 只走注册 handler,sources/commerce-agents/commerce-common/commerce_common/execution.py:69 先处理参数契约。
商业动作有后置 gate
sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:102 与 sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:192 分别约束购物车和商家发布。
记忆写入先过滤
sources/commerce-agents/commerce-common/commerce_common/memory.py:78 定义禁止模式,sources/commerce-agents/commerce-common/commerce_common/memory.py:130 再验证 fact 是否值得保存。
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

这个项目是参考蓝图,不是完整生产平台;它清楚展示了 agent 安全边界,但仍需要接入真实身份、支付、库存一致性、审计存储和队列重试策略。源码里能看到 sources/commerce-agents/shopping-agent/core/shopping_agent/executor.py:199、sources/commerce-agents/merchant-agent/core/merchant_agent/executor.py:211、sources/commerce-agents/commerce-common/commerce_common/memory.py:101、sources/commerce-agents/commerce-common/commerce_common/prompt_assembly.py:103 这些实现点还偏 demo/runtime 层,落地前需要按业务风险继续加厚。

缺少端到端外部一致性
订单、库存、活动等对象多由 fixtures 或内存状态模拟,sources/commerce-agents/examples/demo_common/storefront.py:1 这类示例层不能替代生产事务。
审批语义仍依赖文案
gates 能拦住一批危险动作,但真实系统仍要把 sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:218 的 apply/discard 映射到不可绕过的后端授权。
记忆策略保守但不完整
sources/commerce-agents/commerce-common/commerce_common/memory.py:183 只做匹配选择,生产里还需要租户隔离、保留策略和可删除证明。
模型供应商绑定明显
prompt 与 streaming 事件契约贴近 Claude Agent SDK,sources/commerce-agents/commerce-common/commerce_common/agent_sdk.py:1 的适配层决定了迁移成本。
诚实记录 — 这部分列的是我在源码里看到、但作者没有明确文档化的限制。读者请按需取舍。