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.
- langchain_agentx/__init__.py +1 -1
- langchain_agentx/loop/exit/exit_logic.py +17 -112
- langchain_agentx/loop/exit/reason_codes.py +40 -0
- langchain_agentx/loop/exit/withheld_error.py +55 -0
- langchain_agentx/loop/graph/graph_edges.py +50 -0
- langchain_agentx/loop/hook/__init__.py +7 -4
- langchain_agentx/loop/hook/engine.py +8 -12
- langchain_agentx/loop/hook/progress_payload.py +129 -0
- langchain_agentx/loop/hook/registry.py +29 -15
- langchain_agentx/loop/model/model_node.py +23 -1
- langchain_agentx/loop/model/retrier.py +41 -22
- langchain_agentx/loop/model/retry_events.py +2 -0
- langchain_agentx/loop/model/retry_progress.py +113 -0
- langchain_agentx/loop/subagent/context.py +5 -0
- langchain_agentx/loop/subagent/orchestrator.py +10 -0
- langchain_agentx/loop/subagent/progress.py +3 -2
- langchain_agentx/loop/subagent/runner.py +61 -11
- langchain_agentx/loop/subagent/subagent_termination_classifier.py +99 -0
- langchain_agentx/observability/__init__.py +16 -0
- langchain_agentx/observability/events/__init__.py +18 -0
- langchain_agentx/observability/events/finish_event_payload_composer.py +260 -0
- langchain_agentx/observability/events/finish_outcome.py +233 -0
- langchain_agentx/observability/events/langchain_agentx_event_adapter.py +33 -7
- langchain_agentx/provider/semantics/contracts.py +15 -0
- langchain_agentx/provider/semantics/families/anthropic_finish_reason.py +59 -0
- langchain_agentx/provider/semantics/families/compatible_openai_finish_reason.py +53 -0
- langchain_agentx/provider/semantics/families/openai_finish_reason.py +61 -0
- langchain_agentx/provider/semantics/finish_reason_normalizer.py +224 -0
- langchain_agentx/provider/semantics/profile.py +11 -0
- langchain_agentx/provider/semantics/registry.py +20 -0
- langchain_agentx/session/agent_session.py +4 -1
- langchain_agentx/tools/agent/models.py +5 -0
- langchain_agentx/tools/agent/tool.py +21 -2
- {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/METADATA +1 -1
- {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/RECORD +38 -28
- {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/LICENSE +0 -0
- {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/WHEEL +0 -0
- {langchain_agentx_python-0.9.0.dist-info → langchain_agentx_python-0.9.2.dist-info}/top_level.txt +0 -0
langchain_agentx/__init__.py
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
168
|
+
# Phase 1:使用 HookProgressPayloadBuilder 构建完整 payload
|
|
164
169
|
if self._event_emitter is not None:
|
|
165
|
-
self.
|
|
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",
|