langchain-agentx-python 2.0.1__py3-none-any.whl → 2.1.1__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 +20 -1
  8. langchain_agentx/loop/subagent/orchestrator.py +3 -2
  9. langchain_agentx/loop/subagent/runner.py +13 -1
  10. langchain_agentx/observability/__init__.py +4 -0
  11. langchain_agentx/observability/events/__init__.py +4 -0
  12. langchain_agentx/observability/events/agent_event_projector.py +29 -1
  13. langchain_agentx/observability/events/finish_outcome.py +30 -0
  14. langchain_agentx/observability/events/subagent_window_text_guard.py +45 -6
  15. langchain_agentx/session/__init__.py +2 -6
  16. langchain_agentx/session/agent_session.py +10 -35
  17. langchain_agentx/session/conversation_session.py +8 -0
  18. langchain_agentx/session/recovery.py +6 -86
  19. langchain_agentx/tools/agent/tool.py +2 -2
  20. langchain_agentx/tools/glob/tool.py +13 -2
  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.1.dist-info → langchain_agentx_python-2.1.1.dist-info}/METADATA +1 -1
  25. {langchain_agentx_python-2.0.1.dist-info → langchain_agentx_python-2.1.1.dist-info}/RECORD +28 -27
  26. {langchain_agentx_python-2.0.1.dist-info → langchain_agentx_python-2.1.1.dist-info}/LICENSE +0 -0
  27. {langchain_agentx_python-2.0.1.dist-info → langchain_agentx_python-2.1.1.dist-info}/WHEEL +0 -0
  28. {langchain_agentx_python-2.0.1.dist-info → langchain_agentx_python-2.1.1.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.1"
14
+ __version__ = "2.1.1"
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 (
@@ -85,7 +91,7 @@ class SubagentContext:
85
91
  parent_tool_call_id: str | None = None # 触发本子 Agent 的 tool_call_id
86
92
  parent_conversation_session_id: str | None = None
87
93
  enable_trace: bool = True
88
- runnable_config: Any = None # 父 graph runnable_config,用于子 graph 事件自动冒泡到父 graph
94
+ runnable_config: Any = None # 父 runnable_config:仅供 tool_progress custom event 回调链;非子 raw 合并
89
95
  isolation: str = "none"
90
96
  worktree_dir: str | None = None
91
97
  workspace_root: str = ""
@@ -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
 
@@ -307,7 +307,8 @@ class SubagentOrchestrator:
307
307
  ) -> "AgentToolOutput":
308
308
  """异步执行子代理。
309
309
 
310
- 子 graph 事件通过 config 自动冒泡到父 graph,无需手动回调。
310
+ 子 graph 在独立 LoopStreamDriver 消费(Phase-4);progress 经 custom event 冒泡,
311
+ 裸子 on_tool_* 不并进父 astream。
311
312
  """
312
313
  from langchain_agentx.tools.agent.models import AgentToolOutput
313
314
 
@@ -595,7 +596,7 @@ class SubagentOrchestrator:
595
596
  ) -> Any:
596
597
  """异步启动子 agent。
597
598
 
598
- 子 graph 事件通过 config 自动冒泡到父 graph。
599
+ 子 graph Phase-4 独立 Driver 消费;progress 经 custom event 冒泡到父流。
599
600
  """
600
601
  owner_run_ctx = get_run_context()
601
602
  handle = self._register_background_agent(
@@ -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,
@@ -221,7 +226,7 @@ async def _emit_progress(
221
226
  """发射子 Agent teaser lane 到父 graph(CC 对齐:仅 tool_use / tool_result)。
222
227
 
223
228
  同步 dispatch 优先:并行线程池 / _run_coroutine_sync 子线程内 adispatch 无法触达父回调链。
224
- graph 的 config 通过 ctx.runnable_config 传递(在 _collect_subagent_events 中合并)。
229
+ 父 config ctx.runnable_config 仅服务 custom event 派发(Phase-4 不合并父 callbacks)。
225
230
  """
226
231
  if event.type not in ("tool_use", "tool_result"):
227
232
  return
@@ -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",
@@ -219,6 +219,10 @@ class AgentEventProjector:
219
219
  self.enable_subagent_events: bool = config.get("enable_subagent_events", True)
220
220
  self.synthesize_tool_call: bool = config.get("synthesize_tool_call", True)
221
221
  self.enable_tool_input_start: bool = config.get("enable_tool_input_start", False)
222
+ # Doc-34 F5:活跃子窗内抑制非 Agent 主台 tool-*(默认 on;关则恢复旧投影)
223
+ self.suppress_subagent_child_main_tier_tools: bool = bool(
224
+ config.get("suppress_subagent_child_main_tier_tools", True)
225
+ )
222
226
  self.filter_unparented_subgraph_events: bool = config.get(
223
227
  "filter_unparented_subgraph_events", False
224
228
  )
@@ -528,9 +532,17 @@ class AgentEventProjector:
528
532
  self._sync_accumulated_answer_mirror()
529
533
 
530
534
  def _should_suppress_main_tier_text_stream(self) -> bool:
531
- """D28 SDK-1:子 Agent 窗口内不 emit 父 main-tier TEXT_* / REASONING_*。"""
535
+ """Doc-33:子 Agent 窗口内不 emit 父 main-tier TEXT_* / REASONING_*。"""
532
536
  return self.state.subagent_window_text_guard.should_suppress_main_tier_stream
533
537
 
538
+ def _should_suppress_main_tier_child_tool_events(self, tool_name: str) -> bool:
539
+ """Doc-34 F2/F3/F5:flag on 且非 Agent 且 open_runs>0 → 抑制主台 tool-*。"""
540
+ if not self.suppress_subagent_child_main_tier_tools:
541
+ return False
542
+ if self._is_agent_tool(tool_name):
543
+ return False
544
+ return self.state.subagent_window_text_guard.should_suppress_main_tier_tool_events
545
+
534
546
  def _on_subagent_start_boundary(self) -> None:
535
547
  self.state.subagent_window_text_guard.on_subagent_start()
536
548
 
@@ -888,6 +900,7 @@ class AgentEventProjector:
888
900
  messages=messages if isinstance(messages, list) else None,
889
901
  )
890
902
  self._closeout_saw_finish_emitted = True
903
+ self.state.subagent_window_text_guard.warn_if_unbalanced_at_finish(logger)
891
904
  yield self._event(
892
905
  event_type=LangchainAgentEventType.FINISH,
893
906
  data=finish_data,
@@ -1113,6 +1126,16 @@ class AgentEventProjector:
1113
1126
 
1114
1127
  self.state.current_tool = name
1115
1128
 
1129
+ # Doc-34 F2:非 Agent 且窗开 → 在任何 TOOL_INPUT_* / TOOL_CALL 之前 return
1130
+ if self._should_suppress_main_tier_child_tool_events(name):
1131
+ self.state.subagent_window_text_guard.note_suppressed_child_tool_start()
1132
+ logger.debug(
1133
+ "Doc-34: suppress main-tier tool_start name=%s open_runs=%s",
1134
+ name,
1135
+ self.state.subagent_window_text_guard.open_runs,
1136
+ )
1137
+ return
1138
+
1116
1139
  if self.enable_tool_input_start:
1117
1140
  yield self._event(
1118
1141
  event_type=LangchainAgentEventType.TOOL_INPUT_START,
@@ -1192,6 +1215,11 @@ class AgentEventProjector:
1192
1215
  data = event.get("data", {}) or {}
1193
1216
  tool_call_id = ToolCallIdResolver.resolve_from_langgraph_tool_event(event)
1194
1217
 
1218
+ # Doc-34 F2:窗内非 Agent 对称抑制 RESULT/ERROR(不碰 Agent end → on_subagent_end)
1219
+ if self._should_suppress_main_tier_child_tool_events(name):
1220
+ self.state.current_tool = None
1221
+ return
1222
+
1195
1223
  output = data.get("output")
1196
1224
 
1197
1225
  event_type, event_data = self._classify_tool_end_event(
@@ -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
  ]