langchain-agentx-python 1.0.2__py3-none-any.whl → 1.0.4__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 (45) hide show
  1. langchain_agentx/__init__.py +1 -1
  2. langchain_agentx/loop/graph/factory.py +1 -3
  3. langchain_agentx/task_runtime/tasklist/store.py +23 -9
  4. langchain_agentx/tool_runtime/adapter.py +2 -0
  5. langchain_agentx/tool_runtime/auto_mode/allowlist.py +0 -3
  6. langchain_agentx/tool_runtime/execution/callback_bridge.py +3 -5
  7. langchain_agentx/tool_runtime/execution/engine.py +7 -3
  8. langchain_agentx/tool_runtime/loop_loader.py +0 -4
  9. langchain_agentx/tool_runtime/models.py +2 -2
  10. langchain_agentx/tool_runtime/pipeline.py +25 -83
  11. langchain_agentx/tools/__init__.py +0 -6
  12. langchain_agentx/tools/agent/cwd_resolution.py +5 -3
  13. langchain_agentx/tools/bash/backend.py +24 -16
  14. langchain_agentx/tools/bash/bash_hardening.py +3 -3
  15. langchain_agentx/tools/bash/boundary_check.py +3 -1
  16. langchain_agentx/tools/bash/cwd_reporter.py +24 -36
  17. langchain_agentx/tools/bash/limits.py +12 -6
  18. langchain_agentx/tools/bash/path_security.py +6 -6
  19. langchain_agentx/tools/bash/prompt.py +8 -0
  20. langchain_agentx/tools/bash/result_presenter.py +33 -11
  21. langchain_agentx/tools/bash/tool.py +22 -7
  22. langchain_agentx/tools/edit/tool.py +2 -1
  23. langchain_agentx/tools/glob/tool.py +8 -10
  24. langchain_agentx/tools/grep/tool.py +16 -18
  25. langchain_agentx/tools/read/tool.py +9 -5
  26. langchain_agentx/tools/skill/tool.py +9 -6
  27. langchain_agentx/tools/write/tool.py +2 -1
  28. langchain_agentx/utils/cwd.py +13 -15
  29. langchain_agentx/utils/path.py +114 -0
  30. langchain_agentx/utils/path_user_input.py +8 -27
  31. langchain_agentx/utils/tool_paths.py +86 -0
  32. langchain_agentx/utils/tool_result_storage.py +173 -0
  33. langchain_agentx/utils/windows_paths.py +149 -0
  34. langchain_agentx/workspace/config.py +1 -1
  35. {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/METADATA +1 -1
  36. {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/RECORD +39 -41
  37. langchain_agentx/tools/user_message/__init__.py +0 -19
  38. langchain_agentx/tools/user_message/attachments.py +0 -97
  39. langchain_agentx/tools/user_message/models.py +0 -57
  40. langchain_agentx/tools/user_message/prompt.py +0 -40
  41. langchain_agentx/tools/user_message/runtime_config.py +0 -53
  42. langchain_agentx/tools/user_message/tool.py +0 -238
  43. {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/LICENSE +0 -0
  44. {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/WHEEL +0 -0
  45. {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.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__ = "1.0.2"
14
+ __version__ = "1.0.4"
15
15
 
16
16
  from .loop import ( # noqa: F401
17
17
  create_loop_agent,
@@ -1346,9 +1346,7 @@ class LoopGraphBuilder:
1346
1346
  hint = skill_prefetch_provider._build_listing_hint(scope=skill_init_scope)
1347
1347
  if hint is None:
1348
1348
  return {}
1349
- # 对齐 CC:skill_listing UserMessage,不是 SystemMessage
1350
- # CC src/utils/messages.ts: createUserMessage({ isMeta: true, ... })
1351
- # CC 中包裹在 <system-reminder> 中,但 role 是 "user"
1349
+ # skill_listing HumanMessage 注入(非 SystemMessage),与 CC role=user 对齐
1352
1350
  return {"messages": [HumanMessage(content=f"[skill_listing] {hint.summary}")]}
1353
1351
 
1354
1352
  graph_inner.add_node(
@@ -27,8 +27,10 @@ from __future__ import annotations
27
27
 
28
28
  import asyncio
29
29
  import json
30
+ from collections.abc import Coroutine
31
+ from concurrent.futures import ThreadPoolExecutor
30
32
  from pathlib import Path
31
- from typing import Optional
33
+ from typing import Any, Callable, Optional, TypeVar
32
34
 
33
35
  from langchain_agentx.workspace import ResolvedAgentStateConfig
34
36
  from langchain_agentx.workspace.resolver import resolve_agent_state_config
@@ -491,6 +493,18 @@ def with_lock(lock, func):
491
493
 
492
494
  # ── 同步包装器(供同步工具使用)───────────────────────────────────────
493
495
 
496
+ _T = TypeVar("_T")
497
+ _ASYNC_RUNNER = ThreadPoolExecutor(max_workers=1, thread_name_prefix="taskstore-sync")
498
+
499
+
500
+ def _run_async(coro_factory: Callable[[], Coroutine[Any, Any, _T]]) -> _T:
501
+ """在同步上下文中执行协程;若已有 running loop 则在线程内 asyncio.run。"""
502
+ try:
503
+ asyncio.get_running_loop()
504
+ except RuntimeError:
505
+ return asyncio.run(coro_factory())
506
+ return _ASYNC_RUNNER.submit(lambda: asyncio.run(coro_factory())).result()
507
+
494
508
 
495
509
  class TaskStoreSync:
496
510
  """TaskStore 的同步包装器。
@@ -523,32 +537,32 @@ class TaskStoreSync:
523
537
 
524
538
  def create_task(self, data: TaskCreate) -> str:
525
539
  """创建任务(同步)。"""
526
- return asyncio.run(self._get_store().create_task(data))
540
+ return _run_async(lambda: self._get_store().create_task(data))
527
541
 
528
542
  def get_task(self, task_id: str) -> Task | None:
529
543
  """获取任务(同步)。"""
530
- return asyncio.run(self._get_store().get_task(task_id))
544
+ return _run_async(lambda: self._get_store().get_task(task_id))
531
545
 
532
546
  def update_task(self, task_id: str, data: TaskUpdate) -> Task:
533
547
  """更新任务(同步)。"""
534
- return asyncio.run(self._get_store().update_task(task_id, data))
548
+ return _run_async(lambda: self._get_store().update_task(task_id, data))
535
549
 
536
550
  def delete_task(self, task_id: str) -> bool:
537
551
  """删除任务(同步)。"""
538
- return asyncio.run(self._get_store().delete_task(task_id))
552
+ return _run_async(lambda: self._get_store().delete_task(task_id))
539
553
 
540
554
  def list_tasks(self, filters: TaskFilters | None = None) -> list[Task]:
541
555
  """列出任务(同步)。"""
542
- return asyncio.run(self._get_store().list_tasks(filters))
556
+ return _run_async(lambda: self._get_store().list_tasks(filters))
543
557
 
544
558
  def task_exists(self, task_id: str) -> bool:
545
559
  """检查任务是否存在(同步)。"""
546
- return asyncio.run(self._get_store().task_exists(task_id))
560
+ return _run_async(lambda: self._get_store().task_exists(task_id))
547
561
 
548
562
  def block_task(self, from_id: str, to_id: str) -> bool:
549
563
  """建立任务依赖关系(同步)。"""
550
- return asyncio.run(self._get_store().block_task(from_id, to_id))
564
+ return _run_async(lambda: self._get_store().block_task(from_id, to_id))
551
565
 
552
566
  def reset_task_list(self) -> None:
553
567
  """重置任务列表(同步)。"""
554
- asyncio.run(self._get_store().reset_task_list())
568
+ _run_async(lambda: self._get_store().reset_task_list())
@@ -154,6 +154,8 @@ class LangChainAdapter:
154
154
  # 如果 loop_config 存在但 workspace_root 仍为 None,默认使用当前工作目录
155
155
  if loop_config is not None and workspace_root is None:
156
156
  workspace_root = str(Path.cwd())
157
+ if workspace_root is not None:
158
+ workspace_root = str(workspace_root)
157
159
 
158
160
  # agent_home_segment:优先从新嵌套结构获取,再回退到直接字段,再回退到旧字段
159
161
  agent_home_segment = None
@@ -34,7 +34,6 @@ class SafeToolAllowlist:
34
34
  TaskUpdate / TaskStop / TaskOutput
35
35
  - UI / 计划态:AskUserQuestion / EnterPlanMode / ExitPlanMode
36
36
  - Skill 加载(无副作用):Skill
37
- - UserMessage(带外通信)
38
37
 
39
38
  注意:
40
39
  Edit / Write 不进默认白名单,走 acceptEdits fast-path(L2,待实现)。
@@ -60,8 +59,6 @@ class SafeToolAllowlist:
60
59
  "ExitPlanMode",
61
60
  # Skill 加载(无副作用)
62
61
  "Skill",
63
- # UserMessage(带外通信)
64
- "UserMessage",
65
62
  })
66
63
 
67
64
  def __init__(self, extra: Iterable[str] | None = None):
@@ -174,11 +174,9 @@ class ToolExecutionCallbackBridge:
174
174
 
175
175
  @staticmethod
176
176
  def _resolve_run_id(tool_call_id: str) -> uuid.UUID:
177
- if tool_call_id:
178
- try:
179
- return uuid.UUID(tool_call_id)
180
- except ValueError:
181
- pass
177
+ # 不复用 LLM tool_call_id(常为 UUID):与 LangGraph astream_events 的 run_map
178
+ # 键冲突时会出现 on_tool_end 已 pop、on_tool_error KeyError。
179
+ _ = tool_call_id
182
180
  return uuid.uuid4()
183
181
 
184
182
 
@@ -606,11 +606,15 @@ class RuntimeToolExecutionEngine:
606
606
  override = getattr(store, "cwd_override", None)
607
607
  if not isinstance(override, str) or not override.strip():
608
608
  return
609
- candidate = Path(override)
610
- if candidate.is_dir():
609
+ from langchain_agentx.utils.windows_paths import normalize_subprocess_cwd
610
+
611
+ normalized = normalize_subprocess_cwd(override)
612
+ if normalized and Path(normalized).is_dir():
613
+ if normalized != override and hasattr(store, "set_cwd"):
614
+ store.set_cwd(normalized)
611
615
  return
612
616
  if hasattr(store, "set_cwd"):
613
- store.set_cwd(workspace_root)
617
+ store.set_cwd(str(workspace_root))
614
618
 
615
619
  def _unknown_tool_envelope(self, tool_name: str) -> ToolResultEnvelope:
616
620
  return ToolResultEnvelope(
@@ -82,8 +82,6 @@ class LoopLoader:
82
82
  from langchain_agentx.tools.task_get.tool import TaskGetRuntimeTool
83
83
  from langchain_agentx.tools.task_list.tool import TaskListRuntimeTool
84
84
  from langchain_agentx.tools.task_update.tool import TaskUpdateRuntimeTool
85
- # UserMessageTool 暂时移除(待稳定后可能完全删除)
86
- # from langchain_agentx.tools.user_message import UserMessageTool
87
85
  from langchain_agentx.tools.webfetch import loader as webfetch_loader
88
86
  from langchain_agentx.tools.websearch import loader as websearch_loader
89
87
  from langchain_agentx.tools.write import WriteRuntimeTool
@@ -136,8 +134,6 @@ class LoopLoader:
136
134
  )
137
135
  )
138
136
  self.register(BashRuntimeTool(state_bridge=shared_bridge))
139
- # UserMessageTool 暂时移除(待稳定后可能完全删除)
140
- # self.register(UserMessageTool())
141
137
  self.register(AskUserQuestionTool())
142
138
  self.register(TaskListRuntimeTool(config=cfg))
143
139
  self.register(TaskGetRuntimeTool(config=cfg))
@@ -116,11 +116,11 @@ class ToolExecutionContext:
116
116
  cancel_event: threading.Event | None = None
117
117
  """可选;由 ``RunnableConfig.configurable[\"cancel_event\"]`` 注入。set 后 Grep/Glob 应中断 rg。"""
118
118
  tool_config_path: str | None = None
119
- """可选;`LangChainAdapter` 由 workspace 解析的 `tool_runtime_config.json` 绝对路径(UserMessage v2+)。"""
119
+ """可选;`LangChainAdapter` 由 workspace 解析的 `tool_runtime_config.json` 绝对路径。"""
120
120
  channels: set[str] | None = None
121
121
  """可选;当前激活渠道(如 ``{"cli"}`` / ``{"discord"}``),供 AskUserQuestion 等工具禁用无 TUI 场景。"""
122
122
  trace_collector: Any | None = None
123
- """可选;来自 graph state / configurable 的 `TraceCollector`,供 pipeline 发 `user_message_send`。"""
123
+ """可选;来自 graph state / configurable 的 `TraceCollector`,供 pipeline 发分析事件。"""
124
124
  trace_emit_session_id: str | None = None
125
125
  """可选;`emit_event(session_id=...)` 用,与 Trace 会话键(常为 `_run_id`)对齐。"""
126
126
  question_preview_format: Literal["markdown", "html"] | None = None
@@ -30,9 +30,8 @@ runtime/pipeline.py — 工具执行管道与输出大小管控
30
30
  IdenticalCallMemo;命中则直接返回带 Hint 的 ToolResultEnvelope(不执行 invoke),
31
31
  作为“软提示防重复”策略的执行层硬约束替代。
32
32
 
33
- UserMessage v2
34
- `truncate_if_needed` 之后对 `user_message` 发 `user_message_send`;
35
- 对 `AskUserQuestion` 发 `ask_user_question_completed`(`_emit_post_invoke_analytics`)。
33
+ AskUserQuestion
34
+ `truncate_if_needed` 之后发 `ask_user_question_completed`(`_emit_post_invoke_analytics`)。
36
35
  """
37
36
 
38
37
  from __future__ import annotations
@@ -119,76 +118,6 @@ def _as_run_result(
119
118
  )
120
119
 
121
120
 
122
- _USER_MESSAGE_TOOL_NAME = "user_message"
123
-
124
-
125
- def _emit_user_message_analytics(
126
- *,
127
- tool: RuntimeTool,
128
- parsed_data: dict[str, Any],
129
- envelope: ToolResultEnvelope,
130
- ctx: ToolExecutionContext,
131
- ) -> None:
132
- """
133
- 对齐 CC `tengu_brief_send` 的 `user_message_send` 分析事件(设计 §2.3 / §3.4.6)。
134
-
135
- 当前裁剪范围:
136
- 仅 `tool.name == user_message` 且 `envelope.status == ok`;依赖 `ctx.trace_collector`
137
- 与 `ctx.trace_emit_session_id`;异常吞掉不向上抛。
138
- """
139
- if tool.name != _USER_MESSAGE_TOOL_NAME:
140
- return
141
- if envelope.status != "ok":
142
- return
143
- collector = ctx.trace_collector
144
- session_id = ctx.trace_emit_session_id
145
- if collector is None or not isinstance(session_id, str) or not session_id.strip():
146
- return
147
- try:
148
- has_fn = getattr(collector, "has_session", None)
149
- if callable(has_fn) and not has_fn(session_id):
150
- return
151
- except Exception:
152
- return
153
-
154
- raw_attachments = parsed_data.get("attachments")
155
- if isinstance(raw_attachments, list):
156
- attachment_count = len(raw_attachments)
157
- else:
158
- attachment_count = 0
159
- has_attachments = attachment_count > 0
160
-
161
- raw_msg = parsed_data.get("message")
162
- message_length = len(raw_msg) if isinstance(raw_msg, str) else 0
163
-
164
- meta = envelope.meta or {}
165
- truncated = bool(envelope.truncated) or bool(meta.get("truncated"))
166
-
167
- status_val = parsed_data.get("status", "normal")
168
- if status_val not in ("normal", "proactive"):
169
- status_val = "normal"
170
-
171
- payload: dict[str, Any] = {
172
- "has_attachments": has_attachments,
173
- "attachment_count": attachment_count,
174
- "status": status_val,
175
- "message_length": message_length,
176
- "truncated": truncated,
177
- }
178
-
179
- try:
180
- emit_fn = getattr(collector, "emit_event", None)
181
- if not callable(emit_fn):
182
- return
183
- emit_fn(
184
- session_id=session_id,
185
- event_type="user_message_send",
186
- payload=payload,
187
- )
188
- except Exception as exc:
189
- logger.debug("user_message_send emit skipped: %s", exc)
190
-
191
-
192
121
  _ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion"
193
122
 
194
123
 
@@ -290,9 +219,6 @@ def _emit_post_invoke_analytics(
290
219
  envelope: ToolResultEnvelope,
291
220
  ctx: ToolExecutionContext,
292
221
  ) -> None:
293
- _emit_user_message_analytics(
294
- tool=tool, parsed_data=parsed_data, envelope=envelope, ctx=ctx
295
- )
296
222
  _emit_ask_user_question_analytics(
297
223
  tool=tool, parsed_data=parsed_data, envelope=envelope, ctx=ctx
298
224
  )
@@ -351,9 +277,17 @@ class ToolOutputManager:
351
277
  self,
352
278
  max_result_size_chars: int = DEFAULT_MAX_CHARS,
353
279
  overflow_dir: str = OVERFLOW_DIR,
280
+ storage_presenter: Any | None = None,
354
281
  ) -> None:
355
282
  self._max_chars = max_result_size_chars
356
283
  self._overflow_dir = overflow_dir
284
+ if storage_presenter is None:
285
+ from langchain_agentx.utils.tool_result_storage import (
286
+ ToolResultStoragePresenter,
287
+ )
288
+
289
+ storage_presenter = ToolResultStoragePresenter()
290
+ self._storage = storage_presenter
357
291
 
358
292
  def truncate_if_needed(
359
293
  self,
@@ -376,28 +310,36 @@ class ToolOutputManager:
376
310
  if len(text) <= max_chars:
377
311
  return envelope
378
312
 
379
- # 超限:持久化完整内容
313
+ # 超限:持久化完整内容(对齐 CC buildLargeToolResultMessage)
380
314
  overflow_path = self._persist(text, tool_call_id)
381
-
382
- # 截断 payload
383
- truncated_text = text[:max_chars]
315
+ from langchain_agentx.utils.tool_result_storage import PersistedToolResult
316
+
317
+ preview, has_more = self._storage.generate_preview(text, max_chars)
318
+ persisted = PersistedToolResult(
319
+ filepath=overflow_path,
320
+ original_size=len(text),
321
+ preview=preview,
322
+ has_more=has_more,
323
+ )
324
+ model_overflow_path = self._storage.model_overflow_path(overflow_path)
325
+ truncated_payload = self._storage.build_large_tool_result_message(persisted)
384
326
  truncated_summary = (
385
327
  envelope.summary
386
328
  + f"\n[Output truncated at {max_chars} chars."
387
- + f" Full result saved to: {overflow_path}]"
329
+ + self._storage.build_truncation_summary_suffix(overflow_path)
388
330
  )
389
331
 
390
332
  return ToolResultEnvelope(
391
333
  status=envelope.status,
392
334
  tool_name=envelope.tool_name,
393
335
  summary=truncated_summary,
394
- payload=truncated_text,
336
+ payload=truncated_payload,
395
337
  artifacts=envelope.artifacts,
396
338
  hints=envelope.hints,
397
339
  meta=envelope.meta,
398
340
  error=envelope.error,
399
341
  truncated=True,
400
- overflow_file=overflow_path,
342
+ overflow_file=model_overflow_path,
401
343
  )
402
344
 
403
345
  def _envelope_to_text(self, envelope: ToolResultEnvelope) -> str:
@@ -6,7 +6,6 @@
6
6
  P2(已完成):GrepRuntimeTool / GlobRuntimeTool — 只读搜索工具
7
7
  P3(进行中):WriteRuntimeTool;EditRuntimeTool — Phase 6 单测 + ``tools/`` 回归已跑通(需 ToolStateBridge + 先 Read)
8
8
  P4(已完成):BashRuntimeTool
9
- # P1 用户交互(已移除):UserMessageTool — 用户可见消息(CC BriefTool,因不确定性问题已移除)
10
9
  """
11
10
 
12
11
  from .agent import AgentRuntimeTool
@@ -18,9 +17,6 @@ from .skill import SkillRuntimeTool
18
17
  from .edit import EditRuntimeTool
19
18
  from .write import WriteRuntimeTool
20
19
 
21
- # UserMessageTool 暂时移除(待稳定后可能完全删除)
22
- # from .user_message import UserMessageTool
23
-
24
20
  __all__ = [
25
21
  "ReadRuntimeTool",
26
22
  "AgentRuntimeTool",
@@ -30,6 +26,4 @@ __all__ = [
30
26
  "SkillRuntimeTool",
31
27
  "WriteRuntimeTool",
32
28
  "EditRuntimeTool",
33
- # "UserMessageTool", # 暂时移除
34
29
  ]
35
-
@@ -45,7 +45,7 @@ class AgentToolCwdResolution:
45
45
  "Agent cwd expansion requires workspace_root; "
46
46
  "set workspace_root in AgentLoopConfig.workspace.workspace_root."
47
47
  )
48
- from langchain_agentx.utils.cwd import expand_path
48
+ from langchain_agentx.utils.path import expand_path
49
49
 
50
50
  normalized = normalize_user_path_string_for_expand(raw.strip())
51
51
  return expand_path(normalized, base_dir=workspace_root)
@@ -61,8 +61,10 @@ class AgentToolCwdResolution:
61
61
 
62
62
  if workspace_root:
63
63
  try:
64
- root = Path(workspace_root).expanduser().resolve()
65
- candidate = Path(absolute).expanduser().resolve()
64
+ from langchain_agentx.utils.path import expand_path
65
+
66
+ root = Path(expand_path(workspace_root))
67
+ candidate = Path(expand_path(absolute))
66
68
  except OSError as exc:
67
69
  return f"cwd 无法解析到 workspace 内:{exc}"
68
70
  if not _path_is_descendant(candidate, root):
@@ -46,6 +46,7 @@ from .cwd_reporter import (
46
46
  BashCwdReporter,
47
47
  bash_single_quoted,
48
48
  native_path_for_bash_redirect,
49
+ normalize_subprocess_cwd,
49
50
  read_cwd_file,
50
51
  )
51
52
  from .sandbox_decision import BashSandboxDecision
@@ -90,30 +91,32 @@ def extract_cwd_warning(stderr: str) -> tuple[str, str | None]:
90
91
  return cleaned_stderr, cwd_warning
91
92
 
92
93
 
93
- def _cwd_invalid_reason(cwd: str | None) -> str | None:
94
+ def _cwd_invalid_reason(cwd: str | os.PathLike[str] | None) -> str | None:
94
95
  """返回 cwd 不可用于 subprocess 的原因;None 表示可用(含 cwd is None)。"""
95
96
  if cwd is None:
96
97
  return None
97
- if not cwd.strip():
98
+ normalized = normalize_subprocess_cwd(cwd)
99
+ if normalized is None:
98
100
  return "empty path"
99
- if not os.path.exists(cwd):
101
+ if not os.path.exists(normalized):
100
102
  return "does not exist"
101
- if not os.path.isdir(cwd):
103
+ if not os.path.isdir(normalized):
102
104
  return "not a directory"
103
105
  return None
104
106
 
105
107
 
106
- def _pick_cwd_fallback(fallback: str | None) -> str | None:
108
+ def _pick_cwd_fallback(fallback: str | os.PathLike[str] | None) -> str | None:
107
109
  """选择可 spawn 的回退目录(fail-closed:仅 fallback,不用进程 cwd / ~)。"""
108
- if fallback and _cwd_invalid_reason(fallback) is None:
109
- return os.path.realpath(fallback)
110
+ normalized = normalize_subprocess_cwd(fallback)
111
+ if normalized and _cwd_invalid_reason(normalized) is None:
112
+ return os.path.realpath(normalized)
110
113
  return None
111
114
 
112
115
 
113
116
  def _prepare_spawn_cwd(
114
- cwd: str | None,
117
+ cwd: str | os.PathLike[str] | None,
115
118
  *,
116
- fallback: str | None,
119
+ fallback: str | os.PathLike[str] | None,
117
120
  ) -> tuple[str | None, str]:
118
121
  """
119
122
  spawn 前解析 cwd:无效时 fail-closed 回退到 fallback,并生成 CC 风格 stderr 前缀。
@@ -121,9 +124,10 @@ def _prepare_spawn_cwd(
121
124
  Returns:
122
125
  (effective_cwd, stderr_prefix) — stderr_prefix 形如 ``Shell cwd was reset to ...\\n``
123
126
  """
124
- reason = _cwd_invalid_reason(cwd)
127
+ normalized_cwd = normalize_subprocess_cwd(cwd) if cwd is not None else None
128
+ reason = _cwd_invalid_reason(normalized_cwd)
125
129
  if reason is None:
126
- return cwd, ""
130
+ return normalized_cwd, ""
127
131
  reset_to = _pick_cwd_fallback(fallback)
128
132
  if reset_to is None:
129
133
  detail = f"previous cwd invalid: {cwd}: {reason}" if cwd else f"cwd invalid: {reason}"
@@ -1274,10 +1278,14 @@ class BashBackend:
1274
1278
  @staticmethod
1275
1279
  def _build_stream_wrapper(command: str) -> str:
1276
1280
  marker = "__AGENTX_STREAM_CWD__"
1281
+ if sys.platform == "win32":
1282
+ pwd_expr = '$(cygpath -m "$PWD" 2>/dev/null || printf %s "$PWD")'
1283
+ else:
1284
+ pwd_expr = '"$PWD"'
1277
1285
  return (
1278
1286
  f"{command}\n"
1279
1287
  "__EC=$?\n"
1280
- f'printf "\\n{marker}%s\\n" "$PWD"\n'
1288
+ f'printf "\\n{marker}%s\\n" {pwd_expr}\n'
1281
1289
  "exit $__EC"
1282
1290
  )
1283
1291
 
@@ -1437,7 +1445,8 @@ class BashBackend:
1437
1445
  before, _, after = merged.rpartition(marker)
1438
1446
  # 末尾的 cwd 标记不再作为 chunk 内容处理
1439
1447
  cleaned = before
1440
- cwd_after = after.strip().splitlines()[0] if after.strip() else None
1448
+ raw_cwd = after.strip().splitlines()[0] if after.strip() else None
1449
+ cwd_after = normalize_subprocess_cwd(raw_cwd)
1441
1450
  if cleaned != merged:
1442
1451
  # 让上游获得去 marker 后的稳定输出
1443
1452
  pass
@@ -1696,8 +1705,7 @@ def _read_fd_safe(fd: int) -> str | None:
1696
1705
  if not chunk:
1697
1706
  break
1698
1707
  data += chunk
1699
- result = data.decode("utf-8", errors="replace").strip()
1700
- return result if result else None
1708
+ return normalize_subprocess_cwd(data.decode("utf-8", errors="replace"))
1701
1709
  except OSError:
1702
1710
  return None
1703
1711
 
@@ -1723,7 +1731,7 @@ def _extract_cwd_from_output(raw_output: str) -> tuple[str, str | None]:
1723
1731
  for line in lines:
1724
1732
  stripped = line.rstrip("\n").rstrip("\r")
1725
1733
  if stripped.startswith(_CWD_SENTINEL):
1726
- cwd_after = stripped[len(_CWD_SENTINEL):]
1734
+ cwd_after = normalize_subprocess_cwd(stripped[len(_CWD_SENTINEL) :])
1727
1735
  else:
1728
1736
  clean_lines.append(line)
1729
1737
 
@@ -608,9 +608,9 @@ class BashGitSecurityChecker:
608
608
 
609
609
  @staticmethod
610
610
  def _resolve_path(raw_path: str, cwd: str) -> str:
611
- expanded = os.path.expanduser(raw_path.strip("\"'"))
612
- absolute = expanded if os.path.isabs(expanded) else os.path.join(cwd, expanded)
613
- return os.path.realpath(absolute)
611
+ from langchain_agentx.utils.tool_paths import default_tool_paths
612
+
613
+ return default_tool_paths().resolve_path_in_cwd(raw_path, cwd)
614
614
 
615
615
  @staticmethod
616
616
  def _is_git_internal_path(path: str) -> bool:
@@ -99,7 +99,9 @@ class BashBoundaryChecker:
99
99
  return Path(cwd)
100
100
  target = parts[0]
101
101
  if target == "~" or target.startswith("~/"):
102
- return Path(os.path.expanduser(target)).resolve()
102
+ from langchain_agentx.utils.path import expand_path
103
+
104
+ return Path(expand_path(target)).resolve()
103
105
  p = Path(target)
104
106
  if p.is_absolute():
105
107
  return p.resolve()
@@ -6,56 +6,42 @@ cwd_reporter.py — Bash 执行后 cwd 回传(Unix fd3 / Windows 临时文件
6
6
  Windows 用 **临时文件 + `pwd -P`**(禁止 pass_fds),与 CC `bashProvider` 双轨语义对齐。
7
7
 
8
8
  链路位置:
9
- 仅被 `backend.py` 调用;不读工具入参、不碰权限链。
9
+ 仅被 `backend.py` 调用;路径转换统一委托 ``langchain_agentx.utils.windows_paths``。
10
10
 
11
11
  当前裁剪范围:
12
- 不处理流式 `_stream_execute_raw_iter`;不处理沙箱 backend 内部;UNC 临时目录尽力而为
13
- (`native_path_for_bash_redirect` 覆盖常见 `//server/share` 形态)。
14
-
15
- 设计 SSOT:
16
- docs/design-docs/tool-design/bash-tool-cross-platform.md(Phase 2)
12
+ 不处理流式 `_stream_execute_raw_iter`;不处理沙箱 backend 内部。
17
13
  """
18
14
 
19
15
  from __future__ import annotations
20
16
 
21
- import os
22
17
  from pathlib import Path
23
18
 
19
+ from langchain_agentx.utils.windows_paths import (
20
+ native_path_for_bash_redirect,
21
+ normalize_subprocess_cwd,
22
+ posix_path_to_windows_path,
23
+ windows_path_to_posix_path,
24
+ )
25
+
26
+ # 向后兼容:历史测试/集成从本模块 import 转换函数
27
+ __all__ = [
28
+ "BashCwdReporter",
29
+ "bash_single_quoted",
30
+ "native_path_for_bash_redirect",
31
+ "normalize_subprocess_cwd",
32
+ "posix_path_to_windows_path",
33
+ "read_cwd_file",
34
+ "windows_path_to_posix_path",
35
+ ]
36
+
24
37
 
25
38
  def bash_single_quoted(s: str) -> str:
26
39
  """Bash 单引号字面量(可嵌入 `pwd -P >| ...` 等)。"""
27
40
  return "'" + s.replace("'", "'\\''") + "'"
28
41
 
29
42
 
30
- def native_path_for_bash_redirect(win_native: str) -> str:
31
- """
32
- 将 **本机绝对路径** 转为 Git Bash / MSYS 下用于重定向的 POSIX 形态。
33
-
34
- - `C:\\Users\\x` → `/c/Users/x`
35
- - `\\\\server\\share\\a` → `//server/share/a`
36
- - 长路径前缀 `\\\\?\\` 会剥离后再转换(尽力而为)。
37
- """
38
- p = Path(win_native).resolve()
39
- s = os.fspath(p)
40
- norm = s.replace("/", "\\")
41
- if norm.startswith("\\\\?\\"):
42
- norm = norm[4:]
43
- if norm.upper().startswith("UNC\\"):
44
- norm = "\\" + norm[3:]
45
- if norm.startswith("\\\\"):
46
- body = norm[2:].replace("\\", "/").strip("/")
47
- return "//" + body if body else "//"
48
- if len(norm) >= 2 and norm[1] == ":":
49
- letter = norm[0].lower()
50
- tail = norm[2:].replace("\\", "/")
51
- if not tail.startswith("/"):
52
- tail = "/" + tail.lstrip("/")
53
- return f"/{letter}{tail}"
54
- return norm.replace("\\", "/")
55
-
56
-
57
43
  def read_cwd_file(native_path: str) -> str | None:
58
- """读取 `pwd -P` 写入的临时文件,返回最后一行非空 cwd,失败为 None。"""
44
+ """读取 `pwd -P` 写入的临时文件,返回最后一行非空 cwd(本机路径),失败为 None。"""
59
45
  try:
60
46
  raw = Path(native_path).read_text(encoding="utf-8", errors="replace").strip()
61
47
  except OSError:
@@ -63,7 +49,9 @@ def read_cwd_file(native_path: str) -> str | None:
63
49
  if not raw:
64
50
  return None
65
51
  lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
66
- return lines[-1] if lines else None
52
+ if not lines:
53
+ return None
54
+ return normalize_subprocess_cwd(lines[-1])
67
55
 
68
56
 
69
57
  class BashCwdReporter:
@@ -39,13 +39,19 @@ SLEEP_BLOCK_THRESHOLD_SEC = int(os.getenv("BASH_TOOL_SLEEP_BLOCK_SEC", "30"))
39
39
  # 后台任务
40
40
  # ---------------------------------------------------------------------------
41
41
 
42
- BACKGROUND_TASK_OUTPUT_DIR = os.path.expanduser(
43
- os.getenv(
44
- "BASH_TOOL_BACKGROUND_OUTPUT_DIR",
45
- "~/.cache/langchain_agentx/tasks",
42
+ def _resolve_background_task_output_dir() -> str:
43
+ from langchain_agentx.utils.path import expand_path
44
+
45
+ return expand_path(
46
+ os.getenv(
47
+ "BASH_TOOL_BACKGROUND_OUTPUT_DIR",
48
+ "~/.cache/langchain_agentx/tasks",
49
+ )
46
50
  )
47
- )
48
- """后台任务输出目录。"""
51
+
52
+
53
+ BACKGROUND_TASK_OUTPUT_DIR = _resolve_background_task_output_dir()
54
+ """后台任务输出目录(``expand_path`` 展开 ~)。"""
49
55
 
50
56
  AUTO_BACKGROUND_ENABLED = os.getenv("BASH_TOOL_AUTO_BACKGROUND_ENABLED", "1") == "1"
51
57
  """是否开启自动后台化策略。"""