Commerce Gates 与审批语义 深度解析
这一页拆买家和商家两个方向的 gate:什么时候必须先查证商品,什么时候必须锁定购物车,什么时候商家变更只能 stage 不能直接生效。
导入链与契约
领域 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。
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 )
核心实现,逐段拆
▸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、领域错误和测试断言保持一致。
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 避免模型在一次回复里把商家配置直接改成最终状态。
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 "
关键流程
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 很容易迁移到真实后端。
边界 / 失败模式
- 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 一起形成提示词到后端约束的闭环。