commerce-agents.py
§ · Nested detail page

Commerce Gates 与审批语义 深度解析

这一页拆买家和商家两个方向的 gate:什么时候必须先查证商品,什么时候必须锁定购物车,什么时候商家变更只能 stage 不能直接生效。

§ 00 · IMPORT CHAIN

导入链与契约

领域 executor 负责做动作,gate 负责决定动作前后是否需要补证据或人工确认;购物路径在 sources/commerce-agents/shopping-agent/core/shopping_agent/executor.py:106、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:145、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:58、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:192。

src/sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py · L86-130Python
  86 # The gates read the cart, compute, then write; a second mutation for the same session
  87 # in the same gather must not interleave with that. Locks live only while held.
  88 _cart_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
  89 
  90 
  91 def _cart_lock(session: ShoppingSessionContext) -> asyncio.Lock:
  92     lock = _cart_locks.get(session.session_id)
  93     if lock is None:
  94         lock = _cart_locks[session.session_id] = asyncio.Lock()
  95     return lock
  96 
  97 
  98 def _written(text: str, cart: Cart) -> ToolOutcome:
  99     return ToolOutcome(text, [AgentEvent.cart_update(cart_payload(cart))])
 100 
 101 
 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     )
§ 01 · CORE IMPL

核心实现,逐段拆

▸Shopping executor 把搜索、详情、购物车做成表驱动 handler

ShoppingToolExecutor 初始化时把每个工具名映射到对应 handler,后续 search/detail/cart/order 都走同一个 BaseToolExecutor;sources/commerce-agents/shopping-agent/core/shopping_agent/executor.py:60、sources/commerce-agents/shopping-agent/core/shopping_agent/executor.py:106、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/tests/test_executor.py:112 让工具 contract、领域错误和测试断言保持一致。

src/sources/commerce-agents/shopping-agent/core/shopping_agent/executor.py · L106-151Python
 106     def domain_error(self, error: Exception) -> ToolOutcome | None:
 107         # The message is backend text arriving outside the fence: sanitized and capped.
 108         detail = self._sanitize(str(error), 200)
 109         if isinstance(error, Unavailable):
 110             return ToolOutcome.error(self.sold_out_text.format(detail=detail or "unavailable"))
 111         if isinstance(error, NotOffered):
 112             return ToolOutcome.error(self.not_offered_text.format(detail=detail or "This"))
 113         return None
 114 
 115     def handlers(self) -> dict[str, Handler]:
 116         return {
 117             "search_products": self._search_products,
 118             "get_product_details": self._get_product_details,
 119             "get_cart": self._get_cart,
 120             "add_to_cart": self._add_to_cart,
 121             "update_cart_item": self._update_cart_item,
 122             "remove_from_cart": self._remove_from_cart,
 123             "get_preferences": self._get_preferences,
 124             "get_orders": self._get_orders,
 125             "get_order_status": self._get_order_status,
 126             "search_policies": self._search_policies,
 127             "get_fulfillment_options": self._get_fulfillment_options,
 128         }
 129 
 130     # -- catalog ---------------------------------------------------------------------
 131 
 132     async def _search_products(self, tool_input: dict[str, Any]) -> ToolOutcome:
 133         query = self._sanitize(tool_input.get("query", ""), 300)
 134         filters = (
 135             parse_argument(SearchFilters, tool_input["filters"])
 136             if tool_input.get("filters")
 137             else None
 138         )
 139         limit = self._search_limit(tool_input.get("limit"))
 140         products = await self._backend.search_products(self._session, query, filters, limit)
 141         self._state.remember_products(products)
 142         return ToolOutcome(search_result_text(query, products, self._config.max_fenced_chars))
 143 
 144     async def _get_product_details(self, tool_input: dict[str, Any]) -> ToolOutcome:
 145         product_id = str(tool_input.get("product_id", ""))
 146         details = await self._backend.get_product_details(self._session, product_id)
 147         if details is None:
 148             return ToolOutcome.error(f"No product with id {product_id}.")
 149         # The variants enter provenance with the record, so the cart takes their ids.
 150         self._state.remember_products([details, *details.variants])
 151         return self._fenced(product_details_payload(details))

▸Merchant staged write 把变更和生效拆开

MerchantToolExecutor 会把更新先写入 staged 状态,再交给 gates 生成 follow-through reminder 或 apply/discard 入口;sources/commerce-agents/merchant-agent/core/merchant_agent/executor.py:91、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:25、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:218 避免模型在一次回复里把商家配置直接改成最终状态。

src/sources/commerce-agents/merchant-agent/core/merchant_agent/executor.py · L248-280Python
 248     async def _staged(self, change: StagedChange) -> ToolOutcome:
 249         """The staged record for the model and the ``change_update`` for the host; with
 250         ``stage_shows_preview`` on, also the preview card, produced by the same runner
 251         and enrichment present_change_preview uses, so the portal registers the change
 252         for approval the same way."""
 253         self._state.remember_change(change)
 254         events = [AgentEvent.change_update(_record(change))]
 255         note = STAGED_NOTE
 256         if self._config.stage_shows_preview:
 257             preview = await self._present(
 258                 self.components[PREVIEW_TOOL], {"change_id": change.change_id}
 259             )
 260             if not preview.refused:
 261                 events.extend(preview.events)
 262                 note = STAGED_AND_SHOWN_NOTE
 263         return self._fenced({"staged": _record(change), "note": note}, events)
 264 
 265     async def _stage_listing_update(self, tool_input: dict[str, Any]) -> ToolOutcome:
 266         listing_id = str(tool_input.get("listing_id", ""))
 267         if held := check_listing_provenance(self._state, [listing_id]) or check_listing_record_read(
 268             self._state, listing_id
 269         ):
 270             return held
 271         fields = coerce_object_arg(tool_input.get("fields")) or {}
 272         if not fields:
 273             return ToolOutcome.error("No fields to change.")
 274         # The content goes live once approved: an over-cap value is refused with the
 275         # limit named rather than truncated into a live listing.
 276         cap = self._config.max_listing_field_chars
 277         for name, value in fields.items():
 278             if isinstance(value, str) and len(value) > cap:
 279                 return ToolOutcome.error(
 280                     f"'{name}' is {len(value)} characters; the limit per listing field "
§ 02 · KEY FLOW

关键流程

shoppingground item + lock cart
→
mutationadd/update/remove only after checks
→
merchantstage listing/campaign
→
approvalapply or discard

gate 不是一个独立 policy service,而是围绕 turn outcome 做小而具体的拦截;sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:47、sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:69、sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:133、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:85、sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:113、sources/commerce-agents/merchant-agent/core/tests/test_executor.py:166 说明项目更偏示例蓝图,但这些小 gate 很容易迁移到真实后端。

§ 03 · EDGE CASES

边界 / 失败模式

  • sources/commerce-agents/shopping-agent/core/shopping_agent/gates.py:156 检查 remove 行为,避免用户话术和 cart line 不一致。
  • sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:163 区分 promotion/campaign gate,商家动作不能混成一个万能确认。
  • sources/commerce-agents/merchant-agent/core/tests/test_executor.py:217 验证 staged 结果,降低 prompt 修改导致审批绕过的风险。

▸Prompt 规则必须回到后端 gate

shopping 和 merchant prompt 都写了行为规范,但真正值得借鉴的是规则会回到代码检查;sources/commerce-agents/shopping-agent/core/shopping_agent/prompt.py:58、sources/commerce-agents/merchant-agent/core/merchant_agent/prompt.py:117 与 sources/commerce-agents/merchant-agent/core/merchant_agent/gates.py:234 一起形成提示词到后端约束的闭环。