cloneloop 0.1.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 (118) hide show
  1. cloneloop-0.1.0.dist-info/METADATA +392 -0
  2. cloneloop-0.1.0.dist-info/RECORD +118 -0
  3. cloneloop-0.1.0.dist-info/WHEEL +4 -0
  4. cloneloop-0.1.0.dist-info/entry_points.txt +5 -0
  5. mcp_ai_supervisor/__init__.py +52 -0
  6. mcp_ai_supervisor/__main__.py +551 -0
  7. mcp_ai_supervisor/debug.py +15 -0
  8. mcp_ai_supervisor/desktop_app/__init__.py +29 -0
  9. mcp_ai_supervisor/desktop_app/desktop_app.py +390 -0
  10. mcp_ai_supervisor/helpers/__init__.py +4 -0
  11. mcp_ai_supervisor/helpers/feedback_helpers.py +140 -0
  12. mcp_ai_supervisor/helpers/image_helpers.py +73 -0
  13. mcp_ai_supervisor/i18n.py +14 -0
  14. mcp_ai_supervisor/log_writer.py +16 -0
  15. mcp_ai_supervisor/logging/__init__.py +8 -0
  16. mcp_ai_supervisor/logging/log_parser.py +293 -0
  17. mcp_ai_supervisor/logging/log_tailer.py +367 -0
  18. mcp_ai_supervisor/py.typed +0 -0
  19. mcp_ai_supervisor/server.py +654 -0
  20. mcp_ai_supervisor/utils/__init__.py +28 -0
  21. mcp_ai_supervisor/utils/error_handler.py +10 -0
  22. mcp_ai_supervisor/utils/memory_monitor.py +11 -0
  23. mcp_ai_supervisor/utils/paths.py +7 -0
  24. mcp_ai_supervisor/utils/resource_manager.py +15 -0
  25. mcp_ai_supervisor/utils/structure_checker.py +291 -0
  26. mcp_ai_supervisor/web/__init__.py +26 -0
  27. mcp_ai_supervisor/web/constants/__init__.py +8 -0
  28. mcp_ai_supervisor/web/constants/message_codes.py +173 -0
  29. mcp_ai_supervisor/web/core/__init__.py +30 -0
  30. mcp_ai_supervisor/web/core/agent_bridge.py +957 -0
  31. mcp_ai_supervisor/web/core/auto_responder.py +332 -0
  32. mcp_ai_supervisor/web/core/context_collector.py +124 -0
  33. mcp_ai_supervisor/web/core/conversation_recorder.py +751 -0
  34. mcp_ai_supervisor/web/core/delegate_manager.py +1193 -0
  35. mcp_ai_supervisor/web/core/feedback_mediator.py +259 -0
  36. mcp_ai_supervisor/web/core/message_channel.py +551 -0
  37. mcp_ai_supervisor/web/core/response_builder.py +333 -0
  38. mcp_ai_supervisor/web/core/scene_detector.py +184 -0
  39. mcp_ai_supervisor/web/core/task_state.py +194 -0
  40. mcp_ai_supervisor/web/locales/en/translation.json +643 -0
  41. mcp_ai_supervisor/web/locales/zh-CN/translation.json +629 -0
  42. mcp_ai_supervisor/web/locales/zh-TW/translation.json +648 -0
  43. mcp_ai_supervisor/web/main.py +747 -0
  44. mcp_ai_supervisor/web/managers/__init__.py +8 -0
  45. mcp_ai_supervisor/web/managers/desktop_manager.py +228 -0
  46. mcp_ai_supervisor/web/managers/server_manager.py +170 -0
  47. mcp_ai_supervisor/web/managers/session_manager.py +515 -0
  48. mcp_ai_supervisor/web/managers/workbench_connector.py +186 -0
  49. mcp_ai_supervisor/web/models/__init__.py +13 -0
  50. mcp_ai_supervisor/web/models/feedback_result.py +16 -0
  51. mcp_ai_supervisor/web/models/feedback_session.py +938 -0
  52. mcp_ai_supervisor/web/models/session_cleaner.py +245 -0
  53. mcp_ai_supervisor/web/models/session_commands.py +213 -0
  54. mcp_ai_supervisor/web/models/session_resolver.py +158 -0
  55. mcp_ai_supervisor/web/models/session_timer.py +242 -0
  56. mcp_ai_supervisor/web/routes/__init__.py +12 -0
  57. mcp_ai_supervisor/web/routes/main_routes.py +965 -0
  58. mcp_ai_supervisor/web/routes/settings_routes.py +195 -0
  59. mcp_ai_supervisor/web/routes/ws_handlers.py +270 -0
  60. mcp_ai_supervisor/web/static/css/audio-management.css +545 -0
  61. mcp_ai_supervisor/web/static/css/notification-settings.css +152 -0
  62. mcp_ai_supervisor/web/static/css/prompt-management.css +566 -0
  63. mcp_ai_supervisor/web/static/css/session-management.css +1428 -0
  64. mcp_ai_supervisor/web/static/css/styles.css +2267 -0
  65. mcp_ai_supervisor/web/static/favicon.ico +0 -0
  66. mcp_ai_supervisor/web/static/icon-192.png +0 -0
  67. mcp_ai_supervisor/web/static/icon.svg +11 -0
  68. mcp_ai_supervisor/web/static/index.html +37 -0
  69. mcp_ai_supervisor/web/static/js/app.js +1721 -0
  70. mcp_ai_supervisor/web/static/js/i18n.js +376 -0
  71. mcp_ai_supervisor/web/static/js/modules/audio/audio-manager.js +610 -0
  72. mcp_ai_supervisor/web/static/js/modules/audio/audio-settings-ui.js +732 -0
  73. mcp_ai_supervisor/web/static/js/modules/connection-monitor.js +435 -0
  74. mcp_ai_supervisor/web/static/js/modules/constants/message-codes.js +168 -0
  75. mcp_ai_supervisor/web/static/js/modules/countdown-manager.js +273 -0
  76. mcp_ai_supervisor/web/static/js/modules/drag-drop-handler.js +677 -0
  77. mcp_ai_supervisor/web/static/js/modules/file-upload-manager.js +555 -0
  78. mcp_ai_supervisor/web/static/js/modules/image-handler.js +199 -0
  79. mcp_ai_supervisor/web/static/js/modules/logger.js +404 -0
  80. mcp_ai_supervisor/web/static/js/modules/notification/notification-manager.js +360 -0
  81. mcp_ai_supervisor/web/static/js/modules/notification/notification-settings.js +344 -0
  82. mcp_ai_supervisor/web/static/js/modules/prompt/prompt-input-buttons.js +427 -0
  83. mcp_ai_supervisor/web/static/js/modules/prompt/prompt-manager.js +414 -0
  84. mcp_ai_supervisor/web/static/js/modules/prompt/prompt-modal.js +458 -0
  85. mcp_ai_supervisor/web/static/js/modules/prompt/prompt-settings-ui.js +524 -0
  86. mcp_ai_supervisor/web/static/js/modules/session/session-data-manager.js +1042 -0
  87. mcp_ai_supervisor/web/static/js/modules/session/session-details-modal.js +594 -0
  88. mcp_ai_supervisor/web/static/js/modules/session/session-ui-renderer.js +836 -0
  89. mcp_ai_supervisor/web/static/js/modules/session-manager.js +1059 -0
  90. mcp_ai_supervisor/web/static/js/modules/settings-manager.js +1002 -0
  91. mcp_ai_supervisor/web/static/js/modules/tab-manager.js +235 -0
  92. mcp_ai_supervisor/web/static/js/modules/textarea-height-manager.js +267 -0
  93. mcp_ai_supervisor/web/static/js/modules/ui-manager.js +578 -0
  94. mcp_ai_supervisor/web/static/js/modules/utils/dom-utils.js +392 -0
  95. mcp_ai_supervisor/web/static/js/modules/utils/status-utils.js +403 -0
  96. mcp_ai_supervisor/web/static/js/modules/utils/time-utils.js +440 -0
  97. mcp_ai_supervisor/web/static/js/modules/utils.js +557 -0
  98. mcp_ai_supervisor/web/static/js/modules/websocket-manager.js +875 -0
  99. mcp_ai_supervisor/web/static/js/modules/workbench-notification.js +103 -0
  100. mcp_ai_supervisor/web/static/js/vendor/marked.min.js +6 -0
  101. mcp_ai_supervisor/web/static/js/vendor/purify.min.js +3 -0
  102. mcp_ai_supervisor/web/templates/components/image-upload.html +43 -0
  103. mcp_ai_supervisor/web/templates/components/settings-card.html +58 -0
  104. mcp_ai_supervisor/web/templates/components/status-indicator.html +31 -0
  105. mcp_ai_supervisor/web/templates/components/toggle-switch.html +19 -0
  106. mcp_ai_supervisor/web/templates/feedback.html +1198 -0
  107. mcp_ai_supervisor/web/templates/index.html +378 -0
  108. mcp_ai_supervisor/web/utils/__init__.py +12 -0
  109. mcp_ai_supervisor/web/utils/browser.py +35 -0
  110. mcp_ai_supervisor/web/utils/compression_config.py +195 -0
  111. mcp_ai_supervisor/web/utils/compression_monitor.py +314 -0
  112. mcp_ai_supervisor/web/utils/ide_opener.py +286 -0
  113. mcp_ai_supervisor/web/utils/network.py +66 -0
  114. mcp_ai_supervisor/web/utils/port_manager.py +340 -0
  115. mcp_ai_supervisor/web/utils/session_cleanup_manager.py +543 -0
  116. mcp_ai_supervisor/web/utils/workbench_client.py +899 -0
  117. mcp_ai_supervisor/workbench/__init__.py +5 -0
  118. mcp_ai_supervisor/workbench/__main__.py +5 -0
@@ -0,0 +1,957 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ qodercli Agent 桥接器模块
4
+ =========================
5
+
6
+ 负责与 qodercli Agent 交互,包括:
7
+ - 环境检查(安装、登录、模型探测)
8
+ - Prompt 格式化与子进程调用
9
+ - 响应解析(多策略 fallback)
10
+
11
+ 设计决策参考:
12
+ - D49: Prompt 模板使用 $variable 语法
13
+ - D56: _parse_response 多策略 fallback
14
+ - D59/R20: 按 session_id 管理活跃子进程
15
+ - D64: Prompt 只传路径和元数据
16
+ - D72: 模型探测并行执行
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ import os
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+
30
+ from ...debug import server_debug_log as debug_log
31
+ from ...log_writer import get_logger as _get_struct_logger
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ AGENT_TRACE_LOG = Path.home() / ".mcp" / "agent-trace.log"
36
+
37
+
38
+ AGENT_FAILURE_CATEGORIES = frozenset({
39
+ "none",
40
+ "skill_compliance",
41
+ "content_quality",
42
+ "clarification",
43
+ })
44
+
45
+
46
+ def _normalize_failure_category(raw: object) -> str:
47
+ """未知或非法取值视为 none,保证下游只需处理固定枚举。"""
48
+ if raw is None:
49
+ return "none"
50
+ s = str(raw).strip().lower()
51
+ if s in AGENT_FAILURE_CATEGORIES:
52
+ return s
53
+ return "none"
54
+
55
+
56
+ @dataclass
57
+ class AgentReply:
58
+ """Agent 评估回复结果"""
59
+
60
+ is_valid: bool = True
61
+ is_lazy: bool = False
62
+ is_off_track: bool = False
63
+ needs_user_decision: bool = False
64
+ evaluation: str = ""
65
+ instruction: str = ""
66
+ raw_output: str = ""
67
+ success: bool = True
68
+ error: str = ""
69
+ execution_trace: dict = field(default_factory=dict)
70
+ failure_category: str = "none"
71
+
72
+
73
+ class AgentBridge:
74
+ """qodercli Agent 桥接器 — 负责格式化 Prompt、调用 qodercli 子进程、解析回复"""
75
+
76
+ def __init__(self) -> None:
77
+ self.qodercli_path: str = os.getenv("MCP_QODERCLI_PATH", "qodercli")
78
+ self.model_priority: list[str] = ["ultimate", "auto"]
79
+ self.model: str = ""
80
+ self.total_timeout: int = int(os.getenv("MCP_AGENT_TOTAL_TIMEOUT", "300"))
81
+ self.max_turns: int = int(os.getenv("MCP_AGENT_MAX_TURNS", "10"))
82
+ self._setup_complete: bool = False
83
+ self._qodercli_available: bool = False
84
+ self._login_status: str = "unknown"
85
+ self._active_procs: dict[str, asyncio.subprocess.Process] = {}
86
+
87
+ # ─── 环境检查 ────────────────────────────────────────
88
+
89
+ async def _ensure_setup(self) -> bool:
90
+ """确保 qodercli 已安装、已登录、模型已配置(FR1)。"""
91
+ if self._setup_complete:
92
+ logger.debug("[AgentBridge] setup 已缓存,available=%s, model=%s", self._qodercli_available, self.model)
93
+ return self._qodercli_available
94
+
95
+ debug_log(f"[AgentBridge] 开始环境检查 (qodercli={self.qodercli_path})")
96
+ if not await self._check_installed():
97
+ logger.warning("[AgentBridge] qodercli 未安装,跳过模型探测")
98
+ self._qodercli_available = False
99
+ return False
100
+
101
+ results = await asyncio.gather(
102
+ *[self._probe_model(name) for name in self.model_priority],
103
+ return_exceptions=True,
104
+ )
105
+ for i, ok in enumerate(results):
106
+ if ok is True:
107
+ self.model = self.model_priority[i]
108
+ self._login_status = "logged_in"
109
+ self._qodercli_available = True
110
+ self._setup_complete = True
111
+ debug_log(f"[AgentBridge] qodercli 可用,使用模型: {self.model}")
112
+ return True
113
+
114
+ logger.warning("[AgentBridge] 所有模型探测失败,login_status=not_logged_in,探测结果: %s", results)
115
+ self._login_status = "not_logged_in"
116
+ self._qodercli_available = False
117
+ return False
118
+
119
+ async def recheck_setup(self) -> bool:
120
+ """重置缓存后重新执行环境检查"""
121
+ logger.debug("[AgentBridge] recheck_setup: 重置缓存并重新检查")
122
+ self._setup_complete = False
123
+ return await self._ensure_setup()
124
+
125
+ async def _check_installed(self) -> bool:
126
+ """检查 qodercli 是否安装"""
127
+ try:
128
+ proc = await asyncio.create_subprocess_exec(
129
+ self.qodercli_path, "--version",
130
+ stdout=asyncio.subprocess.PIPE,
131
+ stderr=asyncio.subprocess.PIPE,
132
+ )
133
+ await asyncio.wait_for(proc.wait(), timeout=10)
134
+ return proc.returncode == 0
135
+ except (FileNotFoundError, asyncio.TimeoutError, OSError):
136
+ logger.warning("qodercli 未安装或不可用: %s", self.qodercli_path)
137
+ return False
138
+
139
+ async def _probe_model(self, model_name: str) -> bool:
140
+ """用简单调用探测模型是否可用"""
141
+ logger.debug("[AgentBridge] 探测模型: %s", model_name)
142
+ try:
143
+ proc = await asyncio.create_subprocess_exec(
144
+ self.qodercli_path, "-p", "hello",
145
+ "--model", model_name,
146
+ "--max-turns", "1",
147
+ "--output-format", "text",
148
+ stdout=asyncio.subprocess.PIPE,
149
+ stderr=asyncio.subprocess.PIPE,
150
+ )
151
+ await asyncio.wait_for(proc.communicate(), timeout=30)
152
+ return proc.returncode == 0
153
+ except (asyncio.TimeoutError, OSError, Exception):
154
+ return False
155
+
156
+ # ─── 核心评估 ────────────────────────────────────────
157
+
158
+ async def evaluate(
159
+ self,
160
+ context: dict,
161
+ project_dir: str,
162
+ session_id: str = "",
163
+ task_complete: bool = False,
164
+ ) -> tuple[AgentReply, str]:
165
+ """调用 qodercli Agent 评估 AI 的工作。
166
+
167
+ Args:
168
+ context: ContextCollector.collect() 输出的结构化上下文
169
+ project_dir: 项目目录绝对路径
170
+ session_id: MCP 侧会话 ID,用于子进程管理和取消(R20)
171
+ task_complete: AI 是否声称任务完成
172
+
173
+ Returns:
174
+ tuple[AgentReply, str]: (评估结果, session_id)
175
+ """
176
+ # [BRIDGE_CALL_START] 记录 Agent 评估调用开始
177
+ _bridge_start = __import__('time').monotonic()
178
+ _bridge_logger = _get_struct_logger("agent", project_directory=project_dir)
179
+ _bridge_logger.info(
180
+ f"project={os.path.basename(project_dir)} session={session_id[:8] if session_id else '(none)'}",
181
+ f"[BRIDGE_CALL_START] 开始处理 Agent 评估调用 task_complete={task_complete}",
182
+ )
183
+ debug_log(
184
+ f"[AgentBridge] evaluate 开始: session={session_id}, "
185
+ f"task_complete={task_complete}"
186
+ )
187
+ logger.info(
188
+ "[AgentBridge] evaluate 开始 project=%r session=%s task_complete=%s",
189
+ project_dir,
190
+ session_id or "(none)",
191
+ task_complete,
192
+ )
193
+
194
+ if not await self._ensure_setup():
195
+ logger.warning("[AgentBridge] evaluate 中止: setup 失败, login=%s", self._login_status)
196
+ if self._login_status == "not_logged_in":
197
+ return (
198
+ AgentReply(
199
+ success=False,
200
+ error="qodercli_not_logged_in",
201
+ instruction="请先在终端登录 qodercli:执行 qodercli 后输入 /login",
202
+ ),
203
+ "",
204
+ )
205
+ return (
206
+ AgentReply(
207
+ success=False,
208
+ error="qodercli_not_available",
209
+ instruction="",
210
+ ),
211
+ "",
212
+ )
213
+
214
+ prompt = self._format_prompt(context, task_complete=task_complete)
215
+ logger.info(
216
+ "[AgentBridge] Prompt 已生成 len=%d",
217
+ len(prompt),
218
+ )
219
+ debug_log(f"[AgentBridge] MCP→Agent Prompt 长度: {len(prompt)} 字符")
220
+ debug_log(f"[AgentBridge] MCP→Agent Prompt 全文:\n{prompt}")
221
+
222
+ try:
223
+ raw_output = await self._call_qodercli(
224
+ prompt, project_dir, session_id,
225
+ )
226
+ debug_log(f"[AgentBridge] qodercli 调用成功, 输出长度: {len(raw_output)}")
227
+ except asyncio.TimeoutError:
228
+ # [BRIDGE_ERROR] Agent 调用超时
229
+ _bridge_logger.error(
230
+ f"project={os.path.basename(project_dir)} session={session_id[:8] if session_id else '(none)'} "
231
+ f"error_type=timeout",
232
+ f"[BRIDGE_ERROR] qodercli 调用超时 ({self.total_timeout}s)",
233
+ exc_info=True,
234
+ )
235
+ logger.error("[AgentBridge] qodercli 调用超时 (%ds)", self.total_timeout)
236
+ return (AgentReply(success=False, error="timeout", instruction=""), "")
237
+ except RuntimeError as e:
238
+ error_msg = str(e)
239
+ # [BRIDGE_ERROR] 运行时错误
240
+ _bridge_logger.error(
241
+ f"project={os.path.basename(project_dir)} session={session_id[:8] if session_id else '(none)'} "
242
+ f"error_type=runtime_error",
243
+ f"[BRIDGE_ERROR] qodercli 运行时错误: {error_msg[:200]}",
244
+ exc_info=True,
245
+ )
246
+ logger.error("[AgentBridge] qodercli 运行时错误: %s", error_msg[:300])
247
+ if any(kw in error_msg.lower() for kw in ("login", "unauthorized")):
248
+ return (
249
+ AgentReply(
250
+ success=False,
251
+ error="qodercli_not_logged_in",
252
+ instruction="",
253
+ ),
254
+ "",
255
+ )
256
+ return (
257
+ AgentReply(
258
+ success=False,
259
+ error=f"qodercli_error: {error_msg[:200]}",
260
+ raw_output=error_msg,
261
+ ),
262
+ "",
263
+ )
264
+
265
+ trace = self._extract_execution_trace(raw_output)
266
+ self._log_execution_trace(trace, project_dir, session_id)
267
+ logger.info(
268
+ "[AgentBridge] 执行轨迹摘要: tools=%d, files_read=%d, skills=%s, commands=%d",
269
+ len(trace["tool_calls"]), len(trace["files_read"]),
270
+ trace["skills_loaded"] or "(无)", len(trace["commands_run"]),
271
+ )
272
+
273
+ reply = self._parse_response(raw_output)
274
+ reply.execution_trace = trace
275
+
276
+ # [BRIDGE_CALL_RETURN] 记录 Agent 评估调用返回
277
+ _bridge_duration_ms = int((__import__('time').monotonic() - _bridge_start) * 1000)
278
+ _result_type = "success" if reply.success else f"failed:{reply.error}"
279
+ _bridge_logger.info(
280
+ f"project={os.path.basename(project_dir)} session={session_id[:8] if session_id else '(none)'} "
281
+ f"result_type={_result_type} duration_ms={_bridge_duration_ms}",
282
+ "[BRIDGE_CALL_RETURN] Agent 评估调用返回结果",
283
+ )
284
+ logger.info(
285
+ "[AgentBridge] 评估结果: is_valid=%s, success=%s, evaluation=%s",
286
+ reply.is_valid, reply.success,
287
+ (reply.evaluation[:100] + "...") if len(reply.evaluation) > 100 else reply.evaluation,
288
+ )
289
+ debug_log(
290
+ f"[AgentBridge] 解析结果详情: is_valid={reply.is_valid}, is_lazy={reply.is_lazy}, "
291
+ f"is_off_track={reply.is_off_track}, needs_user_decision={reply.needs_user_decision}, "
292
+ f"error={reply.error}\n"
293
+ f" evaluation: {reply.evaluation or '(空)'}\n"
294
+ f" instruction: {reply.instruction[:500] if reply.instruction else '(空)'}"
295
+ )
296
+ return (reply, "")
297
+
298
+ # ─── Prompt 格式化 ───────────────────────────────────────
299
+
300
+ @staticmethod
301
+ def _format_changed_files_block(cur: list, all_f: list) -> str:
302
+ """本轮与累计文件列表相同时只保留一节,减少重复。"""
303
+ cur = list(cur or [])
304
+ all_f = list(all_f or [])
305
+ if cur == all_f:
306
+ body = "\n".join(cur) if cur else "(无)"
307
+ return (
308
+ "### 变更文件\n"
309
+ + body
310
+ + "\n\n请自行通过 `git diff -- <file>` 查看具体改动。"
311
+ )
312
+ return (
313
+ "### 本轮变更文件\n"
314
+ + ("\n".join(cur) if cur else "(无)")
315
+ + "\n\n### 累计变更文件\n"
316
+ + ("\n".join(all_f) if all_f else "(无)")
317
+ + "\n\n请自行通过 `git diff -- <file>` 查看具体改动。"
318
+ )
319
+
320
+ @staticmethod
321
+ def _format_sub_agent_paths(paths: list | None) -> str:
322
+ """将子 Agent SKILL.md 路径列表格式化为 Prompt 字符串。"""
323
+ if not paths:
324
+ return "(无可用子 Agent)"
325
+ return "\n".join(f"- {p}" for p in paths)
326
+
327
+ def _format_prompt(self, context: dict, task_complete: bool = False) -> str:
328
+ """将上下文格式化为 Prompt(D49 $variable 语法,D64 只传路径)。"""
329
+ from string import Template
330
+
331
+ skills_dirs = context.get("project_skills_dirs", [])
332
+ skills_dirs_str = (
333
+ "\n".join(f"- {d}" for d in skills_dirs)
334
+ if skills_dirs
335
+ else "(无)"
336
+ )
337
+ project_dir = (context.get("project_dir") or "").strip()
338
+ user_task = context.get("original_task", "") or "(无)"
339
+ changed_files_block = AgentBridge._format_changed_files_block(
340
+ context.get("changed_files_this_round", []),
341
+ context.get("changed_files_all", []),
342
+ )
343
+
344
+ template_vars = {
345
+ "project_dir": project_dir,
346
+ "retry_count": str(context.get("retry_count", 0)),
347
+ "task_phase": context.get("task_phase", ""),
348
+ "completion_attempts": str(context.get("completion_attempts", 0)),
349
+ "user_task": user_task,
350
+ "project_rules_dir": context.get("project_rules_dir", "(无)"),
351
+ "project_skills_dirs": skills_dirs_str,
352
+ "sub_agent_skill_paths": AgentBridge._format_sub_agent_paths(
353
+ context.get("sub_agent_skill_paths", [])
354
+ ),
355
+ "current_summary": context.get("current_summary", ""),
356
+ "current_intention": context.get("current_intention", ""),
357
+ "changed_files_block": changed_files_block,
358
+ "agent_deny_count": str(context.get("agent_deny_count", 0)),
359
+ "task_complete": str(task_complete).lower(),
360
+ }
361
+ return Template(PROMPT_TEMPLATE).safe_substitute(template_vars)
362
+
363
+ # ─── 子进程调用 ──────────────────────────
364
+
365
+ async def _call_qodercli(
366
+ self, prompt: str, project_dir: str, session_id: str = "",
367
+ ) -> str:
368
+ """subprocess 调用 qodercli(R20: 按 session_id 管理进程,D51: token 过期检测)"""
369
+ cmd = [
370
+ self.qodercli_path,
371
+ "-p", prompt,
372
+ "--model", self.model,
373
+ "--max-turns", str(self.max_turns),
374
+ "--output-format", "stream-json",
375
+ "-w", project_dir,
376
+ ]
377
+
378
+ cmd_brief = (
379
+ f"{self.qodercli_path} -p \"<prompt {len(prompt)} chars>\" "
380
+ f"--model {self.model} --max-turns {self.max_turns} "
381
+ f"--output-format stream-json -w {project_dir}"
382
+ )
383
+ debug_log(
384
+ f"[AgentBridge] qodercli 调用命令(摘要): {cmd_brief} "
385
+ f"(timeout={self.total_timeout}s, session={session_id})"
386
+ )
387
+ _stream_line_limit = 16 * 1024 * 1024
388
+ proc = await asyncio.create_subprocess_exec(
389
+ *cmd,
390
+ stdout=asyncio.subprocess.PIPE,
391
+ stderr=asyncio.subprocess.PIPE,
392
+ limit=_stream_line_limit,
393
+ )
394
+ if session_id:
395
+ self._active_procs[session_id] = proc
396
+
397
+ collected_lines: list[str] = []
398
+ stderr_chunks: list[bytes] = []
399
+ stream_log_prev_line: str | None = None
400
+ try:
401
+ async def _stream_stdout():
402
+ nonlocal stream_log_prev_line
403
+
404
+ assert proc.stdout is not None
405
+ while True:
406
+ line_bytes = await proc.stdout.readline()
407
+ if not line_bytes:
408
+ break
409
+ line = line_bytes.decode("utf-8", errors="replace").rstrip()
410
+ collected_lines.append(line)
411
+ try:
412
+ if line != stream_log_prev_line:
413
+ stream_log_prev_line = line
414
+ self._log_stream_event(line)
415
+ except Exception:
416
+ pass
417
+
418
+ async def _drain_stderr():
419
+ assert proc.stderr is not None
420
+ while True:
421
+ chunk = await proc.stderr.read(4096)
422
+ if not chunk:
423
+ break
424
+ stderr_chunks.append(chunk)
425
+
426
+ await asyncio.wait_for(
427
+ asyncio.gather(_stream_stdout(), _drain_stderr()),
428
+ timeout=self.total_timeout,
429
+ )
430
+ await proc.wait()
431
+ except asyncio.TimeoutError:
432
+ proc.kill()
433
+ await proc.wait()
434
+ raise
435
+ finally:
436
+ self._active_procs.pop(session_id, None)
437
+
438
+ stderr_bytes = b"".join(stderr_chunks)
439
+ raw = "\n".join(collected_lines)
440
+
441
+ debug_log(f"[AgentBridge] qodercli 退出码: {proc.returncode}, lines={len(collected_lines)}, stderr={len(stderr_bytes)} bytes")
442
+ if proc.returncode != 0:
443
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace")
444
+ logger.error("[AgentBridge] qodercli 非零退出: stderr=%s", stderr_text[:500])
445
+ if any(
446
+ kw in stderr_text.lower()
447
+ for kw in ("unauthorized", "login", "expired", "token")
448
+ ):
449
+ self._setup_complete = False
450
+ self._login_status = "not_logged_in"
451
+ self._qodercli_available = False
452
+ raise RuntimeError(
453
+ f"qodercli 退出码 {proc.returncode}: {stderr_text[:500]}"
454
+ )
455
+ debug_log(f"[AgentBridge] Agent→MCP 原始输出:\n{raw[:3000]}")
456
+ return raw
457
+
458
+ @staticmethod
459
+ def _log_stream_event(line: str) -> None:
460
+ """实时记录 stream-json 事件到日志。"""
461
+ if not line.strip() or not line.strip().startswith("{"):
462
+ return
463
+ try:
464
+ event = json.loads(line)
465
+ except json.JSONDecodeError:
466
+ return
467
+
468
+ ev_type = event.get("type", "")
469
+ ev_sub = event.get("subtype", "")
470
+ msg = event.get("message", {})
471
+ contents = msg.get("content", [])
472
+
473
+ if ev_type == "system" and ev_sub == "init":
474
+ model = event.get("model", "?")
475
+ tools = event.get("tools", [])
476
+ logger.info("[AgentBridge][STREAM] 初始化: model=%s, tools=%d个", model, len(tools))
477
+
478
+ elif ev_type == "assistant":
479
+ _seen_read_paths: set[str] = set()
480
+ _seen_text_prefix: set[str] = set()
481
+ for item in contents:
482
+ if item.get("type") == "function":
483
+ name = item.get("name", "?")
484
+ try:
485
+ inp = json.loads(item.get("input", "{}"))
486
+ except (json.JSONDecodeError, TypeError):
487
+ inp = {}
488
+
489
+ if name == "Read":
490
+ fp = inp.get("file_path", "?")
491
+ if fp in _seen_read_paths:
492
+ continue
493
+ _seen_read_paths.add(str(fp))
494
+ logger.info("[AgentBridge][STREAM] 📖 Agent 读取文件: %s", fp)
495
+ elif name in ("Bash", "BashOutput"):
496
+ logger.info("[AgentBridge][STREAM] 🔧 Agent 执行命令: %s", inp.get("command", "?")[:200])
497
+ elif name == "Skill":
498
+ skill = inp.get("skill_name", "") or inp.get("name", "")
499
+ logger.info("[AgentBridge][STREAM] 📚 Agent 加载 Skill: %s", skill)
500
+ elif name in ("Edit", "Write"):
501
+ logger.info("[AgentBridge][STREAM] ✏️ Agent 写入文件: %s", inp.get("file_path", "?"))
502
+ elif name == "Grep":
503
+ logger.info("[AgentBridge][STREAM] 🔍 Agent Grep: pattern=%s path=%s",
504
+ inp.get("pattern", ""), inp.get("path", ""))
505
+ else:
506
+ logger.info("[AgentBridge][STREAM] 🔧 Agent 调用工具: %s", name)
507
+
508
+ elif item.get("type") == "text":
509
+ text = item.get("text", "")
510
+ if text:
511
+ tp = text[:200]
512
+ if tp in _seen_text_prefix:
513
+ continue
514
+ _seen_text_prefix.add(tp)
515
+ logger.info("[AgentBridge][STREAM] 💬 Agent 输出文本: %s", tp)
516
+
517
+ elif ev_type == "user":
518
+ for item in contents:
519
+ if item.get("type") == "tool_result":
520
+ name = item.get("name", "?")
521
+ is_err = item.get("is_error", False)
522
+ status = "❌ 失败" if is_err else "✅ 成功"
523
+ logger.info("[AgentBridge][STREAM] %s 工具结果: %s", status, name)
524
+
525
+ elif ev_type == "result":
526
+ logger.info("[AgentBridge][STREAM] 🏁 Agent 执行完毕")
527
+
528
+ # ─── 响应解析 ────────────────────────────────────────
529
+
530
+ def _parse_response(self, output: str) -> AgentReply:
531
+ """解析 qodercli --output-format=stream-json 的输出(D56 多策略 fallback)。"""
532
+ logger.debug("[AgentBridge] 开始解析响应, 长度=%d", len(output))
533
+ try:
534
+ text = self._extract_agent_text(output)
535
+ except RuntimeError as e:
536
+ error_type = str(e)
537
+ logger.warning("[AgentBridge] qodercli 返回错误响应: %s", error_type)
538
+ friendly = {
539
+ "qodercli_error_error_max_turns": "Agent 评估未在规定轮次内完成,请重试。",
540
+ "qodercli_error_error_timeout": "Agent 评估超时,请重试。",
541
+ }
542
+ msg = friendly.get(error_type, f"Agent 调用异常: {error_type}")
543
+ return AgentReply(
544
+ success=False,
545
+ error=error_type,
546
+ instruction=msg,
547
+ evaluation=msg,
548
+ raw_output=output,
549
+ )
550
+ if text is None:
551
+ logger.warning("[AgentBridge] 无法提取 Agent text, 走兜底")
552
+ return self._fallback_raw_output(output)
553
+ logger.debug("[AgentBridge] Agent text 提取成功, 长度=%d", len(text))
554
+
555
+ data = self._extract_json_from_text(text)
556
+ if data is None:
557
+ logger.warning("[AgentBridge] 内层 JSON 提取失败, Agent 未按格式输出, text=%s", text[:200])
558
+ return self._fallback_agent_text(text, output)
559
+
560
+ instruction = data.get("instruction", "")
561
+ evaluation = data.get("evaluation", "")
562
+ fc = _normalize_failure_category(data.get("failure_category"))
563
+ debug_log(
564
+ f"[AgentBridge] D31 字段分离: instruction={len(instruction)} chars, "
565
+ f"evaluation={len(evaluation)} chars, is_valid={data.get('is_valid', True)}, "
566
+ f"needs_user_decision={data.get('needs_user_decision', False)}, "
567
+ f"failure_category={fc}"
568
+ )
569
+ return AgentReply(
570
+ is_valid=data.get("is_valid", True),
571
+ is_lazy=data.get("is_lazy", False),
572
+ is_off_track=data.get("is_off_track", False),
573
+ needs_user_decision=data.get("needs_user_decision", False),
574
+ evaluation=evaluation,
575
+ instruction=instruction,
576
+ raw_output=output,
577
+ success=True,
578
+ failure_category=fc,
579
+ )
580
+
581
+ def _extract_agent_text(self, output: str) -> str | None:
582
+ """从 qodercli stdout 中提取 Agent 的实际文本内容。
583
+
584
+ 兼容两代 stream-json schema:
585
+ - 新版 (qodercli >= 1.1.x):{"type":"result","subtype":"success","result":"<正文>"}
586
+ 正文在顶层 result 字段;错误时 is_error=true / subtype!=success。
587
+ - 老版:{"type":"result","message":{"content":[{"text":"<正文>"}]}}
588
+ 另外新版会在会话启动时先输出一串 hook 事件
589
+ ({"type":"system","subtype":"hook_started",...}),若解析失败走到
590
+ 兜底会把这些噪声当成回复发给 AI(历史 bug),因此本函数尽可能多策略命中。
591
+ """
592
+ lines = output.strip().splitlines()
593
+
594
+ for line in reversed(lines):
595
+ line = line.strip()
596
+ if not line or not line.startswith("{"):
597
+ continue
598
+ try:
599
+ obj = json.loads(line)
600
+ except json.JSONDecodeError:
601
+ continue
602
+
603
+ if obj.get("type") == "error":
604
+ subtype = obj.get("subtype", "unknown")
605
+ logger.warning("[AgentBridge] qodercli 返回错误: subtype=%s, code=%s",
606
+ subtype, obj.get("error_code"))
607
+ raise RuntimeError(f"qodercli_error_{subtype}")
608
+
609
+ if obj.get("type") == "result":
610
+ # 新版 schema:错误信号在 result 行里(is_error / subtype)
611
+ if obj.get("is_error") is True:
612
+ subtype = obj.get("subtype", "unknown")
613
+ logger.warning("[AgentBridge] qodercli result 行报错: subtype=%s", subtype)
614
+ raise RuntimeError(f"qodercli_error_{subtype}")
615
+ # 新版 schema:正文在顶层 result 字段
616
+ result_text = obj.get("result")
617
+ if isinstance(result_text, str) and result_text.strip():
618
+ return result_text
619
+ # 老版 schema:message.content[0].text
620
+ try:
621
+ return obj["message"]["content"][0]["text"]
622
+ except (KeyError, IndexError, TypeError):
623
+ logger.warning("[AgentBridge] result 行无法提取正文, keys=%s", list(obj.keys()))
624
+ continue
625
+
626
+ # 兜底 1:没有 result 行(异常退出/截断)时,拼接 assistant 消息的 text 块
627
+ assistant_texts: list[str] = []
628
+ for line in lines:
629
+ line = line.strip()
630
+ if not line or not line.startswith("{"):
631
+ continue
632
+ try:
633
+ obj = json.loads(line)
634
+ except json.JSONDecodeError:
635
+ continue
636
+ if obj.get("type") != "assistant":
637
+ continue
638
+ for item in (obj.get("message", {}) or {}).get("content", []) or []:
639
+ if isinstance(item, dict) and item.get("type") == "text" and item.get("text"):
640
+ assistant_texts.append(item["text"])
641
+ if assistant_texts:
642
+ logger.info("[AgentBridge] 无 result 行,从 %d 段 assistant text 拼接正文", len(assistant_texts))
643
+ return "".join(assistant_texts)
644
+
645
+ # 兜底 2:输出里直接包含评估 JSON 字段(非 stream-json 形态)
646
+ if '"is_valid"' in output or '"instruction"' in output:
647
+ return output
648
+
649
+ logger.warning("[AgentBridge] _extract_agent_text: 所有策略均失败, output 前200字: %s", output[:200])
650
+ return None
651
+
652
+ def _extract_json_from_text(self, text: str) -> dict | None:
653
+ """从 text 中提取 JSON(支持 markdown code block、裸 JSON、正则、引号修复)"""
654
+ candidates: list[str] = []
655
+ inner_json = re.search(r"```(?:json)?\s*\n(.*?)\n```", text, re.DOTALL)
656
+ if inner_json:
657
+ candidates.append(inner_json.group(1))
658
+ candidates.append(text)
659
+ json_match = re.search(r'\{[^{}]*"is_valid"[^{}]*\}', text, re.DOTALL)
660
+ if json_match:
661
+ candidates.append(json_match.group(0))
662
+
663
+ for i, candidate in enumerate(candidates):
664
+ try:
665
+ data = json.loads(candidate)
666
+ labels = ["code block", "裸 JSON", "正则 is_valid"]
667
+ debug_log(f"[AgentBridge] _extract_json_from_text: 从 {labels[i] if i < len(labels) else '候选'} 提取成功")
668
+ return data
669
+ except json.JSONDecodeError:
670
+ continue
671
+
672
+ for candidate in candidates:
673
+ repaired = self._try_repair_json(candidate)
674
+ if repaired is not None:
675
+ debug_log("[AgentBridge] _extract_json_from_text: JSON 引号修复后提取成功")
676
+ return repaired
677
+
678
+ logger.warning("[AgentBridge] _extract_json_from_text: 所有策略均失败, text 前300字: %s", text[:300])
679
+ return None
680
+
681
+ @staticmethod
682
+ def _try_repair_json(text: str) -> dict | None:
683
+ """修复嵌套 JSON 中字符串值内未转义引号的问题。"""
684
+ lines = text.strip().split("\n")
685
+ repaired: list[str] = []
686
+ for line in lines:
687
+ m = re.match(r'^(\s*"[^"]+"\s*:\s*)"(.*)"(,?)$', line)
688
+ if m:
689
+ prefix, value, comma = m.group(1), m.group(2), m.group(3)
690
+ value = value.replace('\\"', '"').replace('"', '\\"')
691
+ repaired.append(f'{prefix}"{value}"{comma}')
692
+ else:
693
+ repaired.append(line)
694
+ try:
695
+ return json.loads("\n".join(repaired))
696
+ except json.JSONDecodeError:
697
+ return None
698
+
699
+ def _fallback_agent_text(self, text: str, raw_output: str) -> AgentReply:
700
+ """text 已提取但不含评估 JSON:Agent 可能去执行了操作而非评估。"""
701
+ plain_text = text[:1000] if len(text) > 1000 else text
702
+ return AgentReply(
703
+ is_valid=False,
704
+ instruction=f"[Agent 未按评估格式输出] {plain_text}",
705
+ raw_output=raw_output,
706
+ success=True,
707
+ evaluation="Agent 未输出评估 JSON,可能在执行操作而非评估",
708
+ )
709
+
710
+ def _fallback_raw_output(self, output: str) -> AgentReply:
711
+ """完全无法提取文本时的兜底(R16: is_valid=False 防误放行)。
712
+
713
+ 注意:不再直接把原始 stdout 前 1000 字当回复——那会把 stream-json 的
714
+ hook 事件噪声({"type":"system","subtype":"hook_started",...})原样发给
715
+ 被监工 AI 和前端 UI(历史 bug)。先过滤掉 JSON 事件行,只保留非 JSON 的
716
+ 自然文本;若过滤后为空,给出固定的友好提示而非噪声。
717
+ """
718
+ non_json_lines = [
719
+ ln for ln in output.strip().splitlines()
720
+ if ln.strip() and not ln.strip().startswith("{")
721
+ ]
722
+ plain_text = "\n".join(non_json_lines).strip()
723
+ if not plain_text:
724
+ plain_text = "(Agent 评估输出无法解析,本轮未生成有效回复,请重试或人工输入)"
725
+ elif len(plain_text) > 1000:
726
+ plain_text = plain_text[:1000]
727
+ return AgentReply(
728
+ is_valid=False,
729
+ instruction=plain_text,
730
+ raw_output=output,
731
+ success=True,
732
+ evaluation="(Agent 输出格式异常,已过滤 JSON 事件噪声后返回)",
733
+ )
734
+
735
+ # ─── 执行轨迹提取与日志 ─────────────────────────────────
736
+
737
+ @staticmethod
738
+ def _extract_execution_trace(output: str) -> dict:
739
+ """从 stream-json 输出中提取 Agent 完整执行轨迹。"""
740
+ trace: dict = {
741
+ "tool_calls": [],
742
+ "skills_loaded": [],
743
+ "files_read": [],
744
+ "files_written": [],
745
+ "commands_run": [],
746
+ "total_turns": 0,
747
+ }
748
+
749
+ pending_calls: dict[str, dict] = {}
750
+
751
+ for line in output.strip().splitlines():
752
+ line = line.strip()
753
+ if not line or not line.startswith("{"):
754
+ continue
755
+ try:
756
+ event = json.loads(line)
757
+ except json.JSONDecodeError:
758
+ continue
759
+
760
+ ev_type = event.get("type", "")
761
+ msg = event.get("message", {})
762
+ contents = msg.get("content", [])
763
+
764
+ if ev_type == "assistant":
765
+ for item in contents:
766
+ if item.get("type") == "function":
767
+ call_id = item.get("id", "")
768
+ name = item.get("name", "")
769
+ try:
770
+ inp = json.loads(item.get("input", "{}"))
771
+ except (json.JSONDecodeError, TypeError):
772
+ inp = {"_raw": item.get("input", "")}
773
+
774
+ call_record = {"name": name, "input": inp}
775
+ pending_calls[call_id] = call_record
776
+
777
+ if name == "Read":
778
+ path = inp.get("file_path", "")
779
+ if path and path not in trace["files_read"]:
780
+ trace["files_read"].append(path)
781
+ elif name in ("Edit", "Write"):
782
+ path = inp.get("file_path", "")
783
+ if path and path not in trace["files_written"]:
784
+ trace["files_written"].append(path)
785
+ elif name in ("Bash", "BashOutput"):
786
+ cmd = inp.get("command", "")
787
+ if cmd:
788
+ trace["commands_run"].append(cmd)
789
+ elif name == "Skill":
790
+ skill_name = inp.get("skill_name", "") or inp.get("name", "")
791
+ if skill_name and skill_name not in trace["skills_loaded"]:
792
+ trace["skills_loaded"].append(skill_name)
793
+ elif name == "Grep":
794
+ path = inp.get("path", "")
795
+ if path and path not in trace["files_read"]:
796
+ trace["files_read"].append(f"[Grep] {path}")
797
+ elif name == "Glob":
798
+ pattern = inp.get("pattern", "")
799
+ if pattern:
800
+ trace["files_read"].append(f"[Glob] {pattern}")
801
+
802
+ trace["total_turns"] += 1
803
+
804
+ elif ev_type == "user":
805
+ for item in contents:
806
+ if item.get("type") == "tool_result":
807
+ call_id = item.get("tool_use_id", "")
808
+ record = pending_calls.pop(call_id, None)
809
+ if record is not None:
810
+ result_content = item.get("content", "")
811
+ if isinstance(result_content, str) and len(result_content) > 500:
812
+ result_content = result_content[:500] + "...(truncated)"
813
+ record["result"] = result_content
814
+ record["is_error"] = item.get("is_error", False)
815
+ trace["tool_calls"].append(record)
816
+
817
+ for call_id, record in pending_calls.items():
818
+ record["result"] = "(no result captured)"
819
+ record["is_error"] = False
820
+ trace["tool_calls"].append(record)
821
+
822
+ return trace
823
+
824
+ @staticmethod
825
+ def _format_trace_text(trace: dict, project_dir: str, session_id: str = "") -> str:
826
+ """将执行轨迹格式化为可读文本。"""
827
+ lines = [
828
+ f"{'=' * 60}",
829
+ f"Agent 执行轨迹 | project={project_dir} | session={session_id}",
830
+ f"{'=' * 60}",
831
+ f"总工具调用次数: {trace['total_turns']}",
832
+ ]
833
+
834
+ if trace["skills_loaded"]:
835
+ lines.append(f" Skills ({len(trace['skills_loaded'])}个): {', '.join(trace['skills_loaded'])}")
836
+ else:
837
+ lines.append(" Skills: (无) Agent 未读取任何 Skill")
838
+
839
+ if trace["files_read"]:
840
+ lines.append(f" 读取文件 ({len(trace['files_read'])}个):")
841
+ for f in trace["files_read"]:
842
+ lines.append(f" - {f}")
843
+ else:
844
+ lines.append(" 读取文件: (无) Agent 未读取任何文件")
845
+
846
+ if trace["files_written"]:
847
+ lines.append(f" 写入文件 ({len(trace['files_written'])}个):")
848
+ for f in trace["files_written"]:
849
+ lines.append(f" - {f}")
850
+
851
+ if trace["commands_run"]:
852
+ lines.append(f" 执行命令 ({len(trace['commands_run'])}个):")
853
+ for c in trace["commands_run"]:
854
+ lines.append(f" $ {c[:200]}")
855
+
856
+ lines.append(f" 工具调用明细 ({len(trace['tool_calls'])}个):")
857
+ for i, call in enumerate(trace["tool_calls"], 1):
858
+ name = call.get("name", "?")
859
+ inp = call.get("input", {})
860
+ is_err = call.get("is_error", False)
861
+ status = "FAIL" if is_err else "OK"
862
+
863
+ inp_summary = ""
864
+ if name == "Read":
865
+ inp_summary = inp.get("file_path", "")
866
+ elif name in ("Bash", "BashOutput"):
867
+ inp_summary = inp.get("command", "")[:150]
868
+ elif name in ("Edit", "Write"):
869
+ inp_summary = inp.get("file_path", "")
870
+ elif name == "Skill":
871
+ inp_summary = inp.get("skill_name", "") or inp.get("name", "")
872
+ elif name == "Grep":
873
+ inp_summary = f"pattern={inp.get('pattern', '')} path={inp.get('path', '')}"
874
+ elif name == "Glob":
875
+ inp_summary = inp.get("pattern", "")
876
+ else:
877
+ inp_summary = json.dumps(inp, ensure_ascii=False)[:150]
878
+
879
+ lines.append(f" [{i}] {name} [{status}] {inp_summary}")
880
+
881
+ lines.append(f"{'=' * 60}")
882
+ return "\n".join(lines)
883
+
884
+ @staticmethod
885
+ def _log_execution_trace(trace: dict, project_dir: str, session_id: str = "") -> None:
886
+ """将执行轨迹写入日志和 agent-trace.log(归档)。"""
887
+ trace_text = AgentBridge._format_trace_text(trace, project_dir, session_id)
888
+
889
+ for line in trace_text.split("\n"):
890
+ logger.info("[AgentBridge] %s", line)
891
+
892
+ try:
893
+ AGENT_TRACE_LOG.parent.mkdir(parents=True, exist_ok=True)
894
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
895
+ with open(AGENT_TRACE_LOG, "a", encoding="utf-8") as fh:
896
+ fh.write(f"\n[{ts}] {trace_text}\n")
897
+ except Exception as e:
898
+ logger.warning("[AgentBridge] 写入 agent-trace.log 失败: %s", e)
899
+
900
+
901
+ # ─── Prompt 模板 ──────────────────────────────────────────
902
+
903
+ PROMPT_TEMPLATE = """\
904
+ # AI 监督评估任务
905
+
906
+ ## 角色
907
+
908
+ 你是 **工作质量监督者**(主 Agent)。请结合工作区内容判断主 AI 的工作是否达标。
909
+
910
+ ## 项目信息
911
+
912
+ - 工作区: $project_dir
913
+ - 迭代轮数: $retry_count
914
+ - 监管阶段: $task_phase
915
+
916
+ ## 用户任务
917
+
918
+ $user_task
919
+
920
+ ## 规则与 Skill 目录(路径须自行 Read)
921
+
922
+ - 规则目录: $project_rules_dir
923
+ - 项目内 Skill 目录: $project_skills_dirs
924
+
925
+ ## 可调度的子 Agent Skill
926
+
927
+ 以下是可用的子 Agent SKILL.md 路径,当需要调度子 Agent 时请 Read 对应文件:
928
+ $sub_agent_skill_paths
929
+
930
+ ## AI 本轮行为
931
+
932
+ ### 工作摘要
933
+ $current_summary
934
+
935
+ ### 完整意图
936
+ $current_intention
937
+
938
+ ## 变更与产物线索
939
+
940
+ $changed_files_block
941
+
942
+ ## 调用状态
943
+
944
+ - AI 声称任务完成: $task_complete
945
+ - 监工连续否决次数: $agent_deny_count(达 5 次时 MCP 弹窗由用户处理)
946
+
947
+ ## 输出要求
948
+
949
+ 请输出 JSON 格式评估结果,包含以下字段:
950
+ - `is_valid` (bool): 工作是否合格
951
+ - `is_lazy` (bool): 是否偷懒/敷衍
952
+ - `is_off_track` (bool): 是否跑偏
953
+ - `needs_user_decision` (bool): 是否需要用户决策
954
+ - `evaluation` (string): 评估说明(宜 80 字以内)
955
+ - `instruction` (string): 返回给主 AI 的指令(宜 500 字以内)
956
+ - `failure_category` (string): `none` | `skill_compliance` | `content_quality` | `clarification`
957
+ """