subagent_tracker.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. """子智能体生命周期跟踪。
  2. ## 为什么换掉 1.5 的做法
  3. 1.5 的 ``_SubAgentTracker`` 从子图命名空间字符串 ``"tools:<X>"`` 里**反查
  4. ``subagent_type``**:拿到 ``<X>`` 这个 task tool_call 的 id 后,还得回头翻
  5. 历史 tool_calls 才能知道它是哪个子智能体。这条链路脆在两个地方——命名空间
  6. 格式由框架内部决定(升级可能变),以及反查依赖「id 还在最近的历史窗口里」
  7. (一旦被上下文压缩裁掉,反查就落空,子智能体身份丢失)。
  8. ## 2.0 的做法
  9. 在**收到 task 工具调用的那一刻**就把 ``tool_call_id → {subagent_type, 描述}``
  10. 写进一张表,之后所有子图事件只用 id 查表:
  11. - 不解析命名空间语义,只取 ``ns[0]`` 里 ``tools:`` 后面的 id
  12. - 不依赖历史窗口,表由本对象持有,生命周期覆盖整轮请求
  13. - 表项在 task 结果回来时标记 done,可据此判断「还有几个子智能体在跑」
  14. 命名空间格式本身仍来自框架,所以这里做了两种格式的兼容(``tools:<id>``
  15. 与裸 ``<id>``),并且**取不到 id 时不猜**——宁可把事件归到主 agent,
  16. 也不要错标成某个子智能体。
  17. """
  18. from __future__ import annotations
  19. import time
  20. from dataclasses import dataclass, field
  21. from typing import Any, Iterable
  22. from obs import get_logger
  23. logger = get_logger(__name__)
  24. # 子图命名空间里子智能体段的固定前缀(deepagents / langgraph 的约定)
  25. _NS_PREFIX = "tools:"
  26. def parse_namespace(ns: Any) -> str | None:
  27. """从子图命名空间里取出 task tool_call 的 id。
  28. ``("tools:abc123",)`` → ``"abc123"``;主图 ``()`` 或无法识别 → ``None``。
  29. """
  30. if not ns:
  31. return None
  32. if isinstance(ns, str):
  33. first: Any = ns
  34. else:
  35. try:
  36. first = list(ns)[0]
  37. except (IndexError, TypeError):
  38. return None
  39. if not isinstance(first, str):
  40. return None
  41. if first.startswith(_NS_PREFIX):
  42. candidate = first[len(_NS_PREFIX):]
  43. return candidate or None
  44. # 不带前缀的裸 id:兼容命名空间格式变化,但仍要求非空且非已知控制段
  45. if first and ":" not in first and "/" not in first:
  46. return first
  47. return None
  48. @dataclass
  49. class SubagentRun:
  50. """一次子智能体执行的记录。"""
  51. call_id: str
  52. subagent_type: str
  53. description: str = ""
  54. status: str = "started" # started | running | done | error
  55. started_at: float = field(default_factory=time.time)
  56. finished_at: float | None = None
  57. tool_calls: int = 0
  58. @property
  59. def duration_ms(self) -> int | None:
  60. if self.finished_at is None:
  61. return None
  62. return int((self.finished_at - self.started_at) * 1000)
  63. class SubagentTracker:
  64. """一轮请求内的子智能体台账。"""
  65. def __init__(self) -> None:
  66. self._runs: dict[str, SubagentRun] = {}
  67. self._order: list[str] = []
  68. # -------- 写入 --------
  69. def note_task_calls(self, tool_calls: Iterable[dict[str, Any]]) -> list[SubagentRun]:
  70. """从主 agent 的 AIMessage 里登记 ``task`` 工具调用。
  71. 返回本次新登记(以及状态有推进)的子智能体,供上层发事件。
  72. """
  73. touched: list[SubagentRun] = []
  74. for call in tool_calls or []:
  75. if not isinstance(call, dict):
  76. continue
  77. if (call.get("name") or "") != "task":
  78. continue
  79. call_id = call.get("id") or ""
  80. if not call_id:
  81. # 没有 id 就无法把后续子图事件关联回来,跳过而不是编一个
  82. logger.debug("task 调用缺少 id,跳过跟踪")
  83. continue
  84. args = call.get("args") or {}
  85. if not isinstance(args, dict):
  86. args = {}
  87. subagent_type = str(args.get("subagent_type") or "unknown")
  88. description = str(args.get("description") or "")
  89. existing = self._runs.get(call_id)
  90. if existing is None:
  91. run = SubagentRun(
  92. call_id=call_id,
  93. subagent_type=subagent_type,
  94. description=description,
  95. )
  96. self._runs[call_id] = run
  97. self._order.append(call_id)
  98. touched.append(run)
  99. logger.info(
  100. "子智能体启动", extra={"kv": {"event": "subagent.started",
  101. "tool_call_id": call_id, "agent": subagent_type}},
  102. )
  103. elif existing.status == "started":
  104. existing.status = "running"
  105. touched.append(existing)
  106. return touched
  107. def note_namespace(self, ns: Any) -> SubagentRun | None:
  108. """按命名空间标记「该子智能体已在产出」。取不到返回 None。"""
  109. call_id = parse_namespace(ns)
  110. if not call_id:
  111. return None
  112. run = self._runs.get(call_id)
  113. if run is None:
  114. # 表里没有:可能是历史遗留的子图事件(压缩后重建)或框架换了格式。
  115. # 不新建条目——凭空造一个 subagent_type=unknown 的记录只会污染 UI。
  116. logger.debug("命名空间 %s 未匹配到已登记的子智能体,事件归主 agent", ns)
  117. return None
  118. if run.status == "started":
  119. run.status = "running"
  120. run.tool_calls += 1
  121. return run
  122. def note_result(self, tool_call_id: str, ok: bool = True) -> SubagentRun | None:
  123. """登记 task 工具返回,标记子智能体结束。"""
  124. run = self._runs.get(tool_call_id or "")
  125. if run is None:
  126. return None
  127. run.status = "done" if ok else "error"
  128. run.finished_at = time.time()
  129. logger.info(
  130. "子智能体结束 id=%s type=%s status=%s 耗时=%sms 工具调用=%d",
  131. run.call_id[:8], run.subagent_type, run.status, run.duration_ms, run.tool_calls,
  132. extra={"kv": {"event": "subagent.completed", "tool_call_id": run.call_id,
  133. "agent": run.subagent_type, "status": run.status, "duration_ms": run.duration_ms}},
  134. )
  135. return run
  136. def finish_all(self, status: str = "done") -> list[SubagentRun]:
  137. """把所有仍处于活跃状态的子智能体收尾。
  138. 流结束时调用——图可能因为异常或中断提前退出,剩下没收到结果的
  139. 子智能体不能永远挂在 started 状态,否则前端卡片会一直转圈。
  140. """
  141. flushed: list[SubagentRun] = []
  142. for run in self.active():
  143. run.status = status
  144. run.finished_at = time.time()
  145. flushed.append(run)
  146. return flushed
  147. # -------- 读取 --------
  148. def get(self, call_id: str) -> SubagentRun | None:
  149. """按 task 调用 id 取记录。"""
  150. return self._runs.get(call_id or "")
  151. def runs(self) -> list[SubagentRun]:
  152. """全部记录,按启动顺序。"""
  153. return [self._runs[cid] for cid in self._order if cid in self._runs]
  154. def active(self) -> list[SubagentRun]:
  155. """仍在运行的子智能体。"""
  156. return [r for r in self.runs() if r.status in ("started", "running")]
  157. def __len__(self) -> int:
  158. return len(self._runs)