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.
- langchain_agentx/__init__.py +1 -1
- langchain_agentx/loop/graph/factory.py +1 -3
- langchain_agentx/task_runtime/tasklist/store.py +23 -9
- langchain_agentx/tool_runtime/adapter.py +2 -0
- langchain_agentx/tool_runtime/auto_mode/allowlist.py +0 -3
- langchain_agentx/tool_runtime/execution/callback_bridge.py +3 -5
- langchain_agentx/tool_runtime/execution/engine.py +7 -3
- langchain_agentx/tool_runtime/loop_loader.py +0 -4
- langchain_agentx/tool_runtime/models.py +2 -2
- langchain_agentx/tool_runtime/pipeline.py +25 -83
- langchain_agentx/tools/__init__.py +0 -6
- langchain_agentx/tools/agent/cwd_resolution.py +5 -3
- langchain_agentx/tools/bash/backend.py +24 -16
- langchain_agentx/tools/bash/bash_hardening.py +3 -3
- langchain_agentx/tools/bash/boundary_check.py +3 -1
- langchain_agentx/tools/bash/cwd_reporter.py +24 -36
- langchain_agentx/tools/bash/limits.py +12 -6
- langchain_agentx/tools/bash/path_security.py +6 -6
- langchain_agentx/tools/bash/prompt.py +8 -0
- langchain_agentx/tools/bash/result_presenter.py +33 -11
- langchain_agentx/tools/bash/tool.py +22 -7
- langchain_agentx/tools/edit/tool.py +2 -1
- langchain_agentx/tools/glob/tool.py +8 -10
- langchain_agentx/tools/grep/tool.py +16 -18
- langchain_agentx/tools/read/tool.py +9 -5
- langchain_agentx/tools/skill/tool.py +9 -6
- langchain_agentx/tools/write/tool.py +2 -1
- langchain_agentx/utils/cwd.py +13 -15
- langchain_agentx/utils/path.py +114 -0
- langchain_agentx/utils/path_user_input.py +8 -27
- langchain_agentx/utils/tool_paths.py +86 -0
- langchain_agentx/utils/tool_result_storage.py +173 -0
- langchain_agentx/utils/windows_paths.py +149 -0
- langchain_agentx/workspace/config.py +1 -1
- {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/METADATA +1 -1
- {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/RECORD +39 -41
- langchain_agentx/tools/user_message/__init__.py +0 -19
- langchain_agentx/tools/user_message/attachments.py +0 -97
- langchain_agentx/tools/user_message/models.py +0 -57
- langchain_agentx/tools/user_message/prompt.py +0 -40
- langchain_agentx/tools/user_message/runtime_config.py +0 -53
- langchain_agentx/tools/user_message/tool.py +0 -238
- {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/LICENSE +0 -0
- {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/WHEEL +0 -0
- {langchain_agentx_python-1.0.2.dist-info → langchain_agentx_python-1.0.4.dist-info}/top_level.txt +0 -0
langchain_agentx/__init__.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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`
|
|
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
|
|
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
|
-
|
|
34
|
-
`truncate_if_needed`
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
+
|
|
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=
|
|
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=
|
|
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.
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
98
|
+
normalized = normalize_subprocess_cwd(cwd)
|
|
99
|
+
if normalized is None:
|
|
98
100
|
return "empty path"
|
|
99
|
-
if not os.path.exists(
|
|
101
|
+
if not os.path.exists(normalized):
|
|
100
102
|
return "does not exist"
|
|
101
|
-
if not os.path.isdir(
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
"""是否开启自动后台化策略。"""
|