Coverage for merco/core/agent.py: 82%

731 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 14:04 +0800

1"""Agent 主循环与核心逻辑""" 

2 

3import json 

4import re 

5import asyncio 

6import logging 

7import shutil 

8import time 

9from typing import Optional 

10from abc import ABC, abstractmethod 

11from rich.console import Console 

12from rich.markdown import Markdown 

13from rich.panel import Panel 

14 

15from .config import MercoConfig 

16from .llm import LLMClient 

17from .session import Session 

18from .message import Message, MessageRole 

19from .context import ContextManager, msg_tokens, estimate_tokens as est_tk 

20from .pipeline import ProcessContext 

21from .interrupt import ( 

22 InterruptCleanupPipeline, CleanupContext, 

23 InjectCancelMessages, TerminateSubprocesses, 

24 CloseMCPConnections, EmitInterruptHooks, SavePartialState 

25) 

26from merco.sandbox.guard import GuardConfirmationRequired, GuardAction 

27 

28console = Console() 

29logger = logging.getLogger("merco.agent") 

30 

31# ── System Prompt 构建器 ───────────────────────────── 

32 

33class PromptChunk: 

34 name: str = "" 

35 def enabled(self, agent) -> bool: return True 

36 def build(self, agent) -> str: raise NotImplementedError 

37 

38class PromptBuilder: 

39 def __init__(self): 

40 self._chunks: list[PromptChunk] = [] 

41 self._disabled: set[str] = set() 

42 def use(self, chunk: PromptChunk) -> "PromptBuilder": 

43 self._chunks.append(chunk); return self 

44 def disable(self, name: str) -> None: self._disabled.add(name) 

45 def enable(self, name: str) -> None: self._disabled.discard(name) 

46 def build(self, agent) -> str: 

47 return "\n\n".join(c.build(agent) for c in self._chunks 

48 if c.name not in self._disabled and c.enabled(agent)) 

49 

50class BasePromptChunk(PromptChunk): 

51 name = "base" 

52 PROMPT = """You are Mercury Code, an AI coding assistant. You can help with: 

53- Writing and editing code 

54- Answering questions about the codebase 

55- Executing shell commands 

56- Searching and managing files 

57 

58When you need to perform actions, use the available tools. 

59Always be concise and helpful.""" 

60 def build(self, agent) -> str: return self.PROMPT 

61 

62class SkillsHintChunk(PromptChunk): 

63 """skill 自动注入:根据当前对话内容匹配相关 skill,注入提示到 system prompt""" 

64 name = "skills_hint" 

65 

66 def enabled(self, agent) -> bool: 

67 return bool(agent.skill_registry and agent.skill_registry.list_skills()) 

68 

69 def build(self, agent) -> str: 

70 registry = agent.skill_registry 

71 if not registry: 

72 return "" 

73 prompt = getattr(agent, "_current_prompt", "") 

74 if not prompt: 

75 return "使用 skill_view 工具可以加载项目相关的技能说明文档。" 

76 

77 relevant = registry.get_relevant(prompt) 

78 if not relevant: 

79 return "" 

80 

81 parts = [] 

82 for skill in relevant: 

83 parts.append( 

84 f"## 相关技能: {skill['name']}\n" 

85 f"{skill['content']}\n" 

86 f"(已自动加载。更多技能用 skill_view 查看。)" 

87 ) 

88 return "\n\n".join(parts) 

89 

90 

91class TimeContextChunk(PromptChunk): 

92 name = "time_context" 

93 def build(self, agent) -> str: 

94 import datetime 

95 now = datetime.datetime.now() 

96 return ( 

97 f"当前时间: {now.strftime('%Y-%m-%d %H:%M:%S')} " 

98 f"{now.astimezone().tzinfo or 'local'} " 

99 f"星期{['一','二','三','四','五','六','日'][now.weekday()]}" 

100 ) 

101 

102 

103def _build_reasoning_panel(text: str) -> Panel: 

104 return Panel(f"[dim]{text.rstrip()}[/dim]", border_style="dim", 

105 title="🧠 思考中…", title_align="left", padding=(0, 1)) 

106 

107 

108class ResponseProvider(ABC): 

109 """响应策略基类 — 工厂模式,Agent 不感知流/非流""" 

110 

111 @abstractmethod 

112 async def get_response(self, agent: "Agent", messages: list, 

113 tools: list | None) -> dict: 

114 ... 

115 

116class NonStreamingProvider(ResponseProvider): 

117 """非流式:一次 chat 返回完整响应""" 

118 

119 async def get_response(self, agent: "Agent", messages: list, 

120 tools: list | None) -> dict: 

121 response = await agent.llm.chat( 

122 messages, tools=tools, tool_choice="auto") 

123 reasoning = response.get("reasoning", "") 

124 if reasoning and reasoning.strip(): 

125 agent._render_reasoning(reasoning) 

126 return response 

127 

128class StreamingProvider(ResponseProvider): 

129 """流式:thinking 用 Live Panel 逐 token 显示,content 不流""" 

130 

131 async def get_response(self, agent: "Agent", messages: list, 

132 tools: list | None) -> dict: 

133 import sys, json as _json 

134 from rich.live import Live 

135 from rich.console import Group 

136 

137 assembled: dict = { 

138 "role": "assistant", "content": "", "reasoning": "", 

139 "tool_calls": [], "finish_reason": None, "usage": None} 

140 reasoning_buf = "" 

141 content_buf = "" 

142 tc_buf: dict[int, dict] = {} 

143 

144 stream_think = agent.config.stream_thinking 

145 render_interval = agent.config.stream_render_interval 

146 _last_render = 0.0 

147 _last_content_update = 0.0 

148 _content_update_interval = 0.3 # 300ms throttle for content panel 

149 

150 # ── 初始等待提示(无 reasoning 时显示"⏳ 思考中…",有则显示推理文字)── 

151 thinking_panel = Panel("[dim]⏳ 思考中…[/dim]", border_style="dim", 

152 title="🧠 思考中…", title_align="left", padding=(0, 1)) 

153 

154 # ── content 面板延迟创建:收到第一个 content chunk 时才创建 ── 

155 content_panel = None # lazy: created on first content chunk 

156 

157 # ── 使用单个 Live 来显示 thinking 面板(content 面板延迟加入 Group)── 

158 live = Live(Group(thinking_panel), console=console, refresh_per_second=4, 

159 transient=agent.config.stream_thinking_transient) 

160 live.start() 

161 

162 # ── 定时刷新任务:防止 API 返回慢时 thinking 面板卡顿 ── 

163 nonlocal_thinking_panel = [thinking_panel] # mutable ref for closure 

164 nonlocal_content_panel: list[Panel | None] = [None] # mutable ref for closure (lazy init) 

165 

166 def _rebuild_group(): 

167 """Rebuild Group with current panels""" 

168 if nonlocal_content_panel[0] is not None: 

169 return Group(nonlocal_thinking_panel[0], nonlocal_content_panel[0]) 

170 return Group(nonlocal_thinking_panel[0]) 

171 

172 async def _refresh_thinking(): 

173 while True: 

174 await asyncio.sleep(0.5) 

175 if reasoning_buf: 

176 nonlocal_thinking_panel[0] = _build_reasoning_panel(reasoning_buf) 

177 live.update(_rebuild_group()) 

178 refresh_task = asyncio.create_task(_refresh_thinking()) 

179 stream_error: Exception | None = None 

180 

181 try: 

182 stream = agent.llm.chat_stream(messages, tools=tools) 

183 async for chunk in stream: 

184 # 取消检查点:如果任务被取消,立即退出 

185 current = asyncio.current_task() 

186 if current and current.cancelled(): 

187 live.stop() 

188 # TODO: 此 checkpoint 无法覆盖「Cancel 在 __anext__ I/O 等待中到达」的情况 

189 # 补救方案:加 except asyncio.CancelledError 兜底 handler,抽离保存逻辑。 

190 # 优先级:低——窗口极小且用户主动取消,丢失的 partial content 是预期行为。 

191 # 保存部分响应 

192 assembled["reasoning"] = reasoning_buf 

193 assembled["content"] = content_buf 

194 if tc_buf: 

195 assembled["tool_calls"] = [ 

196 {"id": v["id"], "name": v["name"], 

197 "arguments": _json.loads(v["arguments"]) 

198 if v["arguments"] else {}} 

199 for v in (tc_buf[i] for i in sorted(tc_buf)) 

200 ] 

201 # 将部分响应添加到 context 和 session 

202 if reasoning_buf or content_buf or tc_buf: 

203 assistant_msg = { 

204 "role": "assistant", 

205 "content": content_buf, 

206 "reasoning": reasoning_buf, 

207 } 

208 if tc_buf: 

209 assistant_msg["tool_calls"] = assembled["tool_calls"] 

210 logger.debug("StreamingProvider 中断: 将 reasoning(%d chars) 存入 context (这是唯一泄漏窗口)", 

211 len(reasoning_buf)) 

212 agent.context.add(assistant_msg) 

213 agent.session.add_message("assistant", content_buf, 

214 reasoning=reasoning_buf, 

215 tool_calls=assembled.get("tool_calls")) 

216 raise asyncio.CancelledError() 

217 r = chunk.get("reasoning", "") 

218 if r: 

219 reasoning_buf += r 

220 if stream_think: 

221 now = time.monotonic() 

222 if render_interval <= 0 or now - _last_render >= render_interval: 

223 _last_render = now 

224 nonlocal_thinking_panel[0] = _build_reasoning_panel(reasoning_buf) 

225 live.update(_rebuild_group()) 

226 content_buf += chunk.get("content", "") 

227 if content_buf.strip() and agent.config.stream_content: 

228 # Lazy init: create content_panel on first content chunk 

229 if content_panel is None: 

230 content_panel = Panel("", border_style="dim", 

231 title_align="left", padding=(0, 1)) 

232 nonlocal_content_panel[0] = content_panel 

233 # Throttle updates to prevent excessive re-rendering 

234 now = time.monotonic() 

235 if now - _last_content_update >= _content_update_interval: 

236 _last_content_update = now 

237 content_panel.renderable = Markdown(content_buf) 

238 live.update(_rebuild_group()) 

239 for tc in chunk.get("tool_calls", []): 

240 idx = tc["index"] 

241 if idx not in tc_buf: 

242 tc_buf[idx] = { 

243 "id": tc.get("id", ""), 

244 "name": tc.get("name", ""), 

245 "arguments": ""} 

246 if tc.get("id"): tc_buf[idx]["id"] = tc["id"] 

247 if tc.get("name"): tc_buf[idx]["name"] = tc["name"] 

248 tc_buf[idx]["arguments"] += tc.get("arguments", "") 

249 if chunk.get("finish_reason"): 

250 assembled["finish_reason"] = chunk["finish_reason"] 

251 if chunk.get("usage"): 

252 assembled["usage"] = chunk["usage"] 

253 # Final update to ensure all content is displayed 

254 if reasoning_buf: 

255 nonlocal_thinking_panel[0] = _build_reasoning_panel(reasoning_buf) 

256 if content_panel and content_buf.strip(): 

257 content_panel.renderable = Markdown(content_buf) 

258 if reasoning_buf or (content_panel and content_buf.strip()): 

259 live.update(_rebuild_group()) 

260 except asyncio.CancelledError: 

261 raise 

262 except Exception as e: 

263 stream_error = e 

264 logger.warning("StreamingProvider API 错误: %s", e, exc_info=True) 

265 from merco.core.llm.error_ui import classify_error, build_error_panel 

266 info = classify_error(e) 

267 nonlocal_thinking_panel[0] = build_error_panel(info) 

268 nonlocal_content_panel[0] = content_panel # preserve partial content 

269 live.update(_rebuild_group()) 

270 await asyncio.sleep(0.15) 

271 finally: 

272 if 'refresh_task' in locals(): 

273 refresh_task.cancel() 

274 try: 

275 await refresh_task 

276 except asyncio.CancelledError: 

277 pass 

278 transient = agent.config.stream_thinking_transient 

279 if live: 

280 live.stop() 

281 if transient and reasoning_buf: 

282 console.print(_build_reasoning_panel(reasoning_buf)) 

283 if transient and content_buf and agent.config.stream_content: 

284 console.print(Panel(Markdown(content_buf), border_style="dim", 

285 title_align="left", padding=(0, 1))) 

286 # Error path: print static red Panel if needed, then re-raise 

287 if stream_error is not None: 

288 from merco.core.llm.error_ui import classify_error, build_error_panel 

289 need_static = transient or (not reasoning_buf and not content_buf) 

290 if need_static: 

291 console.print(build_error_panel(classify_error(stream_error))) 

292 agent._error_displayed_in_stream = True 

293 raise stream_error 

294 

295 assembled["reasoning"] = reasoning_buf 

296 assembled["content"] = content_buf 

297 if tc_buf: 

298 assembled["tool_calls"] = [ 

299 {"id": v["id"], "name": v["name"], 

300 "arguments": _json.loads(v["arguments"]) 

301 if v["arguments"] else {}} 

302 for v in (tc_buf[i] for i in sorted(tc_buf)) 

303 ] 

304 logger.debug( 

305 "stream done: finish=%s content=%d reasoning=%d tool_calls=%d%s", 

306 assembled.get("finish_reason"), len(assembled["content"]), 

307 len(assembled["reasoning"]), len(assembled["tool_calls"]), 

308 f" {[tc['name'] for tc in assembled['tool_calls']]}" if assembled["tool_calls"] else "") 

309 return assembled 

310 

311class Agent: 

312 """AI Agent 核心类,负责对话循环与工具调度""" 

313 

314 

315 def __init__(self, config: MercoConfig, tool_registry=None): 

316 self.config = config 

317 self.session = Session() 

318 from merco.sandbox import snapshot 

319 snapshot.set_current_session(self.session.id) 

320 self.context = ContextManager(max_tokens=config.max_input_tokens) 

321 self.tool_registry = tool_registry 

322 self.skill_registry = None 

323 

324 # 初始化 LLM 客户端 

325 api_key = config.model.api_key or self._get_api_key(config.model.provider) 

326 self.llm = LLMClient( 

327 api_key=api_key, 

328 model=config.model.model, 

329 base_url=config.model.base_url, 

330 temperature=config.model.temperature, 

331 max_tokens=config.model.max_tokens, 

332 cooldown=0.3, # 请求冷却(秒),0=禁用;共享网关可调大 

333 extra_params=config.model.extra_params, 

334 headers=config.model.headers, 

335 stream_options=config.model.stream_options, 

336 ) 

337 

338 self._tool_calls_count = 0 

339 self._max_tool_calls = self.config.max_tool_calls 

340 self._current_prompt = "" 

341 

342 # Flag set by response providers when they've already displayed an error 

343 # panel inline (avoids duplicate Panel print at REPL layer). 

344 self._error_displayed_in_stream = False 

345 

346 # ── 可观察性 ── 

347 from merco.hooks.registry import HookRegistry 

348 from merco.observability.observer import Observer 

349 self.hooks = HookRegistry() 

350 self.observer = Observer(self.hooks) 

351 

352 # ── 守卫:敏感命令执行前确认 ── 

353 from merco.sandbox.guard import ( 

354 ToolGuard, PolicyPipeline, BuiltinDefaultPolicy 

355 ) 

356 self._security_pipeline = PolicyPipeline() 

357 self._security_pipeline.use(BuiltinDefaultPolicy( 

358 mode=config.sandbox_mode, 

359 user_rules=config.sandbox_rules, 

360 )) 

361 self.guard = ToolGuard(pipeline=self._security_pipeline) 

362 

363 # ── Middleware:Guard + EditApply + ErrorHandling 装配到 ToolRegistry ── 

364 from merco.tools.middleware import GuardMiddleware, EditApplyMiddleware, ErrorHandlingMiddleware 

365 self.tool_registry.use(GuardMiddleware(self.guard)) 

366 self.tool_registry.use(EditApplyMiddleware(diff_view=config.diff_view)) 

367 self.tool_registry.use(ErrorHandlingMiddleware()) 

368 

369 # ── 会话持久化 ── 

370 from merco.memory.session_store import SessionStore 

371 from merco.memory.session_search import SessionSearch 

372 self._session_store = SessionStore(_get_db_path()) 

373 self._search = SessionSearch(self._session_store) 

374 self.session = Session.resume_or_create(self._session_store) 

375 # _restore_context() 在 Agent.create()._initialize_async_plugins() 中执行 

376 

377 # ── 工厂:根据 config 选响应策略 ── 

378 if self.config.streaming: 

379 self._provider: ResponseProvider = StreamingProvider() 

380 else: 

381 self._provider: ResponseProvider = NonStreamingProvider() 

382 

383 # ── Pipeline 初始化 ── 

384 from .pipeline import ResultPipeline, RecoveryPipeline, EmptyResponsePipeline 

385 from merco.tools.processors.truncation import TruncationProcessor 

386 from merco.skills.processors import SkillViewProcessor 

387 from merco.core.recovery.wait import WaitRecovery 

388 from merco.context.recovery import ContextCompressRecovery 

389 from merco.core.empty_response import CallbackEmptyResponse 

390 self.result_pipeline = ResultPipeline() 

391 self.result_pipeline.use(TruncationProcessor(max_bytes=16000)) 

392 self.result_pipeline.use(SkillViewProcessor()) 

393 self.recovery_pipeline = RecoveryPipeline() 

394 self.recovery_pipeline.use(WaitRecovery(delay=3.0)) 

395 self.recovery_pipeline.use(ContextCompressRecovery()) 

396 self.empty_response_pipeline = EmptyResponsePipeline() 

397 self.empty_response_pipeline.use(CallbackEmptyResponse()) 

398 

399 # ── Prompt 构建器 ── 

400 self.prompt_builder = PromptBuilder() 

401 self.prompt_builder.use(BasePromptChunk()) 

402 self.prompt_builder.use(SkillsHintChunk()) 

403 self.prompt_builder.use(TimeContextChunk()) 

404 

405 # ── Memory 召回 ── 

406 from merco.memory.recall import HybridRecaller, FTS5Recaller, MemoryRecaller 

407 from merco.memory.store import MemoryStore 

408 from merco.memory.backend import MemoryBackendRegistry 

409 from merco.memory.backends.json_backend import JSONBackend 

410 

411 self.memory_backends = MemoryBackendRegistry() 

412 self.memory_backends.register(JSONBackend(config.memory_path)) 

413 

414 backend_name = config.memory_backend or "json" 

415 selected_backend = self.memory_backends.get(backend_name) or self.memory_backends.get("json") 

416 

417 _fts5 = FTS5Recaller(self._search) 

418 _mem = MemoryRecaller(MemoryStore(backend=selected_backend)) 

419 self.recaller = ( 

420 HybridRecaller(limit=config.memory_recall_limit, max_chars=config.memory_recall_max_chars) 

421 .add(_fts5) 

422 .add(_mem) 

423 ) 

424 

425 # ── Memory 保存链(让 /remember 和 session 结束抽取可写入)── 

426 from merco.memory.save_pipeline import MemorySavePipeline 

427 from merco.memory.strategy import ( 

428 ExplicitRememberStrategy, SessionEndExtractStrategy, 

429 ) 

430 

431 self._memory_store = MemoryStore(backend=selected_backend) 

432 self.memory_save_pipeline = MemorySavePipeline( 

433 store=self._memory_store, 

434 hooks=self.hooks, 

435 ) 

436 self.memory_strategies = [ 

437 ExplicitRememberStrategy(self.memory_save_pipeline), 

438 ] 

439 if self.config.memory_auto_extract_on_session_end: 

440 self.memory_strategies.append( 

441 SessionEndExtractStrategy( 

442 self.memory_save_pipeline, self.llm, 

443 session_store=self._session_store, 

444 max_per_session=self.config.memory_extract_max_per_session, 

445 min_messages=self.config.memory_extract_min_messages, 

446 ) 

447 ) 

448 for strat in self.memory_strategies: 

449 strat.subscribe(self.hooks) 

450 

451 # ── 插件系统 ── 

452 from merco.plugins.base import PluginContext 

453 from merco.plugins.manager import PluginManager 

454 from merco.plugins.builtin.observability.plugin import ObservabilityPlugin 

455 from merco.plugins.builtin.skills.plugin import SkillPlugin 

456 from merco.plugins.builtin.mcp.plugin import MCPPlugin 

457 from merco.plugins.builtin.subagent.plugin import SubAgentPlugin 

458 from merco.plugins.builtin.web.plugin import WebPlugin 

459 from merco.plugins.builtin.scheduler.plugin import SchedulerPlugin 

460 from merco.plugins.builtin.superpower.plugin import SuperpowerPlugin 

461 

462 # ── Context Pipeline ── 

463 from merco.context.pipeline import ContextPipeline 

464 from merco.context.processors.compress import CompressProcessor 

465 from merco.context.processors.cache_optimize import CacheOptimizeProcessor 

466 

467 self.context_pipeline = ContextPipeline() 

468 self.context_pipeline.use(CacheOptimizeProcessor()) 

469 self.context_pipeline.use(CompressProcessor( 

470 max_tokens=config.max_input_tokens, 

471 threshold=config.compression_threshold, 

472 )) 

473 

474 # ── AgentProfile Registry ── 

475 from merco.agents.profile import AgentProfileRegistry, BUILTIN_PROFILES 

476 

477 self.agent_profiles = AgentProfileRegistry() 

478 for p in BUILTIN_PROFILES: 

479 self.agent_profiles.register(p) 

480 

481 # Todo + SubAgent 由 SubAgentPlugin 激活时接管 

482 from merco.todo.manager import TodoManager 

483 from merco.agents.subagent import SubAgentManager 

484 self.todo_manager = TodoManager(f"{config.memory_path}/../todos.db") 

485 self.sub_agent_manager = SubAgentManager(self, self.agent_profiles) 

486 

487 # ── Loop Policy ── 

488 from merco.core.loop_policy import LoopPolicyRegistry, DefaultLoopPolicy 

489 self.loop_policies = LoopPolicyRegistry() 

490 self.loop_policies.register(DefaultLoopPolicy()) 

491 self.loop_policies.set_active("default") 

492 

493 self._plugin_ctx = PluginContext( 

494 hooks=self.hooks, 

495 tool_registry=self.tool_registry, 

496 prompt_builder=self.prompt_builder, 

497 recovery_pipeline=self.recovery_pipeline, 

498 result_pipeline=self.result_pipeline, 

499 memory_save_pipeline=self.memory_save_pipeline, 

500 recaller=self.recaller, 

501 config=config, 

502 observer=self.observer, 

503 todo_manager=self.todo_manager, 

504 sub_agent_manager=self.sub_agent_manager, 

505 context_pipeline=self.context_pipeline, 

506 memory_backends=self.memory_backends, 

507 agent_profiles=self.agent_profiles, 

508 loop_policies=self.loop_policies, 

509 ) 

510 self._plugin_ctx.agent = self 

511 self.plugin_manager = PluginManager(self._plugin_ctx) 

512 

513 # 注册内置插件 

514 self.plugin_manager.register(ObservabilityPlugin()) 

515 self.plugin_manager.register(SkillPlugin()) 

516 self.plugin_manager.register(MCPPlugin()) 

517 self.plugin_manager.register(SubAgentPlugin()) 

518 self.plugin_manager.register(WebPlugin()) 

519 self.plugin_manager.register(SchedulerPlugin()) 

520 self.plugin_manager.register(SuperpowerPlugin()) 

521 

522 # ── MCP 客户端(由 MCPPlugin 激活时创建;legacy 路径下保持 None)── 

523 self.mcp_manager = None 

524 

525 # ── 中断清理管线 ── 

526 self._cleanup_pipeline = (InterruptCleanupPipeline() 

527 .use(InjectCancelMessages()) 

528 .use(TerminateSubprocesses()) 

529 .use(CloseMCPConnections()) 

530 .use(EmitInterruptHooks()) 

531 .use(SavePartialState())) 

532 

533 @classmethod 

534 async def create(cls, config: MercoConfig, tool_registry=None) -> "Agent": 

535 """Create an Agent with deterministic async plugin initialization.""" 

536 agent = cls(config=config, tool_registry=tool_registry) 

537 await agent._initialize_async_plugins() 

538 return agent 

539 

540 async def _initialize_async_plugins(self) -> None: 

541 """Initialize plugins in deterministic order for Agent.create().""" 

542 await self.plugin_manager.activate("observability") 

543 self.observer = self._plugin_ctx.observer 

544 assert self.observer is not None 

545 self._restore_context() 

546 await self.plugin_manager.activate("skills") 

547 await self.plugin_manager.activate("mcp") 

548 await self.plugin_manager.activate("subagent") 

549 await self.plugin_manager.activate("web") 

550 await self.plugin_manager.activate("scheduler") 

551 await self.plugin_manager.activate_all() 

552 

553 async def run(self, prompt: str) -> str: 

554 """执行一次 Agent 循环""" 

555 self._current_prompt = prompt 

556 self._error_displayed_in_stream = False 

557 

558 # ── 生命周期事件:session.create(首次激活时)── 

559 await self.hooks.emit("session.create", session_id=self.session.id) 

560 # ── 生命周期事件:agent.start ── 

561 await self.hooks.emit("agent.start", session_id=self.session.id) 

562 

563 # 添加用户消息 

564 self.session.add_message("user", prompt) 

565 self.context.add({"role": "user", "content": prompt}) 

566 

567 # ── 消息事件:message.receive ── 

568 await self.hooks.emit("message.receive", message=prompt) 

569 

570 tools = self.tool_registry.get_definitions() if self.tool_registry else [] 

571 self.context.set_overhead(await self._build_system_prompt(), len(tools)) 

572 if self.context.needs_compression(): 

573 await self._compress_context() 

574 

575 # 执行 Agent 循环 

576 try: 

577 result = await self._agent_loop() 

578 # 正常结束时:保存 session 

579 self._auto_title(prompt) 

580 self.session.metadata["observer"] = self.observer.snapshot() 

581 self.session.save() 

582 self._session_store.save_metadata(self.session.id, self.session.metadata) 

583 await self.hooks.emit("conversation.turn") 

584 except asyncio.CancelledError: 

585 # 使用 InterruptCleanupPipeline 替换 _inject_interrupted_tool_results 

586 cancelled_tool_calls = self._find_orphan_tool_calls() 

587 cleanup_ctx = CleanupContext( 

588 agent=self, 

589 cancelled_tool_calls=cancelled_tool_calls, 

590 session_id=self.session.id, 

591 ) 

592 await self._cleanup_pipeline.process(cleanup_ctx) 

593 raise 

594 finally: 

595 # ── 生命周期事件:agent.stop(所有退出路径都触发)── 

596 await self.hooks.emit("agent.stop", session_id=self.session.id) 

597 # ── 生命周期事件:session.destroy(清理 session)── 

598 await self.hooks.emit("session.destroy", session_id=self.session.id) 

599 return result 

600 

601 def _restore_context(self): 

602 """清空上下文,然后从持久化会话恢复消息""" 

603 self.context = ContextManager(max_tokens=self.config.max_input_tokens) 

604 snap = self.session.metadata.get("observer") 

605 if snap: 

606 self.observer.restore(snap) 

607 

608 checkpoint = self.session.metadata.get("compress_checkpoint") 

609 if checkpoint: 

610 # Restore from checkpoint: summary + tail only 

611 summary = checkpoint.get("summary", "") 

612 tail_count = checkpoint.get("tail_count", 5) 

613 original_count = checkpoint.get("original_count", 0) 

614 all_msgs = self.session.messages 

615 

616 # 如果之后新增了大量消息,checkpoint 已过时 → 全量恢复后重新压缩 

617 if original_count > 0 and len(all_msgs) > original_count + 20: 

618 logger.debug("_restore_context: checkpoint 过时 (original=%d now=%d),全量恢复", 

619 original_count, len(all_msgs)) 

620 del self.session.metadata["compress_checkpoint"] 

621 # fall through to full restore below 

622 else: 

623 tail = all_msgs[-tail_count * 2:] if len(all_msgs) > tail_count * 2 else all_msgs 

624 

625 if summary: 

626 self.context.add({"role": "system", "content": summary}) 

627 for msg in tail: 

628 entry = {"role": msg["role"], "content": msg.get("content", "")} 

629 if "tool_call_id" in msg: 

630 entry["tool_call_id"] = msg["tool_call_id"] 

631 if msg.get("tool_calls"): 

632 entry["tool_calls"] = msg["tool_calls"] 

633 self.context.add(entry) 

634 return 

635 

636 for msg in self.session.messages: 

637 r = msg.get("reasoning", "") 

638 if r: 

639 logger.debug("_restore_context: session 消息含 reasoning (%d chars, 已丢弃)", 

640 len(r)) 

641 entry = {"role": msg["role"], "content": msg.get("content", "")} 

642 if "tool_call_id" in msg: 

643 entry["tool_call_id"] = msg["tool_call_id"] 

644 if msg.get("tool_calls"): 

645 entry["tool_calls"] = msg["tool_calls"] 

646 self.context.add(entry) 

647 

648 def _auto_title(self, user_input: str): 

649 """用首条用户消息的前 40 字作为会话标题""" 

650 if self.session.title or not user_input: 

651 return 

652 title = user_input.strip()[:40] 

653 self.session.title = title 

654 self._session_store.update_title(self.session.id, title) 

655 

656 def _wrap_up_messages(self, messages): 

657 """构造收尾消息列表:追加总结请求。""" 

658 return messages + [{ 

659 "role": "user", 

660 "content": "已达到最大工具调用次数。请基于已有信息给出最终回复,不要再调用工具。" 

661 }] 

662 

663 async def _wrap_up_call(self, messages): 

664 """收尾调用:无工具文字回应。""" 

665 try: 

666 resp = await self.llm.chat(messages, tools=[], tool_choice="none") 

667 except Exception: 

668 return "模型调用失败" 

669 content = resp.get("content", "") or "已达到调用上限。" 

670 self.session.add_message("assistant", content) 

671 self.context.add({"role": "assistant", "content": content}) 

672 return content 

673 

674 

675 async def _agent_loop(self) -> str: 

676 """Agent 主循环。工具预算耗尽时直接收尾。""" 

677 self._tool_calls_count = 0 

678 self._max_tool_calls = self.config.max_tool_calls 

679 _empty_retries = 0 

680 _recovery_attempts = 0 

681 tools = [] 

682 

683 while True: 

684 messages = await self._build_messages() 

685 tools = list(self.tool_registry.get_definitions() if self.tool_registry else []) 

686 

687 if self._tool_calls_count >= self._max_tool_calls: 

688 return await self._wrap_up_call(self._wrap_up_messages(messages)) 

689 

690 logger.debug("→ Agent 循环 #%d: %d 条消息", self._tool_calls_count, len(messages)) 

691 t0 = time.monotonic() 

692 try: 

693 before = await self.hooks.emit("llm.before_chat", messages=messages, tools=tools) 

694 if before and before.data: 

695 messages = before.data.get("messages", messages) 

696 tools = before.data.get("tools", tools) 

697 if before.stop: 

698 response = before.data["response"] 

699 else: 

700 response = await self._provider.get_response( 

701 self, messages, tools or None) 

702 else: 

703 response = await self._provider.get_response( 

704 self, messages, tools or None) 

705 except Exception as e: 

706 _recovery_attempts += 1 

707 if _recovery_attempts > 3: 

708 from merco.core.llm.errors import llm_error 

709 return llm_error(e) 

710 from openai import APIStatusError 

711 from .pipeline import RecoveryContext 

712 ctx = RecoveryContext( 

713 error=e, 

714 status_code=e.status_code if isinstance(e, APIStatusError) else 0, 

715 context_tokens=self.context.current_tokens, 

716 tool_count=len(tools), model=self.config.model.model) 

717 if await self.recovery_pipeline.attempt(ctx): 

718 if ctx.extra_wait > 0: 

719 await asyncio.sleep(ctx.extra_wait) 

720 if ctx.compress: 

721 await self._compress_context() 

722 if ctx.switch_model: 

723 logger.info("→ 切换模型: %s", ctx.switch_model) 

724 self.llm.model = ctx.switch_model 

725 continue 

726 from merco.core.llm.errors import llm_error 

727 return llm_error(e) 

728 

729 after = await self.hooks.emit("llm.after_chat", response=response) 

730 if after and after.data: 

731 response = after.data.get("response", response) 

732 

733 # 记录 API 返回的实测 token(流式可能无 usage,fallback 到估算值) 

734 usage = response.get("usage") 

735 if usage and usage.get("prompt_tokens"): 

736 self.context.last_actual_tokens = usage["prompt_tokens"] 

737 

738 tokens_in = usage.get("prompt_tokens") if usage else self.context.total_tokens 

739 tokens_out = usage.get("completion_tokens") if usage else est_tk( 

740 (response.get("content") or "") + (response.get("reasoning") or "")) 

741 

742 await self.hooks.emit("llm.chat", 

743 duration=time.monotonic() - t0, 

744 tokens_in=tokens_in, 

745 tokens_out=tokens_out, 

746 cached_tokens=usage.get("cached_tokens", 0) if usage else 0, 

747 cache_read_tokens=usage.get("cache_read_tokens", 0) if usage else 0) 

748 

749 tool_calls = response.get("tool_calls") 

750 if tool_calls: 

751 logger.debug("Agent 循环: 收到 %d 个 tool_calls: %s", 

752 len(tool_calls), 

753 [f"{tc['name']}({tc.get('id','?')[:8]})" for tc in tool_calls]) 

754 

755 from merco.core.loop_policy import LoopState 

756 state = LoopState( 

757 iteration=self._tool_calls_count, 

758 tool_calls_count=self._tool_calls_count, 

759 max_tool_calls=self._max_tool_calls, 

760 has_tool_calls=bool(tool_calls), 

761 finish_reason=response.get("finish_reason"), 

762 ) 

763 decision = await self.loop_policies.active.decide(response, state) 

764 

765 if decision.action == "exit": 

766 content = response.get("content", "") or "" 

767 content = re.sub(r'<\w+:tool_call[^>]*>.*?</\w+:tool_call>', '', content, flags=re.DOTALL).strip() 

768 reasoning = response.get("reasoning", "") 

769 if reasoning: 

770 logger.debug("Agent 循环: 收到 reasoning (%d chars),丢弃(不存入 context)", 

771 len(reasoning)) 

772 if not content: 

773 _empty_retries += 1 

774 if _empty_retries == 1 and reasoning: 

775 from .pipeline import EmptyResponseContext 

776 ectx = EmptyResponseContext( 

777 reasoning=reasoning, retry_count=_empty_retries) 

778 if await self.empty_response_pipeline.attempt(ectx): 

779 if ectx.inject_error: 

780 self.context.add({"role": "user", "content": ectx.inject_error}) 

781 console.print("[dim] \u21bb 空回复 \u2192 回调 LLM…[/dim]") 

782 continue 

783 content = reasoning or "\uff08\u65e0\u56de\u590d\uff09" 

784 self.session.add_message("assistant", content) 

785 self.context.add({"role": "assistant", "content": content}) 

786 return content 

787 

788 # 校验:过滤不在当前工具列表中的幻觉调用(预算耗尽时 tools 为空,全部拦截) 

789 valid_names = {t["function"]["name"] for t in tools} if tools else set() 

790 valid_calls = [tc for tc in tool_calls if tc["name"] in valid_names] 

791 if len(valid_calls) < len(tool_calls): 

792 logger.debug("过滤 %d 个幻觉工具调用: %s", 

793 len(tool_calls) - len(valid_calls), 

794 [tc["name"] for tc in tool_calls if tc["name"] not in valid_names]) 

795 if not valid_calls: 

796 # 全部是幻觉或无工具可用 → 当作文字回答 

797 content = response.get("content", "") or "" 

798 # 清理 LLM 幻觉的工具调用标签 

799 content = re.sub(r'<\w+:tool_call[^>]*>.*?</\w+:tool_call>', '', content, flags=re.DOTALL) 

800 content = content.strip() 

801 if not content: 

802 content = "已达到调用上限。" 

803 self.session.add_message("assistant", content) 

804 self.context.add({"role": "assistant", "content": content}) 

805 return content 

806 tool_calls = valid_calls 

807 

808 # 批量超上限 → 不执行工具,直接收尾 

809 if self._tool_calls_count + len(tool_calls) > self._max_tool_calls: 

810 logger.debug("Agent 循环: 工具调用超上限 (%d + %d > %d),跳过执行直接收尾", 

811 self._tool_calls_count, len(tool_calls), self._max_tool_calls) 

812 console.print("[dim] 已截停,达到调用上限[/dim]") 

813 return await self._wrap_up_call(self._wrap_up_messages(await self._build_messages())) 

814 

815 await self._dispatch_tool_calls(tool_calls, response) 

816 await asyncio.sleep(0.5) 

817 

818 def _find_orphan_tool_calls(self) -> list[dict]: 

819 """查找孤儿 tool_calls(未完成的)。""" 

820 completed_ids = set() 

821 for msg in self.context.messages: 

822 if msg.get("tool_call_id"): 

823 completed_ids.add(msg["tool_call_id"]) 

824 

825 orphans = [] 

826 for msg in reversed(self.context.messages): 

827 if msg.get("role") != "assistant": 

828 continue 

829 for tc in (msg.get("tool_calls") or []): 

830 tc_id = tc.get("id") if isinstance(tc, dict) else None 

831 if tc_id and tc_id not in completed_ids: 

832 orphans.append(tc) 

833 return orphans 

834 

835 async def _dispatch_tool_calls(self, tool_calls: list[dict], response: dict) -> None: 

836 """工具调度:渲染内容 → 写上下文 → 执行工具""" 

837 assistant_content = (response.get("content", "") or "").strip() 

838 if assistant_content: 

839 console.print(Panel(Markdown(assistant_content), border_style="dim")) 

840 api_tool_calls = [ 

841 {"id": tc["id"], "type": "function", 

842 "function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"], ensure_ascii=True)}} 

843 for tc in tool_calls 

844 ] 

845 reasoning = response.get("reasoning", "") 

846 if reasoning: 

847 logger.debug("_dispatch_tool_calls: response 有 reasoning (%d chars) 但未传入 context", 

848 len(reasoning)) 

849 self.context.add({"role": "assistant", "content": assistant_content, "tool_calls": api_tool_calls}) 

850 self.session.add_message("assistant", assistant_content, tool_calls=api_tool_calls) 

851 logger.debug("⚙ 执行 %d 个工具调用: %s", len(tool_calls), [tc["name"] for tc in tool_calls]) 

852 await self._execute_tool_calls(tool_calls) 

853 

854 async def _execute_tool_calls(self, tool_calls: list[dict]): 

855 """执行一组工具调用""" 

856 tool_results = [] 

857 exec_contexts = [] 

858 

859 for tc in tool_calls: 

860 tool_name = tc["name"] 

861 arguments = tc["arguments"] 

862 tool_call_id = tc["id"] 

863 

864 # 构建显示文本 

865 progress = f"{self._tool_calls_count + 1}/{self._max_tool_calls}" 

866 arg_parts = [f"{k}={v}" for k, v in arguments.items()] 

867 arg_str = ", ".join(arg_parts) 

868 term_w = shutil.get_terminal_size().columns or 80 

869 # 截断需要为最终状态 `✓ ... 0.0s` 预留空间 

870 final_line = f"[dim] ✓ {tool_name} ({progress}) {arg_str} 99.9s[/dim]" 

871 if len(final_line) >= term_w: 

872 avail = term_w - len(f"{tool_name} ({progress}) ") - 3 - 7 

873 arg_str = arg_str[:max(0, avail)] + "..." 

874 

875 # ── 守卫检查 ── 

876 t0 = time.monotonic() 

877 approved = await self.guard.check(tool_name, arguments) 

878 if not approved: 

879 result = {"error": "操作已被拦截或取消"} 

880 

881 elif self.tool_registry: 

882 

883 _INTERACTIVE_TOOLS = {"edit_file"} # 会弹确认提示的工具,不能用 spinner(会覆盖终端) 

884 if tool_name in _INTERACTIVE_TOOLS: 

885 console.print(f"[bright_black] ⚙ {tool_name} ({progress}) {arg_str}[/bright_black]") 

886 try: 

887 await self.hooks.emit("tool.before_execute", tool_name=tool_name, args=arguments) 

888 result = await self.tool_registry.execute(tool_name, **arguments) 

889 except GuardConfirmationRequired as e: 

890 # 需要用户确认 

891 if not await self._ask_guard_confirmation(e.result): 

892 result = {"error": "用户取消了敏感操作", "tool": tool_name} 

893 else: 

894 # 用户确认后,直接执行(跳过 guard) 

895 tool = self.tool_registry.get(tool_name) 

896 if tool is None: 

897 result = {"error": f"工具 '{tool_name}' 不存在"} 

898 else: 

899 await self.hooks.emit("tool.before_execute", tool_name=tool_name, args=arguments) 

900 result = await tool.execute(**arguments) 

901 elapsed = time.monotonic() - t0 

902 console.print(f"[bright_black] ✓ {tool_name} ({progress}) {arg_str} {elapsed:.1f}s[/bright_black]") 

903 else: 

904 from rich.live import Live 

905 from rich.text import Text 

906 import itertools 

907 with Live(Text.from_markup(f"[bright_black] ⚙ {tool_name} ({progress}) {arg_str}[/bright_black]"), refresh_per_second=8, transient=False) as live: 

908 spinner = itertools.cycle("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") 

909 async def _run_with_spinner(): 

910 try: 

911 await self.hooks.emit("tool.before_execute", tool_name=tool_name, args=arguments) 

912 task = asyncio.create_task(self.tool_registry.execute(tool_name, **arguments)) 

913 while not task.done(): 

914 live.update(Text.from_markup(f"[bright_black] {next(spinner)} {tool_name} ({progress}) {arg_str}[/bright_black]")) 

915 await asyncio.sleep(0.1) 

916 return await task 

917 except GuardConfirmationRequired as e: 

918 # 需要用户确认 

919 live.stop() 

920 if not await self._ask_guard_confirmation(e.result): 

921 return {"error": "用户取消了敏感操作", "tool": tool_name} 

922 # 用户确认后,直接执行(跳过 guard) 

923 tool = self.tool_registry.get(tool_name) 

924 if tool is None: 

925 return {"error": f"工具 '{tool_name}' 不存在"} 

926 await self.hooks.emit("tool.before_execute", tool_name=tool_name, args=arguments) 

927 return await tool.execute(**arguments) 

928 result = await _run_with_spinner() 

929 elapsed = time.monotonic() - t0 

930 live.update(Text.from_markup(f"[bright_black] ✓ {tool_name} ({progress}) {arg_str} {elapsed:.1f}s[/bright_black]")) 

931 else: 

932 result = {"error": f"Tool '{tool_name}' not available"} 

933 

934 logger.debug(" ◀ 工具 %s 返回: %s", tool_name, 

935 str(result)[:500]) 

936 

937 # ── 可观察性 ── 

938 elapsed = time.monotonic() - t0 

939 if "error" in result: 

940 await self.hooks.emit("tool.error", tool_name=tool_name, 

941 error=result.get("error", "")) 

942 else: 

943 await self.hooks.emit("tool.after_execute", tool_name=tool_name, 

944 duration=elapsed) 

945 

946 # ── Pipeline 处理 ── 

947 tool = self.tool_registry.get(tool_name) if self.tool_registry else None 

948 pctx = ProcessContext( 

949 tool_name=tool_name, arguments=arguments, result=result, 

950 tool_schema=getattr(tool, 'parameters', None), 

951 tool_call_id=tool_call_id) 

952 await self.result_pipeline.process(pctx) 

953 if isinstance(pctx.result, str): 

954 content = pctx.result 

955 else: 

956 try: 

957 content = json.dumps(pctx.result, ensure_ascii=False) 

958 except (TypeError, ValueError): 

959 content = str(pctx.result) 

960 tool_results.append({ 

961 "role": "tool", 

962 "tool_call_id": tool_call_id, 

963 "content": content}) 

964 exec_contexts.append(pctx) 

965 self._tool_calls_count += 1 

966 

967 logger.debug("_execute_tool_calls: %d 个结果存入 context", len(tool_results)) 

968 for tr in tool_results: 

969 self.context.add(tr) 

970 self.session.add_message("tool", tr["content"], 

971 tool_call_id=tr.get("tool_call_id", "")) 

972 for pctx in exec_contexts: 

973 for msg in pctx.extra_messages: 

974 self.context.add(msg) 

975 

976 async def _build_messages(self) -> list[dict]: 

977 """构建发送给 LLM 的消息列表""" 

978 messages = [ 

979 {"role": "system", "content": await self._build_system_prompt()}, 

980 ] 

981 

982 # 添加上下文窗口中的消息 

983 messages.extend(self.context.get_window()) 

984 

985 # 检测 reasoning 泄漏:如果消息列表中有任何非空的 reasoning 字段,记录警告 

986 for i, m in enumerate(messages): 

987 r = m.get("reasoning", "") 

988 if r: 

989 logger.warning("_build_messages: messages[%d] 含有 reasoning (%d chars, 前 100=%s…)", 

990 i, len(r), r[:100].replace("\n", "\\n")) 

991 

992 return messages 

993 

994 async def _build_system_prompt(self) -> str: 

995 base = self.prompt_builder.build(self) 

996 

997 if self.config.memory_recall_enabled and self._current_prompt: 

998 try: 

999 recalled = await self.recaller.recall(self._current_prompt) 

1000 if recalled: 

1001 lines = ["\n## 相关历史对话(仅供参考)"] 

1002 for i, r in enumerate(recalled, 1): 

1003 lines.append(f"{i}. [{r.session_title}] {r.snippet}") 

1004 base += "\n".join(lines) 

1005 except Exception: 

1006 logging.getLogger("merco.agent").debug("Memory recall failed", exc_info=True) 

1007 

1008 return base 

1009 

1010 async def _llm_summary(self, messages: list[dict]) -> str: 

1011 """LLM 生成语义摘要——保留用户意图 + 关键决策""" 

1012 lines = [] 

1013 for m in messages: 

1014 role = m.get("role", "unknown") 

1015 content = m.get("content", "") 

1016 if isinstance(content, str) and content.strip(): 

1017 # tool 结果只取前 200 字 

1018 text = content[:200] if role == "tool" else content[:600] 

1019 lines.append(f"[{role}]: {text}") 

1020 

1021 prompt = ( 

1022 "Summarize this conversation segment into one concise paragraph " 

1023 "(under 150 words). Include: what the user asked, what tools were " 

1024 "used, key findings or decisions. Use natural language, not bullet " 

1025 "points.\n\n" 

1026 + "\n".join(lines[-30:]) # 最多 30 条 

1027 + "\n\nSummary:" 

1028 ) 

1029 try: 

1030 response = await self.llm.chat( 

1031 [{"role": "user", "content": prompt}], tools=[] 

1032 ) 

1033 content = response.get("content", "").strip() 

1034 return f"[Earlier conversation summary]: {content}" 

1035 except Exception as e: 

1036 logger.warning("LLM summary failed: %s", e) 

1037 return f"[{len(messages)} earlier messages — summary unavailable]" 

1038 

1039 async def _compress_context(self): 

1040 """压缩上下文""" 

1041 # 压缩前备份 Session 数据库 

1042 # Note: DB-level backup and session-level auto-fork are independent 

1043 # safeguards, not fallbacks for each other. Backup covers any DB 

1044 # corruption/restore; auto-fork preserves the full session history. 

1045 backup_ok = self._session_store.backup() 

1046 

1047 # Auto-fork: save complete copy before compressing 

1048 if self.config.fork_enabled and self.config.fork_auto_on_compress: 

1049 try: 

1050 self.session.save() 

1051 archived_id = self._session_store.clone_session(self.session.id) 

1052 console.print(f"[dim]📦 原会话已归档: {archived_id[:8]}[/dim]") 

1053 except Exception: 

1054 logger.debug("Auto-fork failed", exc_info=True) 

1055 

1056 summary_result = None 

1057 # Capture original count before context.messages is reassigned 

1058 original_count = len(self.session.messages) 

1059 

1060 async def llm_summary_wrapper(messages: list[dict]) -> str: 

1061 nonlocal summary_result 

1062 result = await self._llm_summary(messages) 

1063 summary_result = result 

1064 return result 

1065 

1066 try: 

1067 await self.hooks.emit("context.compact", strategy="sliding_window") 

1068 compressed = await self.context_pipeline.run( 

1069 self.context.messages, 

1070 summary_fn=llm_summary_wrapper, 

1071 compress_strategy="sliding", 

1072 ) 

1073 self.context.messages = compressed 

1074 self.context.current_tokens = sum( 

1075 msg_tokens(m) for m in compressed 

1076 ) 

1077 # Store compression checkpoint so restart doesn't re-expand 

1078 self.session.metadata["compress_checkpoint"] = { 

1079 "summary": summary_result or "", 

1080 "compressed_at": time.time(), 

1081 "original_count": original_count, 

1082 "tail_count": 5, 

1083 } 

1084 console.print("[dim]→ Context compressed (LLM summarized)[/dim]") 

1085 console.print("[dim]→ 用 /history 查看完整记录[/dim]") 

1086 except Exception: 

1087 # Compression failed — keep backup so user can manually recover 

1088 logger.exception("Context compression failed; backup retained") 

1089 raise 

1090 else: 

1091 # 压缩成功,删除备份 

1092 self._session_store.delete_backup() 

1093 

1094 async def _ask_guard_confirmation(self, result) -> bool: 

1095 """展示安全确认 Panel 并获取用户确认 

1096 

1097 Args: 

1098 result: GuardResult 对象,包含 command, rule 等信息 

1099 

1100 Returns: 

1101 True=用户确认执行, False=用户拒绝 

1102 """ 

1103 rule = result.rule 

1104 

1105 console.print(Panel( 

1106 f"[yellow]{result.command}[/yellow]\n" 

1107 f"[dim]匹配规则: {rule.pattern if rule else '?'}[/dim]", 

1108 title="敏感命令", 

1109 border_style="yellow", 

1110 )) 

1111 

1112 console.print( 

1113 "[bold yellow]确认执行?[/bold yellow] [dim]y/N [/dim]", end="" 

1114 ) 

1115 resp = await asyncio.to_thread(input, "") 

1116 return resp.strip().lower() in ("y", "yes") 

1117 

1118 @staticmethod 

1119 def _render_reasoning(reasoning: str) -> None: 

1120 """非流式:渲染 thinking 面板""" 

1121 text = reasoning.strip() 

1122 if not text: 

1123 return 

1124 console.print(_build_reasoning_panel(text)) 

1125 

1126 def get_context_stats(self) -> dict: 

1127 """上下文窗口使用统计,供 REPL 进度条渲染""" 

1128 max_tokens = self.config.max_input_tokens 

1129 actual = getattr(self.context, "last_actual_tokens", 0) 

1130 current = actual if actual > 0 else self.context.total_tokens 

1131 ratio = min(current / max_tokens, 1.0) if max_tokens > 0 else 0 

1132 return { 

1133 "current": current, "max": max_tokens, "ratio": ratio, 

1134 "threshold": self.config.compression_threshold, 

1135 "is_estimate": actual == 0, 

1136 "tool_count": self._tool_calls_count, 

1137 "max_tool_calls": self._max_tool_calls, 

1138 } 

1139 

1140 def reset(self): 

1141 """重置会话:新建 session + 清空上下文""" 

1142 self.session = Session(store=self._session_store) 

1143 self._session_store.create_session(self.session.id) 

1144 self.context = ContextManager(max_tokens=self.config.max_input_tokens) 

1145 self._tool_calls_count = 0 

1146 self._current_prompt = "" 

1147 self._error_displayed_in_stream = False 

1148 

1149 @staticmethod 

1150 def _get_api_key(provider: str) -> str: 

1151 """从环境变量获取 API Key——由 PROVIDER_REGISTRY 驱动""" 

1152 import os 

1153 from .config import PROVIDER_REGISTRY 

1154 entry = PROVIDER_REGISTRY.get(provider) 

1155 if entry: 

1156 return os.environ.get(entry.key_env, "") 

1157 return "" 

1158 

1159def _get_db_path() -> str: 

1160 """跟随配置路径确定 sessions.db 位置""" 

1161 import os 

1162 from pathlib import Path 

1163 

1164 for candidate in [Path("./.merco"), Path(os.path.expanduser("~/.merco"))]: 

1165 if candidate.exists() or candidate.parent.exists(): 

1166 candidate.mkdir(parents=True, exist_ok=True) 

1167 return str(candidate / "sessions.db") 

1168 

1169 path = Path(os.path.expanduser("~/.merco")) 

1170 path.mkdir(parents=True, exist_ok=True) 

1171 return str(path / "sessions.db") 

1172 

1173 

1174class AgentLoop: 

1175 """Agent 循环控制器 - 管理多轮对话""" 

1176 

1177 def __init__(self, agent: Agent): 

1178 self.agent = agent 

1179 self.history = [] 

1180 

1181 async def step(self, message: str) -> dict: 

1182 """执行单步循环""" 

1183 response = await self.agent.run(message) 

1184 self.history.append({"user": message, "assistant": response}) 

1185 return {"response": response, "history_length": len(self.history)} 

1186 

1187 def reset(self): 

1188 """重置循环""" 

1189 self.history = [] 

1190 self.agent.reset()