langchain-agentx-python 0.2.7__py3-none-any.whl → 0.2.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_agentx/loop/config/agent_loop_config.py +2 -0
- langchain_agentx/loop/subagent/context.py +10 -4
- langchain_agentx/tool_runtime/adapter.py +21 -9
- langchain_agentx/tool_runtime/loader.py +5 -0
- langchain_agentx/tool_runtime/models.py +8 -0
- langchain_agentx/tool_runtime/session_store.py +137 -2
- langchain_agentx/tool_runtime/smoke_test_runtime.py +18 -2
- langchain_agentx/tool_runtime/state_bridge.py +35 -103
- langchain_agentx/tools/bash/tool.py +10 -4
- langchain_agentx/tools/glob/tool.py +2 -2
- langchain_agentx/tools/webfetch/__init__.py +78 -0
- langchain_agentx/tools/webfetch/backend.py +382 -0
- langchain_agentx/tools/webfetch/limits.py +64 -0
- langchain_agentx/tools/webfetch/loader.py +34 -0
- langchain_agentx/tools/webfetch/models.py +103 -0
- langchain_agentx/tools/webfetch/preapproved.py +152 -0
- langchain_agentx/tools/webfetch/prompt.py +71 -0
- langchain_agentx/tools/webfetch/summary.py +35 -0
- langchain_agentx/tools/webfetch/tool.py +309 -0
- langchain_agentx/tools/websearch/__init__.py +37 -0
- langchain_agentx/tools/websearch/backend.py +255 -0
- langchain_agentx/tools/websearch/events.py +58 -0
- langchain_agentx/tools/websearch/limits.py +64 -0
- langchain_agentx/tools/websearch/loader.py +49 -0
- langchain_agentx/tools/websearch/models.py +133 -0
- langchain_agentx/tools/websearch/prompt.py +69 -0
- langchain_agentx/tools/websearch/tool.py +253 -0
- {langchain_agentx_python-0.2.7.dist-info → langchain_agentx_python-0.2.8.dist-info}/METADATA +516 -516
- {langchain_agentx_python-0.2.7.dist-info → langchain_agentx_python-0.2.8.dist-info}/RECORD +32 -15
- {langchain_agentx_python-0.2.7.dist-info → langchain_agentx_python-0.2.8.dist-info}/LICENSE +0 -0
- {langchain_agentx_python-0.2.7.dist-info → langchain_agentx_python-0.2.8.dist-info}/WHEEL +0 -0
- {langchain_agentx_python-0.2.7.dist-info → langchain_agentx_python-0.2.8.dist-info}/top_level.txt +0 -0
|
@@ -47,6 +47,8 @@ class AgentLoopConfig:
|
|
|
47
47
|
|
|
48
48
|
# 工具与权限
|
|
49
49
|
available_tools: list[Any] = field(default_factory=list)
|
|
50
|
+
# 为 False 时禁止需要外网的工具(如 WebSearch);只读/离线模式由宿主设置。
|
|
51
|
+
network_access_allowed: bool = True
|
|
50
52
|
permission_mode: str = "default"
|
|
51
53
|
permission_resolver: Any | None = None
|
|
52
54
|
workspace_root: str | None = None # 工具层 workspace 根路径,权限检查边界
|
|
@@ -5,17 +5,19 @@
|
|
|
5
5
|
CC 对照:src/agent/subagent/context.ts SubagentContext 接口
|
|
6
6
|
当前裁剪范围:v1 全量;system_prompt / is_async 字段驱动执行路径选择;
|
|
7
7
|
``path_prep`` 与遗留 ``cwd``/worktree 自建二选一(编排层须传 ``path_prep``);
|
|
8
|
-
fork + worktree 时可选注入 CC 等价 ``buildWorktreeNotice`` user 消息(见 ``fork_worktree_notice
|
|
8
|
+
fork + worktree 时可选注入 CC 等价 ``buildWorktreeNotice`` user 消息(见 ``fork_worktree_notice``);
|
|
9
|
+
``fork_from_parent=True`` 且父持有 ``AgentSessionStore`` 时子上下文使用 ``clone(file_state_only=True)``。
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
import os
|
|
12
13
|
import shutil
|
|
13
14
|
import uuid
|
|
14
|
-
from dataclasses import dataclass
|
|
15
|
+
from dataclasses import dataclass
|
|
15
16
|
from typing import Any
|
|
16
17
|
|
|
17
18
|
from langchain_agentx.loop.runtime.subagent_execution_paths import PreparedSubagentPaths
|
|
18
19
|
from langchain_agentx.loop.subagent.fork_worktree_notice import build_worktree_notice
|
|
20
|
+
from langchain_agentx.tool_runtime.session_store import AgentSessionStore
|
|
19
21
|
from langchain_agentx.workspace import resolve_agent_workspace_config
|
|
20
22
|
|
|
21
23
|
|
|
@@ -57,7 +59,6 @@ class SubagentContext:
|
|
|
57
59
|
workspace_root: str = ""
|
|
58
60
|
agent_home: str = ".langchain_agentx"
|
|
59
61
|
cwd: str | None = None
|
|
60
|
-
_file_read_state: dict[str, Any] = field(default_factory=dict)
|
|
61
62
|
capabilities: Any = None # RuntimeCapabilities,从父会话继承
|
|
62
63
|
|
|
63
64
|
|
|
@@ -178,6 +179,11 @@ def create_subagent_context(
|
|
|
178
179
|
os.makedirs(worktree_dir, exist_ok=False)
|
|
179
180
|
effective_cwd = (cwd.strip() if isinstance(cwd, str) and cwd.strip() else None) or worktree_dir
|
|
180
181
|
|
|
182
|
+
parent_store = getattr(parent_ctx, "session_store", None)
|
|
183
|
+
session_store: Any = parent_store
|
|
184
|
+
if fork_from_parent and isinstance(parent_store, AgentSessionStore):
|
|
185
|
+
session_store = parent_store.clone(file_state_only=True)
|
|
186
|
+
|
|
181
187
|
return SubagentContext(
|
|
182
188
|
agent_id=agent_id,
|
|
183
189
|
initial_messages=initial_messages,
|
|
@@ -188,7 +194,7 @@ def create_subagent_context(
|
|
|
188
194
|
system_prompt=system_prompt,
|
|
189
195
|
fork_from_parent=fork_from_parent,
|
|
190
196
|
is_async=is_async,
|
|
191
|
-
session_store=
|
|
197
|
+
session_store=session_store,
|
|
192
198
|
tool_registry=getattr(parent_ctx, "tool_registry", None),
|
|
193
199
|
tool_loader=getattr(parent_ctx, "tool_loader", None),
|
|
194
200
|
parent_agent_id=getattr(parent_ctx, "agent_id", None),
|
|
@@ -94,18 +94,20 @@ class LangChainAdapter:
|
|
|
94
94
|
conversation_session_id = getattr(loop_config, "conversation_session_id", None) or session_id
|
|
95
95
|
workspace_root = getattr(loop_config, "workspace_root", None)
|
|
96
96
|
agent_home = getattr(loop_config, "agent_home", ".langchain_agentx")
|
|
97
|
-
# cwd
|
|
98
|
-
# state_bridge cwd 由 BashRuntimeTool.set_cwd() 维护,支持 bash cd 后其他工具自动感知
|
|
97
|
+
# cwd:loop_config.cwd → session_store.cwd_override → workspace_root → get_cwd()
|
|
99
98
|
from langchain_agentx.utils.cwd import get_cwd as _utils_get_cwd
|
|
100
|
-
from langchain_agentx.tool_runtime.state_bridge import ToolStateBridge
|
|
101
99
|
|
|
102
100
|
cwd = getattr(loop_config, "cwd", None)
|
|
103
|
-
if not cwd
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
if not cwd:
|
|
102
|
+
sess = configurable.get("session_store")
|
|
103
|
+
from langchain_agentx.tool_runtime.session_store import (
|
|
104
|
+
AgentSessionStore as _AgentSessionStore,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if isinstance(sess, _AgentSessionStore):
|
|
108
|
+
sess_cwd = sess.cwd_override
|
|
109
|
+
if isinstance(sess_cwd, str) and sess_cwd:
|
|
110
|
+
cwd = sess_cwd
|
|
109
111
|
if not cwd:
|
|
110
112
|
cwd = workspace_root or _utils_get_cwd()
|
|
111
113
|
trace_enabled = bool(getattr(loop_config, "trace_enabled", True))
|
|
@@ -185,6 +187,14 @@ class LangChainAdapter:
|
|
|
185
187
|
if low in ("markdown", "html"):
|
|
186
188
|
question_preview_format = low
|
|
187
189
|
|
|
190
|
+
network_access_allowed = bool(getattr(loop_config, "network_access_allowed", True))
|
|
191
|
+
if "network_access_allowed" in configurable:
|
|
192
|
+
network_access_allowed = bool(configurable.get("network_access_allowed"))
|
|
193
|
+
|
|
194
|
+
tool_progress_callback: Any | None = configurable.get("tool_progress_callback")
|
|
195
|
+
if tool_progress_callback is None and loop_config is not None:
|
|
196
|
+
tool_progress_callback = getattr(loop_config, "tool_progress_callback", None)
|
|
197
|
+
|
|
188
198
|
return ToolExecutionContext(
|
|
189
199
|
tool_name=tool_name,
|
|
190
200
|
# 回退顺序:显式 tool_call_id -> config.tool_call_id -> input.tool_call_id -> input.id -> run_id
|
|
@@ -217,6 +227,8 @@ class LangChainAdapter:
|
|
|
217
227
|
trace_emit_session_id=trace_emit_session_id,
|
|
218
228
|
question_preview_format=question_preview_format,
|
|
219
229
|
capabilities=capabilities,
|
|
230
|
+
network_access_allowed=network_access_allowed,
|
|
231
|
+
tool_progress_callback=tool_progress_callback,
|
|
220
232
|
)
|
|
221
233
|
|
|
222
234
|
def build_structured_tool(
|
|
@@ -117,6 +117,8 @@ class ToolRuntimeLoader:
|
|
|
117
117
|
from langchain_agentx.tools.ripgrep_plugin_exclusions import PluginCacheGlobExclusions
|
|
118
118
|
from langchain_agentx.tools.task_list.tool import TaskListRuntimeTool
|
|
119
119
|
from langchain_agentx.tools.user_message import UserMessageTool
|
|
120
|
+
from langchain_agentx.tools.webfetch import loader as webfetch_loader
|
|
121
|
+
from langchain_agentx.tools.websearch import loader as websearch_loader
|
|
120
122
|
from langchain_agentx.tools.write import WriteRuntimeTool
|
|
121
123
|
from langchain_agentx.tool_runtime.state_bridge import ToolStateBridge
|
|
122
124
|
from langchain_agentx.workspace.config import AgentWorkspaceConfig
|
|
@@ -153,6 +155,9 @@ class ToolRuntimeLoader:
|
|
|
153
155
|
self.register(AskUserQuestionTool())
|
|
154
156
|
self.register(TaskListRuntimeTool(config=cfg))
|
|
155
157
|
|
|
158
|
+
websearch_loader.register(self)
|
|
159
|
+
webfetch_loader.register(self)
|
|
160
|
+
|
|
156
161
|
if include_agent:
|
|
157
162
|
from langchain_agentx.tools.agent import AgentRuntimeTool
|
|
158
163
|
|
|
@@ -14,6 +14,7 @@ runtime/models.py — 工具运行时框架核心数据模型
|
|
|
14
14
|
readFileState、getAppState、messages 等大量 UI/应用层字段。
|
|
15
15
|
本框架的 ToolExecutionContext 只保留框架稳定依赖的最小字段集,
|
|
16
16
|
UI/应用层依赖由 LangChainAdapter 在边界处消费,不透传到工具内部。
|
|
17
|
+
``session_store``(AgentSessionStore)为会话级工具态后端,供 ToolStateBridge 等读写。
|
|
17
18
|
协作取消:``cancel_event`` 对应 CC ``AbortSignal``(由上层 ``set()`` 终止 ripgrep 等子进程)。
|
|
18
19
|
|
|
19
20
|
CC 的 ValidationResult / PermissionResult 是 TypeScript union type。
|
|
@@ -114,6 +115,13 @@ class ToolExecutionContext:
|
|
|
114
115
|
"""
|
|
115
116
|
capabilities: Any | None = None
|
|
116
117
|
"""可选;RuntimeCapabilities 实例,供子 Agent 继承全局 model_profile_registry。"""
|
|
118
|
+
network_access_allowed: bool = True
|
|
119
|
+
"""为 False 时 WebSearch 等外网工具应在 check_permissions 中 deny。"""
|
|
120
|
+
tool_progress_callback: Any | None = None
|
|
121
|
+
"""
|
|
122
|
+
可选;``RunnableConfig.configurable["tool_progress_callback"]`` 或 AgentLoopConfig 注入。
|
|
123
|
+
签名为 ``(event: ProgressEvent) -> None``(具体类型由工具包定义,避免 runtime 循环依赖)。
|
|
124
|
+
"""
|
|
117
125
|
|
|
118
126
|
|
|
119
127
|
# ---------------------------------------------------------------------------
|
|
@@ -6,14 +6,24 @@ runtime/session_store.py — 会话级状态存储
|
|
|
6
6
|
它独立于 LangGraph graph state(turn 级)和 ToolExecutionContext(call 级),
|
|
7
7
|
负责跨 turn 持久化的会话状态:
|
|
8
8
|
|
|
9
|
-
file_read_state — 文件读取记录(为 read-before-write
|
|
9
|
+
file_read_state — 文件读取记录(为 read-before-write / Edit staleness 服务)
|
|
10
10
|
file_history — 文件写入/编辑历史
|
|
11
|
-
audit_log —
|
|
11
|
+
audit_log — 工具调用审计日志(含权限窗口事件与工具框架审计)
|
|
12
12
|
permissions_cache — 权限决策缓存(避免重复询问)
|
|
13
|
+
cwd — Bash cd 等工作目录(与 ToolStateBridge 对齐的会话后端字段)
|
|
14
|
+
last_glob_summary — 最近一次 Glob 摘要(hint,非授权依据)
|
|
15
|
+
|
|
16
|
+
链路位置:
|
|
17
|
+
GraphFactory 注入 configurable["session_store"] → LangChainAdapter.build_tool_execution_context
|
|
18
|
+
→ ToolExecutionContext.session_store;ToolStateBridge **仅**读写本对象。
|
|
19
|
+
|
|
20
|
+
当前裁剪范围:
|
|
21
|
+
不做磁盘持久化;不实现设计文档中的 content LRU(仅保留字段,由 Read 侧截断占位)。
|
|
13
22
|
"""
|
|
14
23
|
|
|
15
24
|
from __future__ import annotations
|
|
16
25
|
|
|
26
|
+
import copy
|
|
17
27
|
import time
|
|
18
28
|
import uuid
|
|
19
29
|
from dataclasses import dataclass
|
|
@@ -27,6 +37,50 @@ class FileReadRecord:
|
|
|
27
37
|
ts: float
|
|
28
38
|
line_start: int | None = None
|
|
29
39
|
line_end: int | None = None
|
|
40
|
+
offset: int | None = None
|
|
41
|
+
limit: int | None = None
|
|
42
|
+
mtime_ms: int = 0
|
|
43
|
+
content: str | None = None
|
|
44
|
+
|
|
45
|
+
def to_bridge_dict(self) -> dict[str, Any]:
|
|
46
|
+
"""转为 ToolStateBridge / StalenessChecker 消费的 dict 形态。"""
|
|
47
|
+
out: dict[str, Any] = {
|
|
48
|
+
"tool_call_id": self.tool_call_id,
|
|
49
|
+
"ts": self.ts,
|
|
50
|
+
"offset": self.offset,
|
|
51
|
+
"limit": self.limit,
|
|
52
|
+
"mtime_ms": self.mtime_ms,
|
|
53
|
+
"line_start": self.line_start,
|
|
54
|
+
"line_end": self.line_end,
|
|
55
|
+
}
|
|
56
|
+
if self.content is not None:
|
|
57
|
+
out["content"] = self.content
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_bridge_dict(cls, path: str, d: dict[str, Any]) -> FileReadRecord:
|
|
62
|
+
ts_raw = d.get("ts")
|
|
63
|
+
ts = float(ts_raw) if isinstance(ts_raw, (int, float)) else time.time()
|
|
64
|
+
mtime_raw = d.get("mtime_ms", 0)
|
|
65
|
+
try:
|
|
66
|
+
mtime_ms = int(mtime_raw) if mtime_raw is not None else 0
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
mtime_ms = 0
|
|
69
|
+
raw_tid = d.get("tool_call_id")
|
|
70
|
+
tool_call_id = raw_tid if isinstance(raw_tid, str) or raw_tid is None else None
|
|
71
|
+
raw_content = d.get("content")
|
|
72
|
+
content = raw_content if isinstance(raw_content, str) or raw_content is None else None
|
|
73
|
+
return cls(
|
|
74
|
+
path=path,
|
|
75
|
+
tool_call_id=tool_call_id,
|
|
76
|
+
ts=ts,
|
|
77
|
+
line_start=d.get("line_start"),
|
|
78
|
+
line_end=d.get("line_end"),
|
|
79
|
+
offset=d.get("offset"),
|
|
80
|
+
limit=d.get("limit"),
|
|
81
|
+
mtime_ms=mtime_ms,
|
|
82
|
+
content=content,
|
|
83
|
+
)
|
|
30
84
|
|
|
31
85
|
|
|
32
86
|
@dataclass
|
|
@@ -47,6 +101,8 @@ class AgentSessionStore:
|
|
|
47
101
|
self._file_history: list[FileWriteRecord] = []
|
|
48
102
|
self._audit_log: list[dict[str, Any]] = []
|
|
49
103
|
self._permissions_cache: dict[str, Any] = {}
|
|
104
|
+
self._cwd: str | None = None
|
|
105
|
+
self._last_glob_summary: dict[str, Any] | None = None
|
|
50
106
|
|
|
51
107
|
def record_file_read(
|
|
52
108
|
self,
|
|
@@ -56,6 +112,10 @@ class AgentSessionStore:
|
|
|
56
112
|
ts: float | None = None,
|
|
57
113
|
line_start: int | None = None,
|
|
58
114
|
line_end: int | None = None,
|
|
115
|
+
offset: int | None = None,
|
|
116
|
+
limit: int | None = None,
|
|
117
|
+
mtime_ms: int = 0,
|
|
118
|
+
content: str | None = None,
|
|
59
119
|
) -> None:
|
|
60
120
|
self._file_read_state[path] = FileReadRecord(
|
|
61
121
|
path=path,
|
|
@@ -63,8 +123,31 @@ class AgentSessionStore:
|
|
|
63
123
|
ts=ts if ts is not None else time.time(),
|
|
64
124
|
line_start=line_start,
|
|
65
125
|
line_end=line_end,
|
|
126
|
+
offset=offset,
|
|
127
|
+
limit=limit,
|
|
128
|
+
mtime_ms=mtime_ms,
|
|
129
|
+
content=content,
|
|
66
130
|
)
|
|
67
131
|
|
|
132
|
+
def upsert_last_read_from_bridge_entry(
|
|
133
|
+
self,
|
|
134
|
+
path: str,
|
|
135
|
+
entry: dict[str, Any],
|
|
136
|
+
) -> None:
|
|
137
|
+
"""用与 LangGraph state 中 last_read 条目同构的 dict 覆盖该路径。"""
|
|
138
|
+
self._file_read_state[path] = FileReadRecord.from_bridge_dict(path, entry)
|
|
139
|
+
|
|
140
|
+
def replace_all_file_reads_from_bridge_map(self, data: dict[str, Any]) -> None:
|
|
141
|
+
"""整体替换文件读映射(对应 ToolStateBridge.set_last_read_map)。"""
|
|
142
|
+
self._file_read_state.clear()
|
|
143
|
+
for path, raw in data.items():
|
|
144
|
+
if isinstance(raw, dict):
|
|
145
|
+
self._file_read_state[path] = FileReadRecord.from_bridge_dict(str(path), raw)
|
|
146
|
+
|
|
147
|
+
def get_last_read_map_bridge_view(self) -> dict[str, dict[str, Any]]:
|
|
148
|
+
"""返回 path → bridge dict,供 ToolStateBridge 与 StalenessChecker 使用。"""
|
|
149
|
+
return {p: rec.to_bridge_dict() for p, rec in self._file_read_state.items()}
|
|
150
|
+
|
|
68
151
|
def has_been_read(self, path: str) -> bool:
|
|
69
152
|
return path in self._file_read_state
|
|
70
153
|
|
|
@@ -110,6 +193,56 @@ class AgentSessionStore:
|
|
|
110
193
|
def get_cached_permission(self, key: str) -> Any | None:
|
|
111
194
|
return self._permissions_cache.get(key)
|
|
112
195
|
|
|
196
|
+
# ---- cwd(与 ToolStateBridge 会话后端对齐)----
|
|
197
|
+
|
|
198
|
+
def get_cwd(self, *, initial: str | None = None) -> str:
|
|
199
|
+
"""已 set 的会话 cwd → initial → os.getcwd()。"""
|
|
200
|
+
if isinstance(self._cwd, str) and self._cwd:
|
|
201
|
+
return self._cwd
|
|
202
|
+
if isinstance(initial, str) and initial:
|
|
203
|
+
return initial
|
|
204
|
+
import os
|
|
205
|
+
|
|
206
|
+
return os.getcwd()
|
|
207
|
+
|
|
208
|
+
def set_cwd(self, cwd: str) -> None:
|
|
209
|
+
self._cwd = cwd
|
|
210
|
+
|
|
211
|
+
def clear_cwd(self) -> None:
|
|
212
|
+
self._cwd = None
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def cwd_override(self) -> str | None:
|
|
216
|
+
"""Bash cd 等显式设置过的 cwd;未设置时为 None。"""
|
|
217
|
+
if isinstance(self._cwd, str) and self._cwd:
|
|
218
|
+
return self._cwd
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
# ---- last_glob_summary ----
|
|
222
|
+
|
|
223
|
+
def set_last_glob_summary(self, payload: dict[str, Any]) -> None:
|
|
224
|
+
self._last_glob_summary = dict(payload)
|
|
225
|
+
|
|
226
|
+
def get_last_glob_summary(self) -> dict[str, Any] | None:
|
|
227
|
+
if self._last_glob_summary is None:
|
|
228
|
+
return None
|
|
229
|
+
return dict(self._last_glob_summary)
|
|
230
|
+
|
|
231
|
+
def clear_last_glob_summary(self) -> None:
|
|
232
|
+
self._last_glob_summary = None
|
|
233
|
+
|
|
234
|
+
# ---- fork:仅克隆文件读状态(权限 / cwd / glob 不共享)----
|
|
235
|
+
|
|
236
|
+
def clone(self, *, file_state_only: bool = True) -> AgentSessionStore:
|
|
237
|
+
new = AgentSessionStore(session_id=str(uuid.uuid4()))
|
|
238
|
+
new._file_read_state = {
|
|
239
|
+
k: copy.deepcopy(v) for k, v in self._file_read_state.items()
|
|
240
|
+
}
|
|
241
|
+
if not file_state_only:
|
|
242
|
+
new._audit_log = copy.deepcopy(self._audit_log)
|
|
243
|
+
new._file_history = copy.deepcopy(self._file_history)
|
|
244
|
+
return new
|
|
245
|
+
|
|
113
246
|
@property
|
|
114
247
|
def created_at(self) -> float:
|
|
115
248
|
return self._created_at
|
|
@@ -122,6 +255,8 @@ class AgentSessionStore:
|
|
|
122
255
|
"file_writes": len(self._file_history),
|
|
123
256
|
"audit_entries": len(self._audit_log),
|
|
124
257
|
"cached_permissions": len(self._permissions_cache),
|
|
258
|
+
"has_cwd_override": bool(self._cwd),
|
|
259
|
+
"has_glob_summary": self._last_glob_summary is not None,
|
|
125
260
|
}
|
|
126
261
|
|
|
127
262
|
def __repr__(self) -> str:
|
|
@@ -52,7 +52,17 @@ def _load_module(sub: str) -> None:
|
|
|
52
52
|
spec.loader.exec_module(mod)
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
for _sub in [
|
|
55
|
+
for _sub in [
|
|
56
|
+
"models",
|
|
57
|
+
"errors",
|
|
58
|
+
"base",
|
|
59
|
+
"policy",
|
|
60
|
+
"session_store",
|
|
61
|
+
"state_bridge",
|
|
62
|
+
"pipeline",
|
|
63
|
+
"adapter",
|
|
64
|
+
"registry",
|
|
65
|
+
]:
|
|
56
66
|
_load_module(_sub)
|
|
57
67
|
|
|
58
68
|
# ---------------------------------------------------------------------------
|
|
@@ -65,6 +75,7 @@ from langchain_agentx.tool_runtime.pipeline import ToolExecutorPipeline
|
|
|
65
75
|
from langchain_agentx.tool_runtime.adapter import LangChainAdapter # noqa: E402
|
|
66
76
|
from langchain_agentx.tool_runtime.registry import RuntimeToolRegistry # noqa: E402
|
|
67
77
|
from langchain_agentx.tool_runtime.policy import ToolPolicyConfig, DefaultPolicyEngine # noqa: E402
|
|
78
|
+
from langchain_agentx.tool_runtime.session_store import AgentSessionStore # noqa: E402
|
|
68
79
|
from langchain_agentx.tool_runtime.state_bridge import ToolStateBridge # noqa: E402
|
|
69
80
|
from pydantic import BaseModel # noqa: E402
|
|
70
81
|
|
|
@@ -104,6 +115,7 @@ def _make_ctx(state=None) -> ToolExecutionContext:
|
|
|
104
115
|
tool_call_id="call_001",
|
|
105
116
|
input_args={"message": "hello"},
|
|
106
117
|
state=state if state is not None else {},
|
|
118
|
+
session_store=AgentSessionStore("smoke"),
|
|
107
119
|
)
|
|
108
120
|
|
|
109
121
|
|
|
@@ -217,7 +229,11 @@ def test_state_bridge() -> None:
|
|
|
217
229
|
bridge = ToolStateBridge()
|
|
218
230
|
state = {}
|
|
219
231
|
ctx = ToolExecutionContext(
|
|
220
|
-
tool_name="echo",
|
|
232
|
+
tool_name="echo",
|
|
233
|
+
tool_call_id="c1",
|
|
234
|
+
input_args={},
|
|
235
|
+
state=state,
|
|
236
|
+
session_store=AgentSessionStore("smoke-bridge"),
|
|
221
237
|
)
|
|
222
238
|
|
|
223
239
|
_td = pathlib.Path(tempfile.gettempdir())
|
|
@@ -2,23 +2,16 @@
|
|
|
2
2
|
runtime/state_bridge.py — 工具级最小共享状态管理
|
|
3
3
|
|
|
4
4
|
职责:
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
CC 中通过 ToolUseContext.readFileState 维护文件读取状态,
|
|
16
|
-
通过 getAppState / setAppState 读写应用级状态。
|
|
17
|
-
本框架将这些能力收敛到 ToolStateBridge,通过 LangGraph state
|
|
18
|
-
作为底层存储,保持与 agent loop 的状态一致性。
|
|
19
|
-
v1:last_read_map(含 mtime_ms、可选 content,供 Edit staleness 方案 B)、tool_audit;
|
|
20
|
-
v2-D:last_glob_summary(可选写入,见 GlobRuntimeTool.after_invoke)。
|
|
21
|
-
不承担业务 workflow state。
|
|
5
|
+
管理工具执行过程中的跨工具共享状态(last_read_map、tool_audit、last_glob_summary、cwd),
|
|
6
|
+
为 write/edit 的 read-before-write、Edit staleness、Glob hint、Bash cd 等提供统一入口。
|
|
7
|
+
|
|
8
|
+
链路位置:
|
|
9
|
+
ToolRuntimeLoader.register_default_tools 注入共享 ToolStateBridge;Read/Edit/Bash/Glob
|
|
10
|
+
after_invoke 或校验阶段调用本类。存储后端**仅**为 ``ToolExecutionContext.session_store``
|
|
11
|
+
(``AgentSessionStore``),由 GraphFactory 经 ``configurable`` 注入。
|
|
12
|
+
|
|
13
|
+
当前裁剪范围:
|
|
14
|
+
不再读写 LangGraph ``ctx.state`` 中 ``_tool_*`` 键(迁移完成);不在此模块内做 LRU。
|
|
22
15
|
"""
|
|
23
16
|
|
|
24
17
|
from __future__ import annotations
|
|
@@ -26,27 +19,17 @@ from __future__ import annotations
|
|
|
26
19
|
from typing import Any
|
|
27
20
|
|
|
28
21
|
from .models import ToolExecutionContext
|
|
22
|
+
from .session_store import AgentSessionStore
|
|
29
23
|
|
|
30
24
|
|
|
31
25
|
class ToolStateBridge:
|
|
32
26
|
"""
|
|
33
27
|
工具级最小共享状态管理器。
|
|
34
28
|
|
|
35
|
-
|
|
36
|
-
使用固定 key 隔离工具框架状态与业务状态。
|
|
37
|
-
|
|
38
|
-
维护:
|
|
39
|
-
last_read_map — 文件最近读取记录(为 read-before-write 规则服务)
|
|
40
|
-
tool_audit — 工具调用审计日志
|
|
41
|
-
last_glob_summary — 最近一次 Glob 摘要(仅路径样本,防 state 膨胀)
|
|
42
|
-
cwd — 当前工作目录(跨工具共享,bash cd 后 read/grep 自动感知)
|
|
43
|
-
|
|
44
|
-
不承担:
|
|
45
|
-
业务 workflow state
|
|
46
|
-
UI 临时态
|
|
47
|
-
task state
|
|
29
|
+
要求 ``ctx.session_store`` 为 ``AgentSessionStore``;否则各方法抛出 ``ValueError``。
|
|
48
30
|
"""
|
|
49
31
|
|
|
32
|
+
# 历史 LangGraph ctx.state 键名;bridge 已不读写 state,常量保留供设计文档/外部对照与 rg。
|
|
50
33
|
LAST_READ_MAP_KEY = "_tool_last_read_map"
|
|
51
34
|
AUDIT_LOG_KEY = "_tool_audit_log"
|
|
52
35
|
LAST_GLOB_SUMMARY_KEY = "_tool_last_glob_summary"
|
|
@@ -61,32 +44,30 @@ class ToolStateBridge:
|
|
|
61
44
|
"""
|
|
62
45
|
self._initial_cwd = initial_cwd
|
|
63
46
|
|
|
47
|
+
def _require_store(self, ctx: ToolExecutionContext) -> AgentSessionStore:
|
|
48
|
+
raw = ctx.session_store
|
|
49
|
+
if not isinstance(raw, AgentSessionStore):
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"ToolStateBridge requires ToolExecutionContext.session_store to be an "
|
|
52
|
+
"AgentSessionStore instance (LangGraph state _tool_* keys are no longer used)."
|
|
53
|
+
)
|
|
54
|
+
return raw
|
|
55
|
+
|
|
64
56
|
# ---- last_read_map ----
|
|
65
57
|
|
|
66
58
|
def get_last_read_map(self, ctx: ToolExecutionContext) -> dict[str, Any]:
|
|
67
|
-
|
|
68
|
-
获取 last_read_map。
|
|
69
|
-
|
|
70
|
-
结构(每条 file_path):至少含 ``tool_call_id``、``ts``;Read 写入时还含
|
|
71
|
-
``offset``、``limit``、``mtime_ms``;可选 ``content``(文本快照或占位符,
|
|
72
|
-
供 Edit staleness)、``line_start`` / ``line_end``(Read 元数据)。
|
|
73
|
-
|
|
74
|
-
不存在时返回空 dict(不修改 state)。
|
|
75
|
-
"""
|
|
76
|
-
return ctx.state.get(self.LAST_READ_MAP_KEY) or {}
|
|
59
|
+
return self._require_store(ctx).get_last_read_map_bridge_view()
|
|
77
60
|
|
|
78
61
|
def set_last_read_map(
|
|
79
62
|
self,
|
|
80
63
|
ctx: ToolExecutionContext,
|
|
81
64
|
value: dict[str, Any],
|
|
82
65
|
) -> None:
|
|
83
|
-
|
|
84
|
-
ctx.state[self.LAST_READ_MAP_KEY] = value
|
|
66
|
+
self._require_store(ctx).replace_all_file_reads_from_bridge_map(value)
|
|
85
67
|
|
|
86
68
|
def get_last_read(
|
|
87
69
|
self, ctx: ToolExecutionContext, file_path: str
|
|
88
70
|
) -> dict[str, Any] | None:
|
|
89
|
-
"""返回 ``file_path`` 对应的最近一次读取记录;无则 ``None``。"""
|
|
90
71
|
raw = self.get_last_read_map(ctx).get(file_path)
|
|
91
72
|
return raw if isinstance(raw, dict) else None
|
|
92
73
|
|
|
@@ -102,13 +83,6 @@ class ToolStateBridge:
|
|
|
102
83
|
line_start: int | None = None,
|
|
103
84
|
line_end: int | None = None,
|
|
104
85
|
) -> None:
|
|
105
|
-
"""
|
|
106
|
-
写入/覆盖某路径的最近一次读取记录(Read ``after_invoke`` 主入口)。
|
|
107
|
-
|
|
108
|
-
``content``:可选;UTF-8 文本快照或 ``<image:...>`` 等占位符,供 Edit
|
|
109
|
-
staleness 内容对比;过大文本由调用方截断为占位符。
|
|
110
|
-
"""
|
|
111
|
-
last_read_map = self.get_last_read_map(ctx)
|
|
112
86
|
entry: dict[str, Any] = {
|
|
113
87
|
"tool_call_id": ctx.tool_call_id,
|
|
114
88
|
"ts": ctx.now_ts,
|
|
@@ -120,8 +94,7 @@ class ToolStateBridge:
|
|
|
120
94
|
}
|
|
121
95
|
if content is not None:
|
|
122
96
|
entry["content"] = content
|
|
123
|
-
|
|
124
|
-
self.set_last_read_map(ctx, last_read_map)
|
|
97
|
+
self._require_store(ctx).upsert_last_read_from_bridge_entry(file_path, entry)
|
|
125
98
|
|
|
126
99
|
def record_read(
|
|
127
100
|
self,
|
|
@@ -131,10 +104,6 @@ class ToolStateBridge:
|
|
|
131
104
|
line_start: int | None = None,
|
|
132
105
|
line_end: int | None = None,
|
|
133
106
|
) -> None:
|
|
134
|
-
"""
|
|
135
|
-
记录一次文件读取(兼容旧 API / 单测),等价于 ``offset=0``、``mtime_ms=0``
|
|
136
|
-
的 ``set_last_read``。
|
|
137
|
-
"""
|
|
138
107
|
self.set_last_read(
|
|
139
108
|
ctx,
|
|
140
109
|
file_path,
|
|
@@ -146,9 +115,6 @@ class ToolStateBridge:
|
|
|
146
115
|
)
|
|
147
116
|
|
|
148
117
|
def has_been_read(self, ctx: ToolExecutionContext, file_path: str) -> bool:
|
|
149
|
-
"""
|
|
150
|
-
检查文件是否已被读取过(用于 read-before-write 约束)。
|
|
151
|
-
"""
|
|
152
118
|
return file_path in self.get_last_read_map(ctx)
|
|
153
119
|
|
|
154
120
|
# ---- tool_audit ----
|
|
@@ -158,24 +124,12 @@ class ToolStateBridge:
|
|
|
158
124
|
ctx: ToolExecutionContext,
|
|
159
125
|
record: dict[str, Any],
|
|
160
126
|
) -> None:
|
|
161
|
-
|
|
162
|
-
追加一条审计记录到 tool_audit 列表。
|
|
163
|
-
|
|
164
|
-
record 建议包含:
|
|
165
|
-
tool — 工具名
|
|
166
|
-
tool_call_id — 调用 ID
|
|
167
|
-
ts — 时间戳
|
|
168
|
-
以及工具特定的关键字段(如 file_path、lines_read 等)
|
|
169
|
-
"""
|
|
170
|
-
audit_log: list = ctx.state.get(self.AUDIT_LOG_KEY) or []
|
|
171
|
-
audit_log.append(record)
|
|
172
|
-
ctx.state[self.AUDIT_LOG_KEY] = audit_log
|
|
127
|
+
self._require_store(ctx).append_audit(record)
|
|
173
128
|
|
|
174
129
|
def get_audit_log(self, ctx: ToolExecutionContext) -> list[dict[str, Any]]:
|
|
175
|
-
|
|
176
|
-
return ctx.state.get(self.AUDIT_LOG_KEY) or []
|
|
130
|
+
return self._require_store(ctx).get_audit_log()
|
|
177
131
|
|
|
178
|
-
# ---- last_glob_summary
|
|
132
|
+
# ---- last_glob_summary ----
|
|
179
133
|
|
|
180
134
|
def record_last_glob(
|
|
181
135
|
self,
|
|
@@ -189,13 +143,8 @@ class ToolStateBridge:
|
|
|
189
143
|
truncated: bool,
|
|
190
144
|
offset: int,
|
|
191
145
|
) -> None:
|
|
192
|
-
"""
|
|
193
|
-
覆盖写入最近一次 Glob 摘要(**不**用于 PolicyEngine,仅供 hint)。
|
|
194
|
-
|
|
195
|
-
``filenames`` 为当前页相对路径列表;仅持久化前 ``MAX_GLOB_SAMPLE_PATHS`` 条。
|
|
196
|
-
"""
|
|
197
146
|
sample = list(filenames[: self.MAX_GLOB_SAMPLE_PATHS])
|
|
198
|
-
|
|
147
|
+
payload = {
|
|
199
148
|
"pattern": pattern,
|
|
200
149
|
"search_root": search_root,
|
|
201
150
|
"sample_paths": sample,
|
|
@@ -206,35 +155,18 @@ class ToolStateBridge:
|
|
|
206
155
|
"tool_call_id": ctx.tool_call_id,
|
|
207
156
|
"ts": ctx.now_ts,
|
|
208
157
|
}
|
|
158
|
+
self._require_store(ctx).set_last_glob_summary(payload)
|
|
209
159
|
|
|
210
160
|
def get_last_glob_summary(self, ctx: ToolExecutionContext) -> dict[str, Any] | None:
|
|
211
|
-
|
|
212
|
-
raw = ctx.state.get(self.LAST_GLOB_SUMMARY_KEY)
|
|
213
|
-
return raw if isinstance(raw, dict) else None
|
|
161
|
+
return self._require_store(ctx).get_last_glob_summary()
|
|
214
162
|
|
|
215
163
|
def clear_last_glob_summary(self, ctx: ToolExecutionContext) -> None:
|
|
216
|
-
|
|
217
|
-
ctx.state.pop(self.LAST_GLOB_SUMMARY_KEY, None)
|
|
164
|
+
self._require_store(ctx).clear_last_glob_summary()
|
|
218
165
|
|
|
219
|
-
# ---- cwd
|
|
166
|
+
# ---- cwd ----
|
|
220
167
|
|
|
221
168
|
def get_cwd(self, ctx: ToolExecutionContext) -> str:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
优先级:
|
|
225
|
-
1. state 中已存储的 cwd(之前 cd 命令设置的)
|
|
226
|
-
2. initial_cwd(构造时传入)
|
|
227
|
-
3. os.getcwd()(进程当前目录)
|
|
228
|
-
"""
|
|
229
|
-
stored = ctx.state.get(self.CWD_KEY)
|
|
230
|
-
if isinstance(stored, str) and stored:
|
|
231
|
-
return stored
|
|
232
|
-
if self._initial_cwd:
|
|
233
|
-
return self._initial_cwd
|
|
234
|
-
import os
|
|
235
|
-
|
|
236
|
-
return os.getcwd()
|
|
169
|
+
return self._require_store(ctx).get_cwd(initial=self._initial_cwd)
|
|
237
170
|
|
|
238
171
|
def set_cwd(self, ctx: ToolExecutionContext, cwd: str) -> None:
|
|
239
|
-
|
|
240
|
-
ctx.state[self.CWD_KEY] = cwd
|
|
172
|
+
self._require_store(ctx).set_cwd(cwd)
|
|
@@ -266,7 +266,7 @@ class BashRuntimeTool(RuntimeTool):
|
|
|
266
266
|
trace_context=TraceContext.from_ctx(ctx),
|
|
267
267
|
)
|
|
268
268
|
|
|
269
|
-
current_cwd = self._get_current_cwd()
|
|
269
|
+
current_cwd = self._get_current_cwd(ctx)
|
|
270
270
|
analysis = self._ast_analyzer.analyze(command)
|
|
271
271
|
hardening_decision = self._security_hardener.check_command(
|
|
272
272
|
command=command,
|
|
@@ -855,12 +855,18 @@ class BashRuntimeTool(RuntimeTool):
|
|
|
855
855
|
ask_prompt=prompt,
|
|
856
856
|
)
|
|
857
857
|
|
|
858
|
-
def _get_current_cwd(self) -> str:
|
|
858
|
+
def _get_current_cwd(self, ctx: ToolExecutionContext | None = None) -> str:
|
|
859
859
|
"""获取当前工作目录(用于 check_permissions 阶段)。
|
|
860
860
|
|
|
861
|
-
|
|
862
|
-
|
|
861
|
+
与 ``invoke()`` 一致:优先 ``state_bridge.get_cwd(ctx)``;否则
|
|
862
|
+
``_initial_cwd``(构造时从 bridge 属性读取);最后 ``os.getcwd()``。
|
|
863
863
|
"""
|
|
864
|
+
if self._state_bridge is not None and ctx is not None:
|
|
865
|
+
getter = getattr(self._state_bridge, "get_cwd", None)
|
|
866
|
+
if callable(getter):
|
|
867
|
+
cwd = getter(ctx)
|
|
868
|
+
if cwd:
|
|
869
|
+
return str(cwd)
|
|
864
870
|
if self._initial_cwd:
|
|
865
871
|
return self._initial_cwd
|
|
866
872
|
return os.getcwd()
|