langchain-agentx-python 2.1.6__py3-none-any.whl → 2.1.8__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 (30) hide show
  1. langchain_agentx/__init__.py +1 -1
  2. langchain_agentx/loop/config/loop_config.py +5 -0
  3. langchain_agentx/loop/config/loop_runtime_overlay.py +6 -1
  4. langchain_agentx/loop/exit/reason_codes.py +4 -0
  5. langchain_agentx/loop/graph/factory.py +22 -2
  6. langchain_agentx/loop/run_context.py +3 -0
  7. langchain_agentx/loop/runtime/context_factory.py +1 -0
  8. langchain_agentx/loop/subagent/async_runner.py +77 -15
  9. langchain_agentx/loop/subagent/context.py +3 -0
  10. langchain_agentx/loop/subagent/graph.py +1 -0
  11. langchain_agentx/observability/__init__.py +16 -0
  12. langchain_agentx/observability/events/__init__.py +18 -0
  13. langchain_agentx/observability/events/agent_event_projector.py +18 -0
  14. langchain_agentx/observability/events/finish_event_payload_composer.py +43 -1
  15. langchain_agentx/observability/events/finish_outcome.py +49 -5
  16. langchain_agentx/observability/events/langchain_agentx_event_adapter.py +9 -1
  17. langchain_agentx/observability/events/oob_event_sink.py +183 -0
  18. langchain_agentx/observability/events/workflow_aware_event_adapter.py +9 -0
  19. langchain_agentx/observability/trace/__init__.py +8 -0
  20. langchain_agentx/observability/trace/persist_policy.py +40 -0
  21. langchain_agentx/session/agent_session.py +26 -0
  22. langchain_agentx/session/conversation_session.py +12 -0
  23. langchain_agentx/session/factory.py +1 -0
  24. langchain_agentx/workflow/base.py +20 -2
  25. langchain_agentx/workspace/capabilities.py +18 -2
  26. {langchain_agentx_python-2.1.6.dist-info → langchain_agentx_python-2.1.8.dist-info}/METADATA +1 -1
  27. {langchain_agentx_python-2.1.6.dist-info → langchain_agentx_python-2.1.8.dist-info}/RECORD +30 -28
  28. {langchain_agentx_python-2.1.6.dist-info → langchain_agentx_python-2.1.8.dist-info}/LICENSE +0 -0
  29. {langchain_agentx_python-2.1.6.dist-info → langchain_agentx_python-2.1.8.dist-info}/WHEEL +0 -0
  30. {langchain_agentx_python-2.1.6.dist-info → langchain_agentx_python-2.1.8.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.1.6"
14
+ __version__ = "2.1.8"
15
15
 
16
16
  from .loop import ( # noqa: F401
17
17
  create_loop_agent,
@@ -81,8 +81,13 @@ class TraceConfig:
81
81
  """观测追踪配置。
82
82
 
83
83
  CC 对照:options.debug / queryTracking 等。
84
+
85
+ - ``enabled``:是否挂 TraceCollector 埋点(lifecycle / callback)。
86
+ - ``persist``:是否挂 ``SqliteTraceStore`` 写 traces.db;默认 False,避免盘胀。
87
+ 亦可经环境变量 ``LANGCHAIN_AGENTX_TRACE_PERSIST=1`` 打开(见 persist_policy)。
84
88
  """
85
89
  enabled: bool = True
90
+ persist: bool = False
86
91
  visibility: str | None = None
87
92
  parent_run_id: str | None = None
88
93
  parent_tool_call_id: str | None = None
@@ -9,7 +9,8 @@ loop_runtime_overlay.py — L4 graph_factory 运行期 overlay(IF-G6)
9
9
  → graph_factory(overlay) → apply_to(AgentLoopConfig) → create_loop_agent
10
10
 
11
11
  当前裁剪范围:
12
- 仅 session_id、trace 父链、subagent 标记、capabilities、enable_trace;不含 container_type。
12
+ 仅 session_id、trace 父链、subagent 标记、capabilities、enable_trace / trace_persist;
13
+ 不含 container_type。
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -26,6 +27,7 @@ class LoopRuntimeOverlay:
26
27
 
27
28
  session_id: str | None = None
28
29
  enable_trace: bool | None = None
30
+ trace_persist: bool | None = None
29
31
  capabilities: Any = None
30
32
  parent_conversation_session_id: str | None = None
31
33
  is_subagent: bool | None = None
@@ -41,6 +43,9 @@ class LoopRuntimeOverlay:
41
43
  if self.enable_trace is not None:
42
44
  trace = replace(trace, enabled=self.enable_trace)
43
45
  trace_changed = True
46
+ if self.trace_persist is not None:
47
+ trace = replace(trace, persist=self.trace_persist)
48
+ trace_changed = True
44
49
  if self.parent_conversation_session_id is not None:
45
50
  trace = replace(trace, parent_conversation_session_id=self.parent_conversation_session_id)
46
51
  trace_changed = True
@@ -48,6 +48,7 @@ TRANSITION_TOKEN_BUDGET_CONTINUATION = "token_budget_continuation"
48
48
  DEFAULT_TOKEN_BUDGET_MAX_CONTINUATIONS = 3
49
49
 
50
50
  # 错误结束 terminal_reason 集合(finish payload is_error 判据)
51
+ # D-EVT-3:含 aborted_* / hook_stopped,与 build_loop_terminal 对齐
51
52
  ERROR_TERMINAL_REASONS = frozenset(
52
53
  {
53
54
  TERMINAL_REASON_ERROR,
@@ -60,6 +61,9 @@ ERROR_TERMINAL_REASONS = frozenset(
60
61
  TERMINAL_REASON_MAX_TOKENS,
61
62
  TERMINAL_REASON_BLOCKING_LIMIT,
62
63
  TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
64
+ TERMINAL_REASON_ABORTED_STREAMING,
65
+ TERMINAL_REASON_ABORTED_TOOLS,
66
+ TERMINAL_REASON_HOOK_STOPPED,
63
67
  }
64
68
  )
65
69
 
@@ -305,6 +305,7 @@ def _flatten_config_for_runtime(config: AgentLoopConfig) -> _FlatLoopConfig:
305
305
  agent_depth=config.agent_depth,
306
306
  subagent_type=config.subagent.subagent_type,
307
307
  trace_enabled=config.trace.enabled,
308
+ trace_persist=config.trace.persist,
308
309
  headless=config.headless,
309
310
  model_profile=config.model_profile,
310
311
  model_profile_registry=config.model_profile_registry,
@@ -1143,9 +1144,28 @@ class LoopGraphBuilder:
1143
1144
  if not self._config.trace.enabled:
1144
1145
  return None
1145
1146
  if self._services.trace_collector is not None:
1147
+ # 显式注入:调用方自管 store 生命周期(测试 / 宿主)
1146
1148
  return self._services.trace_collector
1147
- if self._services.capabilities is not None and self._services.capabilities.trace_collector is not None:
1148
- return self._services.capabilities.trace_collector
1149
+
1150
+ from ...observability.trace.persist_policy import resolve_trace_persist
1151
+
1152
+ persist = resolve_trace_persist(configured=bool(self._config.trace.persist))
1153
+ caps = self._services.capabilities
1154
+ caps_collector = (
1155
+ caps.trace_collector
1156
+ if caps is not None and getattr(caps, "trace_collector", None) is not None
1157
+ else None
1158
+ )
1159
+
1160
+ if not persist:
1161
+ # 不落盘:复用 caps 上无 store 的 collector;若 caps 误带 store 则改用内存 collector,避免写库。
1162
+ if caps_collector is not None and getattr(caps_collector, "_store", None) is None:
1163
+ return caps_collector
1164
+ return TraceCollector(store=None)
1165
+
1166
+ # persist=True:必须挂有 store 的 collector;caps 无 store 时兜底 from_env,避免「开了 persist 仍不落盘」。
1167
+ if caps_collector is not None and getattr(caps_collector, "_store", None) is not None:
1168
+ return caps_collector
1149
1169
  return TraceCollector(SqliteTraceStore.from_env())
1150
1170
 
1151
1171
  def _resolve_response_format_setup(
@@ -42,6 +42,9 @@ class RunContext:
42
42
  child_tasks: set[asyncio.Task] = field(default_factory=set)
43
43
  child_threads: set[threading.Thread] = field(default_factory=set)
44
44
  cancel_requested: bool = False
45
+ # D-EVT-6:宿主在 cancel_tree / killAll 时写入;finish 投影读取;缺省 unknown。
46
+ # 取值:escape | workflow_stop | kill_agents | unknown;禁止从「是否有后台」猜测。
47
+ cancel_source: str | None = None
45
48
 
46
49
  def register_child(self, task: asyncio.Task) -> None:
47
50
  self.child_tasks.add(task)
@@ -167,6 +167,7 @@ class RuntimeContextFactory:
167
167
  ),
168
168
  trace=TraceConfig(
169
169
  enabled=bool(getattr(parent_ctx, "trace_enabled", True)),
170
+ persist=bool(getattr(parent_ctx, "trace_persist", False)),
170
171
  ),
171
172
  subagent=SubagentLinkage(
172
173
  is_subagent=True,
@@ -13,6 +13,7 @@ loop/subagent/async_runner.py — 子 Agent 异步生命周期管理
13
13
 
14
14
  当前裁剪范围:
15
15
  P0:删除 C4;后台仅 C3。P2:killAll 仅遍历 _BACKGROUND_HANDLES(可 Task.cancel)。
16
+ P2 投影:lifecycle / _mark_cancelled_terminal → OOB task-notification(D-EVT-5)。
16
17
  """
17
18
 
18
19
  from __future__ import annotations
@@ -22,7 +23,7 @@ import logging
22
23
  import os
23
24
  import uuid
24
25
  from dataclasses import dataclass
25
- from typing import Any
26
+ from typing import Any, Literal
26
27
 
27
28
  from langchain_agentx.loop.exit.reason_codes import (
28
29
  TERMINAL_REASON_ABORTED_STREAMING,
@@ -106,11 +107,34 @@ def _is_cancel_like_result(result: Any) -> bool:
106
107
  return getattr(result, "error_kind", None) == "aborted"
107
108
 
108
109
 
110
+ def _emit_task_notification_bookend(
111
+ *,
112
+ task_id: str,
113
+ status: Literal["completed", "failed", "stopped"],
114
+ output_file: str = "",
115
+ summary: str = "",
116
+ agent_id: str | None = None,
117
+ terminal_reason: str | None = None,
118
+ ) -> None:
119
+ """D-EVT-5:后台终态 OOB bookend;同 task_id 去重在 sink 内完成。"""
120
+ from langchain_agentx.observability.events.oob_event_sink import get_oob_event_sink
121
+
122
+ get_oob_event_sink().emit_task_notification(
123
+ task_id=task_id,
124
+ status=status,
125
+ output_file=output_file,
126
+ summary=summary,
127
+ agent_id=agent_id,
128
+ terminal_reason=terminal_reason,
129
+ )
130
+
131
+
109
132
  def _mark_cancelled_terminal(
110
133
  *,
111
134
  task_id: str,
112
135
  output_file: str,
113
136
  task_runtime: Any | None,
137
+ agent_id: str | None = None,
114
138
  ) -> None:
115
139
  """写 cancelled 输出 + TaskRuntime STOPPED(对齐 TaskRuntime.cancel / CC killed)。"""
116
140
  try:
@@ -119,18 +143,27 @@ def _mark_cancelled_terminal(
119
143
  fp.write("cancelled")
120
144
  except Exception:
121
145
  logger.debug("failed to write cancelled output for task_id=%s", task_id, exc_info=True)
122
- if task_runtime is None:
123
- return
124
- try:
125
- task_runtime.mark_terminal(
126
- task_id,
127
- TaskStatus.STOPPED,
128
- "subagent cancelled",
129
- output_file=output_file,
130
- terminal_reason="cancelled",
131
- )
132
- except Exception:
133
- logger.debug("failed to mark cancelled terminal for task_id=%s", task_id, exc_info=True)
146
+ if task_runtime is not None:
147
+ try:
148
+ task_runtime.mark_terminal(
149
+ task_id,
150
+ TaskStatus.STOPPED,
151
+ "subagent cancelled",
152
+ output_file=output_file,
153
+ terminal_reason="cancelled",
154
+ )
155
+ except Exception:
156
+ logger.debug(
157
+ "failed to mark cancelled terminal for task_id=%s", task_id, exc_info=True
158
+ )
159
+ _emit_task_notification_bookend(
160
+ task_id=task_id,
161
+ status="stopped",
162
+ output_file=output_file,
163
+ summary="cancelled",
164
+ agent_id=agent_id,
165
+ terminal_reason="cancelled",
166
+ )
134
167
 
135
168
 
136
169
  async def _execute_background_lifecycle(
@@ -146,6 +179,7 @@ async def _execute_background_lifecycle(
146
179
  """共享后台生命周期:run_subagent → 写 output → task_runtime 终态。"""
147
180
  execution_ctx = ctx
148
181
  execution_ctx.is_async = False
182
+ agent_id = getattr(ctx, "agent_id", None)
149
183
  try:
150
184
  result = await run_subagent(
151
185
  ctx=execution_ctx,
@@ -160,37 +194,64 @@ async def _execute_background_lifecycle(
160
194
  task_id=task_id,
161
195
  output_file=output_file,
162
196
  task_runtime=task_runtime,
197
+ agent_id=agent_id,
163
198
  )
164
199
  return
165
200
  with open(output_file, "w", encoding="utf-8") as fp:
166
201
  fp.write(result.content)
202
+ summary = (
203
+ result.content[:500]
204
+ if result.content
205
+ else (result.error_message or "subagent completed")
206
+ )
207
+ notif_status: Literal["completed", "failed"] = (
208
+ "completed" if result.status == "completed" else "failed"
209
+ )
167
210
  if task_runtime is not None:
168
211
  status = TaskStatus.COMPLETED if result.status == "completed" else TaskStatus.FAILED
169
212
  task_runtime.mark_terminal(
170
213
  task_id,
171
214
  status,
172
- result.content[:500] if result.content else (result.error_message or "subagent completed"),
215
+ summary,
173
216
  output_file=output_file,
174
217
  terminal_reason=result.terminal_reason or None,
175
218
  )
219
+ _emit_task_notification_bookend(
220
+ task_id=task_id,
221
+ status=notif_status,
222
+ output_file=output_file,
223
+ summary=summary,
224
+ agent_id=agent_id,
225
+ terminal_reason=result.terminal_reason or None,
226
+ )
176
227
  except asyncio.CancelledError:
177
228
  _mark_cancelled_terminal(
178
229
  task_id=task_id,
179
230
  output_file=output_file,
180
231
  task_runtime=task_runtime,
232
+ agent_id=agent_id,
181
233
  )
182
234
  raise
183
235
  except Exception as exc:
184
236
  with open(output_file, "w", encoding="utf-8") as fp:
185
237
  fp.write(str(exc))
238
+ summary = f"Subagent background lifecycle failed: {exc}"
186
239
  if task_runtime is not None:
187
240
  task_runtime.mark_terminal(
188
241
  task_id,
189
242
  TaskStatus.FAILED,
190
- f"Subagent background lifecycle failed: {exc}",
243
+ summary,
191
244
  output_file=output_file,
192
245
  terminal_reason=TERMINAL_REASON_GRAPH_RUNTIME_ERROR,
193
246
  )
247
+ _emit_task_notification_bookend(
248
+ task_id=task_id,
249
+ status="failed",
250
+ output_file=output_file,
251
+ summary=summary,
252
+ agent_id=agent_id,
253
+ terminal_reason=TERMINAL_REASON_GRAPH_RUNTIME_ERROR,
254
+ )
194
255
 
195
256
 
196
257
  def register_background_agent(
@@ -309,6 +370,7 @@ def kill_background_agent(task_id: str) -> bool:
309
370
  task_id=task_id,
310
371
  output_file=handle.output_file,
311
372
  task_runtime=handle.task_runtime,
373
+ agent_id=handle.agent_id,
312
374
  )
313
375
  return True
314
376
 
@@ -91,6 +91,7 @@ class SubagentContext:
91
91
  parent_tool_call_id: str | None = None # 触发本子 Agent 的 tool_call_id
92
92
  parent_conversation_session_id: str | None = None
93
93
  enable_trace: bool = True
94
+ trace_persist: bool = False
94
95
  runnable_config: Any = None # 父 runnable_config:仅供 tool_progress custom event 回调链;非子 raw 合并
95
96
  isolation: str = "none"
96
97
  worktree_dir: str | None = None
@@ -324,6 +325,7 @@ def create_subagent_context(
324
325
  parent_tool_call_id=parent_tool_call_id,
325
326
  parent_conversation_session_id=parent_conversation_id,
326
327
  enable_trace=bool(getattr(parent_ctx, "trace_enabled", True)),
328
+ trace_persist=bool(getattr(parent_ctx, "trace_persist", False)),
327
329
  isolation=isolation,
328
330
  worktree_dir=worktree_dir,
329
331
  workspace_root=str(parent_workspace_root),
@@ -476,6 +478,7 @@ class SubagentContextFactory:
476
478
  parent_run_id=parent_runtime.parent_run_id,
477
479
  parent_conversation_session_id=parent_runtime.parent_conversation_session_id,
478
480
  enable_trace=not skip_transcript,
481
+ trace_persist=bool(getattr(parent_runtime, "trace_persist", False)),
479
482
  workspace_root=workspace_root,
480
483
  agent_home_segment=agent_home_segment,
481
484
  cwd=effective_cwd,
@@ -108,6 +108,7 @@ def create_subagent_graph(
108
108
  ),
109
109
  trace=TraceConfig(
110
110
  enabled=ctx.enable_trace,
111
+ persist=ctx.trace_persist,
111
112
  # 背景 fork / 异步子代理默认隐藏
112
113
  visibility=(
113
114
  "hidden"
@@ -10,13 +10,21 @@ from .events import (
10
10
  LangchainAgentEvent,
11
11
  LangchainAgentEventType,
12
12
  LangGraphToLangchainAgentEventAdapter,
13
+ OOB_EVENT_QUEUE_CAP,
14
+ OobEventSink,
13
15
  RecoveryAction,
14
16
  RecoveryActionResolver,
15
17
  RecoveryPolicy,
16
18
  RecoveryPolicyMode,
17
19
  coerce_recovery_policy,
18
20
  create_langgraph_event_adapter,
21
+ drain_oob_events,
19
22
  enrich_run_result,
23
+ ensure_oob_event_sink,
24
+ get_oob_event_sink,
25
+ install_oob_event_sink,
26
+ reset_oob_event_sink,
27
+ teardown_oob_event_sink,
20
28
  )
21
29
  from .replay.service import (
22
30
  export_call_chain,
@@ -62,6 +70,8 @@ __all__ = [
62
70
  "LogContext",
63
71
  "LoggingConfigBuilder",
64
72
  "OBS_SCHEMA_VERSION",
73
+ "OOB_EVENT_QUEUE_CAP",
74
+ "OobEventSink",
65
75
  "ObservabilityLoggerAdapter",
66
76
  "build_log_context",
67
77
  "configure_logging_once",
@@ -69,6 +79,12 @@ __all__ = [
69
79
  "LangchainAgentEventType",
70
80
  "LangGraphToLangchainAgentEventAdapter",
71
81
  "create_langgraph_event_adapter",
82
+ "drain_oob_events",
83
+ "ensure_oob_event_sink",
84
+ "get_oob_event_sink",
85
+ "install_oob_event_sink",
86
+ "reset_oob_event_sink",
87
+ "teardown_oob_event_sink",
72
88
  "RecoveryAction",
73
89
  "RecoveryActionResolver",
74
90
  "RecoveryPolicy",
@@ -22,6 +22,16 @@ from .factory import (
22
22
  ILangGraphEventAdapter,
23
23
  create_langgraph_event_adapter,
24
24
  )
25
+ from .oob_event_sink import (
26
+ OOB_EVENT_QUEUE_CAP,
27
+ OobEventSink,
28
+ drain_oob_events,
29
+ ensure_oob_event_sink,
30
+ get_oob_event_sink,
31
+ install_oob_event_sink,
32
+ reset_oob_event_sink,
33
+ teardown_oob_event_sink,
34
+ )
25
35
 
26
36
  __all__ = [
27
37
  "LangchainAgentEventType",
@@ -40,4 +50,12 @@ __all__ = [
40
50
  "enrich_run_result",
41
51
  "ILangGraphEventAdapter",
42
52
  "create_langgraph_event_adapter",
53
+ "OOB_EVENT_QUEUE_CAP",
54
+ "OobEventSink",
55
+ "drain_oob_events",
56
+ "ensure_oob_event_sink",
57
+ "get_oob_event_sink",
58
+ "install_oob_event_sink",
59
+ "reset_oob_event_sink",
60
+ "teardown_oob_event_sink",
43
61
  ]
@@ -139,6 +139,8 @@ class LangchainAgentEventType(Enum):
139
139
  TASK_NOTIFICATION_ENQUEUED = "task-notification-enqueued"
140
140
  TASK_NOTIFICATION_CONSUMED = "task-notification-consumed"
141
141
  TASK_TERMINATED = "task-terminated"
142
+ # D-EVT-5:OOB 后台终态书档(对标 CC system/task_notification)
143
+ TASK_NOTIFICATION = "task-notification"
142
144
 
143
145
 
144
146
  @dataclass
@@ -1249,6 +1251,22 @@ class AgentEventProjector:
1249
1251
  tool_name=name,
1250
1252
  session_id=str(child_session_id) if child_session_id else None,
1251
1253
  )
1254
+ # D-EVT-5′:图内 SUBAGENT_END 覆盖时登记;若 payload 含 task_id 则抑制同 id OOB
1255
+ end_task_id: str | None = None
1256
+ if isinstance(output, dict):
1257
+ raw_tid = output.get("task_id")
1258
+ if not isinstance(raw_tid, str):
1259
+ artifact = output.get("artifact")
1260
+ if isinstance(artifact, dict):
1261
+ raw_tid = artifact.get("task_id")
1262
+ if isinstance(raw_tid, str) and raw_tid.strip():
1263
+ end_task_id = raw_tid.strip()
1264
+ if end_task_id:
1265
+ from langchain_agentx.observability.events.oob_event_sink import (
1266
+ get_oob_event_sink,
1267
+ )
1268
+
1269
+ get_oob_event_sink().note_terminal_projected(end_task_id)
1252
1270
  self._on_subagent_end_boundary()
1253
1271
 
1254
1272
  self.state.current_tool = None
@@ -11,6 +11,7 @@ finish_event_payload_composer.py — finish 事件 data 组装协作者
11
11
  当前裁剪范围:
12
12
  - 不发射事件、不读 LangGraph raw event
13
13
  - 不处理 SubAgent 终态;model_retry 进度见 retry_progress.py(Phase 6)
14
+ - INTERRUPT 文案仅经 display_interrupt_text 投影(D-EVT-4),不入 messages
14
15
  """
15
16
 
16
17
  from __future__ import annotations
@@ -22,8 +23,11 @@ from langchain_core.messages import AIMessage
22
23
 
23
24
  from langchain_agentx.loop.exit.reason_codes import (
24
25
  ERROR_TERMINAL_REASONS,
26
+ TERMINAL_REASON_ABORTED_STREAMING,
27
+ TERMINAL_REASON_ABORTED_TOOLS,
25
28
  TERMINAL_REASON_COMPLETED,
26
29
  TERMINAL_REASON_ERROR,
30
+ TERMINAL_REASON_HOOK_STOPPED,
27
31
  TERMINAL_REASON_MAX_TOKENS,
28
32
  TRANSITION_API_ERROR_TERMINAL,
29
33
  TRANSITION_CONTEXT_WINDOW_RECOVERY_EXHAUSTED,
@@ -32,6 +36,7 @@ from langchain_agentx.loop.exit.reason_codes import (
32
36
  )
33
37
  from langchain_agentx.loop.exit.terminal import read_terminal_reason
34
38
  from langchain_agentx.loop.exit.withheld_error import resolve_overflow_kind
39
+ from langchain_agentx.loop.run_context import get_run_context
35
40
 
36
41
  _RECOVERY_EXHAUSTED_TRANSITIONS = frozenset(
37
42
  {
@@ -41,6 +46,17 @@ _RECOVERY_EXHAUSTED_TRANSITIONS = frozenset(
41
46
  }
42
47
  )
43
48
 
49
+ # D-EVT-2/4:用户取消 display 文案(对齐 CC INTERRUPT;禁止注入 messages)
50
+ DEFAULT_DISPLAY_INTERRUPT_TEXT = "[Request interrupted by user]"
51
+ ERROR_KIND_USER_CANCELLED = "user_cancelled"
52
+ ERROR_KIND_HOOK_STOPPED = "hook_stopped"
53
+ _USER_CANCELLED_TERMINAL_REASONS = frozenset(
54
+ {
55
+ TERMINAL_REASON_ABORTED_STREAMING,
56
+ TERMINAL_REASON_ABORTED_TOOLS,
57
+ }
58
+ )
59
+
44
60
  _SENSITIVE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
45
61
  (re.compile(r"sk-[a-zA-Z0-9_-]{8,}", re.I), "<redacted_api_key>"),
46
62
  (re.compile(r"bearer\s+\S+", re.I), "Bearer <redacted>"),
@@ -157,7 +173,7 @@ class FinishEventPayloadComposer:
157
173
  messages=messages,
158
174
  )
159
175
 
160
- return {
176
+ payload: dict[str, Any] = {
161
177
  "graph_name": graph_name,
162
178
  "answer": answer,
163
179
  "terminal_reason": terminal_reason,
@@ -168,6 +184,10 @@ class FinishEventPayloadComposer:
168
184
  "error_kind": error_kind,
169
185
  "recovery_exhausted": recovery_exhausted,
170
186
  }
187
+ if error_kind == ERROR_KIND_USER_CANCELLED:
188
+ payload["display_interrupt_text"] = DEFAULT_DISPLAY_INTERRUPT_TEXT
189
+ payload["cancel_source"] = self._resolve_cancel_source(snap)
190
+ return payload
171
191
 
172
192
  @staticmethod
173
193
  def _resolve_terminal_reason(
@@ -265,6 +285,25 @@ class FinishEventPayloadComposer:
265
285
  if terminal_reason == TERMINAL_REASON_COMPLETED:
266
286
  return "unknown"
267
287
 
288
+ if terminal_reason in _USER_CANCELLED_TERMINAL_REASONS:
289
+ return ERROR_KIND_USER_CANCELLED
290
+
291
+ if terminal_reason == TERMINAL_REASON_HOOK_STOPPED:
292
+ return ERROR_KIND_HOOK_STOPPED
293
+
294
+ return "unknown"
295
+
296
+ @staticmethod
297
+ def _resolve_cancel_source(snap: dict[str, Any]) -> str:
298
+ """D-EVT-6:宿主写入优先;缺省 unknown;禁止猜测反推。"""
299
+ raw = snap.get("cancel_source")
300
+ if raw is not None and str(raw):
301
+ return str(raw)
302
+ ctx = get_run_context()
303
+ if ctx is not None:
304
+ source = getattr(ctx, "cancel_source", None)
305
+ if source is not None and str(source):
306
+ return str(source)
268
307
  return "unknown"
269
308
 
270
309
  @staticmethod
@@ -323,6 +362,9 @@ class FinishEventPayloadComposer:
323
362
 
324
363
 
325
364
  __all__ = [
365
+ "DEFAULT_DISPLAY_INTERRUPT_TEXT",
366
+ "ERROR_KIND_HOOK_STOPPED",
367
+ "ERROR_KIND_USER_CANCELLED",
326
368
  "FinishEventPayloadComposer",
327
369
  "sanitize_finish_error_message",
328
370
  ]
@@ -20,8 +20,15 @@ from dataclasses import dataclass
20
20
  from enum import Enum
21
21
  from typing import Any, Literal
22
22
 
23
+ from langchain_agentx.loop.exit.reason_codes import (
24
+ TERMINAL_REASON_ABORTED_STREAMING,
25
+ TERMINAL_REASON_ABORTED_TOOLS,
26
+ TERMINAL_REASON_HOOK_STOPPED,
27
+ )
23
28
  from langchain_agentx.loop.exit.terminal import read_terminal_reason
24
29
  from langchain_agentx.observability.events.finish_event_payload_composer import (
30
+ ERROR_KIND_HOOK_STOPPED,
31
+ ERROR_KIND_USER_CANCELLED,
25
32
  FinishEventPayloadComposer,
26
33
  )
27
34
 
@@ -53,6 +60,27 @@ FINISH_OUTCOME_KEYS = frozenset(
53
60
 
54
61
  RecoveryPolicyMode = Literal["interactive", "auto"]
55
62
 
63
+ _CANCEL_ERROR_KINDS = frozenset(
64
+ {
65
+ ERROR_KIND_USER_CANCELLED,
66
+ ERROR_KIND_HOOK_STOPPED,
67
+ }
68
+ )
69
+ _CANCEL_TERMINAL_REASONS = frozenset(
70
+ {
71
+ TERMINAL_REASON_ABORTED_STREAMING,
72
+ TERMINAL_REASON_ABORTED_TOOLS,
73
+ TERMINAL_REASON_HOOK_STOPPED,
74
+ }
75
+ )
76
+
77
+
78
+ def _is_cancel_outcome(outcome: "FinishOutcome") -> bool:
79
+ """用户取消 / hook 终局:禁止建议 compact(D-EVT-2 / CLI 投影)。"""
80
+ if outcome.error_kind in _CANCEL_ERROR_KINDS:
81
+ return True
82
+ return outcome.terminal_reason in _CANCEL_TERMINAL_REASONS
83
+
56
84
 
57
85
  @dataclass(frozen=True)
58
86
  class RecoveryPolicy:
@@ -149,6 +177,7 @@ class FinishOutcomeParser:
149
177
  snap = dict(state or {})
150
178
  terminal = snap.get("terminal") if isinstance(snap.get("terminal"), dict) else None
151
179
  if terminal is not None and "is_error" in terminal:
180
+ # from_terminal 已对缺失 error_kind 做 composer 对齐补推(D-EVT-2)
152
181
  return self.from_terminal(terminal, answer=answer)
153
182
  msgs = messages if messages is not None else snap.get("messages")
154
183
  if not isinstance(msgs, list):
@@ -178,11 +207,23 @@ class FinishOutcomeParser:
178
207
  *,
179
208
  answer: str = "",
180
209
  ) -> FinishOutcome:
210
+ terminal_reason = _optional_str(read_terminal_reason(terminal))
211
+ is_error = bool(terminal.get("is_error"))
212
+ error_kind = _optional_str(terminal.get("error_kind"))
213
+ if is_error and error_kind is None:
214
+ error_kind = FinishEventPayloadComposer._infer_error_kind(
215
+ terminal_reason=terminal_reason,
216
+ is_error=True,
217
+ withheld_error=None,
218
+ transition_reason=None,
219
+ terminal=terminal,
220
+ messages=None,
221
+ )
181
222
  return FinishOutcome(
182
- terminal_reason=_optional_str(read_terminal_reason(terminal)),
183
- is_error=bool(terminal.get("is_error")),
223
+ terminal_reason=terminal_reason,
224
+ is_error=is_error,
184
225
  recoverable=bool(terminal.get("recoverable")),
185
- error_kind=_optional_str(terminal.get("error_kind")),
226
+ error_kind=error_kind,
186
227
  recovery_exhausted=bool(terminal.get("recovery_exhausted")),
187
228
  errors=tuple(str(e) for e in (terminal.get("errors") or []) if e),
188
229
  answer=answer,
@@ -220,6 +261,8 @@ class RecoveryActionResolver:
220
261
  ) -> RecoveryAction:
221
262
  if not outcome.is_error:
222
263
  return RecoveryAction.NONE
264
+ if _is_cancel_outcome(outcome):
265
+ return RecoveryAction.FAIL
223
266
  if not outcome.recoverable:
224
267
  return RecoveryAction.FAIL
225
268
 
@@ -242,12 +285,13 @@ class RecoveryActionResolver:
242
285
 
243
286
  @staticmethod
244
287
  def blocks_auto_compact(outcome: FinishOutcome) -> bool:
245
- """Workflow 门禁:recovery_exhausted 后禁止再次 auto-compact(Phase 5.5)。"""
288
+ """Workflow 门禁:cancel / recovery_exhausted 后禁止再次 auto-compact"""
246
289
  if not outcome.is_error:
247
290
  return False
291
+ if _is_cancel_outcome(outcome):
292
+ return True
248
293
  return outcome.recovery_exhausted
249
294
 
250
-
251
295
  def enrich_run_result(
252
296
  result: dict[str, Any],
253
297
  *,
@@ -28,6 +28,7 @@ from langchain_agentx.observability.events.agent_event_projector import (
28
28
  LangchainAgentEvent,
29
29
  LangchainAgentEventType,
30
30
  )
31
+ from langchain_agentx.observability.events.oob_event_sink import drain_oob_events
31
32
 
32
33
 
33
34
  class LangGraphToLangchainAgentEventAdapter:
@@ -51,12 +52,19 @@ class LangGraphToLangchainAgentEventAdapter:
51
52
  async def adapt(
52
53
  self, events: AsyncIterator[Dict[str, Any]]
53
54
  ) -> AsyncIterator[LangchainAgentEvent]:
54
- """主转换函数:将 LangGraph 事件流转换为 LangChain AgentX 风格事件流。"""
55
+ """主转换函数:将 LangGraph 事件流转换为 LangChain AgentX 风格事件流。
56
+
57
+ 每批 raw 事件后合并 OOB task-notification(D-EVT-5 / D-EVT-10)。
58
+ """
55
59
  self._projector.reset_stream_closeout_diagnostics()
56
60
  try:
57
61
  async for event in events:
58
62
  async for oc_event in self._projector.consume(event):
59
63
  yield oc_event
64
+ for oob in drain_oob_events():
65
+ yield oob
66
+ for oob in drain_oob_events():
67
+ yield oob
60
68
  finally:
61
69
  self._projector.log_stream_closeout_diagnostics()
62
70