langchain-agentx-python 2.3.9__py3-none-any.whl → 2.4.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 (29) hide show
  1. langchain_agentx/__init__.py +1 -1
  2. langchain_agentx/loop/config/loop_config.py +2 -0
  3. langchain_agentx/loop/graph/factory.py +6 -1
  4. langchain_agentx/loop/run_context.py +186 -23
  5. langchain_agentx/loop/runtime/context_factory.py +7 -1
  6. langchain_agentx/loop/stream/loop_driver.py +5 -0
  7. langchain_agentx/loop/subagent/async_runner.py +12 -1
  8. langchain_agentx/loop/subagent/graph.py +2 -0
  9. langchain_agentx/loop/subagent/orchestrator.py +515 -366
  10. langchain_agentx/loop/subagent/runner.py +43 -16
  11. langchain_agentx/tool_runtime/adapter.py +5 -0
  12. langchain_agentx/tool_runtime/models.py +4 -1
  13. langchain_agentx/tools/agent/backend.py +33 -11
  14. langchain_agentx/tools/agent/limits.py +28 -3
  15. langchain_agentx/tools/agent/loader.py +2 -1
  16. langchain_agentx/tools/agent/nesting_policy.py +65 -0
  17. langchain_agentx/tools/agent/scope.py +16 -15
  18. langchain_agentx/tools/agent/tool.py +14 -1
  19. langchain_agentx/tools/bash/ast_security.py +19 -1
  20. langchain_agentx/tools/bash/command_allow_rules.py +27 -0
  21. langchain_agentx/tools/bash/read_only_validation.py +40 -2
  22. langchain_agentx/tools/skill/fork.py +105 -19
  23. langchain_agentx/tools/skill/tool.py +52 -0
  24. langchain_agentx/workspace/project_memory_layout.py +2 -1
  25. {langchain_agentx_python-2.3.9.dist-info → langchain_agentx_python-2.4.1.dist-info}/METADATA +1 -1
  26. {langchain_agentx_python-2.3.9.dist-info → langchain_agentx_python-2.4.1.dist-info}/RECORD +29 -28
  27. {langchain_agentx_python-2.3.9.dist-info → langchain_agentx_python-2.4.1.dist-info}/LICENSE +0 -0
  28. {langchain_agentx_python-2.3.9.dist-info → langchain_agentx_python-2.4.1.dist-info}/WHEEL +0 -0
  29. {langchain_agentx_python-2.3.9.dist-info → langchain_agentx_python-2.4.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.3.9"
14
+ __version__ = "2.4.1"
15
15
 
16
16
  from .mcp.call import McpToolCaller
17
17
  from .mcp.client_manager import McpClientManager, McpConnectedClient
@@ -136,6 +136,8 @@ class SubagentLinkage:
136
136
  subagent_type: str | None = None
137
137
  agent_depth: int = 0
138
138
  parent_agent_id: str | None = None
139
+ fork_from_parent: bool = False
140
+ """True = 当前子 loop 为 ForkA worker(会话继承 fork);供 ToolExecutionContext / L1。"""
139
141
 
140
142
 
141
143
  @dataclass(frozen=True, init=False)
@@ -312,7 +312,12 @@ def _flatten_config_for_runtime(config: AgentLoopConfig) -> _FlatLoopConfig:
312
312
  conversation_session_id=config.conversation_session_id,
313
313
  agent_id=config.agent_id,
314
314
  parent_agent_id=config.subagent.parent_agent_id,
315
- agent_depth=config.agent_depth,
315
+ agent_depth=(
316
+ config.subagent.agent_depth
317
+ if config.subagent.is_subagent
318
+ else config.agent_depth
319
+ ),
320
+ fork_from_parent=bool(config.subagent.fork_from_parent),
316
321
  subagent_type=config.subagent.subagent_type,
317
322
  trace_enabled=config.trace.enabled,
318
323
  trace_persist=config.trace.persist,
@@ -13,6 +13,8 @@ run_context.py — Workflow/Agent 结构化并发上下文(parent → child ca
13
13
  P1:reset 失败时在仍可见 owner 的 derived logical context 内 force-clear;
14
14
  宿主 detached ingest 用 create_task_with_copied_context / run_context_scope。
15
15
  nested Parallelization:child_contexts + 递归 cancel_tree;W4 可用 use_cancel_authority。
16
+ agent loop 登记:供 skill fork 同步桥 ``run_coroutine_threadsafe`` 解析父 loop
17
+ (ContextVar 在 ThreadPoolExecutor 上常丢失,故用进程级 stack)。
16
18
  """
17
19
 
18
20
  from __future__ import annotations
@@ -34,6 +36,111 @@ _current_run_context: ContextVar[Optional["RunContext"]] = ContextVar(
34
36
  "run_ctx", default=None
35
37
  )
36
38
 
39
+ # 进程级父 agent loop / RunContext 栈:ThreadPoolExecutor worker 看不见 ContextVar,
40
+ # 须 threadsafe 回父 loop(bug4)并恢复 owner 供 cancel 登记(skill-fork cancel orphan)。
41
+ _agent_loop_lock = threading.Lock()
42
+ _agent_loop_stack: list[asyncio.AbstractEventLoop] = []
43
+ _agent_run_ctx_stack: list["RunContext"] = []
44
+
45
+
46
+ def register_agent_loop(loop: asyncio.AbstractEventLoop) -> None:
47
+ """登记当前存活的 agent/session event loop(可嵌套;同 loop 可重复 push)。"""
48
+ with _agent_loop_lock:
49
+ _agent_loop_stack.append(loop)
50
+
51
+
52
+ def unregister_agent_loop(loop: asyncio.AbstractEventLoop) -> None:
53
+ """弹出最近一次与 ``loop`` 相同的登记(LIFO;未见则 no-op)。"""
54
+ with _agent_loop_lock:
55
+ for index in range(len(_agent_loop_stack) - 1, -1, -1):
56
+ if _agent_loop_stack[index] is loop:
57
+ del _agent_loop_stack[index]
58
+ return
59
+
60
+
61
+ def resolve_agent_loop(
62
+ *,
63
+ owner: RunContext | None = None,
64
+ ) -> asyncio.AbstractEventLoop | None:
65
+ """解析仍在跑的父 agent loop;优先 ``owner.root_task``,否则栈顶存活项。"""
66
+ if owner is not None:
67
+ try:
68
+ loop = owner.root_task.get_loop()
69
+ if not loop.is_closed() and loop.is_running():
70
+ return loop
71
+ except Exception:
72
+ pass
73
+ with _agent_loop_lock:
74
+ for loop in reversed(_agent_loop_stack):
75
+ try:
76
+ if not loop.is_closed() and loop.is_running():
77
+ return loop
78
+ except Exception:
79
+ continue
80
+ return None
81
+
82
+
83
+ def resolve_owner_run_context(
84
+ owner: RunContext | None = None,
85
+ ) -> RunContext | None:
86
+ """解析取消挂接用的 owner:显式参数 → ContextVar → 进程级 install 栈顶。"""
87
+ if owner is not None:
88
+ return owner
89
+ current = get_run_context()
90
+ if current is not None:
91
+ return current
92
+ with _agent_loop_lock:
93
+ if _agent_run_ctx_stack:
94
+ return _agent_run_ctx_stack[-1]
95
+ return None
96
+
97
+
98
+ async def run_under_owner_run_context(
99
+ coro: Awaitable[T],
100
+ *,
101
+ owner: RunContext | None,
102
+ ) -> T:
103
+ """在指定 owner 的 ContextVar 下 await(threadsafe 桥恢复父 RunContext)。"""
104
+ if owner is None:
105
+ return await coro
106
+ if get_run_context() is owner:
107
+ return await coro
108
+ token = _current_run_context.set(owner)
109
+ try:
110
+ return await coro
111
+ finally:
112
+ _current_run_context.reset(token)
113
+
114
+
115
+ @dataclass
116
+ class SubagentConcurrencyGate:
117
+ """会话级并发子 Agent 计数(挂在 RunContext.cancel_authority 根上)。
118
+
119
+ Doc-37 C9 / Phase 2:独立于深度;无「总 spawn」帽。
120
+ """
121
+
122
+ _running: int = field(default=0, repr=False)
123
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
124
+
125
+ def try_acquire(self, *, limit: int) -> bool:
126
+ if limit < 1:
127
+ return False
128
+ with self._lock:
129
+ if self._running >= limit:
130
+ return False
131
+ self._running += 1
132
+ return True
133
+
134
+ def release(self) -> None:
135
+ with self._lock:
136
+ if self._running > 0:
137
+ self._running -= 1
138
+
139
+ @property
140
+ def running(self) -> int:
141
+ with self._lock:
142
+ return self._running
143
+
37
144
 
38
145
  @dataclass
39
146
  class RunContext:
@@ -50,6 +157,28 @@ class RunContext:
50
157
  # D-EVT-6:宿主在 cancel_tree / killAll 时写入;finish 投影读取;缺省 unknown。
51
158
  # 取值:escape | workflow_stop | kill_agents | unknown;禁止从「是否有后台」猜测。
52
159
  cancel_source: str | None = None
160
+ # Phase 2:并发子 Agent 槽位;acquire/release 始终走 cancel_authority 根上的 gate。
161
+ subagent_concurrency: SubagentConcurrencyGate = field(
162
+ default_factory=SubagentConcurrencyGate, repr=False
163
+ )
164
+ # install 时若成功 register_agent_loop,记下以便 reset 精确 unregister(勿误弹外层)。
165
+ _registered_agent_loop: asyncio.AbstractEventLoop | None = field(
166
+ default=None, repr=False, compare=False
167
+ )
168
+ # install 时是否 push 进进程级 RunContext 栈(供 worker 解析 owner)。
169
+ _registered_on_ctx_stack: bool = field(default=False, repr=False, compare=False)
170
+
171
+ def try_acquire_subagent_slot(self, *, limit: int) -> bool:
172
+ """在 outermost RunContext 上占用一个并发槽;满则 False。"""
173
+ return self.cancel_authority().subagent_concurrency.try_acquire(limit=limit)
174
+
175
+ def release_subagent_slot(self) -> None:
176
+ """释放 outermost 上的一个并发槽(幂等下限 0)。"""
177
+ self.cancel_authority().subagent_concurrency.release()
178
+
179
+ @property
180
+ def running_subagent_count(self) -> int:
181
+ return self.cancel_authority().subagent_concurrency.running
53
182
 
54
183
  def register_child(self, task: asyncio.Task) -> None:
55
184
  self.child_tasks.add(task)
@@ -167,6 +296,18 @@ def install_run_context(
167
296
  parent.register_child(ctx.root_task)
168
297
  parent.register_child_context(ctx)
169
298
  token = _current_run_context.set(ctx)
299
+ try:
300
+ loop = ctx.root_task.get_loop()
301
+ register_agent_loop(loop)
302
+ ctx._registered_agent_loop = loop
303
+ except Exception:
304
+ logger.debug(
305
+ "register_agent_loop skipped at install_run_context",
306
+ exc_info=True,
307
+ )
308
+ with _agent_loop_lock:
309
+ _agent_run_ctx_stack.append(ctx)
310
+ ctx._registered_on_ctx_stack = True
170
311
  return ctx, token
171
312
 
172
313
 
@@ -177,33 +318,53 @@ def reset_run_context(token: Token, *, owner: RunContext | None = None) -> None:
177
318
  P1:若当前 logical context 仍可见 ``owner``(derived context 继承值但 token 无效),
178
319
  显式 ``set(None)`` 清理本层,避免 workflow finally 后 run_ctx 悬挂。
179
320
  """
321
+ source = owner if owner is not None else get_run_context()
322
+ loop_to_unreg: asyncio.AbstractEventLoop | None = None
323
+ ctx_to_unreg: RunContext | None = None
324
+ if source is not None and source._registered_agent_loop is not None:
325
+ loop_to_unreg = source._registered_agent_loop
326
+ source._registered_agent_loop = None
327
+ if source is not None and source._registered_on_ctx_stack:
328
+ ctx_to_unreg = source
329
+ source._registered_on_ctx_stack = False
330
+
180
331
  try:
181
- _current_run_context.reset(token)
182
- return
183
- except ValueError:
184
- pass
332
+ try:
333
+ _current_run_context.reset(token)
334
+ return
335
+ except ValueError:
336
+ pass
337
+
338
+ current = get_run_context()
339
+ if owner is not None and current is owner:
340
+ _current_run_context.set(None)
341
+ logger.warning(
342
+ "run_ctx force-cleared: token reset failed in derived logical context "
343
+ "(task=%r)",
344
+ owner.root_task.get_name(),
345
+ )
346
+ return
185
347
 
186
- current = get_run_context()
187
- if owner is not None and current is owner:
188
- _current_run_context.set(None)
189
- logger.warning(
190
- "run_ctx force-cleared: token reset failed in derived logical context "
191
- "(task=%r)",
192
- owner.root_task.get_name(),
193
- )
194
- return
348
+ if owner is not None and owner.root_task.done():
349
+ logger.debug(
350
+ "run_ctx reset skipped: owner task %r already done; context discarded",
351
+ owner.root_task.get_name(),
352
+ )
353
+ return
195
354
 
196
- if owner is not None and owner.root_task.done():
197
- logger.debug(
198
- "run_ctx reset skipped: owner task %r already done; context discarded",
199
- owner.root_task.get_name(),
355
+ logger.warning(
356
+ "run_ctx reset skipped: token created in a different context",
357
+ exc_info=True,
200
358
  )
201
- return
202
-
203
- logger.warning(
204
- "run_ctx reset skipped: token created in a different context",
205
- exc_info=True,
206
- )
359
+ finally:
360
+ if loop_to_unreg is not None:
361
+ unregister_agent_loop(loop_to_unreg)
362
+ if ctx_to_unreg is not None:
363
+ with _agent_loop_lock:
364
+ for index in range(len(_agent_run_ctx_stack) - 1, -1, -1):
365
+ if _agent_run_ctx_stack[index] is ctx_to_unreg:
366
+ del _agent_run_ctx_stack[index]
367
+ break
207
368
 
208
369
 
209
370
  @asynccontextmanager
@@ -297,6 +458,7 @@ def register_spawned_thread(
297
458
 
298
459
  __all__ = [
299
460
  "RunContext",
461
+ "SubagentConcurrencyGate",
300
462
  "create_task_with_copied_context",
301
463
  "drive_async_iterator_in_current_task",
302
464
  "get_run_context",
@@ -305,5 +467,6 @@ __all__ = [
305
467
  "register_spawned_thread",
306
468
  "reset_run_context",
307
469
  "resolve_cancel_authority",
470
+ "resolve_owner_run_context",
308
471
  "run_context_scope",
309
472
  ]
@@ -146,12 +146,15 @@ class RuntimeContextFactory:
146
146
  subagent_query_source = (
147
147
  f"agent:{subagent_type}" if subagent_type else "agent:default"
148
148
  )
149
+ # 子 depth 与顶层 agent_depth 必须一致:factory flatten 在 is_subagent 时
150
+ # 优先读 subagent.agent_depth(嵌套 L1→L2 若只写顶层会误成 0)。
151
+ child_depth = int(getattr(parent_ctx, "agent_depth", 0) or 0) + 1
149
152
 
150
153
  return AgentLoopConfig(
151
154
  model=resolved_model,
152
155
  session_id=getattr(parent_ctx, "session_id", "") or "",
153
156
  agent_id=agent_id,
154
- agent_depth=(parent_ctx.agent_depth or 0) + 1,
157
+ agent_depth=child_depth,
155
158
  max_steps=resolved_max_steps,
156
159
  permission_mode=resolved_permission,
157
160
  model_retry=ModelRetrySettings(query_source=subagent_query_source),
@@ -171,6 +174,9 @@ class RuntimeContextFactory:
171
174
  ),
172
175
  subagent=SubagentLinkage(
173
176
  is_subagent=True,
177
+ subagent_type=str(subagent_type) if subagent_type else None,
174
178
  parent_agent_id=parent_agent_id,
179
+ agent_depth=child_depth,
180
+ # fork_from_parent 由 orchestrator mode 决定,derive 不知 mode → False
175
181
  ),
176
182
  )
@@ -11,6 +11,7 @@ loop_driver.py — Loop-aware shared stream driver.
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
13
  R1 反应恢复:单次 drain 内 budgeted continue 重入(S2,对齐 CC queryLoop continue)。
14
+ 进入/退出时登记 agent loop,供 Ask 路径无 Workflow RunContext 时 fork 同步桥解析。
14
15
  """
15
16
 
16
17
  from __future__ import annotations
@@ -22,6 +23,7 @@ from typing import Any, AsyncIterator, Literal
22
23
  from langgraph.errors import GraphRecursionError
23
24
 
24
25
  from langchain_agentx.loop.loop_abort import LoopAbortSignal, is_cancel_requested
26
+ from langchain_agentx.loop.run_context import register_agent_loop, unregister_agent_loop
25
27
  from ..exit.reason_codes import (
26
28
  TERMINAL_REASON_ABORTED_STREAMING,
27
29
  TERMINAL_REASON_ABORTED_TOOLS,
@@ -84,6 +86,8 @@ class LoopStreamDriver:
84
86
  self.result.reactive_continue_drains = 0
85
87
  current_input = dict(graph_input)
86
88
  reactive_continues = 0
89
+ agent_loop = asyncio.get_running_loop()
90
+ register_agent_loop(agent_loop)
87
91
  try:
88
92
  while True:
89
93
  async for event in self._stream_single_drain(
@@ -113,6 +117,7 @@ class LoopStreamDriver:
113
117
  (self.result.graph_output or {}).get("transition", {}).get("reason"),
114
118
  )
115
119
  finally:
120
+ unregister_agent_loop(agent_loop)
116
121
  self._log_stream_closeout_summary()
117
122
 
118
123
  async def _stream_single_drain(
@@ -175,8 +175,12 @@ async def _execute_background_lifecycle(
175
175
  task_id: str,
176
176
  task_runtime: Any | None,
177
177
  allowed_tool_names: set[str] | None = None,
178
+ slot_owner: RunContext | None = None,
178
179
  ) -> None:
179
- """共享后台生命周期:run_subagent → 写 output → task_runtime 终态。"""
180
+ """共享后台生命周期:run_subagent → 写 output → task_runtime 终态。
181
+
182
+ ``slot_owner``:Phase 2 并发槽从 orchestrator 移交至此;终态时 release。
183
+ """
180
184
  execution_ctx = ctx
181
185
  execution_ctx.is_async = False
182
186
  agent_id = getattr(ctx, "agent_id", None)
@@ -252,6 +256,9 @@ async def _execute_background_lifecycle(
252
256
  agent_id=agent_id,
253
257
  terminal_reason=TERMINAL_REASON_GRAPH_RUNTIME_ERROR,
254
258
  )
259
+ finally:
260
+ if slot_owner is not None:
261
+ slot_owner.release_subagent_slot()
255
262
 
256
263
 
257
264
  def register_background_agent(
@@ -264,6 +271,7 @@ def register_background_agent(
264
271
  parent_ctx: Any | None = None,
265
272
  owner_run_ctx: RunContext | None = None,
266
273
  allowed_tool_names: set[str] | None = None,
274
+ slot_owner: RunContext | None = None,
267
275
  ) -> BackgroundAgentHandle:
268
276
  """注册并启动后台子 Agent(仅同 loop C3;无/异 loop 拒绝,D-BG-1)。"""
269
277
  task_runtime = _resolve_task_runtime(parent_ctx)
@@ -307,6 +315,7 @@ def register_background_agent(
307
315
  task_runtime=task_runtime,
308
316
  owner_run_ctx=owner_run_ctx,
309
317
  allowed_tool_names=allowed_tool_names,
318
+ slot_owner=slot_owner,
310
319
  )
311
320
 
312
321
 
@@ -321,6 +330,7 @@ def _register_background_agent_task(
321
330
  task_runtime: Any | None,
322
331
  owner_run_ctx: RunContext | None,
323
332
  allowed_tool_names: set[str] | None = None,
333
+ slot_owner: RunContext | None = None,
324
334
  ) -> BackgroundAgentHandle:
325
335
  async def _task_body() -> None:
326
336
  await _execute_background_lifecycle(
@@ -331,6 +341,7 @@ def _register_background_agent_task(
331
341
  task_id=task_id,
332
342
  task_runtime=task_runtime,
333
343
  allowed_tool_names=allowed_tool_names,
344
+ slot_owner=slot_owner,
334
345
  )
335
346
 
336
347
  task = loop.create_task(_task_body(), name=f"agentx-subagent-{task_id}")
@@ -119,11 +119,13 @@ def create_subagent_graph(
119
119
  parent_tool_call_id=ctx.parent_tool_call_id,
120
120
  parent_conversation_session_id=ctx.parent_conversation_session_id,
121
121
  ),
122
+ agent_depth=ctx.agent_depth,
122
123
  subagent=SubagentLinkage(
123
124
  is_subagent=True,
124
125
  subagent_type=ctx.subagent_type,
125
126
  parent_agent_id=ctx.parent_agent_id,
126
127
  agent_depth=ctx.agent_depth,
128
+ fork_from_parent=bool(ctx.fork_from_parent),
127
129
  ),
128
130
  max_steps=ctx.max_steps,
129
131
  headless=bool(ctx.headless),