vox-code 2.0.0__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.
- vox_code-2.0.0.dist-info/METADATA +258 -0
- vox_code-2.0.0.dist-info/RECORD +88 -0
- vox_code-2.0.0.dist-info/WHEEL +4 -0
- vox_code-2.0.0.dist-info/entry_points.txt +3 -0
- voxcli/__init__.py +3 -0
- voxcli/__main__.py +5 -0
- voxcli/agent/__init__.py +12 -0
- voxcli/agent/agent.py +449 -0
- voxcli/agent/agent_budget.py +133 -0
- voxcli/agent/agent_orchestrator.py +414 -0
- voxcli/agent/plan_execute_agent.py +514 -0
- voxcli/agent/roles.py +80 -0
- voxcli/agent/sub_agent.py +351 -0
- voxcli/catalog.py +477 -0
- voxcli/chat.py +91 -0
- voxcli/cli/__init__.py +4 -0
- voxcli/cli/main.py +452 -0
- voxcli/cli/parser.py +71 -0
- voxcli/config.py +518 -0
- voxcli/gui/__main__.py +3 -0
- voxcli/gui/main.py +22 -0
- voxcli/gui/pet/__init__.py +5 -0
- voxcli/gui/pet/base.py +62 -0
- voxcli/gui/pet/coordinator.py +888 -0
- voxcli/gui/pet/data.py +430 -0
- voxcli/gui/pet/widgets.py +683 -0
- voxcli/gui/pet/windows.py +2298 -0
- voxcli/gui/pet/workers.py +54 -0
- voxcli/gui/pet_app.py +7 -0
- voxcli/hitl/__init__.py +11 -0
- voxcli/hitl/handler.py +11 -0
- voxcli/hitl/policy.py +32 -0
- voxcli/hitl/request.py +13 -0
- voxcli/hitl/result.py +11 -0
- voxcli/hitl/terminal_handler.py +64 -0
- voxcli/hitl/tool_registry.py +64 -0
- voxcli/llm/base.py +93 -0
- voxcli/llm/factory.py +178 -0
- voxcli/llm/ollama_client.py +137 -0
- voxcli/llm/openai_compatible.py +249 -0
- voxcli/memory/base.py +16 -0
- voxcli/memory/budget.py +53 -0
- voxcli/memory/compressor.py +198 -0
- voxcli/memory/entry.py +36 -0
- voxcli/memory/long_term.py +126 -0
- voxcli/memory/manager.py +101 -0
- voxcli/memory/retriever.py +72 -0
- voxcli/memory/short_term.py +84 -0
- voxcli/memory/tokenizer.py +21 -0
- voxcli/plan/__init__.py +5 -0
- voxcli/plan/execution_plan.py +225 -0
- voxcli/plan/planner.py +198 -0
- voxcli/plan/task.py +123 -0
- voxcli/policy/audit_log.py +111 -0
- voxcli/policy/command_guard.py +34 -0
- voxcli/policy/exception.py +5 -0
- voxcli/policy/path_guard.py +32 -0
- voxcli/prompting/__init__.py +7 -0
- voxcli/prompting/presenter.py +154 -0
- voxcli/rag/__init__.py +16 -0
- voxcli/rag/analyzer.py +89 -0
- voxcli/rag/chunk.py +17 -0
- voxcli/rag/chunker.py +137 -0
- voxcli/rag/embedding.py +75 -0
- voxcli/rag/formatter.py +40 -0
- voxcli/rag/index.py +96 -0
- voxcli/rag/relation.py +14 -0
- voxcli/rag/retriever.py +58 -0
- voxcli/rag/store.py +155 -0
- voxcli/rag/tokenizer.py +26 -0
- voxcli/runtime/__init__.py +6 -0
- voxcli/runtime/session_controller.py +386 -0
- voxcli/tool/__init__.py +3 -0
- voxcli/tool/tool_registry.py +433 -0
- voxcli/util/animation.py +219 -0
- voxcli/util/ansi.py +82 -0
- voxcli/util/markdown.py +98 -0
- voxcli/web/__init__.py +17 -0
- voxcli/web/base.py +20 -0
- voxcli/web/extractor.py +77 -0
- voxcli/web/factory.py +38 -0
- voxcli/web/fetch_result.py +27 -0
- voxcli/web/fetcher.py +42 -0
- voxcli/web/network_policy.py +49 -0
- voxcli/web/result.py +23 -0
- voxcli/web/searxng.py +55 -0
- voxcli/web/serpapi.py +53 -0
- voxcli/web/zhipu.py +55 -0
voxcli/agent/agent.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Agent 核心类 - 实现 ReAct 循环"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import logging
|
|
8
|
+
from typing import List, Optional, Dict
|
|
9
|
+
|
|
10
|
+
from ..chat import GuiChatSubmission
|
|
11
|
+
from ..llm.base import LlmClient, Message, ToolCall
|
|
12
|
+
from ..memory.manager import MemoryManager
|
|
13
|
+
from ..tool import ToolRegistry, ToolInvocation
|
|
14
|
+
from ..util.ansi import heading, section, subtle
|
|
15
|
+
from ..util.animation import ThinkingDots, Typewriter, ToolCallAnimator
|
|
16
|
+
from .agent_budget import AgentBudget, ExitReason
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_SYSTEM_PROMPT = """你是一个智能编程 Agent Vox Code,可以帮助用户完成各种任务。
|
|
22
|
+
|
|
23
|
+
你可以使用以下工具来完成任务:
|
|
24
|
+
1. read_file - 读取文件内容
|
|
25
|
+
2. write_file - 写入文件内容
|
|
26
|
+
3. list_dir - 列出目录内容
|
|
27
|
+
4. execute_command - 执行Shell命令
|
|
28
|
+
5. create_project - 创建新项目结构
|
|
29
|
+
6. search_code - 语义检索代码库,参数:{"query": "自然语言描述", "top_k": 5}
|
|
30
|
+
7. web_search - 搜索互联网获取实时信息(最新版本、官方文档、技术资讯等),参数:{"query": "搜索关键词", "top_k": 5}
|
|
31
|
+
8. web_fetch - 抓取已知 URL 并返回正文 Markdown,参数:{"url": "https://...", "max_chars": 8000}
|
|
32
|
+
|
|
33
|
+
当需要操作文件、执行命令或创建项目时,请使用工具调用。
|
|
34
|
+
使用工具后,根据工具返回的结果继续思考下一步行动。
|
|
35
|
+
对于当前项目内的文件和代码,请优先使用 read_file、list_dir、search_code。
|
|
36
|
+
execute_command 只适合在当前项目目录执行短时命令(如 git status、mvn test),不要用它扫描 /、~ 或整个文件系统。
|
|
37
|
+
安全策略硬规则(HITL 之外的兜底,无法绕过,请提前规避):
|
|
38
|
+
- read_file / write_file / list_dir / create_project 的路径必须在项目根之内,绝对路径或 .. 越界会被拒绝
|
|
39
|
+
- write_file 单文件 5MB 上限
|
|
40
|
+
- execute_command 禁止 sudo、rm -rf 全盘或用户目录、mkfs、dd 写裸设备、fork bomb、curl|sh、find /、chmod 777 /、shutdown
|
|
41
|
+
- 若调用被策略拒绝(结果以 "🛡️ 策略拒绝" 开头),不要原样重试,改用项目内相对路径或更安全的方式
|
|
42
|
+
同一轮返回多个工具调用时,系统会并行执行这些工具;如果工具之间有依赖关系,请分多轮调用。
|
|
43
|
+
如果需要同时检查多个已知且互不依赖的文件或目录(例如同时读取 pom.xml、README.md、ROADMAP.md,
|
|
44
|
+
或同时列出 src/main/java、src/test/java、src/main/resources),请在同一轮返回多个 read_file/list_dir 工具调用。
|
|
45
|
+
|
|
46
|
+
工具选择优先级:
|
|
47
|
+
- 代码库相关问题("这个类是干什么的"、"哪里用了某个功能")→ search_code,不要走 web_search
|
|
48
|
+
- 训练数据已知的稳定知识(语法、稳定 API、基础概念)→ 直接回答,不要联网
|
|
49
|
+
- 时效性 / 最新信息 / 不确定的事实 → web_search 找入口,找到 URL 后再 web_fetch 拿全文
|
|
50
|
+
- 已经有具体 URL → 直接 web_fetch,不要再 web_search 一次
|
|
51
|
+
- web_fetch 拿到空正文(提示 SPA / 防爬墙)→ 这是已知边界,告知用户即可,不要反复重试
|
|
52
|
+
|
|
53
|
+
如果提供了相关记忆,请参考其中的信息来辅助决策。
|
|
54
|
+
|
|
55
|
+
请用中文回复用户。"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Agent:
|
|
59
|
+
def __init__(self, llm_client: LlmClient, tool_registry: Optional[ToolRegistry] = None):
|
|
60
|
+
self._llm = llm_client
|
|
61
|
+
self._tool_registry = tool_registry or ToolRegistry()
|
|
62
|
+
self._conversation_history: List[Message] = [Message.system(_SYSTEM_PROMPT)]
|
|
63
|
+
self._memory_manager = MemoryManager(llm_client)
|
|
64
|
+
|
|
65
|
+
def set_llm_client(self, llm_client: LlmClient):
|
|
66
|
+
self._llm = llm_client
|
|
67
|
+
self._memory_manager.set_llm_client(llm_client)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def memory_manager(self) -> MemoryManager:
|
|
71
|
+
return self._memory_manager
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def tool_registry(self) -> ToolRegistry:
|
|
75
|
+
return self._tool_registry
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def conversation_history(self) -> List[Message]:
|
|
79
|
+
return list(self._conversation_history)
|
|
80
|
+
|
|
81
|
+
# ---- Public API ----
|
|
82
|
+
|
|
83
|
+
def run(self, user_input: str | GuiChatSubmission) -> str:
|
|
84
|
+
submission = user_input if isinstance(user_input, GuiChatSubmission) else None
|
|
85
|
+
input_text = submission.summary_text if submission is not None else user_input
|
|
86
|
+
logger.info("ReAct run started: inputLength=%d", len(input_text) if input_text else 0)
|
|
87
|
+
self._memory_manager.add_user_message(input_text)
|
|
88
|
+
|
|
89
|
+
memory_context = self._memory_manager.build_context_for_query(input_text, 500)
|
|
90
|
+
self._update_system_prompt(memory_context)
|
|
91
|
+
|
|
92
|
+
if submission is not None:
|
|
93
|
+
self._conversation_history.append(
|
|
94
|
+
Message.user(submission.text, attachments=submission.attachments)
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
self._conversation_history.append(Message.user(user_input))
|
|
98
|
+
reasoning_transcript: List[str] = []
|
|
99
|
+
thinking_dots = ThinkingDots()
|
|
100
|
+
stream_renderer = _StreamRenderer(stop_thinking=thinking_dots.stop)
|
|
101
|
+
|
|
102
|
+
start_nanos = time.time()
|
|
103
|
+
budget = AgentBudget.from_env()
|
|
104
|
+
|
|
105
|
+
while True:
|
|
106
|
+
exit_reason = budget.check()
|
|
107
|
+
if exit_reason != ExitReason.WITHIN_BUDGET:
|
|
108
|
+
stats = self._format_token_stats(budget.total_input_tokens,
|
|
109
|
+
budget.total_output_tokens, start_nanos)
|
|
110
|
+
desc = budget.describe_exit(exit_reason)
|
|
111
|
+
logger.warning("ReAct budget exhausted: reason=%s, iteration=%d, tokens=%d/%d",
|
|
112
|
+
exit_reason, budget.iteration,
|
|
113
|
+
budget.total_input_tokens + budget.total_output_tokens,
|
|
114
|
+
budget.token_budget)
|
|
115
|
+
return f"❌ {desc}\n\n{stats}"
|
|
116
|
+
|
|
117
|
+
iteration = budget.begin_iteration()
|
|
118
|
+
thinking_dots.start()
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
response = self._llm.chat(
|
|
122
|
+
self._conversation_history,
|
|
123
|
+
self._tool_registry.get_tool_definitions(),
|
|
124
|
+
stream_renderer,
|
|
125
|
+
)
|
|
126
|
+
thinking_dots.stop()
|
|
127
|
+
|
|
128
|
+
if response is None:
|
|
129
|
+
return "❌ LLM 返回空响应,请检查模型接口是否正常"
|
|
130
|
+
|
|
131
|
+
budget.record_tokens(response.input_tokens or 0, response.output_tokens or 0)
|
|
132
|
+
|
|
133
|
+
if response.has_tool_calls:
|
|
134
|
+
self._append_reasoning(reasoning_transcript, response.reasoning_content)
|
|
135
|
+
logger.info("LLM requested %d tool call(s) in iteration %d",
|
|
136
|
+
len(response.tool_calls), iteration)
|
|
137
|
+
budget.record_tool_calls(response.tool_calls)
|
|
138
|
+
|
|
139
|
+
tool_anim = ToolCallAnimator()
|
|
140
|
+
_format_tool_calls_info(response.tool_calls, tool_anim)
|
|
141
|
+
|
|
142
|
+
self._conversation_history.append(Message.assistant(
|
|
143
|
+
content=response.content or "",
|
|
144
|
+
reasoning_content=response.reasoning_content,
|
|
145
|
+
tool_calls=response.tool_calls,
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
tool_results = self._execute_tool_calls(response.tool_calls, iteration)
|
|
149
|
+
for tr in tool_results:
|
|
150
|
+
self._memory_manager.add_tool_result(tr.name, tr.result)
|
|
151
|
+
self._conversation_history.append(Message.tool(tr.id, tr.result))
|
|
152
|
+
|
|
153
|
+
# 原地将工具调用标记为 ✓(finish_all 必须在 reset 之前,否则多出的空行会破坏 ANSI 定位)
|
|
154
|
+
tool_anim.finish_all()
|
|
155
|
+
|
|
156
|
+
stream_renderer.reset_between_iterations()
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
self._append_reasoning(reasoning_transcript, response.reasoning_content)
|
|
160
|
+
self._conversation_history.append(Message.assistant(
|
|
161
|
+
content=response.content or "",
|
|
162
|
+
reasoning_content=response.reasoning_content,
|
|
163
|
+
))
|
|
164
|
+
|
|
165
|
+
self._memory_manager.add_assistant_message(response.content or "")
|
|
166
|
+
self._memory_manager.record_token_usage(
|
|
167
|
+
budget.total_input_tokens, budget.total_output_tokens)
|
|
168
|
+
|
|
169
|
+
logger.info("ReAct finished: inputTokens=%d, outputTokens=%d, reasoningChars=%d, answerChars=%d",
|
|
170
|
+
budget.total_input_tokens, budget.total_output_tokens,
|
|
171
|
+
len(response.reasoning_content or ""), len(response.content or ""))
|
|
172
|
+
|
|
173
|
+
stats = self._format_token_stats(budget.total_input_tokens,
|
|
174
|
+
budget.total_output_tokens, start_nanos)
|
|
175
|
+
|
|
176
|
+
if stream_renderer.has_streamed_output():
|
|
177
|
+
stream_renderer.finish()
|
|
178
|
+
print(subtle(stats))
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
result = self._format_response("\n".join(reasoning_transcript), response.content or "")
|
|
182
|
+
return result + "\n\n" + subtle(stats)
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
thinking_dots.stop()
|
|
186
|
+
logger.error("LLM call failed in ReAct loop", exc_info=True)
|
|
187
|
+
return f"❌ 调用 LLM 失败: {e}"
|
|
188
|
+
|
|
189
|
+
def clear_history(self):
|
|
190
|
+
system_msg = self._conversation_history[0]
|
|
191
|
+
self._conversation_history.clear()
|
|
192
|
+
self._conversation_history.append(system_msg)
|
|
193
|
+
self._memory_manager.clear_short_term()
|
|
194
|
+
|
|
195
|
+
def clear_attachment_context(self):
|
|
196
|
+
for message in self._conversation_history:
|
|
197
|
+
if message.attachments:
|
|
198
|
+
message.attachments = ()
|
|
199
|
+
|
|
200
|
+
def get_context_status(self) -> str:
|
|
201
|
+
system_count = user_count = assistant_count = tool_count = 0
|
|
202
|
+
total_chars = 0
|
|
203
|
+
for msg in self._conversation_history:
|
|
204
|
+
total_chars += len(msg.content or "")
|
|
205
|
+
role_counts = {"system": 0, "user": 0, "assistant": 0, "tool": 0}
|
|
206
|
+
r = msg.role
|
|
207
|
+
if r == "system":
|
|
208
|
+
system_count += 1
|
|
209
|
+
elif r == "user":
|
|
210
|
+
user_count += 1
|
|
211
|
+
elif r == "assistant":
|
|
212
|
+
assistant_count += 1
|
|
213
|
+
elif r == "tool":
|
|
214
|
+
tool_count += 1
|
|
215
|
+
|
|
216
|
+
total_messages = len(self._conversation_history)
|
|
217
|
+
rounds = user_count
|
|
218
|
+
return (
|
|
219
|
+
f"对话上下文: {total_messages} 条消息, {rounds} 轮对话, ~{total_chars} 字符\n"
|
|
220
|
+
f" system: {system_count} / user: {user_count} / assistant: {assistant_count} / tool: {tool_count}\n"
|
|
221
|
+
f"{self._memory_manager.status_summary()}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# ---- Internal ----
|
|
225
|
+
|
|
226
|
+
def _update_system_prompt(self, memory_context: str):
|
|
227
|
+
if memory_context:
|
|
228
|
+
enriched = _SYSTEM_PROMPT + "\n" + memory_context
|
|
229
|
+
self._conversation_history[0] = Message.system(enriched)
|
|
230
|
+
else:
|
|
231
|
+
self._conversation_history[0] = Message.system(_SYSTEM_PROMPT)
|
|
232
|
+
|
|
233
|
+
def _execute_tool_calls(self, tool_calls: List, iteration: int) -> List:
|
|
234
|
+
invocations = []
|
|
235
|
+
for tc in tool_calls:
|
|
236
|
+
if isinstance(tc, ToolCall):
|
|
237
|
+
func_name = tc.name
|
|
238
|
+
func_args = tc.arguments
|
|
239
|
+
tool_id = tc.id
|
|
240
|
+
else:
|
|
241
|
+
func_name = tc.get("function", {}).get("name", "")
|
|
242
|
+
func_args = tc.get("function", {}).get("arguments", "")
|
|
243
|
+
tool_id = tc.get("id", "")
|
|
244
|
+
logger.info("Scheduling tool: %s (iteration=%d)", func_name, iteration)
|
|
245
|
+
invocations.append(ToolInvocation(tool_id, func_name, func_args))
|
|
246
|
+
|
|
247
|
+
if len(invocations) > 1:
|
|
248
|
+
logger.info("Executing %d tool calls in parallel (iteration=%d)",
|
|
249
|
+
len(invocations), iteration)
|
|
250
|
+
return self._tool_registry.execute_tools(invocations)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _append_reasoning(transcript: List[str], reasoning: Optional[str]):
|
|
254
|
+
if reasoning and reasoning.strip():
|
|
255
|
+
if transcript:
|
|
256
|
+
transcript.append("")
|
|
257
|
+
transcript.append(reasoning.strip())
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _format_response(reasoning: str, answer: str) -> str:
|
|
261
|
+
if not reasoning:
|
|
262
|
+
return answer
|
|
263
|
+
if not answer:
|
|
264
|
+
return f"🧠 思考过程:\n{reasoning}"
|
|
265
|
+
return f"🧠 思考过程:\n{reasoning}\n\n🤖 回复:\n{answer}"
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _format_token_stats(input_tokens: int, output_tokens: int, start: float) -> str:
|
|
269
|
+
elapsed = time.time() - start
|
|
270
|
+
return subtle(
|
|
271
|
+
f"📊 Token: {input_tokens} 输入 / {output_tokens} 输出 / "
|
|
272
|
+
f"{input_tokens + output_tokens} 合计 | ⏱ {elapsed:.1f}s"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _format_tool_calls_info(tool_calls: List, anim: ToolCallAnimator):
|
|
277
|
+
"""用 ToolCallAnimator 展示工具调用信息"""
|
|
278
|
+
for tc in tool_calls:
|
|
279
|
+
if isinstance(tc, ToolCall):
|
|
280
|
+
name = tc.name
|
|
281
|
+
args_json = tc.arguments
|
|
282
|
+
else:
|
|
283
|
+
name = tc.get("function", {}).get("name", "")
|
|
284
|
+
args_json = tc.get("function", {}).get("arguments", "{}")
|
|
285
|
+
detail = _extract_key_param(name, args_json)
|
|
286
|
+
anim.running(name, detail)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _print_tool_calls(tool_calls: List):
|
|
290
|
+
"""Legacy: 直接打印工具调用(保持向后兼容)"""
|
|
291
|
+
grouped: Dict[str, list] = {}
|
|
292
|
+
for tc in tool_calls:
|
|
293
|
+
if isinstance(tc, ToolCall):
|
|
294
|
+
name = tc.name
|
|
295
|
+
else:
|
|
296
|
+
name = tc.get("function", {}).get("name", "")
|
|
297
|
+
grouped.setdefault(name, []).append(tc)
|
|
298
|
+
|
|
299
|
+
for tool_name, calls in grouped.items():
|
|
300
|
+
print(subtle(f" {_tool_label(tool_name, len(calls))}"))
|
|
301
|
+
for tc in calls:
|
|
302
|
+
if isinstance(tc, ToolCall):
|
|
303
|
+
args_json = tc.arguments
|
|
304
|
+
else:
|
|
305
|
+
args_json = tc.get("function", {}).get("arguments", "{}")
|
|
306
|
+
detail = _extract_key_param(tool_name, args_json)
|
|
307
|
+
if detail:
|
|
308
|
+
print(subtle(f" └ {detail}"))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _tool_label(name: str, count: int) -> str:
|
|
312
|
+
labels = {
|
|
313
|
+
"read_file": f"📖 读取 {count} 个文件",
|
|
314
|
+
"write_file": f"✏️ 写入 {count} 个文件",
|
|
315
|
+
"list_dir": f"📂 列出 {count} 个目录",
|
|
316
|
+
"execute_command": f"⚡ 执行 {count} 条命令",
|
|
317
|
+
"create_project": f"🏗️ 创建 {count} 个项目",
|
|
318
|
+
"search_code": f"🔍 搜索代码 {count} 次",
|
|
319
|
+
"web_search": f"🌐 联网搜索 {count} 次",
|
|
320
|
+
"web_fetch": f"📰 抓取 {count} 个网页",
|
|
321
|
+
}
|
|
322
|
+
return labels.get(name, f"🔧 {name} × {count}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _extract_key_param(tool_name: str, args_json: str) -> str:
|
|
326
|
+
try:
|
|
327
|
+
args = json.loads(args_json)
|
|
328
|
+
key_map = {
|
|
329
|
+
"read_file": "path", "write_file": "path", "list_dir": "path",
|
|
330
|
+
"execute_command": "command", "create_project": "name",
|
|
331
|
+
"search_code": "query", "web_search": "query", "web_fetch": "url",
|
|
332
|
+
}
|
|
333
|
+
key = key_map.get(tool_name)
|
|
334
|
+
if key and key in args:
|
|
335
|
+
value = str(args[key])
|
|
336
|
+
return value if len(value) <= 80 else value[:77] + "..."
|
|
337
|
+
return ""
|
|
338
|
+
except json.JSONDecodeError:
|
|
339
|
+
return args_json[:80] if len(args_json) > 80 else args_json
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class _StreamRenderer:
|
|
343
|
+
"""流式输出渲染器 + Claude Code 风格动画
|
|
344
|
+
|
|
345
|
+
- LLM 思考期间显示 ● ● ● 脉冲动画
|
|
346
|
+
- 首个 delta 到达时立即停止动画(防覆盖),切换为打字机效果
|
|
347
|
+
"""
|
|
348
|
+
def __init__(self, stop_thinking=None):
|
|
349
|
+
self._pending_reasoning = ""
|
|
350
|
+
self._late_reasoning = ""
|
|
351
|
+
self._reasoning_heading_printed = False
|
|
352
|
+
self._reasoning_started = False
|
|
353
|
+
self._content_started = False
|
|
354
|
+
self._streamed_output = False
|
|
355
|
+
self._tw = Typewriter()
|
|
356
|
+
self._stop_thinking = stop_thinking
|
|
357
|
+
self._thinking_stopped = False
|
|
358
|
+
|
|
359
|
+
def _stop_dots(self):
|
|
360
|
+
"""首个 delta 到达时立刻停止 thinking 动画(防 \r 覆盖内容)"""
|
|
361
|
+
if not self._thinking_stopped and self._stop_thinking:
|
|
362
|
+
self._stop_thinking()
|
|
363
|
+
self._thinking_stopped = True
|
|
364
|
+
|
|
365
|
+
def _ensure_clean_line(self):
|
|
366
|
+
"""清空当前行并将光标移至行首(防线程残留 \r 字符)"""
|
|
367
|
+
sys.stdout.write("\r\033[K")
|
|
368
|
+
sys.stdout.flush()
|
|
369
|
+
|
|
370
|
+
def __call__(self, delta: str):
|
|
371
|
+
if delta:
|
|
372
|
+
self._on_content_delta(delta)
|
|
373
|
+
|
|
374
|
+
def on_reasoning_delta(self, delta: str):
|
|
375
|
+
if not delta:
|
|
376
|
+
return
|
|
377
|
+
self._stop_dots()
|
|
378
|
+
if self._content_started:
|
|
379
|
+
self._late_reasoning += delta
|
|
380
|
+
return
|
|
381
|
+
if not self._reasoning_started:
|
|
382
|
+
self._pending_reasoning += delta
|
|
383
|
+
if not self._pending_reasoning.strip():
|
|
384
|
+
return
|
|
385
|
+
if "\n" not in self._pending_reasoning and "\r" not in self._pending_reasoning:
|
|
386
|
+
return
|
|
387
|
+
self._ensure_clean_line()
|
|
388
|
+
self._print_reasoning_heading()
|
|
389
|
+
self._tw.write_fast(self._pending_reasoning)
|
|
390
|
+
self._pending_reasoning = ""
|
|
391
|
+
self._reasoning_started = True
|
|
392
|
+
self._streamed_output = True
|
|
393
|
+
else:
|
|
394
|
+
self._tw.write(delta)
|
|
395
|
+
|
|
396
|
+
def on_content_delta(self, delta: str):
|
|
397
|
+
if not delta:
|
|
398
|
+
return
|
|
399
|
+
self._stop_dots()
|
|
400
|
+
if not self._content_started:
|
|
401
|
+
if self._reasoning_started:
|
|
402
|
+
self._tw.newline()
|
|
403
|
+
elif self._pending_reasoning.strip():
|
|
404
|
+
self._ensure_clean_line()
|
|
405
|
+
self._print_reasoning_heading()
|
|
406
|
+
self._tw.write_fast(self._pending_reasoning)
|
|
407
|
+
self._tw.newline()
|
|
408
|
+
self._pending_reasoning = ""
|
|
409
|
+
self._reasoning_started = True
|
|
410
|
+
self._ensure_clean_line()
|
|
411
|
+
print(section("🤖 回复"))
|
|
412
|
+
self._content_started = True
|
|
413
|
+
self._streamed_output = True
|
|
414
|
+
self._tw.write(delta)
|
|
415
|
+
|
|
416
|
+
def _on_content_delta(self, delta: str):
|
|
417
|
+
self.on_content_delta(delta)
|
|
418
|
+
|
|
419
|
+
def reset_between_iterations(self):
|
|
420
|
+
self._thinking_stopped = False
|
|
421
|
+
self._pending_reasoning = ""
|
|
422
|
+
late = self._late_reasoning.strip()
|
|
423
|
+
if late:
|
|
424
|
+
print(f"\n{heading('🧠 补充思考')}")
|
|
425
|
+
self._tw.write_fast(late)
|
|
426
|
+
self._late_reasoning = ""
|
|
427
|
+
self._streamed_output = True
|
|
428
|
+
self._reasoning_started = False
|
|
429
|
+
self._content_started = False
|
|
430
|
+
if self._streamed_output:
|
|
431
|
+
print()
|
|
432
|
+
|
|
433
|
+
def finish(self):
|
|
434
|
+
late = self._late_reasoning.strip()
|
|
435
|
+
if late:
|
|
436
|
+
print(f"\n{heading('🧠 补充思考')}")
|
|
437
|
+
self._tw.write_fast(late)
|
|
438
|
+
self._late_reasoning = ""
|
|
439
|
+
self._streamed_output = True
|
|
440
|
+
if self._streamed_output:
|
|
441
|
+
print()
|
|
442
|
+
|
|
443
|
+
def has_streamed_output(self) -> bool:
|
|
444
|
+
return self._streamed_output
|
|
445
|
+
|
|
446
|
+
def _print_reasoning_heading(self):
|
|
447
|
+
if not self._reasoning_heading_printed:
|
|
448
|
+
print(heading("🧠 思考过程"))
|
|
449
|
+
self._reasoning_heading_printed = True
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Agent 循环的退出预算 - token/停滞/硬轮数兜底"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from collections import deque
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from ..llm.base import ToolCall
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExitReason(Enum):
|
|
12
|
+
WITHIN_BUDGET = "WITHIN_BUDGET"
|
|
13
|
+
TOKEN_BUDGET_EXCEEDED = "TOKEN_BUDGET_EXCEEDED"
|
|
14
|
+
STAGNATION_DETECTED = "STAGNATION_DETECTED"
|
|
15
|
+
HARD_ITERATION_LIMIT = "HARD_ITERATION_LIMIT"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_DEFAULT_TOKEN_BUDGET = 300_000
|
|
19
|
+
_DEFAULT_STAGNATION_WINDOW = 3
|
|
20
|
+
_DEFAULT_HARD_MAX_ITERATIONS = 50
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AgentBudget:
|
|
24
|
+
def __init__(self, token_budget: int = _DEFAULT_TOKEN_BUDGET,
|
|
25
|
+
stagnation_window: int = _DEFAULT_STAGNATION_WINDOW,
|
|
26
|
+
hard_max_iterations: int = _DEFAULT_HARD_MAX_ITERATIONS):
|
|
27
|
+
if token_budget <= 0:
|
|
28
|
+
raise ValueError("token_budget must be positive")
|
|
29
|
+
if stagnation_window < 2:
|
|
30
|
+
raise ValueError("stagnation_window must be >= 2")
|
|
31
|
+
if hard_max_iterations <= 0:
|
|
32
|
+
raise ValueError("hard_max_iterations must be positive")
|
|
33
|
+
|
|
34
|
+
self._token_budget = token_budget
|
|
35
|
+
self._stagnation_window = stagnation_window
|
|
36
|
+
self._hard_max_iterations = hard_max_iterations
|
|
37
|
+
self._recent_tool_signatures: deque = deque()
|
|
38
|
+
self._iteration = 0
|
|
39
|
+
self._total_input_tokens = 0
|
|
40
|
+
self._total_output_tokens = 0
|
|
41
|
+
self._stagnant = False
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_env(cls) -> "AgentBudget":
|
|
45
|
+
return cls(
|
|
46
|
+
_read_int_env("VOX_CODE_REACT_TOKEN_BUDGET", _DEFAULT_TOKEN_BUDGET),
|
|
47
|
+
_read_int_env("VOX_CODE_REACT_STAGNATION_WINDOW", _DEFAULT_STAGNATION_WINDOW),
|
|
48
|
+
_read_int_env("VOX_CODE_REACT_HARD_MAX_ITERATIONS", _DEFAULT_HARD_MAX_ITERATIONS),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def begin_iteration(self) -> int:
|
|
52
|
+
self._iteration += 1
|
|
53
|
+
return self._iteration
|
|
54
|
+
|
|
55
|
+
def record_tokens(self, input_tokens: int, output_tokens: int):
|
|
56
|
+
self._total_input_tokens += max(0, input_tokens)
|
|
57
|
+
self._total_output_tokens += max(0, output_tokens)
|
|
58
|
+
|
|
59
|
+
def record_tool_calls(self, tool_calls: Optional[List[ToolCall]]):
|
|
60
|
+
if not tool_calls:
|
|
61
|
+
self._recent_tool_signatures.clear()
|
|
62
|
+
return
|
|
63
|
+
sig = self._signature(tool_calls)
|
|
64
|
+
self._recent_tool_signatures.append(sig)
|
|
65
|
+
while len(self._recent_tool_signatures) > self._stagnation_window:
|
|
66
|
+
self._recent_tool_signatures.popleft()
|
|
67
|
+
if len(self._recent_tool_signatures) == self._stagnation_window:
|
|
68
|
+
first = self._recent_tool_signatures[0]
|
|
69
|
+
self._stagnant = all(s == first for s in self._recent_tool_signatures)
|
|
70
|
+
|
|
71
|
+
def check(self) -> ExitReason:
|
|
72
|
+
if self._stagnant:
|
|
73
|
+
return ExitReason.STAGNATION_DETECTED
|
|
74
|
+
if self._total_input_tokens + self._total_output_tokens >= self._token_budget:
|
|
75
|
+
return ExitReason.TOKEN_BUDGET_EXCEEDED
|
|
76
|
+
if self._iteration >= self._hard_max_iterations:
|
|
77
|
+
return ExitReason.HARD_ITERATION_LIMIT
|
|
78
|
+
return ExitReason.WITHIN_BUDGET
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def iteration(self) -> int:
|
|
82
|
+
return self._iteration
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def total_input_tokens(self) -> int:
|
|
86
|
+
return self._total_input_tokens
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def total_output_tokens(self) -> int:
|
|
90
|
+
return self._total_output_tokens
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def token_budget(self) -> int:
|
|
94
|
+
return self._token_budget
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def hard_max_iterations(self) -> int:
|
|
98
|
+
return self._hard_max_iterations
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def stagnation_window(self) -> int:
|
|
102
|
+
return self._stagnation_window
|
|
103
|
+
|
|
104
|
+
def describe_exit(self, reason: ExitReason) -> str:
|
|
105
|
+
descriptions = {
|
|
106
|
+
ExitReason.WITHIN_BUDGET: "未触发兜底条件",
|
|
107
|
+
ExitReason.TOKEN_BUDGET_EXCEEDED: (
|
|
108
|
+
f"Token 预算已用尽({self._total_input_tokens + self._total_output_tokens} / "
|
|
109
|
+
f"{self._token_budget}),任务被强制收尾"
|
|
110
|
+
),
|
|
111
|
+
ExitReason.STAGNATION_DETECTED: (
|
|
112
|
+
f"检测到连续 {self._stagnation_window} 轮重复的工具调用,疑似死循环,已强制收尾"
|
|
113
|
+
),
|
|
114
|
+
ExitReason.HARD_ITERATION_LIMIT: (
|
|
115
|
+
f"达到硬轮数上限({self._hard_max_iterations}),已强制收尾"
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
return descriptions.get(reason, "未知原因")
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _signature(tool_calls: List[ToolCall]) -> str:
|
|
122
|
+
return ";".join(f"{tc.name}|{tc.arguments}" for tc in tool_calls)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_int_env(key: str, default: int) -> int:
|
|
126
|
+
raw = os.environ.get(key, "").strip()
|
|
127
|
+
if not raw:
|
|
128
|
+
return default
|
|
129
|
+
try:
|
|
130
|
+
parsed = int(raw)
|
|
131
|
+
return parsed if parsed > 0 else default
|
|
132
|
+
except ValueError:
|
|
133
|
+
return default
|