langchain-agentx-python 0.9.0__py3-none-any.whl → 0.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. langchain_agentx/__init__.py +1 -1
  2. langchain_agentx/loop/exit/exit_logic.py +17 -112
  3. langchain_agentx/loop/exit/reason_codes.py +40 -0
  4. langchain_agentx/loop/exit/withheld_error.py +55 -0
  5. langchain_agentx/loop/graph/graph_edges.py +50 -0
  6. langchain_agentx/loop/hook/__init__.py +7 -4
  7. langchain_agentx/loop/hook/engine.py +8 -12
  8. langchain_agentx/loop/hook/progress_payload.py +129 -0
  9. langchain_agentx/loop/hook/registry.py +29 -15
  10. langchain_agentx/loop/model/model_node.py +23 -1
  11. langchain_agentx/loop/model/retrier.py +41 -22
  12. langchain_agentx/loop/model/retry_events.py +2 -0
  13. langchain_agentx/loop/model/retry_progress.py +113 -0
  14. langchain_agentx/loop/subagent/context.py +5 -0
  15. langchain_agentx/loop/subagent/orchestrator.py +10 -0
  16. langchain_agentx/loop/subagent/progress.py +3 -2
  17. langchain_agentx/loop/subagent/runner.py +61 -11
  18. langchain_agentx/loop/subagent/subagent_termination_classifier.py +99 -0
  19. langchain_agentx/observability/__init__.py +16 -0
  20. langchain_agentx/observability/events/__init__.py +18 -0
  21. langchain_agentx/observability/events/finish_event_payload_composer.py +260 -0
  22. langchain_agentx/observability/events/finish_outcome.py +233 -0
  23. langchain_agentx/observability/events/langchain_agentx_event_adapter.py +33 -7
  24. langchain_agentx/provider/semantics/contracts.py +15 -0
  25. langchain_agentx/provider/semantics/families/anthropic_finish_reason.py +59 -0
  26. langchain_agentx/provider/semantics/families/compatible_openai_finish_reason.py +53 -0
  27. langchain_agentx/provider/semantics/families/openai_finish_reason.py +61 -0
  28. langchain_agentx/provider/semantics/finish_reason_normalizer.py +224 -0
  29. langchain_agentx/provider/semantics/profile.py +11 -0
  30. langchain_agentx/provider/semantics/registry.py +20 -0
  31. langchain_agentx/session/agent_session.py +4 -1
  32. langchain_agentx/tools/agent/models.py +5 -0
  33. langchain_agentx/tools/agent/tool.py +21 -2
  34. {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/METADATA +1 -1
  35. {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/RECORD +38 -28
  36. {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/LICENSE +0 -0
  37. {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/WHEEL +0 -0
  38. {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,7 @@ from langchain_agentx import create_loop_agent
11
11
  ```
12
12
  """
13
13
 
14
- __version__ = "0.9.0"
14
+ __version__ = "0.9.2"
15
15
 
16
16
  from .loop import ( # noqa: F401
17
17
  create_loop_agent,
@@ -42,77 +42,28 @@ _logger = logging.getLogger(__name__)
42
42
  # 需要继续执行的 finish_reason 集合(与 OpenCode 一致)
43
43
  CONTINUE_FINISH_REASONS = frozenset({"tool-calls", "unknown"})
44
44
 
45
- # extract_finish_reason 输出的框架归一化值(含内部写入 metadata 的哨兵,如 corrector 写 "tool-calls")。
46
- # 维护规则:新增 extract_finish_reason 分支输出时同步补入;与 _PROVIDER_FINISH_REASONS 分离。
47
- _FRAMEWORK_NORMALIZED_FINISH_REASONS: frozenset[str] = frozenset(
48
- {
49
- "tool-calls",
50
- "unknown",
51
- "stop",
52
- "length",
53
- "content-filter",
54
- "error",
55
- "sensitive",
56
- }
57
- )
58
-
59
- # merge_dicts 拼接还原白名单:provider 原始 finish_reason 枚举(非框架归一化输出)。
60
- # 维护规则:新增 provider 协议枚举时补入;框架哨兵见 _FRAMEWORK_NORMALIZED_FINISH_REASONS。
61
- _PROVIDER_FINISH_REASONS: frozenset[str] = frozenset(
62
- {
63
- # OpenAI 协议
64
- "tool_calls",
65
- "function_call",
66
- "tool_use",
67
- "stop",
68
- "stop_sequence",
69
- "length",
70
- "max_tokens",
71
- "content_filter",
72
- "content-filter",
73
- "refusal",
74
- "error",
75
- # Anthropic 协议
76
- "end_turn",
77
- # GLM / 国产模型特有
78
- "sensitive",
79
- # 部分 adapter 可能写入的继续哨兵(与 CONTINUE_FINISH_REASONS 对齐)
80
- "unknown",
81
- }
82
- )
83
45
 
46
+ def extract_finish_reason(response: AIMessage | None) -> str | None:
47
+ """
48
+ 从 LangChain `AIMessage` 中提取并标准化 `finish_reason`。
84
49
 
85
- def _normalize_concat_finish_reason(raw: str) -> str:
86
- """将 merge_dicts 拼接的畸形 finish_reason 还原为已知枚举值。
50
+ 委托 ProviderSemantics family normalizer 处理 provider 差异,
51
+ 再经框架级别名收口;metadata_override 仍由独立函数负责。
52
+ """
53
+ from langchain_agentx.provider.semantics.finish_reason_normalizer import (
54
+ get_default_finish_reason_dispatcher,
55
+ )
87
56
 
88
- 算法原理:
89
- langchain_core.utils._merge.merge_dicts 合并流式 chunk 的 response_metadata 时,
90
- 对字符串值使用 ``+=`` 拼接。当每个 chunk 都携带相同 finish_reason(如 GLM),
91
- N 个 ``"tool_calls"`` 会被拼成 ``"tool_callstool_calls...tool_calls"``。
92
- 本函数检测输入是否为白名单枚举的整数次重复,若是则还原为单次枚举。
57
+ return get_default_finish_reason_dispatcher().extract(response)
93
58
 
94
- 与 merge_dicts 的关系:
95
- 对未拼接的正常枚举值是 no-op;仅修复拼接畸形。merge_dicts 行为变更不影响安全性。
96
59
 
97
- 白名单维护:
98
- 仅覆盖 provider 原始枚举(``_PROVIDER_FINISH_REASONS``);框架归一化值见
99
- ``_FRAMEWORK_NORMALIZED_FINISH_REASONS``,不在此做拼接还原。
60
+ def interpret_finish_reason(response: AIMessage | None):
61
+ """返回 finish_reason 完整解释结果(含 overflow_kind / provider_family)。"""
62
+ from langchain_agentx.provider.semantics.finish_reason_normalizer import (
63
+ get_default_finish_reason_dispatcher,
64
+ )
100
65
 
101
- 未命中行为:
102
- 无法还原时原样透传;告警在 ``extract_finish_reason`` 分类完成后、对仍未识别的
103
- provider 枚举打出(不含框架已归一化值)。
104
- """
105
- if raw in _FRAMEWORK_NORMALIZED_FINISH_REASONS:
106
- return raw
107
- if raw in _PROVIDER_FINISH_REASONS:
108
- return raw
109
- for base in _PROVIDER_FINISH_REASONS:
110
- unit_len = len(base)
111
- if len(raw) > unit_len and len(raw) % unit_len == 0:
112
- repeat_count = len(raw) // unit_len
113
- if raw == base * repeat_count:
114
- return base
115
- return raw
66
+ return get_default_finish_reason_dispatcher().interpret(response)
116
67
 
117
68
 
118
69
  # 可恢复终态集合(terminal_reason 枚举,先 withhold,由 loop_controller 走恢复链)
@@ -137,53 +88,6 @@ def resolve_escalated_max_output_tokens(max_output_tokens_cap: int | None) -> in
137
88
  return min(ESCALATED_MAX_OUTPUT_TOKENS, int(max_output_tokens_cap))
138
89
 
139
90
 
140
- def extract_finish_reason(response: AIMessage | None) -> str | None:
141
- """
142
- 从 LangChain `AIMessage` 中提取并标准化 `finish_reason`。
143
-
144
- 该实现与原始 LangChain AgentX 逻辑保持一致。
145
- """
146
- if response is None:
147
- return None
148
-
149
- meta = getattr(response, "response_metadata", None) or {}
150
- if not isinstance(meta, dict):
151
- meta = {}
152
-
153
- raw_reason = (
154
- meta.get("finish_reason")
155
- or meta.get("stop_reason")
156
- or meta.get("done_reason")
157
- )
158
-
159
- if raw_reason is None:
160
- return None
161
-
162
- finish_reason = _normalize_concat_finish_reason(str(raw_reason))
163
-
164
- if finish_reason in _FRAMEWORK_NORMALIZED_FINISH_REASONS:
165
- return finish_reason
166
-
167
- if finish_reason in {"tool_calls", "function_call", "tool_use"}:
168
- return "tool-calls"
169
- if finish_reason in {"content_filter", "content-filter"}:
170
- return "content-filter"
171
- if finish_reason in {"length", "max_tokens"}:
172
- return "length"
173
- if finish_reason in {"stop", "end_turn", "stop_sequence"}:
174
- return "stop"
175
- if finish_reason == "refusal":
176
- return "content-filter"
177
- if finish_reason == "error":
178
- return "error"
179
-
180
- if finish_reason:
181
- _logger.warning(
182
- "unrecognized finish_reason from provider metadata: %r", finish_reason
183
- )
184
- return finish_reason
185
-
186
-
187
91
  def metadata_override_terminal_reason(response: AIMessage | None) -> str | None:
188
92
  """Infer terminal_reason from response_metadata / provider error shape (PTL / media / blocking).
189
93
 
@@ -415,6 +319,7 @@ __all__ = [
415
319
  "MAX_OUTPUT_TOKENS_RECOVERY_LIMIT",
416
320
  "resolve_escalated_max_output_tokens",
417
321
  "extract_finish_reason",
322
+ "interpret_finish_reason",
418
323
  "determine_should_end",
419
324
  "create_initial_state",
420
325
  "AgentLoopState",
@@ -30,6 +30,7 @@ TRANSITION_REACTIVE_COMPACT_RETRY = "reactive_compact_retry"
30
30
  TRANSITION_MAX_OUTPUT_TOKENS_ESCALATE = "max_output_tokens_escalate"
31
31
  TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY = "max_output_tokens_recovery"
32
32
  TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY_EXHAUSTED = "max_output_tokens_recovery_exhausted"
33
+ TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED = "context_window_recovery_exhausted"
33
34
  TRANSITION_TASK_NOTIFICATION_ARRIVED = "task_notification_arrived"
34
35
  TRANSITION_STOP_HOOK_PREVENTED = "stop_hook_prevented"
35
36
  TRANSITION_STOP_HOOK_BLOCKING = "stop_hook_blocking"
@@ -37,3 +38,42 @@ TRANSITION_TOKEN_BUDGET_CONTINUATION = "token_budget_continuation"
37
38
 
38
39
  # token_budget continuation 默认上限(与 CC token budget nudge 次数护栏同向)
39
40
  DEFAULT_TOKEN_BUDGET_MAX_CONTINUATIONS = 3
41
+
42
+ # 错误结束 terminal_reason 集合(finish payload is_error 判据)
43
+ ERROR_TERMINAL_REASONS = frozenset(
44
+ {
45
+ TERMINAL_REASON_ERROR,
46
+ TERMINAL_REASON_MAX_TOKENS,
47
+ TERMINAL_REASON_BLOCKING_LIMIT,
48
+ }
49
+ )
50
+
51
+ __all__ = [
52
+ "TERMINAL_REASON_COMPLETED",
53
+ "TERMINAL_REASON_MAX_STEPS",
54
+ "TERMINAL_REASON_MAX_TURNS",
55
+ "TERMINAL_REASON_MAX_TOKENS",
56
+ "TERMINAL_REASON_CONTENT_FILTER",
57
+ "TERMINAL_REASON_BLOCKING_LIMIT",
58
+ "TERMINAL_REASON_STOP_HOOK_PREVENTED",
59
+ "TERMINAL_REASON_ERROR",
60
+ "TERMINAL_REASON_ABORTED_STREAMING",
61
+ "TERMINAL_REASON_ABORTED_TOOLS",
62
+ "TERMINAL_REASON_HOOK_STOPPED",
63
+ "ERROR_TERMINAL_REASONS",
64
+ "TRANSITION_NEXT_TURN",
65
+ "TRANSITION_API_ERROR_TERMINAL",
66
+ "TRANSITION_WITHHOLD_RECOVERY_RETRY",
67
+ "TRANSITION_WITHHOLD_RECOVERY_EXHAUSTED",
68
+ "TRANSITION_COLLAPSE_DRAIN_RETRY",
69
+ "TRANSITION_REACTIVE_COMPACT_RETRY",
70
+ "TRANSITION_MAX_OUTPUT_TOKENS_ESCALATE",
71
+ "TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY",
72
+ "TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY_EXHAUSTED",
73
+ "TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED",
74
+ "TRANSITION_TASK_NOTIFICATION_ARRIVED",
75
+ "TRANSITION_STOP_HOOK_PREVENTED",
76
+ "TRANSITION_STOP_HOOK_BLOCKING",
77
+ "TRANSITION_TOKEN_BUDGET_CONTINUATION",
78
+ "DEFAULT_TOKEN_BUDGET_MAX_CONTINUATIONS",
79
+ ]
@@ -0,0 +1,55 @@
1
+ """
2
+ withheld_error.py — withhold 恢复错误载荷组装
3
+
4
+ 职责:
5
+ 从 terminal_reason / finish_reason / AIMessage 解释结果组装 withheld_error 结构,
6
+ 供 ModelNode 与 GraphEdges 恢复状态机共享。
7
+
8
+ 链路位置:
9
+ ModelNode._resolve_withhold_recovery_update → withheld_error → GraphEdges._handle_withheld_error
10
+
11
+ 当前裁剪范围:
12
+ - 不包含恢复梯子路由(见 graph_edges.py)
13
+ - 不包含 finish 事件投影(Phase 3)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from langchain_core.messages import AIMessage
22
+
23
+
24
+ def build_withheld_error(
25
+ *,
26
+ terminal_reason: str,
27
+ finish_reason: str | None,
28
+ last_ai_message: "AIMessage | None" = None,
29
+ ) -> dict[str, Any]:
30
+ """组装 withheld_error 载荷(含 overflow 子语义字段)。"""
31
+ from langchain_agentx.loop.exit.exit_logic import interpret_finish_reason
32
+
33
+ interpretation = (
34
+ interpret_finish_reason(last_ai_message) if last_ai_message is not None else None
35
+ )
36
+ return {
37
+ "reason": terminal_reason,
38
+ "finish_reason": finish_reason,
39
+ "overflow_kind": interpretation.overflow_kind if interpretation else None,
40
+ "raw_finish_reason": interpretation.raw_reason if interpretation else None,
41
+ "provider_family": interpretation.provider_family if interpretation else None,
42
+ }
43
+
44
+
45
+ def resolve_overflow_kind(withheld_error: dict[str, Any] | None) -> str:
46
+ """解析 withheld_error 的 overflow 子语义;缺失时保守为 unknown。"""
47
+ if not isinstance(withheld_error, dict):
48
+ return "unknown"
49
+ kind = withheld_error.get("overflow_kind")
50
+ if kind in ("context_window", "output_limit"):
51
+ return str(kind)
52
+ return "unknown"
53
+
54
+
55
+ __all__ = ["build_withheld_error", "resolve_overflow_kind"]
@@ -47,6 +47,7 @@ from ..exit.exit_logic import (
47
47
  TOKEN_BUDGET_NUDGE_USER_MESSAGE,
48
48
  resolve_escalated_max_output_tokens,
49
49
  )
50
+ from ..exit.withheld_error import resolve_overflow_kind
50
51
  from ..exit.reason_codes import (
51
52
  DEFAULT_TOKEN_BUDGET_MAX_CONTINUATIONS,
52
53
  TERMINAL_REASON_ABORTED_STREAMING,
@@ -58,6 +59,7 @@ from ..exit.reason_codes import (
58
59
  TERMINAL_REASON_STOP_HOOK_PREVENTED,
59
60
  TRANSITION_API_ERROR_TERMINAL,
60
61
  TRANSITION_COLLAPSE_DRAIN_RETRY,
62
+ TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED,
61
63
  TRANSITION_MAX_OUTPUT_TOKENS_ESCALATE,
62
64
  TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY,
63
65
  TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY_EXHAUSTED,
@@ -327,9 +329,57 @@ class LoopControllerEdge:
327
329
  if not withheld_error:
328
330
  return None
329
331
  if withheld_error.get("reason") == TERMINAL_REASON_MAX_TOKENS:
332
+ overflow_kind = resolve_overflow_kind(withheld_error)
333
+ if overflow_kind == "context_window":
334
+ return self._handle_context_window_recovery(state, withheld_error)
330
335
  return self._handle_max_tokens_recovery(state, withheld_error)
331
336
  return self._handle_generic_withheld(state, withheld_error)
332
337
 
338
+ def _handle_context_window_recovery(
339
+ self,
340
+ state: dict[str, Any],
341
+ withheld_error: dict[str, Any],
342
+ ) -> str:
343
+ if not state.get("has_attempted_reactive_compact", False):
344
+ state["has_attempted_reactive_compact"] = True
345
+ state["transition"] = {
346
+ "reason": TRANSITION_REACTIVE_COMPACT_RETRY,
347
+ "error_reason": TERMINAL_REASON_MAX_TOKENS,
348
+ "overflow_kind": "context_window",
349
+ }
350
+ emit_loop_decision_event(
351
+ state=state,
352
+ decision="continue",
353
+ selected_edge=self._model_destination,
354
+ reason_code="reactive_compact_retry",
355
+ evidence={
356
+ "withheld_error": withheld_error,
357
+ "overflow_kind": "context_window",
358
+ "step": state.get("step", 0),
359
+ },
360
+ )
361
+ return self._model_destination
362
+ state["withheld_error"] = None
363
+ state["should_end"] = True
364
+ state["terminal_reason"] = TERMINAL_REASON_MAX_TOKENS
365
+ state["transition"] = {
366
+ "reason": TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED,
367
+ "error_reason": TERMINAL_REASON_MAX_TOKENS,
368
+ "overflow_kind": "context_window",
369
+ }
370
+ emit_loop_decision_event(
371
+ state=state,
372
+ decision="terminal",
373
+ selected_edge=self._end_destination,
374
+ reason_code="context_window_recovery_exhausted",
375
+ evidence={
376
+ "withheld_error": withheld_error,
377
+ "overflow_kind": "context_window",
378
+ "step": state.get("step", 0),
379
+ },
380
+ )
381
+ return self._end_destination
382
+
333
383
  def _handle_max_tokens_recovery(
334
384
  self,
335
385
  state: dict[str, Any],
@@ -9,10 +9,11 @@ loop.hook — Hook 子系统(P1/P2)。
9
9
 
10
10
  当前裁剪范围:
11
11
  已包含 P1 + P2 基础执行器导出;并行聚合与治理细节见 HookEngine/规划文档。
12
+ Phase 1:新增 HookProgressPayloadBuilder 用于 progress 展示元数据构建。
12
13
  """
13
14
 
14
- from .config import HookCandidate, HookCandidateDecision, HookSpec, HooksConfigSnapshot
15
15
  from .async_hook_runner import AsyncHookRunner
16
+ from .config import HookCandidate, HookCandidateDecision, HookSpec, HooksConfigSnapshot
16
17
  from .engine import HookEngine
17
18
  from .executors import AgentExecutor, CommandExecutor, HttpExecutor, PromptExecutor
18
19
  from .graph_wiring import HookGraphWiring, derive_hook_outcome
@@ -25,6 +26,7 @@ from .hook_projection import (
25
26
  derive_attachment_type,
26
27
  hook_result_to_event_dict,
27
28
  )
29
+ from .progress_payload import HookProgressPayloadBuilder
28
30
  from .registry import HookRegistry
29
31
  from .trust import WorkspaceTrustChecker
30
32
  from .types import (
@@ -36,13 +38,14 @@ from .types import (
36
38
  )
37
39
 
38
40
  __all__ = [
41
+ "AggregatedHookResult",
42
+ "AsyncHookRunner",
39
43
  "ATTACHMENT_HOOK_BLOCKING_ERROR",
40
44
  "ATTACHMENT_HOOK_CANCELLED",
41
45
  "ATTACHMENT_HOOK_NON_BLOCKING_ERROR",
42
46
  "ATTACHMENT_HOOK_STOPPED_CONTINUATION",
43
47
  "ATTACHMENT_HOOK_SUCCESS",
44
- "AggregatedHookResult",
45
- "AsyncHookRunner",
48
+ "CommandExecutor",
46
49
  "HookCandidate",
47
50
  "HookCandidateDecision",
48
51
  "HookContext",
@@ -50,11 +53,11 @@ __all__ = [
50
53
  "HookEvent",
51
54
  "HookExecutionRecord",
52
55
  "HookGraphWiring",
56
+ "HookProgressPayloadBuilder",
53
57
  "HookRegistry",
54
58
  "HookResult",
55
59
  "HookSpec",
56
60
  "HooksConfigSnapshot",
57
- "CommandExecutor",
58
61
  "HttpExecutor",
59
62
  "PromptExecutor",
60
63
  "AgentExecutor",
@@ -10,6 +10,7 @@ engine.py — Hook 执行引擎(P1/P2)。
10
10
  当前裁剪范围:
11
11
  已覆盖 function/callback + command/http/prompt/agent 执行器;
12
12
  当前执行策略为 callback/function 顺序、其余执行器并行。
13
+ Phase 1:使用 HookProgressPayloadBuilder 构建 progress payload。
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -38,6 +39,7 @@ from .types import (
38
39
  PermissionBehavior,
39
40
  )
40
41
  from .hook_projection import build_hook_info
42
+ from .progress_payload import HookProgressPayloadBuilder
41
43
  from .trust import WorkspaceTrustChecker
42
44
 
43
45
  if TYPE_CHECKING:
@@ -86,6 +88,7 @@ class HookEngine:
86
88
  *,
87
89
  event_emitter: HookEventEmitter | None = None,
88
90
  trust_checker: WorkspaceTrustChecker | None = None,
91
+ progress_payload_builder: HookProgressPayloadBuilder | None = None,
89
92
  ) -> None:
90
93
  self._snapshot = snapshot
91
94
  self._function_executor = FunctionExecutor()
@@ -96,6 +99,8 @@ class HookEngine:
96
99
  self._agent_executor = AgentExecutor()
97
100
  self._event_emitter = event_emitter
98
101
  self._trust_checker = trust_checker
102
+ # Phase 1:使用专门的 builder 而非手写 payload dict
103
+ self._progress_builder = progress_payload_builder or HookProgressPayloadBuilder()
99
104
 
100
105
  async def execute(self, ctx: HookContext) -> AggregatedHookResult:
101
106
  batch_started_at = time.perf_counter()
@@ -160,19 +165,10 @@ class HookEngine:
160
165
  ctx: HookContext,
161
166
  agg: AggregatedHookResult,
162
167
  ) -> HookResult:
163
- hook_name = str(candidate.spec.config.get("name") or candidate.spec.hook_id)
168
+ # Phase 1:使用 HookProgressPayloadBuilder 构建完整 payload
164
169
  if self._event_emitter is not None:
165
- self._event_emitter.emit_progress(
166
- {
167
- "hook_id": candidate.spec.hook_id,
168
- "hook_name": hook_name,
169
- "hook_event": ctx.event.value,
170
- "tool_call_id": ctx.tool_call_id,
171
- "session_id": ctx.session_id,
172
- "node_level": False,
173
- "phase": "running",
174
- }
175
- )
170
+ progress_payload = self._progress_builder.build(candidate, ctx)
171
+ self._event_emitter.emit_progress(progress_payload)
176
172
  started_at = time.perf_counter()
177
173
  one = await self._run_one(candidate, ctx)
178
174
  duration_ms = (time.perf_counter() - started_at) * 1000
@@ -0,0 +1,129 @@
1
+ """
2
+ progress_payload.py — Hook Progress 展示元数据构建器(Phase 1)。
3
+
4
+ 职责:
5
+ 从 HookSpec + HookContext 生成包含 status_message/command/prompt_text/executor_type
6
+ 的 progress payload,供 CLI spinner 使用。
7
+
8
+ 链路位置:
9
+ HookEngine._run_with_record() 调用 builder 构建 HOOK_PROGRESS.data,
10
+ 避免在 engine 中手写 payload dict。
11
+
12
+ 当前裁剪范围:
13
+ 仅处理四类 P2 执行器(command/http/prompt/agent)的展示元数据推导;
14
+ CLI 侧的 spinner suffix 推导与截断逻辑不在本模块。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ if TYPE_CHECKING:
22
+ from .config import HookCandidate, HookSpec
23
+ from .types import HookContext
24
+
25
+ # 模块常量:展示预览的最大长度(避免污染 TUI)
26
+ # CLI 可根据需要进一步调整策略
27
+ _MAX_PREVIEW_LENGTH = 120
28
+ _TRUNCATION_SUFFIX = "..."
29
+
30
+
31
+ def _truncate_preview(text: str) -> str:
32
+ """截断文本到最大预览长度。"""
33
+ if len(text) > _MAX_PREVIEW_LENGTH:
34
+ return text[:_MAX_PREVIEW_LENGTH] + _TRUNCATION_SUFFIX
35
+ return text
36
+
37
+
38
+ def _extract_display_command(spec: HookSpec) -> str | None:
39
+ """从 command hook 提取展示用的 command(截断、脱敏)。"""
40
+ cmd = spec.config.get("command")
41
+ if isinstance(cmd, str) and cmd.strip():
42
+ return _truncate_preview(cmd.strip())
43
+ return None
44
+
45
+
46
+ def _extract_prompt_text(spec: HookSpec) -> str | None:
47
+ """从 prompt/agent hook 提取 prompt_text(截断)。"""
48
+ prompt = spec.config.get("prompt")
49
+ if isinstance(prompt, str) and prompt.strip():
50
+ return _truncate_preview(prompt.strip())
51
+ return None
52
+
53
+
54
+ def _get_status_message(spec: HookSpec) -> str | None:
55
+ """从 HookSpec.config 读取 status_message(snake_case 契约)。"""
56
+ msg = spec.config.get("status_message")
57
+ if isinstance(msg, str) and msg.strip():
58
+ return msg.strip()
59
+ return None
60
+
61
+
62
+ class HookProgressPayloadBuilder:
63
+ """Hook Progress 展示元数据构建器。
64
+
65
+ 职责边界:
66
+ - 输入:HookCandidate + HookContext
67
+ - 输出:包含 status_message/command/prompt_text/executor_type 的 payload dict
68
+ - 不做:CLI spinner suffix 拼接、UI 文案生成、HookContext 污染
69
+
70
+ 设计原则:
71
+ - SDK 输出稳定元数据字段,CLI 负责展示逻辑
72
+ - command/prompt_text 提供截断预览,CLI 可进一步处理
73
+ - executor_type 固定输出,帮助 CLI 分类处理
74
+ """
75
+
76
+ def build(self, candidate: "HookCandidate", ctx: "HookContext") -> dict[str, Any]:
77
+ """构建 HOOK_PROGRESS.data payload。
78
+
79
+ Returns:
80
+ 包含以下字段的 dict:
81
+ - hook_id: per-script hook ID
82
+ - hook_name: 展示名(config.name 优先)
83
+ - hook_event: 事件类型
84
+ - executor_type: 执行器类型(command/http/prompt/agent 等)
85
+ - status_message: 自定义状态消息(可选)
86
+ - command: command hook 的展示命令(仅 executor_type == "command")
87
+ - prompt_text: prompt/agent hook 的 prompt 预览(仅相关类型)
88
+ - tool_call_id: 工具调用 ID
89
+ - session_id: 会话 ID
90
+ - node_level: 是否为节点级事件
91
+ - phase: "running"
92
+ """
93
+ spec = candidate.spec
94
+
95
+ # 基础字段
96
+ payload: dict[str, Any] = {
97
+ "hook_id": spec.hook_id,
98
+ "hook_name": str(spec.config.get("name") or spec.hook_id),
99
+ "hook_event": ctx.event.value,
100
+ "executor_type": spec.executor_type,
101
+ "tool_call_id": ctx.tool_call_id,
102
+ "session_id": ctx.session_id,
103
+ "node_level": False,
104
+ "phase": "running",
105
+ }
106
+
107
+ # 展示元数据:status_message(所有类型通用)
108
+ status_msg = _get_status_message(spec)
109
+ if status_msg:
110
+ payload["status_message"] = status_msg
111
+
112
+ # 展示元数据:command(仅 command hook)
113
+ if spec.executor_type == "command":
114
+ display_cmd = _extract_display_command(spec)
115
+ if display_cmd:
116
+ payload["command"] = display_cmd
117
+
118
+ # 展示元数据:prompt_text(仅 prompt/agent hook)
119
+ if spec.executor_type in ("prompt", "agent"):
120
+ prompt_preview = _extract_prompt_text(spec)
121
+ if prompt_preview:
122
+ payload["prompt_text"] = prompt_preview
123
+
124
+ return payload
125
+
126
+
127
+ __all__ = [
128
+ "HookProgressPayloadBuilder",
129
+ ]
@@ -159,19 +159,23 @@ class HookRegistry:
159
159
  once: bool = False,
160
160
  async_: bool = False,
161
161
  async_rewake: bool = False,
162
+ status_message: str | None = None,
162
163
  ) -> str:
163
164
  """注册 command hook。"""
165
+ config = {
166
+ "command": command,
167
+ "shell": shell,
168
+ "if": condition,
169
+ "once": once,
170
+ "async": async_,
171
+ "asyncRewake": async_rewake,
172
+ }
173
+ if status_message:
174
+ config["status_message"] = status_message
164
175
  spec = self._make_spec(
165
176
  event=event,
166
177
  executor_type="command",
167
- config={
168
- "command": command,
169
- "shell": shell,
170
- "if": condition,
171
- "once": once,
172
- "async": async_,
173
- "asyncRewake": async_rewake,
174
- },
178
+ config=config,
175
179
  matcher=matcher,
176
180
  timeout=timeout,
177
181
  source="session",
@@ -191,18 +195,22 @@ class HookRegistry:
191
195
  allowed_env_vars: list[str] | None = None,
192
196
  condition: str | None = None,
193
197
  once: bool = False,
198
+ status_message: str | None = None,
194
199
  ) -> str:
195
200
  """注册 http hook。"""
201
+ config = {
202
+ "url": url,
203
+ "headers": headers or {},
204
+ "allowedEnvVars": allowed_env_vars or [],
205
+ "if": condition,
206
+ "once": once,
207
+ }
208
+ if status_message:
209
+ config["status_message"] = status_message
196
210
  spec = self._make_spec(
197
211
  event=event,
198
212
  executor_type="http",
199
- config={
200
- "url": url,
201
- "headers": headers or {},
202
- "allowedEnvVars": allowed_env_vars or [],
203
- "if": condition,
204
- "once": once,
205
- },
213
+ config=config,
206
214
  matcher=matcher,
207
215
  timeout=timeout,
208
216
  source="session",
@@ -222,6 +230,7 @@ class HookRegistry:
222
230
  model: str | None = None,
223
231
  condition: str | None = None,
224
232
  once: bool = False,
233
+ status_message: str | None = None,
225
234
  ) -> str:
226
235
  """注册 prompt hook。"""
227
236
  config: dict[str, Any] = {
@@ -232,6 +241,8 @@ class HookRegistry:
232
241
  }
233
242
  if model is not None:
234
243
  config["model"] = model
244
+ if status_message:
245
+ config["status_message"] = status_message
235
246
  spec = self._make_spec(
236
247
  event=event,
237
248
  executor_type="prompt",
@@ -255,6 +266,7 @@ class HookRegistry:
255
266
  model: str | None = None,
256
267
  condition: str | None = None,
257
268
  once: bool = False,
269
+ status_message: str | None = None,
258
270
  ) -> str:
259
271
  """注册 agent hook。"""
260
272
  config: dict[str, Any] = {
@@ -265,6 +277,8 @@ class HookRegistry:
265
277
  }
266
278
  if model is not None:
267
279
  config["model"] = model
280
+ if status_message:
281
+ config["status_message"] = status_message
268
282
  spec = self._make_spec(
269
283
  event=event,
270
284
  executor_type="agent",