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.
Files changed (88) hide show
  1. vox_code-2.0.0.dist-info/METADATA +258 -0
  2. vox_code-2.0.0.dist-info/RECORD +88 -0
  3. vox_code-2.0.0.dist-info/WHEEL +4 -0
  4. vox_code-2.0.0.dist-info/entry_points.txt +3 -0
  5. voxcli/__init__.py +3 -0
  6. voxcli/__main__.py +5 -0
  7. voxcli/agent/__init__.py +12 -0
  8. voxcli/agent/agent.py +449 -0
  9. voxcli/agent/agent_budget.py +133 -0
  10. voxcli/agent/agent_orchestrator.py +414 -0
  11. voxcli/agent/plan_execute_agent.py +514 -0
  12. voxcli/agent/roles.py +80 -0
  13. voxcli/agent/sub_agent.py +351 -0
  14. voxcli/catalog.py +477 -0
  15. voxcli/chat.py +91 -0
  16. voxcli/cli/__init__.py +4 -0
  17. voxcli/cli/main.py +452 -0
  18. voxcli/cli/parser.py +71 -0
  19. voxcli/config.py +518 -0
  20. voxcli/gui/__main__.py +3 -0
  21. voxcli/gui/main.py +22 -0
  22. voxcli/gui/pet/__init__.py +5 -0
  23. voxcli/gui/pet/base.py +62 -0
  24. voxcli/gui/pet/coordinator.py +888 -0
  25. voxcli/gui/pet/data.py +430 -0
  26. voxcli/gui/pet/widgets.py +683 -0
  27. voxcli/gui/pet/windows.py +2298 -0
  28. voxcli/gui/pet/workers.py +54 -0
  29. voxcli/gui/pet_app.py +7 -0
  30. voxcli/hitl/__init__.py +11 -0
  31. voxcli/hitl/handler.py +11 -0
  32. voxcli/hitl/policy.py +32 -0
  33. voxcli/hitl/request.py +13 -0
  34. voxcli/hitl/result.py +11 -0
  35. voxcli/hitl/terminal_handler.py +64 -0
  36. voxcli/hitl/tool_registry.py +64 -0
  37. voxcli/llm/base.py +93 -0
  38. voxcli/llm/factory.py +178 -0
  39. voxcli/llm/ollama_client.py +137 -0
  40. voxcli/llm/openai_compatible.py +249 -0
  41. voxcli/memory/base.py +16 -0
  42. voxcli/memory/budget.py +53 -0
  43. voxcli/memory/compressor.py +198 -0
  44. voxcli/memory/entry.py +36 -0
  45. voxcli/memory/long_term.py +126 -0
  46. voxcli/memory/manager.py +101 -0
  47. voxcli/memory/retriever.py +72 -0
  48. voxcli/memory/short_term.py +84 -0
  49. voxcli/memory/tokenizer.py +21 -0
  50. voxcli/plan/__init__.py +5 -0
  51. voxcli/plan/execution_plan.py +225 -0
  52. voxcli/plan/planner.py +198 -0
  53. voxcli/plan/task.py +123 -0
  54. voxcli/policy/audit_log.py +111 -0
  55. voxcli/policy/command_guard.py +34 -0
  56. voxcli/policy/exception.py +5 -0
  57. voxcli/policy/path_guard.py +32 -0
  58. voxcli/prompting/__init__.py +7 -0
  59. voxcli/prompting/presenter.py +154 -0
  60. voxcli/rag/__init__.py +16 -0
  61. voxcli/rag/analyzer.py +89 -0
  62. voxcli/rag/chunk.py +17 -0
  63. voxcli/rag/chunker.py +137 -0
  64. voxcli/rag/embedding.py +75 -0
  65. voxcli/rag/formatter.py +40 -0
  66. voxcli/rag/index.py +96 -0
  67. voxcli/rag/relation.py +14 -0
  68. voxcli/rag/retriever.py +58 -0
  69. voxcli/rag/store.py +155 -0
  70. voxcli/rag/tokenizer.py +26 -0
  71. voxcli/runtime/__init__.py +6 -0
  72. voxcli/runtime/session_controller.py +386 -0
  73. voxcli/tool/__init__.py +3 -0
  74. voxcli/tool/tool_registry.py +433 -0
  75. voxcli/util/animation.py +219 -0
  76. voxcli/util/ansi.py +82 -0
  77. voxcli/util/markdown.py +98 -0
  78. voxcli/web/__init__.py +17 -0
  79. voxcli/web/base.py +20 -0
  80. voxcli/web/extractor.py +77 -0
  81. voxcli/web/factory.py +38 -0
  82. voxcli/web/fetch_result.py +27 -0
  83. voxcli/web/fetcher.py +42 -0
  84. voxcli/web/network_policy.py +49 -0
  85. voxcli/web/result.py +23 -0
  86. voxcli/web/searxng.py +55 -0
  87. voxcli/web/serpapi.py +53 -0
  88. 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