langchain-agentx-python 0.9.2__py3-none-any.whl → 0.9.4__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.
@@ -1,64 +1,64 @@
1
- """
2
- LangChain-based agent utilities for CodeBaseX.
3
-
4
- This package provides:
5
- - LangChain-AgentX LangGraph agent factory (`loop`)
6
-
7
- Typical usage:
8
-
9
- ```python
10
- from langchain_agentx import create_loop_agent
11
- ```
12
- """
13
-
14
- __version__ = "0.9.2"
15
-
16
- from .loop import ( # noqa: F401
17
- create_loop_agent,
18
- )
19
-
20
- from .loop.hook import ( # noqa: F401
21
- ATTACHMENT_HOOK_BLOCKING_ERROR,
22
- ATTACHMENT_HOOK_CANCELLED,
23
- ATTACHMENT_HOOK_NON_BLOCKING_ERROR,
24
- ATTACHMENT_HOOK_STOPPED_CONTINUATION,
25
- ATTACHMENT_HOOK_SUCCESS,
26
- )
27
- from .observability import ( # noqa: F401
28
- EventAdapterState,
29
- LangchainAgentEvent,
30
- LangchainAgentEventType,
31
- LangGraphToLangchainAgentEventAdapter,
32
- TraceCollector,
33
- TraceSession,
34
- TraceSpan,
35
- )
36
- from .provider import CompatibleChatOpenAI # noqa: F401
37
- from .session import AgentSession, create_agent_session # noqa: F401
38
- from .health import HealthCheck, HealthReport, PreflightResult, preflight, sdk_health # noqa: F401
39
- from .workflow import BaseWorkflow # noqa: F401
40
-
41
- __all__ = [
42
- "ATTACHMENT_HOOK_BLOCKING_ERROR",
43
- "ATTACHMENT_HOOK_CANCELLED",
44
- "ATTACHMENT_HOOK_NON_BLOCKING_ERROR",
45
- "ATTACHMENT_HOOK_STOPPED_CONTINUATION",
46
- "ATTACHMENT_HOOK_SUCCESS",
47
- "create_loop_agent",
48
- "create_agent_session",
49
- "AgentSession",
50
- "BaseWorkflow",
51
- "EventAdapterState",
52
- "LangchainAgentEvent",
53
- "LangchainAgentEventType",
54
- "LangGraphToLangchainAgentEventAdapter",
55
- "TraceCollector",
56
- "TraceSession",
57
- "TraceSpan",
58
- "CompatibleChatOpenAI",
59
- "HealthCheck",
60
- "HealthReport",
61
- "PreflightResult",
62
- "preflight",
63
- "sdk_health",
64
- ]
1
+ """
2
+ LangChain-based agent utilities for CodeBaseX.
3
+
4
+ This package provides:
5
+ - LangChain-AgentX LangGraph agent factory (`loop`)
6
+
7
+ Typical usage:
8
+
9
+ ```python
10
+ from langchain_agentx import create_loop_agent
11
+ ```
12
+ """
13
+
14
+ __version__ = "0.9.4"
15
+
16
+ from .loop import ( # noqa: F401
17
+ create_loop_agent,
18
+ )
19
+
20
+ from .loop.hook import ( # noqa: F401
21
+ ATTACHMENT_HOOK_BLOCKING_ERROR,
22
+ ATTACHMENT_HOOK_CANCELLED,
23
+ ATTACHMENT_HOOK_NON_BLOCKING_ERROR,
24
+ ATTACHMENT_HOOK_STOPPED_CONTINUATION,
25
+ ATTACHMENT_HOOK_SUCCESS,
26
+ )
27
+ from .observability import ( # noqa: F401
28
+ EventAdapterState,
29
+ LangchainAgentEvent,
30
+ LangchainAgentEventType,
31
+ LangGraphToLangchainAgentEventAdapter,
32
+ TraceCollector,
33
+ TraceSession,
34
+ TraceSpan,
35
+ )
36
+ from .provider import CompatibleChatOpenAI # noqa: F401
37
+ from .session import AgentSession, create_agent_session # noqa: F401
38
+ from .health import HealthCheck, HealthReport, PreflightResult, preflight, sdk_health # noqa: F401
39
+ from .workflow import BaseWorkflow # noqa: F401
40
+
41
+ __all__ = [
42
+ "ATTACHMENT_HOOK_BLOCKING_ERROR",
43
+ "ATTACHMENT_HOOK_CANCELLED",
44
+ "ATTACHMENT_HOOK_NON_BLOCKING_ERROR",
45
+ "ATTACHMENT_HOOK_STOPPED_CONTINUATION",
46
+ "ATTACHMENT_HOOK_SUCCESS",
47
+ "create_loop_agent",
48
+ "create_agent_session",
49
+ "AgentSession",
50
+ "BaseWorkflow",
51
+ "EventAdapterState",
52
+ "LangchainAgentEvent",
53
+ "LangchainAgentEventType",
54
+ "LangGraphToLangchainAgentEventAdapter",
55
+ "TraceCollector",
56
+ "TraceSession",
57
+ "TraceSpan",
58
+ "CompatibleChatOpenAI",
59
+ "HealthCheck",
60
+ "HealthReport",
61
+ "PreflightResult",
62
+ "preflight",
63
+ "sdk_health",
64
+ ]
@@ -7,7 +7,8 @@
7
7
 
8
8
  设计要点:
9
9
  - `LoopContextCompaction` 构造注入 `ContextPipeline`,便于单测替换阶段序列;
10
- - `default_loop_context_compaction()` 返回进程内单例,避免每轮重复装配。
10
+ - `default_loop_context_compaction()` 返回进程内单例,避免每轮重复装配;
11
+ - 支持 `mode` 参数区分 PROACTIVE(常规)与 REACTIVE_CONTEXT_WINDOW(溢出恢复)。
11
12
 
12
13
  注意:
13
14
  - 不修改 `should_end` / `finish_reason` 等 exit 字段;只产出 `messages` 增量补丁。
@@ -19,6 +20,7 @@ from __future__ import annotations
19
20
  from typing import Any, Dict, Mapping
20
21
 
21
22
  from .pipeline import ContextPipeline
23
+ from .recovery_mode import CompactionMode
22
24
  from .settings import CompactionSettings
23
25
  from .types import CompactProgressCallback, ContextCompactionContext
24
26
 
@@ -38,9 +40,22 @@ class LoopContextCompaction:
38
40
  """使用 CC 默认五段顺序装配管道。"""
39
41
  return cls(ContextPipeline.with_default_stages(settings=settings))
40
42
 
41
- def run(self, state: Mapping[str, Any], model: Any | None = None) -> Dict[str, Any]:
42
- """返回 `Command(update=...)` 可用的补丁(常见为 ``{}`` 或 ``{"messages": ...}``)。"""
43
- ctx = ContextCompactionContext.from_state(state, model)
43
+ def run(
44
+ self,
45
+ state: Mapping[str, Any],
46
+ model: Any | None = None,
47
+ *,
48
+ mode: str | CompactionMode = CompactionMode.PROACTIVE,
49
+ ) -> Dict[str, Any]:
50
+ """返回 `Command(update=...)` 可用的补丁(常见为 ``{}`` 或 ``{"messages": ...}``)。
51
+
52
+ 参数:
53
+ state: LangGraph state
54
+ model: 可选的 LLM model(供 autocompact 使用)
55
+ mode: 压缩模式,默认 PROACTIVE
56
+ """
57
+ mode_str = mode if isinstance(mode, str) else mode.value
58
+ ctx = ContextCompactionContext.from_state(state, model, mode=mode_str)
44
59
  return self._pipeline.run(state, ctx)
45
60
 
46
61
  async def arun(
@@ -48,6 +63,8 @@ class LoopContextCompaction:
48
63
  state: Mapping[str, Any],
49
64
  model: Any | None = None,
50
65
  progress_callback: CompactProgressCallback = None,
66
+ *,
67
+ mode: str | CompactionMode = CompactionMode.PROACTIVE,
51
68
  ) -> Dict[str, Any]:
52
69
  """Async 版本(供 /compact 命令使用),支持进度回调。
53
70
 
@@ -55,11 +72,13 @@ class LoopContextCompaction:
55
72
  state: LangGraph state
56
73
  model: 可选的 LLM model(供 autocompact 使用)
57
74
  progress_callback: 进度回调函数,接收 CompactProgress 对象
75
+ mode: 压缩模式,默认 PROACTIVE
58
76
 
59
77
  返回:
60
78
  与 run() 相同的补丁格式
61
79
  """
62
- ctx = ContextCompactionContext.from_state(state, model)
80
+ mode_str = mode if isinstance(mode, str) else mode.value
81
+ ctx = ContextCompactionContext.from_state(state, model, mode=mode_str)
63
82
  ctx.progress_callback = progress_callback
64
83
  return await self._pipeline.arun(state, ctx)
65
84
 
@@ -114,6 +114,7 @@ class ContextPipeline:
114
114
  "snip_tokens_freed_carry_final": ctx.snip_tokens_freed_carry,
115
115
  "token_warning_level": tw.level,
116
116
  "stages": stages_meta,
117
+ "mode": ctx.mode,
117
118
  }
118
119
 
119
120
  if not patch:
@@ -194,6 +195,7 @@ class ContextPipeline:
194
195
  "snip_tokens_freed_carry_final": ctx.snip_tokens_freed_carry,
195
196
  "token_warning_level": tw.level,
196
197
  "stages": stages_meta,
198
+ "mode": ctx.mode,
197
199
  }
198
200
 
199
201
  if not patch:
@@ -0,0 +1,37 @@
1
+ """
2
+ recovery_mode.py — 上下文压缩模式枚举。
3
+
4
+ 职责:
5
+ 定义 PROACTIVE(常规 pre-model)与 REACTIVE_CONTEXT_WINDOW(溢出恢复)两种模式,
6
+ 供 ContextPipeline 与 ReactiveCompactNode 使用。
7
+
8
+ 链路位置:
9
+ - CompactionMode 传入 ContextCompactionContext.mode
10
+ - 写入 compaction_pipeline_meta.mode 字段
11
+ - ReactiveCompactNode 使用 REACTIVE_CONTEXT_WINDOW 模式
12
+
13
+ 当前裁剪范围:
14
+ 当前仅定义枚举类型;未来可扩展 mode 切换检测逻辑。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from enum import Enum
20
+
21
+
22
+ class CompactionMode(str, Enum):
23
+ """上下文压缩模式。"""
24
+
25
+ PROACTIVE = "proactive"
26
+ """常规 pre-model 治理模式(prune_and_compress 节点默认)。"""
27
+
28
+ REACTIVE_CONTEXT_WINDOW = "reactive_context_window"
29
+ """上下文窗口溢出恢复模式(reactive_compact 节点专用)。"""
30
+
31
+ @classmethod
32
+ def is_valid(cls, value: str) -> bool:
33
+ """检查字符串是否为有效 CompactionMode。"""
34
+ return value in {m.value for m in cls}
35
+
36
+
37
+ __all__ = ["CompactionMode"]
@@ -40,16 +40,19 @@ class ContextCompactionContext:
40
40
  query_source: str | None = None
41
41
  config: CompactionConfig = field(default_factory=CompactionConfig)
42
42
  progress_callback: CompactProgressCallback = None
43
+ mode: str = "proactive"
44
+ """压缩模式:proactive(常规)或 reactive_context_window(溢出恢复)。"""
43
45
 
44
46
  @classmethod
45
47
  def from_state(
46
- cls, state: Mapping[str, Any], model: Any | None
48
+ cls, state: Mapping[str, Any], model: Any | None, mode: str = "proactive"
47
49
  ) -> ContextCompactionContext:
48
50
  return cls(
49
51
  model=model,
50
52
  query_source=state.get("query_source")
51
53
  if isinstance(state.get("query_source"), str)
52
54
  else None,
55
+ mode=mode,
53
56
  )
54
57
 
55
58
 
@@ -52,6 +52,7 @@ from ..exit.exit_logic import (
52
52
  LoopAgentStateFields,
53
53
  )
54
54
  from .graph_edges import _make_model_to_model_edge, _make_tools_to_model_edge, _make_loop_controller_edge
55
+ from .reactive_compact_node import ReactiveCompactNode
55
56
  from ..model.schema_and_format import (
56
57
  STRUCTURED_OUTPUT_ERROR_TEMPLATE,
57
58
  FALLBACK_MODELS_WITH_STRUCTURED_OUTPUT,
@@ -1372,7 +1373,27 @@ class LoopGraphBuilder:
1372
1373
  graph_inner.add_edge("hook_after_model", "hook_stop")
1373
1374
  graph_inner.add_edge("hook_stop", "loop_controller")
1374
1375
 
1375
- loop_controller_destinations = ["tools", loop_entry_node, exit_node]
1376
+ # 添加 reactive_compact 节点(上下文窗口溢出恢复专用节点)
1377
+ def _reactive_compact_node(state, runtime):
1378
+ node = ReactiveCompactNode(
1379
+ compaction_service=default_loop_context_compaction(),
1380
+ loop_entry_destination=loop_entry_node,
1381
+ end_destination=exit_node,
1382
+ )
1383
+ return node(state)
1384
+
1385
+ graph_inner.add_node(
1386
+ "reactive_compact",
1387
+ RunnableCallable(_reactive_compact_node, None, trace=False),
1388
+ )
1389
+ # reactive_compact 成功路径 → loop_entry_node
1390
+ graph_inner.add_conditional_edges(
1391
+ "reactive_compact",
1392
+ lambda state: state.get("destination", loop_entry_node),
1393
+ [loop_entry_node, exit_node],
1394
+ )
1395
+
1396
+ loop_controller_destinations = ["tools", loop_entry_node, exit_node, "reactive_compact"]
1376
1397
  if provider_chain and "task_event_commit_ack" not in loop_controller_destinations:
1377
1398
  loop_controller_destinations.append("task_event_commit_ack")
1378
1399
  graph_inner.add_conditional_edges(
@@ -328,10 +328,12 @@ class LoopControllerEdge:
328
328
  withheld_error = state.get("withheld_error")
329
329
  if not withheld_error:
330
330
  return None
331
+ # 先检查是否为 context_window 溢出(CC 对齐:prompt_too_long 或 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)
335
+ # 再检查是否为 max_tokens(输出限制)
331
336
  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)
335
337
  return self._handle_max_tokens_recovery(state, withheld_error)
336
338
  return self._handle_generic_withheld(state, withheld_error)
337
339
 
@@ -340,25 +342,27 @@ class LoopControllerEdge:
340
342
  state: dict[str, Any],
341
343
  withheld_error: dict[str, Any],
342
344
  ) -> str:
345
+ """处理上下文窗口溢出恢复。
346
+
347
+ CC 对齐设计:
348
+ - 路由到 reactive_compact 节点,由节点决定成功或 exhausted
349
+ - transition 在 reactive_compact 成功后才写入
350
+ - 已尝试过时直接走 exhausted 路径
351
+ """
343
352
  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
353
  emit_loop_decision_event(
351
354
  state=state,
352
355
  decision="continue",
353
- selected_edge=self._model_destination,
354
- reason_code="reactive_compact_retry",
356
+ selected_edge="reactive_compact",
357
+ reason_code="context_window_recovery_to_compact",
355
358
  evidence={
356
359
  "withheld_error": withheld_error,
357
360
  "overflow_kind": "context_window",
358
361
  "step": state.get("step", 0),
359
362
  },
360
363
  )
361
- return self._model_destination
364
+ return "reactive_compact"
365
+ # 已尝试过,走 exhausted 路径
362
366
  state["withheld_error"] = None
363
367
  state["should_end"] = True
364
368
  state["terminal_reason"] = TERMINAL_REASON_MAX_TOKENS
@@ -0,0 +1,230 @@
1
+ """
2
+ reactive_compact_node.py — 上下文窗口溢出恢复节点。
3
+
4
+ 职责:
5
+ 作为专用 overflow recovery 节点,仅在 context_window 溢出时触发,
6
+ 执行 recovery compact 成功后才写入 reactive_compact_retry transition。
7
+
8
+ 链路位置:
9
+ loop_controller (检测 context_window 溢出) → reactive_compact → loop_entry
10
+ 失败路径: reactive_compact → exhausted → END
11
+
12
+ 当前裁剪范围:
13
+ - 实现 _should_attempt_recovery 判断
14
+ - 实现 _execute_recovery_compact 核心逻辑
15
+ - 成功路径:写 transition、回 loop_entry
16
+ - 失败路径:exhausted、走 END
17
+ - 暂不实现 LLM 摘要(mode=REACTIVE 时只走 prune 路径)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any
24
+
25
+ from ..context.compaction_service import LoopContextCompaction
26
+ from ..context.recovery_mode import CompactionMode
27
+ from ..exit.reason_codes import (
28
+ TERMINAL_REASON_MAX_TOKENS,
29
+ TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED,
30
+ TRANSITION_REACTIVE_COMPACT_RETRY,
31
+ )
32
+ from ...observability.trace.event_emitter import emit_loop_decision_event
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # 状态键常量
37
+ STATE_HAS_ATTEMPTED_REACTIVE_COMPACT = "has_attempted_reactive_compact"
38
+ STATE_MESSAGES = "messages"
39
+ STATE_WITHHELD_ERROR = "withheld_error"
40
+ STATE_SHOULD_END = "should_end"
41
+ STATE_TERMINAL_REASON = "terminal_reason"
42
+ STATE_TRANSITION = "transition"
43
+
44
+
45
+ class ReactiveCompactNode:
46
+ """上下文窗口溢出恢复专用节点。
47
+
48
+ 与 prune_and_compress 的区别:
49
+ - prune_and_compress:通用 pre-model 治理节点,PROACTIVE 模式
50
+ - reactive_compact:仅在 context_window 溢出时触发,REACTIVE_CONTEXT_WINDOW 模式
51
+
52
+ 关键契约:
53
+ - 只在 compact 成功(messages 实际被压缩)后才写 transition
54
+ - 失败时设置 exhausted=True,走 terminal 分支
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ compaction_service: LoopContextCompaction | None = None,
60
+ *,
61
+ loop_entry_destination: str = "loop_entry_node",
62
+ end_destination: str = "__end__",
63
+ ) -> None:
64
+ """初始化 ReactiveCompactNode。
65
+
66
+ 参数:
67
+ compaction_service: 压缩服务,默认使用 default_loop_context_compaction()
68
+ loop_entry_destination: 成功后跳转目标(默认 loop_entry_node)
69
+ end_destination: 失败后跳转目标(默认 __end__)
70
+ """
71
+ if compaction_service is None:
72
+ from ..context.compaction_service import default_loop_context_compaction
73
+
74
+ compaction_service = default_loop_context_compaction()
75
+ self._compaction_service = compaction_service
76
+ self._loop_entry_destination = loop_entry_destination
77
+ self._end_destination = end_destination
78
+
79
+ def _should_attempt_recovery(self, state: dict[str, Any]) -> bool:
80
+ """判断是否应该尝试恢复。
81
+
82
+ 检查 state.has_attempted_reactive_compact 标记:
83
+ - False:首次溢出,应尝试 recovery
84
+ - True:已尝试过,应走 exhausted 分支
85
+
86
+ 参数:
87
+ state: LangGraph state
88
+
89
+ 返回:
90
+ True 表示应尝试 recovery,False 表示应直接 exhausted
91
+ """
92
+ return not state.get(STATE_HAS_ATTEMPTED_REACTIVE_COMPACT, False)
93
+
94
+ def _execute_recovery_compact(
95
+ self, state: dict[str, Any]
96
+ ) -> dict[str, Any]:
97
+ """执行 recovery compact 并返回状态补丁。
98
+
99
+ 参数:
100
+ state: LangGraph state
101
+
102
+ 返回:
103
+ 状态补丁,可能包含:
104
+ - 成功:{messages: [...], transition: {...}, has_attempted_reactive_compact: True}
105
+ - 失败:{exhausted: True, error_kind: "context_window", should_end: True, ...}
106
+ """
107
+ messages = state.get(STATE_MESSAGES, [])
108
+ if not isinstance(messages, list):
109
+ logger.warning("Recovery compact: messages 不是列表,跳过 compact")
110
+ return self._build_exhausted_patch(state)
111
+
112
+ # 记录原始 messages 用于比较
113
+ original_id = id(messages)
114
+ original_len = len(messages)
115
+
116
+ try:
117
+ # 使用 REACTIVE_CONTEXT_WINDOW 模式执行 compact
118
+ patch = self._compaction_service.run(
119
+ state,
120
+ model=None, # 暂不支持 LLM 摘要
121
+ mode=CompactionMode.REACTIVE_CONTEXT_WINDOW,
122
+ )
123
+ except Exception as e:
124
+ logger.exception(f"Recovery compact 异常: {e}")
125
+ return self._build_exhausted_patch(state)
126
+
127
+ # 检查 compact 是否实际压缩了 messages
128
+ # 成功标准:messages 被替换(id 变化)或数量减少
129
+ if not patch:
130
+ logger.warning("Recovery compact: 无变化,视为失败")
131
+ return self._build_exhausted_patch(state)
132
+
133
+ new_messages = patch.get(STATE_MESSAGES, messages)
134
+ if not isinstance(new_messages, list):
135
+ logger.warning("Recovery compact: 返回 messages 不是列表")
136
+ return self._build_exhausted_patch(state)
137
+
138
+ # 检查是否实际压缩
139
+ is_compacted = (
140
+ id(new_messages) != original_id or len(new_messages) < original_len
141
+ )
142
+
143
+ if not is_compacted:
144
+ logger.warning("Recovery compact: messages 未实际压缩,视为失败")
145
+ return self._build_exhausted_patch(state)
146
+
147
+ # 成功:构建返回补丁
148
+ result = dict(patch)
149
+ result[STATE_HAS_ATTEMPTED_REACTIVE_COMPACT] = True
150
+ result[STATE_TRANSITION] = {
151
+ "reason": TRANSITION_REACTIVE_COMPACT_RETRY,
152
+ "error_reason": TERMINAL_REASON_MAX_TOKENS,
153
+ "overflow_kind": "context_window",
154
+ }
155
+ result["destination"] = self._loop_entry_destination
156
+
157
+ logger.info(f"Recovery compact 成功: {original_len} → {len(new_messages)} 条消息")
158
+ return result
159
+
160
+ def _build_exhausted_patch(
161
+ self, state: dict[str, Any]
162
+ ) -> dict[str, Any]:
163
+ """构建 exhausted 状态补丁。
164
+
165
+ 参数:
166
+ state: LangGraph state
167
+
168
+ 返回:
169
+ exhausted 状态补丁
170
+ """
171
+ return {
172
+ STATE_HAS_ATTEMPTED_REACTIVE_COMPACT: True,
173
+ STATE_WITHHELD_ERROR: None,
174
+ STATE_SHOULD_END: True,
175
+ STATE_TERMINAL_REASON: TERMINAL_REASON_MAX_TOKENS,
176
+ STATE_TRANSITION: {
177
+ "reason": TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED,
178
+ "error_reason": TERMINAL_REASON_MAX_TOKENS,
179
+ "overflow_kind": "context_window",
180
+ },
181
+ "error_kind": "context_window",
182
+ "exhausted": True,
183
+ "destination": self._end_destination,
184
+ }
185
+
186
+ def __call__(self, state: dict[str, Any]) -> dict[str, Any]:
187
+ """节点入口(LangGraph 调用约定)。
188
+
189
+ 参数:
190
+ state: LangGraph state
191
+
192
+ 返回:
193
+ 状态补丁
194
+ """
195
+ if not self._should_attempt_recovery(state):
196
+ logger.info("Recovery compact: 已尝试过,直接 exhausted")
197
+ result = self._build_exhausted_patch(state)
198
+ emit_loop_decision_event(
199
+ state=state,
200
+ decision="terminal",
201
+ selected_edge=self._end_destination,
202
+ reason_code="context_window_recovery_exhausted",
203
+ evidence={"step": state.get("step", 0), "already_attempted": True},
204
+ )
205
+ return result
206
+
207
+ logger.info("Recovery compact: 首次溢出,尝试 recovery")
208
+ result = self._execute_recovery_compact(state)
209
+
210
+ if result.get("exhausted"):
211
+ emit_loop_decision_event(
212
+ state=state,
213
+ decision="terminal",
214
+ selected_edge=self._end_destination,
215
+ reason_code="context_window_recovery_exhausted",
216
+ evidence={"step": state.get("step", 0), "compact_failed": True},
217
+ )
218
+ else:
219
+ emit_loop_decision_event(
220
+ state=state,
221
+ decision="continue",
222
+ selected_edge=self._loop_entry_destination,
223
+ reason_code="reactive_compact_retry",
224
+ evidence={"step": state.get("step", 0)},
225
+ )
226
+
227
+ return result
228
+
229
+
230
+ __all__ = ["ReactiveCompactNode"]
@@ -22,8 +22,10 @@ from langchain_core.messages import AIMessage
22
22
 
23
23
  from langchain_agentx.loop.exit.reason_codes import (
24
24
  ERROR_TERMINAL_REASONS,
25
+ TERMINAL_REASON_COMPLETED,
25
26
  TERMINAL_REASON_ERROR,
26
27
  TERMINAL_REASON_MAX_TOKENS,
28
+ TRANSITION_API_ERROR_TERMINAL,
27
29
  TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED,
28
30
  TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY_EXHAUSTED,
29
31
  TRANSITION_WITHHOLD_RECOVERY_EXHAUSTED,
@@ -117,7 +119,9 @@ class FinishEventPayloadComposer:
117
119
 
118
120
  from langchain_agentx.loop.exit.exit_logic import RECOVERABLE_TERMINAL_REASONS
119
121
 
120
- is_error = self._resolve_is_error(terminal_reason, withheld_error, snap)
122
+ is_error = self._resolve_is_error(
123
+ terminal_reason, withheld_error, snap, messages=messages
124
+ )
121
125
  recoverable = (
122
126
  terminal_reason in RECOVERABLE_TERMINAL_REASONS if terminal_reason else False
123
127
  )
@@ -125,6 +129,14 @@ class FinishEventPayloadComposer:
125
129
  recovery_exhausted = (
126
130
  transition_reason in _RECOVERY_EXHAUSTED_TRANSITIONS if is_error else False
127
131
  )
132
+ if (
133
+ not recovery_exhausted
134
+ and is_error
135
+ and terminal_reason == TERMINAL_REASON_MAX_TOKENS
136
+ and resolve_overflow_kind(withheld_error) == "context_window"
137
+ and bool(snap.get("has_attempted_reactive_compact"))
138
+ ):
139
+ recovery_exhausted = True
128
140
  error_kind = self._infer_error_kind(
129
141
  terminal_reason=terminal_reason,
130
142
  is_error=is_error,
@@ -166,7 +178,14 @@ class FinishEventPayloadComposer:
166
178
  terminal_reason: str | None,
167
179
  withheld_error: dict[str, Any] | None,
168
180
  snap: dict[str, Any],
181
+ *,
182
+ messages: list[Any] | None = None,
169
183
  ) -> bool:
184
+ transition_reason = _transition_reason(snap)
185
+ if transition_reason == TRANSITION_API_ERROR_TERMINAL:
186
+ return True
187
+ if _is_api_error_message(_last_ai_message(messages)):
188
+ return True
170
189
  if terminal_reason in ERROR_TERMINAL_REASONS:
171
190
  return True
172
191
  if isinstance(withheld_error, dict) and withheld_error and snap.get("should_end"):
@@ -185,6 +204,13 @@ class FinishEventPayloadComposer:
185
204
  if not is_error:
186
205
  return None
187
206
 
207
+ last_ai = _last_ai_message(messages)
208
+ if (
209
+ transition_reason == TRANSITION_API_ERROR_TERMINAL
210
+ or _is_api_error_message(last_ai)
211
+ ):
212
+ return "api_error"
213
+
188
214
  overflow_kind = resolve_overflow_kind(withheld_error)
189
215
  if terminal_reason == TERMINAL_REASON_MAX_TOKENS:
190
216
  if overflow_kind == "context_window" or (
@@ -198,9 +224,9 @@ class FinishEventPayloadComposer:
198
224
  return "unknown"
199
225
 
200
226
  if terminal_reason == TERMINAL_REASON_ERROR:
201
- last_ai = _last_ai_message(messages)
202
- if _is_api_error_message(last_ai):
203
- return "api_error"
227
+ return "unknown"
228
+
229
+ if terminal_reason == TERMINAL_REASON_COMPLETED:
204
230
  return "unknown"
205
231
 
206
232
  return "unknown"