langchain-agentx-python 2.0.0__py3-none-any.whl → 2.1.0__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 (28) hide show
  1. langchain_agentx/__init__.py +1 -1
  2. langchain_agentx/loop/graph/graph_edges.py +2 -18
  3. langchain_agentx/loop/graph/reactive_compact_node.py +1 -0
  4. langchain_agentx/loop/model/model_node.py +23 -0
  5. langchain_agentx/loop/stream/loop_driver.py +162 -69
  6. langchain_agentx/loop/stream/result.py +1 -0
  7. langchain_agentx/loop/subagent/context.py +19 -0
  8. langchain_agentx/loop/subagent/runner.py +12 -0
  9. langchain_agentx/observability/__init__.py +4 -0
  10. langchain_agentx/observability/events/__init__.py +4 -0
  11. langchain_agentx/observability/events/finish_outcome.py +30 -0
  12. langchain_agentx/session/__init__.py +2 -6
  13. langchain_agentx/session/agent_session.py +10 -35
  14. langchain_agentx/session/conversation_session.py +8 -0
  15. langchain_agentx/session/recovery.py +6 -86
  16. langchain_agentx/tools/bash/path_security.py +89 -21
  17. langchain_agentx/tools/bash/semantic_matrix.py +7 -5
  18. langchain_agentx/tools/glob/tool.py +30 -2
  19. langchain_agentx/tools/grep/backend.py +13 -3
  20. langchain_agentx/tools/grep/rg_subprocess_controller.py +2 -0
  21. langchain_agentx/tools/grep/tool.py +22 -2
  22. langchain_agentx/tools/search_path_resolver.py +132 -0
  23. langchain_agentx/workflow/execution.py +14 -0
  24. {langchain_agentx_python-2.0.0.dist-info → langchain_agentx_python-2.1.0.dist-info}/METADATA +1 -1
  25. {langchain_agentx_python-2.0.0.dist-info → langchain_agentx_python-2.1.0.dist-info}/RECORD +28 -27
  26. {langchain_agentx_python-2.0.0.dist-info → langchain_agentx_python-2.1.0.dist-info}/LICENSE +0 -0
  27. {langchain_agentx_python-2.0.0.dist-info → langchain_agentx_python-2.1.0.dist-info}/WHEEL +0 -0
  28. {langchain_agentx_python-2.0.0.dist-info → langchain_agentx_python-2.1.0.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__ = "2.0.0"
14
+ __version__ = "2.1.0"
15
15
 
16
16
  from .loop import ( # noqa: F401
17
17
  create_loop_agent,
@@ -70,7 +70,6 @@ from ..exit.reason_codes import (
70
70
  TRANSITION_MAX_OUTPUT_TOKENS_ESCALATE,
71
71
  TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY,
72
72
  TRANSITION_MAX_OUTPUT_TOKENS_RECOVERY_EXHAUSTED,
73
- TRANSITION_REACTIVE_COMPACT_RETRY,
74
73
  TRANSITION_STOP_HOOK_BLOCKING,
75
74
  TRANSITION_STOP_HOOK_PREVENTED,
76
75
  TRANSITION_TASK_NOTIFICATION_ARRIVED,
@@ -452,6 +451,8 @@ class LoopControllerEdge:
452
451
  step=state.get("step", 0),
453
452
  turn_count=int(state.get("turn_count", 0) or 0),
454
453
  provider_finish_reason=state.get("finish_reason"),
454
+ error_kind="context_window",
455
+ recovery_exhausted=True,
455
456
  )
456
457
  state["transition"] = {
457
458
  "reason": TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED,
@@ -585,23 +586,6 @@ class LoopControllerEdge:
585
586
  state,
586
587
  destination=self._model_destination,
587
588
  )
588
- if not state.get("has_attempted_reactive_compact", False):
589
- state["has_attempted_reactive_compact"] = True
590
- state["transition"] = {
591
- "reason": TRANSITION_REACTIVE_COMPACT_RETRY,
592
- "error_reason": withheld_error.get("reason"),
593
- }
594
- emit_loop_decision_event(
595
- state=state,
596
- decision="continue",
597
- selected_edge=self._model_destination,
598
- reason_code="reactive_compact_retry",
599
- evidence={"withheld_error": withheld_error, "step": state.get("step", 0)},
600
- )
601
- return self._set_continue_resolution(
602
- state,
603
- destination=self._model_destination,
604
- )
605
589
  state["transition"] = {
606
590
  "reason": TRANSITION_WITHHOLD_RECOVERY_RETRY,
607
591
  "error_reason": withheld_error.get("reason"),
@@ -171,6 +171,7 @@ class ReactiveCompactNode:
171
171
  terminal = build_loop_terminal(
172
172
  reason=TERMINAL_REASON_MAX_TOKENS,
173
173
  error_kind="context_window",
174
+ recovery_exhausted=True,
174
175
  )
175
176
  patch = {
176
177
  STATE_HAS_ATTEMPTED_REACTIVE_COMPACT: True,
@@ -1248,6 +1248,29 @@ class ModelNode(Runnable):
1248
1248
  and max_output_tokens_override is not None
1249
1249
  and max_output_tokens_recovery_count < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT
1250
1250
  )
1251
+ if (
1252
+ should_end
1253
+ and terminal_reason in RECOVERABLE_TERMINAL_REASONS
1254
+ and overflow_kind == "context_window"
1255
+ ):
1256
+ withheld_error = build_withheld_error(
1257
+ terminal_reason=terminal_reason,
1258
+ finish_reason=finish_reason,
1259
+ last_ai_message=last_ai_message,
1260
+ )
1261
+ return {
1262
+ "should_end": False,
1263
+ "terminal_reason": None,
1264
+ "withheld_error": withheld_error,
1265
+ "withhold_retry_count": withhold_retry_count,
1266
+ "max_output_tokens_override": max_output_tokens_override,
1267
+ "max_output_tokens_recovery_count": max_output_tokens_recovery_count,
1268
+ "transition": {
1269
+ "reason": TRANSITION_WITHHOLD_RECOVERY_RETRY,
1270
+ "error_reason": withheld_error["reason"],
1271
+ "attempt": withhold_retry_count,
1272
+ },
1273
+ }
1251
1274
  if should_end and terminal_reason in RECOVERABLE_TERMINAL_REASONS and (
1252
1275
  can_default_withhold_retry or can_max_tokens_recovery_retry
1253
1276
  ):
@@ -10,6 +10,7 @@ loop_driver.py — Loop-aware shared stream driver.
10
10
  当前裁剪范围:
11
11
  root closeout 捕获、synthetic error/finish、orphan tool_result repair(stream surface)。
12
12
  正常结束但缺 root on_chain_end(LangGraph) 时打 anomaly WARNING(不伪造 synthetic finish)。
13
+ R1 反应恢复:单次 drain 内 budgeted continue 重入(S2,对齐 CC queryLoop continue)。
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -23,6 +24,7 @@ from langgraph.errors import GraphRecursionError
23
24
  from ..exit.reason_codes import (
24
25
  TERMINAL_REASON_GRAPH_RUNTIME_ERROR,
25
26
  TERMINAL_REASON_RUNTIME_BUDGET_EXHAUSTED,
27
+ TRANSITION_REACTIVE_COMPACT_RETRY,
26
28
  )
27
29
  from ..exit.terminal import apply_terminal_reason_projection, build_loop_terminal, read_terminal_reason
28
30
  from langchain_agentx.observability.events.tool_call_id_resolver import ToolCallIdResolver
@@ -57,6 +59,7 @@ class LoopStreamDriver:
57
59
  *,
58
60
  config: dict[str, Any] | None = None,
59
61
  version: str = "v2",
62
+ max_reactive_continue_per_drain: int = 0,
60
63
  ) -> AsyncIterator[dict[str, Any]]:
61
64
  self._graph_input = dict(graph_input)
62
65
  self._assistant_turn_count = 0
@@ -66,83 +69,173 @@ class LoopStreamDriver:
66
69
  self.result.last_raw_event = None
67
70
  self.result.saw_chat_model_end_last_turn = False
68
71
  self.result.saw_partial_superseded = False
72
+ self.result.reactive_continue_drains = 0
73
+ current_input = dict(graph_input)
74
+ reactive_continues = 0
69
75
  try:
70
- try:
71
- async for event in graph.astream_events(
72
- graph_input,
76
+ while True:
77
+ async for event in self._stream_single_drain(
78
+ graph,
79
+ current_input,
73
80
  config=config,
74
81
  version=version,
75
82
  ):
76
- self._observe_event(event)
77
83
  yield event
78
- self.result.graph_completed = True
79
- self._log_missing_root_chain_end_anomaly()
80
- except asyncio.CancelledError as exc:
81
- self.result.final_error = exc
82
- for repair in self._orphan_tool_result_repairs(closeout_kind="cancelled"):
83
- yield repair
84
- raise
85
- except GraphRecursionError as exc:
86
- self.result.final_error = exc
87
- self._log_catch_error(exc)
88
- for repair in self._orphan_tool_result_repairs(closeout_kind="interrupted"):
89
- yield repair
90
- if not self.result.saw_error_visible_surface:
91
- self.result.saw_error_visible_surface = True
92
- yield build_synthetic_error_visible_event(
93
- message="Runtime budget exhausted (recursion limit reached)",
94
- error_kind="graph_recursion_error",
95
- )
96
- if not self.result.saw_finish_surface:
97
- terminal = build_loop_terminal(
98
- reason=TERMINAL_REASON_RUNTIME_BUDGET_EXHAUSTED,
99
- error_kind="graph_recursion_error",
100
- step=(self.result.last_loop_snapshot or {}).get("step"),
101
- turn_count=(self.result.last_loop_snapshot or {}).get("turn_count"),
102
- provider_finish_reason=(self.result.last_loop_snapshot or {}).get("finish_reason"),
103
- errors=[str(exc)],
104
- metadata={
105
- "langgraph_error_type": "GraphRecursionError",
106
- },
107
- )
108
- self.result.terminal = terminal
109
- synthetic_output = self._build_synthetic_graph_output(terminal)
110
- self.result.graph_output = synthetic_output
111
- self.result.saw_finish_surface = True
112
- yield build_synthetic_root_chain_end(graph_output=synthetic_output)
113
- self._log_catch_error_diagnostic(exc)
114
- raise
115
- except Exception as exc:
116
- self.result.final_error = exc
117
- # CC catch 协议 §5.3.3:先记录日志 / 观测,再补 stream surface,最后 re-raise
118
- self._log_catch_error(exc)
119
- for repair in self._orphan_tool_result_repairs(closeout_kind="interrupted"):
120
- yield repair
121
- if not self.result.saw_error_visible_surface:
122
- self.result.saw_error_visible_surface = True
123
- yield build_synthetic_error_visible_event(
124
- message=str(exc),
125
- error_kind="graph_runtime_error",
126
- )
127
- if not self.result.saw_finish_surface:
128
- terminal = build_loop_terminal(
129
- reason=TERMINAL_REASON_GRAPH_RUNTIME_ERROR,
130
- error_kind="graph_runtime_error",
131
- step=(self.result.last_loop_snapshot or {}).get("step"),
132
- turn_count=(self.result.last_loop_snapshot or {}).get("turn_count"),
133
- provider_finish_reason=(self.result.last_loop_snapshot or {}).get("finish_reason"),
134
- errors=[str(exc)],
135
- )
136
- self.result.terminal = terminal
137
- synthetic_output = self._build_synthetic_graph_output(terminal)
138
- self.result.graph_output = synthetic_output
139
- self.result.saw_finish_surface = True
140
- yield build_synthetic_root_chain_end(graph_output=synthetic_output)
141
- self._log_catch_error_diagnostic(exc)
142
- raise
84
+ if not self._should_reactive_continue_drain(
85
+ continues_used=reactive_continues,
86
+ max_continues=max_reactive_continue_per_drain,
87
+ ):
88
+ break
89
+ reactive_continues += 1
90
+ self.result.reactive_continue_drains = reactive_continues
91
+ current_input = self._build_continue_graph_input(
92
+ current_input,
93
+ self.result.graph_output or {},
94
+ )
95
+ self._graph_input = dict(current_input)
96
+ self._reset_for_continue_drain()
97
+ logger.info(
98
+ "LoopStreamDriver R1 continue: drain %s/%s transition=%s",
99
+ reactive_continues,
100
+ max_reactive_continue_per_drain,
101
+ (self.result.graph_output or {}).get("transition", {}).get("reason"),
102
+ )
143
103
  finally:
144
104
  self._log_stream_closeout_summary()
145
105
 
106
+ async def _stream_single_drain(
107
+ self,
108
+ graph: Any,
109
+ graph_input: dict[str, Any],
110
+ *,
111
+ config: dict[str, Any] | None = None,
112
+ version: str = "v2",
113
+ ) -> AsyncIterator[dict[str, Any]]:
114
+ try:
115
+ async for event in graph.astream_events(
116
+ graph_input,
117
+ config=config,
118
+ version=version,
119
+ ):
120
+ self._observe_event(event)
121
+ yield event
122
+ self.result.graph_completed = True
123
+ self._log_missing_root_chain_end_anomaly()
124
+ except asyncio.CancelledError as exc:
125
+ self.result.final_error = exc
126
+ for repair in self._orphan_tool_result_repairs(closeout_kind="cancelled"):
127
+ yield repair
128
+ raise
129
+ except GraphRecursionError as exc:
130
+ self.result.final_error = exc
131
+ self._log_catch_error(exc)
132
+ for repair in self._orphan_tool_result_repairs(closeout_kind="interrupted"):
133
+ yield repair
134
+ if not self.result.saw_error_visible_surface:
135
+ self.result.saw_error_visible_surface = True
136
+ yield build_synthetic_error_visible_event(
137
+ message="Runtime budget exhausted (recursion limit reached)",
138
+ error_kind="graph_recursion_error",
139
+ )
140
+ if not self.result.saw_finish_surface:
141
+ terminal = build_loop_terminal(
142
+ reason=TERMINAL_REASON_RUNTIME_BUDGET_EXHAUSTED,
143
+ error_kind="graph_recursion_error",
144
+ step=(self.result.last_loop_snapshot or {}).get("step"),
145
+ turn_count=(self.result.last_loop_snapshot or {}).get("turn_count"),
146
+ provider_finish_reason=(self.result.last_loop_snapshot or {}).get("finish_reason"),
147
+ errors=[str(exc)],
148
+ metadata={
149
+ "langgraph_error_type": "GraphRecursionError",
150
+ },
151
+ )
152
+ self.result.terminal = terminal
153
+ synthetic_output = self._build_synthetic_graph_output(terminal)
154
+ self.result.graph_output = synthetic_output
155
+ self.result.saw_finish_surface = True
156
+ yield build_synthetic_root_chain_end(graph_output=synthetic_output)
157
+ self._log_catch_error_diagnostic(exc)
158
+ raise
159
+ except Exception as exc:
160
+ self.result.final_error = exc
161
+ # CC catch 协议 §5.3.3:先记录日志 / 观测,再补 stream surface,最后 re-raise
162
+ self._log_catch_error(exc)
163
+ for repair in self._orphan_tool_result_repairs(closeout_kind="interrupted"):
164
+ yield repair
165
+ if not self.result.saw_error_visible_surface:
166
+ self.result.saw_error_visible_surface = True
167
+ yield build_synthetic_error_visible_event(
168
+ message=str(exc),
169
+ error_kind="graph_runtime_error",
170
+ )
171
+ if not self.result.saw_finish_surface:
172
+ terminal = build_loop_terminal(
173
+ reason=TERMINAL_REASON_GRAPH_RUNTIME_ERROR,
174
+ error_kind="graph_runtime_error",
175
+ step=(self.result.last_loop_snapshot or {}).get("step"),
176
+ turn_count=(self.result.last_loop_snapshot or {}).get("turn_count"),
177
+ provider_finish_reason=(self.result.last_loop_snapshot or {}).get("finish_reason"),
178
+ errors=[str(exc)],
179
+ )
180
+ self.result.terminal = terminal
181
+ synthetic_output = self._build_synthetic_graph_output(terminal)
182
+ self.result.graph_output = synthetic_output
183
+ self.result.saw_finish_surface = True
184
+ yield build_synthetic_root_chain_end(graph_output=synthetic_output)
185
+ self._log_catch_error_diagnostic(exc)
186
+ raise
187
+
188
+ def _should_reactive_continue_drain(
189
+ self,
190
+ *,
191
+ continues_used: int,
192
+ max_continues: int,
193
+ ) -> bool:
194
+ if max_continues <= 0 or continues_used >= max_continues:
195
+ return False
196
+ if self.result.final_error is not None:
197
+ return False
198
+ if not self.result.saw_root_chain_end or not self.result.graph_completed:
199
+ return False
200
+ output = self.result.graph_output
201
+ if not isinstance(output, dict):
202
+ return False
203
+ resolution = output.get("resolution")
204
+ if not isinstance(resolution, dict) or resolution.get("kind") != "continue":
205
+ return False
206
+ transition = resolution.get("transition")
207
+ if not isinstance(transition, dict):
208
+ transition = output.get("transition")
209
+ if not isinstance(transition, dict):
210
+ return False
211
+ return transition.get("reason") == TRANSITION_REACTIVE_COMPACT_RETRY
212
+
213
+ def _build_continue_graph_input(
214
+ self,
215
+ prior_input: dict[str, Any],
216
+ graph_output: dict[str, Any],
217
+ ) -> dict[str, Any]:
218
+ continued = dict(prior_input)
219
+ messages = graph_output.get("messages")
220
+ if isinstance(messages, list):
221
+ continued["messages"] = list(messages)
222
+ for key in (
223
+ "has_attempted_reactive_compact",
224
+ "_session_id",
225
+ "_run_id",
226
+ "_trace_collector",
227
+ ):
228
+ if key in graph_output:
229
+ continued[key] = graph_output[key]
230
+ return continued
231
+
232
+ def _reset_for_continue_drain(self) -> None:
233
+ self._pending_tool_calls.clear()
234
+ self.result.saw_root_chain_end = False
235
+ self.result.saw_finish_surface = False
236
+ self.result.graph_completed = False
237
+ self.result.final_error = None
238
+
146
239
  def _observe_event(self, event: dict[str, Any]) -> None:
147
240
  event_name = str(event.get("event") or "")
148
241
  name = str(event.get("name") or "")
@@ -31,6 +31,7 @@ class StreamDriverResult:
31
31
  last_raw_event: str | None = None
32
32
  saw_chat_model_end_last_turn: bool = False
33
33
  saw_partial_superseded: bool = False
34
+ reactive_continue_drains: int = 0
34
35
 
35
36
 
36
37
  __all__ = ["StreamDriverResult"]
@@ -29,6 +29,12 @@ from collections.abc import Callable
29
29
  from dataclasses import dataclass, field
30
30
  from typing import Any, Literal
31
31
 
32
+ from langchain_agentx.observability.events.finish_outcome import (
33
+ RecoveryPolicy,
34
+ RecoveryPolicyMode,
35
+ coerce_recovery_policy,
36
+ )
37
+
32
38
  from langchain_agentx.loop.context.query_source import LoopCompilationContext
33
39
  from langchain_agentx.loop.exit.query_source import QuerySource
34
40
  from langchain_agentx.loop.runtime.subagent_execution_paths import (
@@ -107,6 +113,9 @@ class SubagentContext:
107
113
  # Thinking 控制字段(request-side 生成意图,与 UI 展示意图分离)
108
114
  thinking_generation_enabled: bool = True # 子代理 request-side thinking 生成开关
109
115
 
116
+ # R1 恢复策略(S4→S2;None 在 runner 层按 interactive 解析)
117
+ recovery: RecoveryPolicy | None = None
118
+
110
119
 
111
120
  @dataclass
112
121
  class SubagentResult:
@@ -150,6 +159,7 @@ def create_subagent_context(
150
159
  thinking_generation_enabled: bool = True,
151
160
  query_source: str | None = None,
152
161
  is_builtin_subagent: bool = False,
162
+ recovery: RecoveryPolicy | RecoveryPolicyMode | None = None,
153
163
  ) -> SubagentContext:
154
164
  """工厂函数:从父上下文创建子 Agent 上下文
155
165
 
@@ -285,6 +295,14 @@ def create_subagent_context(
285
295
  effective_cwd=effective_cwd,
286
296
  )
287
297
 
298
+ resolved_recovery: RecoveryPolicy | None = None
299
+ if recovery is not None:
300
+ resolved_recovery = coerce_recovery_policy(recovery)
301
+ else:
302
+ parent_recovery = getattr(parent_ctx, "recovery", None)
303
+ if isinstance(parent_recovery, (RecoveryPolicy, str)):
304
+ resolved_recovery = coerce_recovery_policy(parent_recovery)
305
+
288
306
  return SubagentContext(
289
307
  agent_id=agent_id,
290
308
  initial_messages=initial_messages,
@@ -317,6 +335,7 @@ def create_subagent_context(
317
335
  available_tools=list(available_tools or []),
318
336
  thinking_generation_enabled=thinking_generation_enabled,
319
337
  query_source=resolved_query_source,
338
+ recovery=resolved_recovery,
320
339
  )
321
340
 
322
341
 
@@ -55,6 +55,11 @@ from langchain_agentx.loop.message_contract import (
55
55
  is_synthetic_transcript_tool_id,
56
56
  to_record,
57
57
  )
58
+ from langchain_agentx.observability.events.finish_outcome import (
59
+ RecoveryPolicy,
60
+ RecoveryPolicyMode,
61
+ coerce_recovery_policy,
62
+ )
58
63
  from langchain_agentx.observability.events.tool_call_id_resolver import ToolCallIdResolver
59
64
  from langchain_agentx.observability.events.tool_outcome_resolver import (
60
65
  error_message_for_outcome,
@@ -504,6 +509,7 @@ async def _collect_subagent_events(
504
509
  run_tags: list[str],
505
510
  transcript: SubagentTranscriptManager,
506
511
  trace: SubagentExecutionTrace,
512
+ recovery: RecoveryPolicy | RecoveryPolicyMode | None = None,
507
513
  ) -> _EventCollectionResult:
508
514
  collected_assistant_messages: list[Any] = []
509
515
  terminal_reason = "completed"
@@ -533,12 +539,16 @@ async def _collect_subagent_events(
533
539
  config["configurable"] = dict(configurable)
534
540
 
535
541
  try:
542
+ recovery_policy = coerce_recovery_policy(
543
+ recovery if recovery is not None else ctx.recovery
544
+ )
536
545
  driver = LoopStreamDriver()
537
546
  async for event in driver.run(
538
547
  graph,
539
548
  graph_input,
540
549
  config=config,
541
550
  version="v2",
551
+ max_reactive_continue_per_drain=recovery_policy.reactive_continue_budget(),
542
552
  ):
543
553
  event_name = event.get("event", "")
544
554
  data = event.get("data") or {}
@@ -667,6 +677,7 @@ async def run_subagent(
667
677
  loader: Any,
668
678
  model: Any | None = None,
669
679
  allowed_tool_names: set[str] | None = None,
680
+ recovery: RecoveryPolicy | RecoveryPolicyMode | None = None,
670
681
  ) -> SubagentResult:
671
682
  """运行子 Agent(plain/fork 同步路径)。
672
683
 
@@ -715,6 +726,7 @@ async def run_subagent(
715
726
  run_tags=run_tags,
716
727
  transcript=transcript,
717
728
  trace=trace,
729
+ recovery=recovery,
718
730
  )
719
731
  return await _build_subagent_result(
720
732
  ctx=ctx,
@@ -12,7 +12,9 @@ from .events import (
12
12
  LangGraphToLangchainAgentEventAdapter,
13
13
  RecoveryAction,
14
14
  RecoveryActionResolver,
15
+ RecoveryPolicy,
15
16
  RecoveryPolicyMode,
17
+ coerce_recovery_policy,
16
18
  create_langgraph_event_adapter,
17
19
  enrich_run_result,
18
20
  )
@@ -69,7 +71,9 @@ __all__ = [
69
71
  "create_langgraph_event_adapter",
70
72
  "RecoveryAction",
71
73
  "RecoveryActionResolver",
74
+ "RecoveryPolicy",
72
75
  "RecoveryPolicyMode",
76
+ "coerce_recovery_policy",
73
77
  "enrich_run_result",
74
78
  "REASON_CODES",
75
79
  "export_call_chain",
@@ -13,7 +13,9 @@ from .finish_outcome import (
13
13
  FinishOutcomeParser,
14
14
  RecoveryAction,
15
15
  RecoveryActionResolver,
16
+ RecoveryPolicy,
16
17
  RecoveryPolicyMode,
18
+ coerce_recovery_policy,
17
19
  enrich_run_result,
18
20
  )
19
21
  from .factory import (
@@ -32,7 +34,9 @@ __all__ = [
32
34
  "FinishOutcomeParser",
33
35
  "RecoveryAction",
34
36
  "RecoveryActionResolver",
37
+ "RecoveryPolicy",
35
38
  "RecoveryPolicyMode",
39
+ "coerce_recovery_policy",
36
40
  "enrich_run_result",
37
41
  "ILangGraphEventAdapter",
38
42
  "create_langgraph_event_adapter",
@@ -54,6 +54,34 @@ FINISH_OUTCOME_KEYS = frozenset(
54
54
  RecoveryPolicyMode = Literal["interactive", "auto"]
55
55
 
56
56
 
57
+ @dataclass(frozen=True)
58
+ class RecoveryPolicy:
59
+ """Stream 恢复策略配置面(S4 消费方 → S2 LoopStreamDriver 注入)。
60
+
61
+ 非 Session 编排 API:仅控制 R1 reactive continue 预算与 finish 消费模式。
62
+ """
63
+
64
+ mode: RecoveryPolicyMode = "interactive"
65
+ max_reactive_continue_per_drain: int = 1
66
+
67
+ def reactive_continue_budget(self) -> int:
68
+ """返回 LoopStreamDriver R1 continue 预算(interactive 禁用自动 continue)。"""
69
+ if self.mode == "interactive":
70
+ return 0
71
+ return self.max_reactive_continue_per_drain
72
+
73
+
74
+ def coerce_recovery_policy(
75
+ recovery: RecoveryPolicy | RecoveryPolicyMode | None = None,
76
+ ) -> RecoveryPolicy:
77
+ """将消费方传入的 recovery 规范化为 RecoveryPolicy。"""
78
+ if recovery is None:
79
+ return RecoveryPolicy()
80
+ if isinstance(recovery, str):
81
+ return RecoveryPolicy(mode=recovery)
82
+ return recovery
83
+
84
+
57
85
  class RecoveryAction(str, Enum):
58
86
  """消费方建议动作(不含具体 UI 文案)。"""
59
87
 
@@ -248,6 +276,8 @@ __all__ = [
248
276
  "FinishOutcomeParser",
249
277
  "RecoveryAction",
250
278
  "RecoveryActionResolver",
279
+ "RecoveryPolicy",
251
280
  "RecoveryPolicyMode",
281
+ "coerce_recovery_policy",
252
282
  "enrich_run_result",
253
283
  ]
@@ -20,10 +20,8 @@ from .plan_mode import (
20
20
  )
21
21
  from .protocol import LoopContainer
22
22
  from .recovery import (
23
- SessionRecoveryRunResult,
24
23
  finish_outcome_from_graph_output,
25
- parse_result_finish_outcome,
26
- run_with_recovery_loop,
24
+ finish_outcome_from_stream_driver_result,
27
25
  )
28
26
 
29
27
  __all__ = [
@@ -35,15 +33,13 @@ __all__ = [
35
33
  "DEFAULT_CLEANUP_ACTION_TIMEOUT",
36
34
  "ConversationSession",
37
35
  "LoopContainer",
38
- "SessionRecoveryRunResult",
39
36
  "create_agent_session",
40
37
  "create_conversation_session",
41
38
  "build_plan_mode_transition_event",
42
39
  "enter_plan_mode",
43
40
  "exit_plan_mode",
44
41
  "finish_outcome_from_graph_output",
42
+ "finish_outcome_from_stream_driver_result",
45
43
  "load_session_messages",
46
- "parse_result_finish_outcome",
47
44
  "PlanModeTransition",
48
- "run_with_recovery_loop",
49
45
  ]
@@ -38,13 +38,16 @@ from ..command.builtin import (
38
38
  make_memory_command,
39
39
  make_reload_plugins_command,
40
40
  )
41
- from ..observability.events.finish_outcome import RecoveryPolicyMode, enrich_run_result
41
+ from ..observability.events.finish_outcome import (
42
+ RecoveryPolicy,
43
+ RecoveryPolicyMode,
44
+ coerce_recovery_policy,
45
+ enrich_run_result,
46
+ )
42
47
  from ..loop.config import merge_run_config
43
48
  from .recovery import (
44
- SessionRecoveryRunResult,
45
49
  finish_outcome_from_graph_output,
46
50
  finish_outcome_from_stream_driver_result,
47
- run_with_recovery_loop,
48
51
  )
49
52
  from ..loop.config.loop_runtime_overlay import LoopRuntimeOverlay
50
53
  from ..loop.context.compaction_service import default_loop_context_compaction
@@ -226,37 +229,6 @@ class AgentSession:
226
229
  finally:
227
230
  reset_command_tool_scope(store)
228
231
 
229
- async def resume_after_compact(self) -> dict[str, Any]:
230
- """compact 后重跑当前轮:不再 append 新的 HumanMessage。
231
-
232
- 适用于 CLI `/compact` 或 headless auto 恢复的第二跳;与 ``run_loop(same_query)``
233
- 不同,不会把同一用户问题重复写入 messages / transcript。
234
- """
235
- return await self._run_loop_turn(user_input=None)
236
-
237
- async def run_with_recovery(
238
- self,
239
- user_input: str,
240
- *,
241
- mode: RecoveryPolicyMode = "auto",
242
- max_outer_retries: int = 1,
243
- progress_callback: Any = None,
244
- ) -> SessionRecoveryRunResult:
245
- """B1 外层自动恢复:recoverable 时 compact + resume,受 ``max_outer_retries`` 约束。
246
-
247
- - ``mode="auto"``:可恢复且未 exhausted 时自动 compact 并 resume。
248
- - ``mode="interactive"``:只跑一轮,把 ``RecoveryAction`` 留给 CLI 决策。
249
- """
250
- return await run_with_recovery_loop(
251
- user_input=user_input,
252
- mode=mode,
253
- max_outer_retries=max_outer_retries,
254
- run_new_turn=lambda inp: self._run_loop_turn(user_input=inp),
255
- resume_current_turn=lambda: self._run_loop_turn(user_input=None),
256
- compact=self.compact_context,
257
- progress_callback=progress_callback,
258
- )
259
-
260
232
  async def _run_loop_turn(self, *, user_input: str | None) -> dict[str, Any]:
261
233
  if self._graph is None:
262
234
  raise RuntimeError("AgentSession graph is not initialized")
@@ -302,6 +274,7 @@ class AgentSession:
302
274
  user_input: str,
303
275
  *,
304
276
  config: dict[str, Any] | None = None,
277
+ recovery: RecoveryPolicy | RecoveryPolicyMode | None = None,
305
278
  ) -> AsyncIterator[dict[str, Any]]:
306
279
  """
307
280
  流式执行一轮 loop,复用 AgentSession 的消息上下文。
@@ -322,6 +295,7 @@ class AgentSession:
322
295
  output_messages: list[Any] | None = None
323
296
  graph_output: dict[str, Any] | None = None
324
297
  run_config = merge_run_config(self._graph, config)
298
+ recovery_policy = coerce_recovery_policy(recovery)
325
299
  driver = LoopStreamDriver()
326
300
  try:
327
301
  try:
@@ -333,6 +307,7 @@ class AgentSession:
333
307
  },
334
308
  config=run_config,
335
309
  version="v2",
310
+ max_reactive_continue_per_drain=recovery_policy.reactive_continue_budget(),
336
311
  ):
337
312
  if event.get("event") == "on_chain_end" and event.get("name") == "LangGraph":
338
313
  data = event.get("data", {}) or {}
@@ -594,4 +569,4 @@ class AgentSession:
594
569
  self._runtime_command_registry.register(make_reload_plugins_command())
595
570
 
596
571
 
597
- __all__ = ["AgentSession", "SessionRecoveryRunResult"]
572
+ __all__ = ["AgentSession"]