commerce-agents.py
§ · Nested detail page

Turn Loop 与 Streaming Round 深度解析

这一页只拆公共 turn runtime:如何找到最新用户输入、压缩历史、边收 stream 边合成 tool_use,并在中断后 salvage 成可继续处理的事件序列。

§ 00 · IMPORT CHAIN

导入链与契约

turn.py 的入口并不复杂,但职责很集中:sources/commerce-agents/commerce-common/commerce_common/turn.py:67、sources/commerce-agents/commerce-common/commerce_common/turn.py:87、sources/commerce-agents/commerce-common/commerce_common/turn.py:110、sources/commerce-agents/commerce-common/commerce_common/turn.py:124 先定义本轮上下文怎么从历史里取出来,再把后续流式事件放进一个可以被 executor 消费的结构。

src/sources/commerce-agents/commerce-common/commerce_common/turn.py · L67-95Python
  67 def latest_user_text(messages: list[dict[str, Any]], host_texts: Collection[str] = ()) -> str:
  68     """The most recent user-authored text; "" when the conversation has none."""
  69     for message in reversed(messages):
  70         if message.get("role") != "user":
  71             continue
  72         content = message.get("content")
  73         texts = _text_blocks(content)
  74         if texts and all(text in host_texts for text in texts):
  75             continue
  76         if texts:
  77             return "\n".join(texts)
  78         # A message of tool results is the loop's own; anything else ends the search.
  79         if isinstance(content, list) and any(
  80             isinstance(block, dict) and block.get("type") == "tool_result" for block in content
  81         ):
  82             continue
  83         return ""
  84     return ""
  85 
  86 
  87 def latest_exchange(
  88     messages: list[dict[str, Any]], host_texts: Collection[str] = ()
  89 ) -> list[dict[str, Any]]:
  90     """The slice from the most recent user-authored message onward: what one turn's
  91     extraction pass reads, so earlier turns are not re-extracted in new words."""
  92     for index in range(len(messages) - 1, -1, -1):
  93         if _is_user_text(messages[index], host_texts):
  94             return messages[index:]
  95     return messages
§ 01 · CORE IMPL

核心实现,逐段拆

▸EagerDispatcher 让工具调用提前执行

dispatcher 观察 tool_use block 的 JSON 参数,一旦满足 contract 就提前 dispatch;sources/commerce-agents/commerce-common/commerce_common/turn.py:174、sources/commerce-agents/commerce-common/commerce_common/turn.py:186、sources/commerce-agents/commerce-common/commerce_common/turn.py:199、sources/commerce-agents/commerce-common/commerce_common/turn.py:214、sources/commerce-agents/commerce-common/commerce_common/execution.py:214 说明它既追求低延迟,也保留了失败时返回到普通 round 处理的空间。

src/sources/commerce-agents/commerce-common/commerce_common/turn.py · L174-220Python
 174 class EagerDispatcher:
 175     """Tool execution started mid-stream. When a ``tool_use`` block's buffered JSON
 176     parses at ``content_block_stop``, the call's arguments are final — the loop starts
 177     executing then, while the model writes the rest of the round, so tool time overlaps
 178     generation time. ``collect`` is the post-stream join: started calls are awaited,
 179     anything the stream path missed (unparseable buffer, dispatch off) executes from the
 180     final message's canonical args, and every call runs exactly once. ``cancel`` is the
 181     error and malformed-stream backstop; a started task must not outlive its turn."""
 182 
 183     def __init__(
 184         self, execute: Callable[[str, dict[str, Any]], Awaitable[ToolOutcome]], enabled: bool
 185     ) -> None:
 186         self._execute = execute
 187         self._enabled = enabled
 188         self._tasks: dict[str, asyncio.Future[ToolOutcome]] = {}
 189 
 190     def started(self, tool_use_id: str) -> bool:
 191         return tool_use_id in self._tasks
 192 
 193     def dispatch(self, name: str, tool_use_id: str, args: dict[str, Any] | None) -> bool:
 194         """Start ``name`` on ``args``; False when it did not start (dispatch off, already
 195         started, or no parsed args) and the join executes it later."""
 196         if not self._enabled or args is None or tool_use_id in self._tasks:
 197             return False
 198         self._tasks[tool_use_id] = asyncio.ensure_future(self._execute(name, args))
 199         return True
 200 
 201     def settle(self, tool_use_id: str, outcome: ToolOutcome) -> None:
 202         """Give ``tool_use_id`` its outcome without executing it (a call whose input
 203         never parsed); the join returns it in place. A started call keeps its task."""
 204         if tool_use_id in self._tasks:
 205             return
 206         settled: asyncio.Future[ToolOutcome] = asyncio.get_running_loop().create_future()
 207         settled.set_result(outcome)
 208         self._tasks[tool_use_id] = settled
 209 
 210     async def collect(self, tool_uses: list[Any]) -> list[ToolOutcome]:
 211         return await asyncio.gather(
 212             *(
 213                 self._tasks[block.id]
 214                 if block.id in self._tasks
 215                 else self._execute(block.name, dict(block.input or {}))
 216                 for block in tool_uses
 217             )
 218         )
 219 
 220     def cancel(self) -> None:

▸salvage 与 close_open_tool_uses 兜住半截 stream

当模型输出停在 tool_use 半路,runtime 不能把会话留在不一致状态;sources/commerce-agents/commerce-common/commerce_common/turn.py:379、sources/commerce-agents/commerce-common/commerce_common/turn.py:401、sources/commerce-agents/commerce-common/commerce_common/turn.py:501、sources/commerce-agents/commerce-common/commerce_common/turn.py:520、sources/commerce-agents/commerce-common/commerce_common/turn.py:544 把 partial round 转成可记录事件,再补齐未关闭工具调用的结果。

src/sources/commerce-agents/commerce-common/commerce_common/turn.py · L401-427Python
 401 def salvage_round(
 402     streamed: StreamedRound,
 403     dispatcher: EagerDispatcher,
 404     caller: logging.Logger,
 405     session_id: str | None,
 406     round_index: int,
 407 ) -> tuple[dict[str, Any] | None, list[Any], set[str]]:
 408     """:meth:`StreamedRound.salvaged` for a round the SDK's accumulator abandoned, with
 409     each unreadable client call settled on ``dispatcher`` as an error result so the model
 410     sends it again, and a WARNING per unreadable call on ``caller`` naming it and the size
 411     of what arrived, never the input."""
 412     reply, tool_uses, unreadable = streamed.salvaged()
 413     for tool in streamed.tools.values():
 414         if tool.id not in unreadable:
 415             continue
 416         caller.warning(
 417             "tool input unreadable session=%s round=%d tool=%s id=%s chars=%d",
 418             session_tag(session_id),
 419             round_index,
 420             tool.name,
 421             tool.id,
 422             len(tool.buffer),
 423             stacklevel=2,
 424         )
 425         if not tool.server:
 426             dispatcher.settle(tool.id, ToolOutcome.error(UNREADABLE_INPUT_TEXT))
 427     return reply, tool_uses, unreadable
§ 02 · KEY FLOW

关键流程

historylatest_exchange + compact_history
→
stream blockstext/tool_use/json_delta
→
eager dispatchcontract-ready inputs
→
salvageoutcome events + closure

完整流程由历史提取、stream feed、frame/relay、outcome event 和关闭工具调用组成;这些节点分布在 sources/commerce-agents/commerce-common/commerce_common/turn.py:124、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:344、sources/commerce-agents/commerce-common/commerce_common/turn.py:501、sources/commerce-agents/commerce-common/tests/test_turn.py:77,测试直接覆盖了文本输出、工具结果和中断恢复。

§ 03 · EDGE CASES

边界 / 失败模式

  • 如果 partial JSON 不能被解析,sources/commerce-agents/commerce-common/commerce_common/turn.py:379 会保留原始输入而不是伪造参数。
  • 如果 stop_reason 已经关闭一轮,sources/commerce-agents/commerce-common/commerce_common/turn.py:520 会影响是否继续追加工具结果。
  • sources/commerce-agents/commerce-common/tests/test_turn.py:147 和 sources/commerce-agents/commerce-common/tests/test_turn.py:191 覆盖了 salvage 与未关闭 tool_use。

▸测试如何固定 turn loop 语义

turn loop 的测试价值在于覆盖异常形态,而不是只测一条完整文本回复;sources/commerce-agents/commerce-common/tests/test_turn.py:42、sources/commerce-agents/commerce-common/tests/test_turn.py:106 与 sources/commerce-agents/commerce-common/tests/test_turn.py:220 分别把最新输入、tool_use 聚合和关闭动作固定下来。