agent-cli-Mrzhou300 0.2.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.
- agent_cli/__init__.py +2 -0
- agent_cli/compact/__init__.py +1 -0
- agent_cli/compact/pipeline.py +347 -0
- agent_cli/config.py +178 -0
- agent_cli/core/__init__.py +0 -0
- agent_cli/core/executor.py +65 -0
- agent_cli/core/loop.py +190 -0
- agent_cli/core/provider.py +746 -0
- agent_cli/hooks/__init__.py +0 -0
- agent_cli/hooks/manager.py +97 -0
- agent_cli/main.py +1284 -0
- agent_cli/mcp/__init__.py +6 -0
- agent_cli/mcp/bridge.py +370 -0
- agent_cli/mcp/models.py +60 -0
- agent_cli/memory/__init__.py +1 -0
- agent_cli/memory/file_memory.py +357 -0
- agent_cli/memory/manager.py +103 -0
- agent_cli/memory/project_memory.py +130 -0
- agent_cli/memory/session_memory.py +118 -0
- agent_cli/monitor/__init__.py +6 -0
- agent_cli/monitor/alerts.py +201 -0
- agent_cli/monitor/metrics.py +197 -0
- agent_cli/permissions/__init__.py +0 -0
- agent_cli/permissions/engine.py +163 -0
- agent_cli/permissions/hook.py +155 -0
- agent_cli/planning/__init__.py +6 -0
- agent_cli/planning/models.py +141 -0
- agent_cli/planning/planner.py +287 -0
- agent_cli/py.typed +0 -0
- agent_cli/session/__init__.py +0 -0
- agent_cli/session/store.py +206 -0
- agent_cli/skills/__init__.py +6 -0
- agent_cli/skills/loader.py +255 -0
- agent_cli/skills/models.py +44 -0
- agent_cli/subagent/__init__.py +5 -0
- agent_cli/subagent/manager.py +260 -0
- agent_cli/swarm/__init__.py +5 -0
- agent_cli/swarm/coordinator.py +443 -0
- agent_cli/tools/__init__.py +0 -0
- agent_cli/tools/agent_tool.py +65 -0
- agent_cli/tools/base.py +100 -0
- agent_cli/tools/bash.py +138 -0
- agent_cli/tools/file.py +330 -0
- agent_cli/tools/registry.py +84 -0
- agent_cli/tools/web.py +83 -0
- agent_cli/ui/__init__.py +0 -0
- agent_cli/ui/renderer.py +58 -0
- agent_cli/ui/repl.py +332 -0
- agent_cli_mrzhou300-0.2.1.dist-info/METADATA +379 -0
- agent_cli_mrzhou300-0.2.1.dist-info/RECORD +53 -0
- agent_cli_mrzhou300-0.2.1.dist-info/WHEEL +4 -0
- agent_cli_mrzhou300-0.2.1.dist-info/entry_points.txt +2 -0
- agent_cli_mrzhou300-0.2.1.dist-info/licenses/LICENSE +21 -0
agent_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Context Compression — Phase 2 实现
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""CompactPipeline — 四层上下文压缩管道。
|
|
2
|
+
|
|
3
|
+
设计哲学(来源:learn-claude-code):
|
|
4
|
+
这是 learn-claude-code 项目最有特色的设计。
|
|
5
|
+
L1-L3 零 API 调用,纯算法压缩。
|
|
6
|
+
L4 使用 LLM 重写关键上下文。
|
|
7
|
+
70% 阈值触发 L1-L3,90% 阈值触发 L4。
|
|
8
|
+
|
|
9
|
+
L1 (丢弃层):
|
|
10
|
+
- 丢弃已完成的 tool_use 调用 details(保留 tool_result)
|
|
11
|
+
- 丢弃 tool_result 中过大的内容(> 2000 字符截断)
|
|
12
|
+
- 移除空的 tool_result 块
|
|
13
|
+
|
|
14
|
+
L2 (合并层):
|
|
15
|
+
- 合并连续的 tool_result 消息
|
|
16
|
+
- 合并连续的 text-only assistant 消息
|
|
17
|
+
- 合并连续的 user 消息
|
|
18
|
+
|
|
19
|
+
L3 (摘要层):
|
|
20
|
+
- 对早期对话做结构化摘要
|
|
21
|
+
- 提取关键决策和结论
|
|
22
|
+
- 移除非核心的中间步骤
|
|
23
|
+
|
|
24
|
+
L4 (重写层 — 需要 LLM):
|
|
25
|
+
- 用 LLM 重写关键上下文
|
|
26
|
+
- 保持语义完整性
|
|
27
|
+
- 标记压缩来源位置
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import math
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from agent_cli.core.provider import IModelProvider
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# 估算 token 数的经验常量
|
|
42
|
+
_CHARS_PER_TOKEN = 4
|
|
43
|
+
_TOOL_BLOCK_BASE = 50 # 每个 tool_use block 的固定开销
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _estimate_tokens(data: Any) -> int:
|
|
47
|
+
"""估算任意数据的 token 数。
|
|
48
|
+
|
|
49
|
+
使用简单字符计数法(~4 chars/token),
|
|
50
|
+
对结构化的 tool block 添加额外开销估算。
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(data, str):
|
|
53
|
+
return math.ceil(len(data) / _CHARS_PER_TOKEN)
|
|
54
|
+
if isinstance(data, list):
|
|
55
|
+
return sum(_estimate_tokens(item) for item in data)
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
total = sum(_estimate_tokens(v) for v in data.values())
|
|
58
|
+
# tool block 有结构化开销
|
|
59
|
+
if data.get("type") in ("tool_use", "tool_result"):
|
|
60
|
+
total += _TOOL_BLOCK_BASE
|
|
61
|
+
return total
|
|
62
|
+
return len(str(data)) // _CHARS_PER_TOKEN
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CompactPipeline:
|
|
66
|
+
"""四层上下文压缩管道。
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
pipeline = CompactPipeline(max_tokens=100000)
|
|
70
|
+
if pipeline.should_compact(messages):
|
|
71
|
+
messages = pipeline.compress(messages)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
COMPACT_RATIO = 0.7 # 70% → 触发 L1-L3
|
|
75
|
+
CRITICAL_RATIO = 0.9 # 90% → 触发 L4
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
max_tokens: int = 100000,
|
|
80
|
+
provider: IModelProvider | None = None,
|
|
81
|
+
):
|
|
82
|
+
self._max_tokens = max_tokens
|
|
83
|
+
self._provider = provider
|
|
84
|
+
self.compression_count = 0
|
|
85
|
+
self.last_ratio = 0.0
|
|
86
|
+
|
|
87
|
+
def should_compact(self, messages: list[dict]) -> bool:
|
|
88
|
+
"""检查是否需要压缩。
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
messages: 消息列表。
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
超过 70% 阈值返回 True。
|
|
95
|
+
"""
|
|
96
|
+
if not messages:
|
|
97
|
+
return False
|
|
98
|
+
current = _estimate_tokens(messages)
|
|
99
|
+
ratio = current / self._max_tokens
|
|
100
|
+
self.last_ratio = ratio
|
|
101
|
+
return ratio >= self.COMPACT_RATIO
|
|
102
|
+
|
|
103
|
+
def compress(self, messages: list[dict]) -> list[dict]:
|
|
104
|
+
"""执行渐进压缩。
|
|
105
|
+
|
|
106
|
+
根据当前 Token 比率自动选择压缩层级:
|
|
107
|
+
- < 70%: 不压缩
|
|
108
|
+
- 70-90%: L1 丢弃
|
|
109
|
+
- >= 90%: L1 → L2 → L3
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
messages: 消息列表。
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
压缩后的消息列表。
|
|
116
|
+
"""
|
|
117
|
+
if not messages:
|
|
118
|
+
return messages
|
|
119
|
+
|
|
120
|
+
ratio = _estimate_tokens(messages) / self._max_tokens
|
|
121
|
+
self.last_ratio = ratio
|
|
122
|
+
self.compression_count += 1
|
|
123
|
+
|
|
124
|
+
if ratio < self.COMPACT_RATIO:
|
|
125
|
+
logger.debug("压缩跳过: ratio=%.2f < %.2f", ratio, self.COMPACT_RATIO)
|
|
126
|
+
return messages
|
|
127
|
+
|
|
128
|
+
logger.info("压缩触发 #%d: ratio=%.2f", self.compression_count, ratio)
|
|
129
|
+
|
|
130
|
+
# L1: 丢弃层
|
|
131
|
+
compressed = self._layer1_discard(messages)
|
|
132
|
+
if ratio < self.CRITICAL_RATIO:
|
|
133
|
+
compressed.append(self._compression_marker("L1"))
|
|
134
|
+
logger.info("L1 压缩完成: %d → %d 条", len(messages), len(compressed))
|
|
135
|
+
return compressed
|
|
136
|
+
|
|
137
|
+
# L2: 合并层
|
|
138
|
+
compressed = self._layer2_merge(compressed)
|
|
139
|
+
compressed.append(self._compression_marker("L1+L2"))
|
|
140
|
+
logger.info("L1+L2 压缩完成: %d → %d 条", len(messages), len(compressed))
|
|
141
|
+
|
|
142
|
+
# L3: 摘要层
|
|
143
|
+
compressed = self._layer3_summarize(compressed)
|
|
144
|
+
|
|
145
|
+
# L4: 重写层(仅当有 LLM Provider 时)
|
|
146
|
+
if ratio >= self.CRITICAL_RATIO and self._provider is not None:
|
|
147
|
+
compressed = self._layer4_rewrite(compressed)
|
|
148
|
+
|
|
149
|
+
compressed.append(self._compression_marker("L3+L4" if self._provider else "L3"))
|
|
150
|
+
logger.info("压缩完成: %d → %d 条", len(messages), len(compressed))
|
|
151
|
+
return compressed
|
|
152
|
+
|
|
153
|
+
def _compression_marker(self, level: str) -> dict:
|
|
154
|
+
"""生成压缩标记消息。"""
|
|
155
|
+
return {
|
|
156
|
+
"role": "system",
|
|
157
|
+
"content": f"[compressed: {level}] 上下文已压缩以减少 Token 消耗。"
|
|
158
|
+
f" 压缩 #{self.compression_count}, ratio={self.last_ratio:.1%}",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# ── L1: 丢弃层 ──────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def _layer1_discard(self, messages: list[dict]) -> list[dict]:
|
|
164
|
+
"""丢弃过时细节,保留核心信息。"""
|
|
165
|
+
result: list[dict] = []
|
|
166
|
+
for msg in messages:
|
|
167
|
+
content = msg.get("content", "")
|
|
168
|
+
|
|
169
|
+
# 跳过空的 tool_result
|
|
170
|
+
if isinstance(content, list):
|
|
171
|
+
filtered_blocks = []
|
|
172
|
+
for block in content:
|
|
173
|
+
if block.get("type") == "tool_result":
|
|
174
|
+
block_content = block.get("content", "")
|
|
175
|
+
# 截断过大的 tool_result
|
|
176
|
+
if isinstance(block_content, str) and len(block_content) > 2000:
|
|
177
|
+
block = dict(block)
|
|
178
|
+
block["content"] = block_content[:2000] + "\n... [截断]"
|
|
179
|
+
# 跳过完全空的 tool_result
|
|
180
|
+
if not block_content or (
|
|
181
|
+
isinstance(block_content, dict) and block_content.get("content") == ""
|
|
182
|
+
):
|
|
183
|
+
continue
|
|
184
|
+
filtered_blocks.append(block)
|
|
185
|
+
if filtered_blocks:
|
|
186
|
+
result.append({**msg, "content": filtered_blocks})
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
if isinstance(content, str) and len(content) > 4000:
|
|
190
|
+
result.append({**msg, "content": content[:4000] + "\n... [截断]"})
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
result.append(msg)
|
|
194
|
+
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
# ── L2: 合并层 ──────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def _layer2_merge(self, messages: list[dict]) -> list[dict]:
|
|
200
|
+
"""合并相邻同类消息。"""
|
|
201
|
+
if not messages:
|
|
202
|
+
return messages
|
|
203
|
+
|
|
204
|
+
merged: list[dict] = [messages[0]]
|
|
205
|
+
|
|
206
|
+
for msg in messages[1:]:
|
|
207
|
+
last = merged[-1]
|
|
208
|
+
last_role = last.get("role", "")
|
|
209
|
+
curr_role = msg.get("role", "")
|
|
210
|
+
|
|
211
|
+
# 合并连续 tool_result (user role)
|
|
212
|
+
last_content = last.get("content")
|
|
213
|
+
msg_content = msg.get("content")
|
|
214
|
+
|
|
215
|
+
def _all_tool_results(items: list) -> bool:
|
|
216
|
+
return all(isinstance(b, dict) and b.get("type") == "tool_result" for b in items)
|
|
217
|
+
|
|
218
|
+
if (
|
|
219
|
+
last_role == "user"
|
|
220
|
+
and curr_role == "user"
|
|
221
|
+
and isinstance(last_content, list)
|
|
222
|
+
and isinstance(msg_content, list)
|
|
223
|
+
and _all_tool_results(last_content)
|
|
224
|
+
and _all_tool_results(msg_content)
|
|
225
|
+
):
|
|
226
|
+
merged[-1] = {**last, "content": last["content"] + msg["content"]}
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# 合并连续 assistant text-only 消息
|
|
230
|
+
if last_role == "assistant" and curr_role == "assistant":
|
|
231
|
+
last_text = self._get_text_content(last)
|
|
232
|
+
curr_text = self._get_text_content(msg)
|
|
233
|
+
if last_text and curr_text:
|
|
234
|
+
merged[-1] = {
|
|
235
|
+
"role": "assistant",
|
|
236
|
+
"content": last_text + "\n" + curr_text,
|
|
237
|
+
}
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
merged.append(msg)
|
|
241
|
+
|
|
242
|
+
return merged
|
|
243
|
+
|
|
244
|
+
def _get_text_content(self, msg: dict) -> str:
|
|
245
|
+
"""从消息中提取文本内容。"""
|
|
246
|
+
content = msg.get("content", "")
|
|
247
|
+
if isinstance(content, str):
|
|
248
|
+
return content
|
|
249
|
+
if isinstance(content, list):
|
|
250
|
+
parts = [
|
|
251
|
+
b.get("text", "")
|
|
252
|
+
for b in content
|
|
253
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
254
|
+
]
|
|
255
|
+
return " ".join(parts)
|
|
256
|
+
return ""
|
|
257
|
+
|
|
258
|
+
# ── L3: 摘要层 ──────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def _layer3_summarize(self, messages: list[dict]) -> list[dict]:
|
|
261
|
+
"""对早期对话做结构化摘要。
|
|
262
|
+
|
|
263
|
+
保留最近 6 条消息完整,之前的消息压缩为摘要。
|
|
264
|
+
"""
|
|
265
|
+
if len(messages) <= 6:
|
|
266
|
+
return messages
|
|
267
|
+
|
|
268
|
+
keep_recent = messages[-6:]
|
|
269
|
+
early = messages[:-6]
|
|
270
|
+
|
|
271
|
+
# 从早期消息中提取关键信息
|
|
272
|
+
topics: list[str] = []
|
|
273
|
+
files: set[str] = set()
|
|
274
|
+
|
|
275
|
+
for msg in early:
|
|
276
|
+
role = msg.get("role", "")
|
|
277
|
+
content = msg.get("content", "")
|
|
278
|
+
|
|
279
|
+
if role == "user":
|
|
280
|
+
text = content if isinstance(content, str) else ""
|
|
281
|
+
if text:
|
|
282
|
+
topics.append(text[:100])
|
|
283
|
+
|
|
284
|
+
elif role == "assistant":
|
|
285
|
+
text = content if isinstance(content, str) else ""
|
|
286
|
+
if isinstance(content, list):
|
|
287
|
+
for block in content:
|
|
288
|
+
if isinstance(block, dict):
|
|
289
|
+
text += block.get("text", "") or ""
|
|
290
|
+
# 提取文件路径
|
|
291
|
+
import re
|
|
292
|
+
|
|
293
|
+
found_files = re.findall(r"[\w/.-]+\.\w+", text)
|
|
294
|
+
files.update(f for f in found_files if "/" in f or "\\" in f)
|
|
295
|
+
|
|
296
|
+
summary_text = (
|
|
297
|
+
f"[对话摘要] 共 {len(early)} 条早期消息。\n"
|
|
298
|
+
f"涉及文件: {', '.join(list(files)[:10]) if files else 'N/A'}\n"
|
|
299
|
+
)
|
|
300
|
+
if topics:
|
|
301
|
+
summary_text += f"关键主题: {' | '.join(topics[:5])}\n"
|
|
302
|
+
|
|
303
|
+
return [
|
|
304
|
+
{"role": "system", "content": summary_text},
|
|
305
|
+
*keep_recent,
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
# ── L4: 重写层 ──────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
def _layer4_rewrite(self, messages: list[dict]) -> list[dict]:
|
|
311
|
+
"""用 LLM 重写关键上下文。"""
|
|
312
|
+
if not self._provider:
|
|
313
|
+
logger.warning("L4 需要 LLM Provider,跳过")
|
|
314
|
+
return messages
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
prompt = (
|
|
318
|
+
"请压缩以下对话历史,保留所有关键信息、决策和上下文,"
|
|
319
|
+
"但使用更简洁的表达。请直接输出压缩后的对话内容。\n\n"
|
|
320
|
+
f"对话历史:\n{json.dumps(messages[-20:], ensure_ascii=False)}"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
response = self._provider.invoke(
|
|
324
|
+
messages=[{"role": "user", "content": prompt}],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if response.text:
|
|
328
|
+
return [
|
|
329
|
+
{
|
|
330
|
+
"role": "system",
|
|
331
|
+
"content": f"[compressed: L4 - LLM 重写]\n{response.text[:5000]}",
|
|
332
|
+
},
|
|
333
|
+
]
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error("L4 重写失败: %s", e)
|
|
336
|
+
|
|
337
|
+
return messages
|
|
338
|
+
|
|
339
|
+
def get_stats(self) -> dict:
|
|
340
|
+
"""获取压缩统计信息。"""
|
|
341
|
+
return {
|
|
342
|
+
"compression_count": self.compression_count,
|
|
343
|
+
"last_ratio": round(self.last_ratio, 3),
|
|
344
|
+
"max_tokens": self._max_tokens,
|
|
345
|
+
"compact_ratio": self.COMPACT_RATIO,
|
|
346
|
+
"critical_ratio": self.CRITICAL_RATIO,
|
|
347
|
+
}
|
agent_cli/config.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""配置管理 — 从 config.json 加载默认设置。
|
|
2
|
+
|
|
3
|
+
设计原则:
|
|
4
|
+
- 配置文件位于 .agent/config.json
|
|
5
|
+
- CLI 参数 > 环境变量 > 配置文件 > 默认值
|
|
6
|
+
- 支持 provider、logging、retry 等配置项
|
|
7
|
+
- init 命令自动创建默认配置
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONFIG_PATH = ".agent/config.json"
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
20
|
+
"provider": {
|
|
21
|
+
"default": "auto",
|
|
22
|
+
"model": "deepseek-chat",
|
|
23
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
24
|
+
"max_tokens": 4096,
|
|
25
|
+
},
|
|
26
|
+
"logging": {
|
|
27
|
+
"level": "WARNING",
|
|
28
|
+
"format": "text",
|
|
29
|
+
"file": ".agent/logs/agent-cli.log",
|
|
30
|
+
},
|
|
31
|
+
"retry": {
|
|
32
|
+
"max_retries": 3,
|
|
33
|
+
"base_delay": 1.0,
|
|
34
|
+
"max_delay": 30.0,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_config_path(path: str | None = None) -> str:
|
|
40
|
+
"""获取配置文件路径。"""
|
|
41
|
+
return path or os.environ.get("AGENT_CLI_CONFIG") or DEFAULT_CONFIG_PATH
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config(path: str | None = None) -> dict[str, Any]:
|
|
45
|
+
"""从 JSON 文件加载配置。
|
|
46
|
+
|
|
47
|
+
如果文件不存在或格式错误,返回默认配置。
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
path: 配置文件路径。默认使用 .agent/config.json。
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
合并后的配置字典。
|
|
54
|
+
"""
|
|
55
|
+
config_path = Path(get_config_path(path))
|
|
56
|
+
if not config_path.exists():
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
61
|
+
user_config: dict = json.loads(raw)
|
|
62
|
+
return user_config
|
|
63
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
64
|
+
import logging
|
|
65
|
+
|
|
66
|
+
logging.getLogger(__name__).warning("加载配置文件失败 %s: %s", config_path, e)
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def merge_config(
|
|
71
|
+
cli_args: dict[str, Any],
|
|
72
|
+
env_prefix: str = "",
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""合并 CLI 参数、环境变量、配置文件。
|
|
75
|
+
|
|
76
|
+
优先级(从高到低):
|
|
77
|
+
1. CLI 参数(显式传入的值)
|
|
78
|
+
2. 环境变量
|
|
79
|
+
3. 配置文件(.agent/config.json)
|
|
80
|
+
4. 默认值
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
cli_args: CLI 参数字典(已解析的值)。
|
|
84
|
+
env_prefix: 环境变量前缀(用于查找覆盖)。
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
合并后的配置。
|
|
88
|
+
"""
|
|
89
|
+
config = dict(DEFAULT_CONFIG)
|
|
90
|
+
file_config = load_config()
|
|
91
|
+
|
|
92
|
+
# 深度合并文件配置
|
|
93
|
+
_deep_merge(config, file_config)
|
|
94
|
+
|
|
95
|
+
# 环境变量覆盖
|
|
96
|
+
provider_env = _get_env_provider(env_prefix)
|
|
97
|
+
_deep_merge(config, provider_env)
|
|
98
|
+
|
|
99
|
+
# CLI 参数覆盖(只覆盖非 None/非空的值)
|
|
100
|
+
_deep_merge(config, cli_args)
|
|
101
|
+
|
|
102
|
+
return config
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _deep_merge(base: dict, override: dict) -> None:
|
|
106
|
+
"""深度合并两个字典。"""
|
|
107
|
+
for key, value in override.items():
|
|
108
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
109
|
+
_deep_merge(base[key], value)
|
|
110
|
+
else:
|
|
111
|
+
base[key] = value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _get_env_provider(prefix: str = "") -> dict:
|
|
115
|
+
"""从环境变量读取 provider 配置。"""
|
|
116
|
+
env: dict[str, Any] = {}
|
|
117
|
+
api_key = os.environ.get(f"{prefix}COMPATIBLE_API_KEY") or os.environ.get("COMPATIBLE_API_KEY")
|
|
118
|
+
anthro_key = os.environ.get(f"{prefix}ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")
|
|
119
|
+
|
|
120
|
+
if api_key or anthro_key:
|
|
121
|
+
env["provider"] = {}
|
|
122
|
+
if api_key:
|
|
123
|
+
env["provider"]["api_key"] = api_key
|
|
124
|
+
if anthro_key:
|
|
125
|
+
env["provider"]["anthropic_key"] = anthro_key
|
|
126
|
+
|
|
127
|
+
log_level = os.environ.get(f"{prefix}AGENT_CLI_LOG_LEVEL") or os.environ.get(
|
|
128
|
+
"AGENT_CLI_LOG_LEVEL"
|
|
129
|
+
)
|
|
130
|
+
if log_level:
|
|
131
|
+
env["logging"] = {"level": log_level}
|
|
132
|
+
|
|
133
|
+
return env
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def save_config(config: dict[str, Any], path: str | None = None) -> str:
|
|
137
|
+
"""保存配置到 JSON 文件。
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
config: 要保存的配置字典。
|
|
141
|
+
path: 配置文件路径。默认使用 .agent/config.json。
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
保存的配置文件路径。
|
|
145
|
+
"""
|
|
146
|
+
from copy import deepcopy
|
|
147
|
+
|
|
148
|
+
cfg_path = Path(get_config_path(path))
|
|
149
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
|
|
151
|
+
# 清理空值,避免写入 null
|
|
152
|
+
cleaned = _clean_none(deepcopy(config))
|
|
153
|
+
|
|
154
|
+
cfg_path.write_text(
|
|
155
|
+
json.dumps(cleaned, indent=2, ensure_ascii=False) + "\n",
|
|
156
|
+
encoding="utf-8",
|
|
157
|
+
)
|
|
158
|
+
return str(cfg_path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _clean_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
"""递归删除字典中的 None 值。"""
|
|
163
|
+
result = {}
|
|
164
|
+
for k, v in d.items():
|
|
165
|
+
if v is None:
|
|
166
|
+
continue
|
|
167
|
+
if isinstance(v, dict):
|
|
168
|
+
sub = _clean_none(v)
|
|
169
|
+
if sub:
|
|
170
|
+
result[k] = sub
|
|
171
|
+
else:
|
|
172
|
+
result[k] = v
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def save_default_config(path: str | None = None) -> str:
|
|
177
|
+
"""保存默认配置文件。"""
|
|
178
|
+
return save_config(DEFAULT_CONFIG, path=path)
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Executor — 执行层。
|
|
2
|
+
|
|
3
|
+
在 Agent Loop 的工具执行阶段提供额外服务:
|
|
4
|
+
- 权限检查(委派给 PermissionEngine)
|
|
5
|
+
- 执行结果格式化
|
|
6
|
+
- 错误处理与重试
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agent_cli.permissions.engine import PermissionEngine
|
|
15
|
+
from agent_cli.tools.registry import ToolRegistry
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Executor:
|
|
21
|
+
"""工具执行器。
|
|
22
|
+
|
|
23
|
+
在 Agent Loop 与 ToolRegistry 之间提供一层服务:
|
|
24
|
+
1. 权限检查
|
|
25
|
+
2. 执行调度
|
|
26
|
+
3. 异常处理
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, registry: ToolRegistry, permissions: PermissionEngine | None = None):
|
|
30
|
+
self.registry = registry
|
|
31
|
+
self.permissions = permissions or PermissionEngine()
|
|
32
|
+
|
|
33
|
+
def execute(self, name: str, **kwargs: Any) -> dict:
|
|
34
|
+
"""执行工具(带权限检查和错误处理)。
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
name: 工具名。
|
|
38
|
+
**kwargs: 工具参数。
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
执行结果字典,始终包含 'success' 字段。
|
|
42
|
+
"""
|
|
43
|
+
# 1. 检查工具是否存在
|
|
44
|
+
tool = self.registry.get(name)
|
|
45
|
+
if tool is None:
|
|
46
|
+
return {"success": False, "error": f"工具 '{name}' 不存在"}
|
|
47
|
+
|
|
48
|
+
# 2. 权限检查
|
|
49
|
+
spec = tool.spec()
|
|
50
|
+
decision = self.permissions.check(name, spec.safety.value)
|
|
51
|
+
if decision == "deny":
|
|
52
|
+
return {"success": False, "error": f"权限拒绝: 操作 '{name}' 被禁止"}
|
|
53
|
+
# ask 决策留给上层(CLI/UI)处理
|
|
54
|
+
|
|
55
|
+
# 3. 执行
|
|
56
|
+
try:
|
|
57
|
+
result = self.registry.execute(name, **kwargs)
|
|
58
|
+
if isinstance(result, dict):
|
|
59
|
+
result.setdefault("success", True)
|
|
60
|
+
else:
|
|
61
|
+
result = {"success": True, "result": result}
|
|
62
|
+
return result
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error("工具执行异常: %s — %s", name, e)
|
|
65
|
+
return {"success": False, "error": str(e)}
|