langchain-agentx-python 2.1.4__py3-none-any.whl → 2.1.6__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.
@@ -11,7 +11,7 @@ from langchain_agentx import create_loop_agent
11
11
  ```
12
12
  """
13
13
 
14
- __version__ = "2.1.4"
14
+ __version__ = "2.1.6"
15
15
 
16
16
  from .loop import ( # noqa: F401
17
17
  create_loop_agent,
@@ -35,13 +35,14 @@ for chunk in agent.stream({"messages": [{"role": "user", "content": "Hello"}]}):
35
35
  from .graph.factory import (
36
36
  create_loop_agent,
37
37
  )
38
- from .loop_abort import LoopAbortSignal
38
+ from .loop_abort import LoopAbortSignal, raise_if_cancel_requested
39
39
  from .config import build_agent_config
40
40
 
41
41
  __all__ = [
42
42
  # 主要接口
43
43
  "create_loop_agent",
44
44
  "LoopAbortSignal",
45
+ "raise_if_cancel_requested",
45
46
  # 配置助手
46
47
  "build_agent_config",
47
48
  ]
@@ -1,12 +1,14 @@
1
1
  """
2
2
  内置 loop 控制:捕获 ``LoopAbortSignal``,写入 ``abort_terminal_reason``(及工具侧合成 ToolMessage)。
3
3
 
4
- 始终由 ``create_loop_agent`` 注入到 ``wrap_model_call`` / ``wrap_tool_call`` 链的最外层,
5
- 用户 middleware 在内层抛出 ``LoopAbortSignal`` 即可,无需手写 Command。
4
+ ModelNode / RuntimeToolsNode invoke 路径最外层调用;handler 前读
5
+ ``raise_if_cancel_requested``(Doc-35 P1 / D-ABORT-1)。用户侧抛出 ``LoopAbortSignal``
6
+ 即可,无需手写 Command。
6
7
  """
7
8
 
8
9
  from __future__ import annotations
9
10
 
11
+ import asyncio
10
12
  import copy
11
13
  import logging
12
14
  from typing import Any
@@ -21,7 +23,7 @@ from ..exit.reason_codes import (
21
23
  TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
22
24
  TERMINAL_REASON_HOOK_STOPPED,
23
25
  )
24
- from ..loop_abort import LoopAbortSignal
26
+ from ..loop_abort import LoopAbortSignal, raise_if_cancel_requested
25
27
  from ..model.orphan_tool_results import synthetic_tool_messages_for_orphan_tool_calls
26
28
  from ...tool_runtime.classifier_denial_limit import ClassifierDenialLimitAbort
27
29
  from ...tool_runtime.permission_context import clear_auto_approved_window
@@ -50,46 +52,51 @@ def _session_store_from_request(request: Any) -> Any | None:
50
52
  return None
51
53
 
52
54
 
55
+ def _model_abort_response(sig: LoopAbortSignal) -> ExtendedModelResponse:
56
+ ai = AIMessage(
57
+ content=_abort_model_content(sig.terminal_reason, sig.detail),
58
+ response_metadata={"finish_reason": "stop"},
59
+ )
60
+ return ExtendedModelResponse(
61
+ model_response=ModelResponse(result=[ai], structured_response=None),
62
+ command=Command(update={"abort_terminal_reason": sig.terminal_reason}),
63
+ )
64
+
65
+
53
66
  class _LoopBuiltinModelControl:
54
- """内部单例:捕获模型侧的 ``LoopAbortSignal``。"""
67
+ """内部单例:协作读停令 + 捕获模型侧的 ``LoopAbortSignal``。"""
55
68
 
56
69
  def wrap_model_call(self, request: Any, handler: Any) -> Any:
57
70
  clear_auto_approved_window(_session_store_from_request(request))
58
71
  try:
72
+ raise_if_cancel_requested(phase="model")
59
73
  return handler(request)
74
+ except asyncio.CancelledError:
75
+ raise_if_cancel_requested(phase="model")
76
+ raise
60
77
  except LoopAbortSignal as sig:
61
78
  if sig.terminal_reason not in (
62
79
  TERMINAL_REASON_ABORTED_STREAMING,
63
80
  TERMINAL_REASON_HOOK_STOPPED,
64
81
  ):
65
82
  raise
66
- ai = AIMessage(
67
- content=_abort_model_content(sig.terminal_reason, sig.detail),
68
- response_metadata={"finish_reason": "stop"},
69
- )
70
- return ExtendedModelResponse(
71
- model_response=ModelResponse(result=[ai], structured_response=None),
72
- command=Command(update={"abort_terminal_reason": sig.terminal_reason}),
73
- )
83
+ return _model_abort_response(sig)
74
84
 
75
85
  async def awrap_model_call(self, request: Any, handler: Any) -> Any:
76
86
  clear_auto_approved_window(_session_store_from_request(request))
77
87
  try:
88
+ raise_if_cancel_requested(phase="model")
78
89
  return await handler(request)
90
+ except asyncio.CancelledError:
91
+ raise_if_cancel_requested(phase="model")
92
+ raise
79
93
  except LoopAbortSignal as sig:
80
94
  if sig.terminal_reason not in (
81
95
  TERMINAL_REASON_ABORTED_STREAMING,
82
96
  TERMINAL_REASON_HOOK_STOPPED,
83
97
  ):
84
98
  raise
85
- ai = AIMessage(
86
- content=_abort_model_content(sig.terminal_reason, sig.detail),
87
- response_metadata={"finish_reason": "stop"},
88
- )
89
- return ExtendedModelResponse(
90
- model_response=ModelResponse(result=[ai], structured_response=None),
91
- command=Command(update={"abort_terminal_reason": sig.terminal_reason}),
92
- )
99
+ return _model_abort_response(sig)
93
100
 
94
101
 
95
102
  _LOOP_BUILTIN_MODEL_SINGLETON = _LoopBuiltinModelControl()
@@ -173,8 +180,12 @@ def _tool_abort_command(
173
180
 
174
181
  def builtin_sync_tool_wrapper(request: Any, handler: Any) -> Any:
175
182
  try:
183
+ raise_if_cancel_requested(phase="tools")
176
184
  result = handler(_with_injected_agent_tool_model(request))
177
185
  return apply_tool_message_status_to_result(result)
186
+ except asyncio.CancelledError:
187
+ raise_if_cancel_requested(phase="tools")
188
+ raise
178
189
  except ClassifierDenialLimitAbort as exc:
179
190
  return _tool_abort_command(
180
191
  request,
@@ -192,8 +203,12 @@ def builtin_sync_tool_wrapper(request: Any, handler: Any) -> Any:
192
203
 
193
204
  async def builtin_async_tool_wrapper(request: Any, handler: Any) -> Any:
194
205
  try:
206
+ raise_if_cancel_requested(phase="tools")
195
207
  result = await handler(_with_injected_agent_tool_model(request))
196
208
  return apply_tool_message_status_to_result(result)
209
+ except asyncio.CancelledError:
210
+ raise_if_cancel_requested(phase="tools")
211
+ raise
197
212
  except ClassifierDenialLimitAbort as exc:
198
213
  return _tool_abort_command(
199
214
  request,
@@ -209,8 +224,33 @@ async def builtin_async_tool_wrapper(request: Any, handler: Any) -> Any:
209
224
  )
210
225
 
211
226
 
227
+ def tools_batch_abort_command(
228
+ tool_calls: list[dict[str, Any]],
229
+ *,
230
+ terminal_reason: str = TERMINAL_REASON_ABORTED_TOOLS,
231
+ content_template: str | None = None,
232
+ ) -> Command:
233
+ """tools 节点批次入口 abort:合成 orphan ToolMessage + abort_terminal_reason。"""
234
+ synth = (
235
+ synthetic_tool_messages_for_orphan_tool_calls(
236
+ pending_tool_calls=list(tool_calls),
237
+ reason="user_interrupted",
238
+ content_template=content_template,
239
+ )
240
+ if tool_calls
241
+ else []
242
+ )
243
+ return Command(
244
+ update={
245
+ "abort_terminal_reason": terminal_reason,
246
+ "messages": synth,
247
+ }
248
+ )
249
+
250
+
212
251
  __all__ = [
213
252
  "builtin_async_tool_wrapper",
214
253
  "builtin_sync_tool_wrapper",
215
254
  "get_loop_builtin_model_middleware",
255
+ "tools_batch_abort_command",
216
256
  ]
@@ -9,17 +9,21 @@ loop/graph/runtime_tools_node.py — 新 tools 执行 graph bridge
9
9
 
10
10
  当前裁剪范围:
11
11
  Phase 4 readonly_parallel 由 engine 对 safe_group 受控并发;exclusive 仍串行。
12
+ P1:批次入口读 cancel_requested → LoopAbortSignal 单轨(D-ABORT-1)。
12
13
  """
13
14
 
14
15
  from __future__ import annotations
15
16
 
17
+ import asyncio
16
18
  from typing import Any, MutableMapping
17
19
 
18
20
  from langchain_core.messages import AIMessage, ToolMessage
19
21
  from langchain_core.runnables import Runnable, RunnableConfig
20
22
  from langchain_core.tools import BaseTool
23
+ from langgraph.types import Command
21
24
 
22
25
  from ...tool_runtime.adapter import LangChainAdapter
26
+ from ...tool_runtime.classifier_denial_limit import ClassifierDenialLimitAbort
23
27
  from ...tool_runtime.execution import (
24
28
  EngineConfig,
25
29
  EnginePolicy,
@@ -29,6 +33,12 @@ from ...tool_runtime.execution import (
29
33
  )
30
34
  from ...tool_runtime.pipeline import ToolExecutorPipeline
31
35
  from ...tool_runtime.registry import RuntimeToolRegistry
36
+ from ..exit.reason_codes import (
37
+ TERMINAL_REASON_ABORTED_TOOLS,
38
+ TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
39
+ )
40
+ from ..loop_abort import LoopAbortSignal, raise_if_cancel_requested
41
+ from .builtin_loop_control import tools_batch_abort_command
32
42
 
33
43
 
34
44
  class RuntimeToolsNode(Runnable):
@@ -87,35 +97,71 @@ class RuntimeToolsNode(Runnable):
87
97
  self,
88
98
  state: MutableMapping[str, Any],
89
99
  config: RunnableConfig | None,
90
- ) -> dict[str, Any]:
100
+ ) -> dict[str, Any] | Command:
91
101
  _ai_message, tool_calls = _extract_tool_calls(state)
92
102
  if not tool_calls:
93
103
  return {}
94
- batches = self._planner.plan(tool_calls)
95
- records = self._engine.execute_turn(
96
- batches=batches,
97
- state=state,
98
- config=config,
99
- )
100
- tool_messages = self._message_adapter.records_to_graph_messages(records)
101
- return {"messages": tool_messages}
104
+ try:
105
+ raise_if_cancel_requested(phase="tools")
106
+ batches = self._planner.plan(tool_calls)
107
+ records = self._engine.execute_turn(
108
+ batches=batches,
109
+ state=state,
110
+ config=config,
111
+ )
112
+ tool_messages = self._message_adapter.records_to_graph_messages(records)
113
+ return {"messages": tool_messages}
114
+ except asyncio.CancelledError:
115
+ raise_if_cancel_requested(phase="tools")
116
+ raise
117
+ except ClassifierDenialLimitAbort as exc:
118
+ return tools_batch_abort_command(
119
+ tool_calls,
120
+ terminal_reason=TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
121
+ content_template=exc.message,
122
+ )
123
+ except LoopAbortSignal as sig:
124
+ if sig.terminal_reason != TERMINAL_REASON_ABORTED_TOOLS:
125
+ raise
126
+ return tools_batch_abort_command(
127
+ tool_calls,
128
+ terminal_reason=TERMINAL_REASON_ABORTED_TOOLS,
129
+ )
102
130
 
103
131
  async def _arun(
104
132
  self,
105
133
  state: MutableMapping[str, Any],
106
134
  config: RunnableConfig | None,
107
- ) -> dict[str, Any]:
135
+ ) -> dict[str, Any] | Command:
108
136
  _ai_message, tool_calls = _extract_tool_calls(state)
109
137
  if not tool_calls:
110
138
  return {}
111
- batches = self._planner.plan(tool_calls)
112
- records = await self._engine.aexecute_turn(
113
- batches=batches,
114
- state=state,
115
- config=config,
116
- )
117
- tool_messages = self._message_adapter.records_to_graph_messages(records)
118
- return {"messages": tool_messages}
139
+ try:
140
+ raise_if_cancel_requested(phase="tools")
141
+ batches = self._planner.plan(tool_calls)
142
+ records = await self._engine.aexecute_turn(
143
+ batches=batches,
144
+ state=state,
145
+ config=config,
146
+ )
147
+ tool_messages = self._message_adapter.records_to_graph_messages(records)
148
+ return {"messages": tool_messages}
149
+ except asyncio.CancelledError:
150
+ raise_if_cancel_requested(phase="tools")
151
+ raise
152
+ except ClassifierDenialLimitAbort as exc:
153
+ return tools_batch_abort_command(
154
+ tool_calls,
155
+ terminal_reason=TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
156
+ content_template=exc.message,
157
+ )
158
+ except LoopAbortSignal as sig:
159
+ if sig.terminal_reason != TERMINAL_REASON_ABORTED_TOOLS:
160
+ raise
161
+ return tools_batch_abort_command(
162
+ tool_calls,
163
+ terminal_reason=TERMINAL_REASON_ABORTED_TOOLS,
164
+ )
119
165
 
120
166
 
121
167
  def _extract_tool_calls(
@@ -1,19 +1,27 @@
1
- """流式 / 工具 / hook 中断信号:由业务 middleware 或工具抛出,由内置 wrapper 转为 state 契约。"""
1
+ """流式 / 工具 / hook 中断信号:由业务 middleware 或工具抛出,由内置 wrapper 转为 state 契约。
2
+
3
+ P1(Doc-35 D-ABORT-1):``raise_if_cancel_requested`` 读 ``RunContext.cancel_requested``,
4
+ 真则抛既有 ``LoopAbortSignal``(单轨);禁止平行 Abort 异常族。
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
9
+ from typing import Literal
10
+
5
11
  from .exit.reason_codes import (
6
12
  TERMINAL_REASON_ABORTED_STREAMING,
7
13
  TERMINAL_REASON_ABORTED_TOOLS,
8
14
  TERMINAL_REASON_HOOK_STOPPED,
9
15
  )
10
16
 
17
+ CancelAbortPhase = Literal["model", "tools"]
18
+
11
19
 
12
20
  class LoopAbortSignal(Exception):
13
21
  """请求以 CC 式 abort 终局结束(写入 ``abort_terminal_reason``)。
14
22
 
15
23
  - ``aborted_streaming`` / ``hook_stopped``:在 ``wrap_model_call`` / ``awrap_model_call`` 中抛出,
16
- 由 ``_LoopBuiltinModelControlMiddleware`` 捕获并合成 AIMessage + Command。
24
+ 由 ``_LoopBuiltinModelControl`` 捕获并合成 AIMessage + Command。
17
25
  - ``aborted_tools``:在工具执行或 ``wrap_tool_call`` 中抛出,由内置 tool wrapper 捕获并
18
26
  注入合成 ``ToolMessage`` + ``abort_terminal_reason``。
19
27
  """
@@ -33,4 +41,27 @@ class LoopAbortSignal(Exception):
33
41
  super().__init__(detail or terminal_reason)
34
42
 
35
43
 
36
- __all__ = ["LoopAbortSignal"]
44
+ def raise_if_cancel_requested(*, phase: CancelAbortPhase) -> None:
45
+ """若当前 ``RunContext.cancel_requested`` 为真,抛既有 ``LoopAbortSignal``。
46
+
47
+ 对齐 CC ``query.ts`` 读 ``signal.aborted``;SDK 原语为 bool(D-SEM-1),
48
+ 退出语义仅走 ``LoopAbortSignal`` → ``builtin_loop_control``(D-ABORT-1)。
49
+ 无 RunContext 时 no-op。
50
+ """
51
+ from .run_context import get_run_context
52
+
53
+ ctx = get_run_context()
54
+ if ctx is None or not ctx.cancel_requested:
55
+ return
56
+ if phase == "tools":
57
+ raise LoopAbortSignal(
58
+ TERMINAL_REASON_ABORTED_TOOLS,
59
+ detail="cancel_requested",
60
+ )
61
+ raise LoopAbortSignal(
62
+ TERMINAL_REASON_ABORTED_STREAMING,
63
+ detail="cancel_requested",
64
+ )
65
+
66
+
67
+ __all__ = ["CancelAbortPhase", "LoopAbortSignal", "raise_if_cancel_requested"]
@@ -338,55 +338,103 @@ class ModelNode(Runnable):
338
338
 
339
339
  def _run(self, state: dict, config: RunnableConfig) -> dict:
340
340
  """核心同步执行逻辑。"""
341
- runtime = config.get("configurable", {}).get("__pregel_runtime")
342
- request = self._build_request(state, runtime, config=config)
341
+ from langchain.agents.middleware.types import ExtendedModelResponse
342
+ from langgraph.types import Command
343
343
 
344
- synth = _model_response_if_context_token_blocked(
345
- state_messages=state.get("messages", []),
346
- name=self._name,
347
- initial_response_format=self._initial_response_format,
348
- structured_output_tools=self._structured_output_tools,
349
- compaction_settings=self._compaction_settings,
350
- query_source=state.get("query_source")
351
- if isinstance(state.get("query_source"), str)
352
- else None,
344
+ from langchain_agentx.loop.graph.builtin_loop_control import (
345
+ get_loop_builtin_model_middleware,
353
346
  )
354
- if synth is not None:
355
- model_response = synth
347
+
348
+ runtime = config.get("configurable", {}).get("__pregel_runtime")
349
+ request = self._build_request(state, runtime, config=config)
350
+ mw = get_loop_builtin_model_middleware()
351
+
352
+ def _handler(req: Any) -> Any:
353
+ synth = _model_response_if_context_token_blocked(
354
+ state_messages=state.get("messages", []),
355
+ name=self._name,
356
+ initial_response_format=self._initial_response_format,
357
+ structured_output_tools=self._structured_output_tools,
358
+ compaction_settings=self._compaction_settings,
359
+ query_source=state.get("query_source")
360
+ if isinstance(state.get("query_source"), str)
361
+ else None,
362
+ )
363
+ if synth is not None:
364
+ return synth
365
+ model_response, _ = self._run_sync(req, state, config=config)
366
+ return model_response
367
+
368
+ wrapped = mw.wrap_model_call(request, _handler)
369
+ abort_reason: str | None = None
370
+ if isinstance(wrapped, ExtendedModelResponse):
371
+ model_response = wrapped.model_response
372
+ cmd = wrapped.command
373
+ if isinstance(cmd, Command) and isinstance(cmd.update, dict):
374
+ reason = cmd.update.get("abort_terminal_reason")
375
+ if isinstance(reason, str):
376
+ abort_reason = reason
356
377
  else:
357
- model_response, _ = self._run_sync(request, state, config=config)
378
+ model_response = wrapped
358
379
 
359
- return self._build_state_update(
380
+ updates = self._build_state_update(
360
381
  state=state,
361
382
  model_response=model_response,
362
383
  mode="sync",
363
384
  )
385
+ if abort_reason:
386
+ return list(updates) + [Command(update={"abort_terminal_reason": abort_reason})]
387
+ return updates
364
388
 
365
389
  async def _arun(self, state: dict, config: RunnableConfig) -> dict:
366
390
  """核心异步执行逻辑。"""
367
- runtime = config.get("configurable", {}).get("__pregel_runtime")
368
- request = self._build_request(state, runtime, config=config)
391
+ from langchain.agents.middleware.types import ExtendedModelResponse
392
+ from langgraph.types import Command
369
393
 
370
- synth = _model_response_if_context_token_blocked(
371
- state_messages=state.get("messages", []),
372
- name=self._name,
373
- initial_response_format=self._initial_response_format,
374
- structured_output_tools=self._structured_output_tools,
375
- compaction_settings=self._compaction_settings,
376
- query_source=state.get("query_source")
377
- if isinstance(state.get("query_source"), str)
378
- else None,
394
+ from langchain_agentx.loop.graph.builtin_loop_control import (
395
+ get_loop_builtin_model_middleware,
379
396
  )
380
- if synth is not None:
381
- model_response = synth
397
+
398
+ runtime = config.get("configurable", {}).get("__pregel_runtime")
399
+ request = self._build_request(state, runtime, config=config)
400
+ mw = get_loop_builtin_model_middleware()
401
+
402
+ async def _handler(req: Any) -> Any:
403
+ synth = _model_response_if_context_token_blocked(
404
+ state_messages=state.get("messages", []),
405
+ name=self._name,
406
+ initial_response_format=self._initial_response_format,
407
+ structured_output_tools=self._structured_output_tools,
408
+ compaction_settings=self._compaction_settings,
409
+ query_source=state.get("query_source")
410
+ if isinstance(state.get("query_source"), str)
411
+ else None,
412
+ )
413
+ if synth is not None:
414
+ return synth
415
+ model_response, _ = await self._arun_async(req, state, config=config)
416
+ return model_response
417
+
418
+ wrapped = await mw.awrap_model_call(request, _handler)
419
+ abort_reason: str | None = None
420
+ if isinstance(wrapped, ExtendedModelResponse):
421
+ model_response = wrapped.model_response
422
+ cmd = wrapped.command
423
+ if isinstance(cmd, Command) and isinstance(cmd.update, dict):
424
+ reason = cmd.update.get("abort_terminal_reason")
425
+ if isinstance(reason, str):
426
+ abort_reason = reason
382
427
  else:
383
- model_response, _ = await self._arun_async(request, state, config=config)
428
+ model_response = wrapped
384
429
 
385
- return self._build_state_update(
430
+ updates = self._build_state_update(
386
431
  state=state,
387
432
  model_response=model_response,
388
433
  mode="async",
389
434
  )
435
+ if abort_reason:
436
+ return list(updates) + [Command(update={"abort_terminal_reason": abort_reason})]
437
+ return updates
390
438
 
391
439
  # ===== 请求构建 =====
392
440
 
@@ -28,7 +28,10 @@ from langchain_agentx.loop.subagent.forked_runner import (
28
28
  )
29
29
  from langchain_agentx.loop.subagent.async_runner import (
30
30
  BackgroundAgentHandle,
31
+ BackgroundAgentLoopRequiredError,
31
32
  get_background_agent_handle,
33
+ kill_all_background_agents,
34
+ kill_background_agent,
32
35
  register_background_agent,
33
36
  run_async_agent_lifecycle,
34
37
  )
@@ -67,7 +70,10 @@ __all__ = [
67
70
  "_run_coroutine_sync",
68
71
  "register_background_agent",
69
72
  "get_background_agent_handle",
73
+ "kill_background_agent",
74
+ "kill_all_background_agents",
70
75
  "BackgroundAgentHandle",
76
+ "BackgroundAgentLoopRequiredError",
71
77
  "run_async_agent_lifecycle",
72
78
  "JsonlTranscriptStore",
73
79
  "SubagentTranscriptManager",