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.
- cloneloop-0.1.0.dist-info/METADATA +392 -0
- cloneloop-0.1.0.dist-info/RECORD +118 -0
- cloneloop-0.1.0.dist-info/WHEEL +4 -0
- cloneloop-0.1.0.dist-info/entry_points.txt +5 -0
- mcp_ai_supervisor/__init__.py +52 -0
- mcp_ai_supervisor/__main__.py +551 -0
- mcp_ai_supervisor/debug.py +15 -0
- mcp_ai_supervisor/desktop_app/__init__.py +29 -0
- mcp_ai_supervisor/desktop_app/desktop_app.py +390 -0
- mcp_ai_supervisor/helpers/__init__.py +4 -0
- mcp_ai_supervisor/helpers/feedback_helpers.py +140 -0
- mcp_ai_supervisor/helpers/image_helpers.py +73 -0
- mcp_ai_supervisor/i18n.py +14 -0
- mcp_ai_supervisor/log_writer.py +16 -0
- mcp_ai_supervisor/logging/__init__.py +8 -0
- mcp_ai_supervisor/logging/log_parser.py +293 -0
- mcp_ai_supervisor/logging/log_tailer.py +367 -0
- mcp_ai_supervisor/py.typed +0 -0
- mcp_ai_supervisor/server.py +654 -0
- mcp_ai_supervisor/utils/__init__.py +28 -0
- mcp_ai_supervisor/utils/error_handler.py +10 -0
- mcp_ai_supervisor/utils/memory_monitor.py +11 -0
- mcp_ai_supervisor/utils/paths.py +7 -0
- mcp_ai_supervisor/utils/resource_manager.py +15 -0
- mcp_ai_supervisor/utils/structure_checker.py +291 -0
- mcp_ai_supervisor/web/__init__.py +26 -0
- mcp_ai_supervisor/web/constants/__init__.py +8 -0
- mcp_ai_supervisor/web/constants/message_codes.py +173 -0
- mcp_ai_supervisor/web/core/__init__.py +30 -0
- mcp_ai_supervisor/web/core/agent_bridge.py +957 -0
- mcp_ai_supervisor/web/core/auto_responder.py +332 -0
- mcp_ai_supervisor/web/core/context_collector.py +124 -0
- mcp_ai_supervisor/web/core/conversation_recorder.py +751 -0
- mcp_ai_supervisor/web/core/delegate_manager.py +1193 -0
- mcp_ai_supervisor/web/core/feedback_mediator.py +259 -0
- mcp_ai_supervisor/web/core/message_channel.py +551 -0
- mcp_ai_supervisor/web/core/response_builder.py +333 -0
- mcp_ai_supervisor/web/core/scene_detector.py +184 -0
- mcp_ai_supervisor/web/core/task_state.py +194 -0
- mcp_ai_supervisor/web/locales/en/translation.json +643 -0
- mcp_ai_supervisor/web/locales/zh-CN/translation.json +629 -0
- mcp_ai_supervisor/web/locales/zh-TW/translation.json +648 -0
- mcp_ai_supervisor/web/main.py +747 -0
- mcp_ai_supervisor/web/managers/__init__.py +8 -0
- mcp_ai_supervisor/web/managers/desktop_manager.py +228 -0
- mcp_ai_supervisor/web/managers/server_manager.py +170 -0
- mcp_ai_supervisor/web/managers/session_manager.py +515 -0
- mcp_ai_supervisor/web/managers/workbench_connector.py +186 -0
- mcp_ai_supervisor/web/models/__init__.py +13 -0
- mcp_ai_supervisor/web/models/feedback_result.py +16 -0
- mcp_ai_supervisor/web/models/feedback_session.py +938 -0
- mcp_ai_supervisor/web/models/session_cleaner.py +245 -0
- mcp_ai_supervisor/web/models/session_commands.py +213 -0
- mcp_ai_supervisor/web/models/session_resolver.py +158 -0
- mcp_ai_supervisor/web/models/session_timer.py +242 -0
- mcp_ai_supervisor/web/routes/__init__.py +12 -0
- mcp_ai_supervisor/web/routes/main_routes.py +965 -0
- mcp_ai_supervisor/web/routes/settings_routes.py +195 -0
- mcp_ai_supervisor/web/routes/ws_handlers.py +270 -0
- mcp_ai_supervisor/web/static/css/audio-management.css +545 -0
- mcp_ai_supervisor/web/static/css/notification-settings.css +152 -0
- mcp_ai_supervisor/web/static/css/prompt-management.css +566 -0
- mcp_ai_supervisor/web/static/css/session-management.css +1428 -0
- mcp_ai_supervisor/web/static/css/styles.css +2267 -0
- mcp_ai_supervisor/web/static/favicon.ico +0 -0
- mcp_ai_supervisor/web/static/icon-192.png +0 -0
- mcp_ai_supervisor/web/static/icon.svg +11 -0
- mcp_ai_supervisor/web/static/index.html +37 -0
- mcp_ai_supervisor/web/static/js/app.js +1721 -0
- mcp_ai_supervisor/web/static/js/i18n.js +376 -0
- mcp_ai_supervisor/web/static/js/modules/audio/audio-manager.js +610 -0
- mcp_ai_supervisor/web/static/js/modules/audio/audio-settings-ui.js +732 -0
- mcp_ai_supervisor/web/static/js/modules/connection-monitor.js +435 -0
- mcp_ai_supervisor/web/static/js/modules/constants/message-codes.js +168 -0
- mcp_ai_supervisor/web/static/js/modules/countdown-manager.js +273 -0
- mcp_ai_supervisor/web/static/js/modules/drag-drop-handler.js +677 -0
- mcp_ai_supervisor/web/static/js/modules/file-upload-manager.js +555 -0
- mcp_ai_supervisor/web/static/js/modules/image-handler.js +199 -0
- mcp_ai_supervisor/web/static/js/modules/logger.js +404 -0
- mcp_ai_supervisor/web/static/js/modules/notification/notification-manager.js +360 -0
- mcp_ai_supervisor/web/static/js/modules/notification/notification-settings.js +344 -0
- mcp_ai_supervisor/web/static/js/modules/prompt/prompt-input-buttons.js +427 -0
- mcp_ai_supervisor/web/static/js/modules/prompt/prompt-manager.js +414 -0
- mcp_ai_supervisor/web/static/js/modules/prompt/prompt-modal.js +458 -0
- mcp_ai_supervisor/web/static/js/modules/prompt/prompt-settings-ui.js +524 -0
- mcp_ai_supervisor/web/static/js/modules/session/session-data-manager.js +1042 -0
- mcp_ai_supervisor/web/static/js/modules/session/session-details-modal.js +594 -0
- mcp_ai_supervisor/web/static/js/modules/session/session-ui-renderer.js +836 -0
- mcp_ai_supervisor/web/static/js/modules/session-manager.js +1059 -0
- mcp_ai_supervisor/web/static/js/modules/settings-manager.js +1002 -0
- mcp_ai_supervisor/web/static/js/modules/tab-manager.js +235 -0
- mcp_ai_supervisor/web/static/js/modules/textarea-height-manager.js +267 -0
- mcp_ai_supervisor/web/static/js/modules/ui-manager.js +578 -0
- mcp_ai_supervisor/web/static/js/modules/utils/dom-utils.js +392 -0
- mcp_ai_supervisor/web/static/js/modules/utils/status-utils.js +403 -0
- mcp_ai_supervisor/web/static/js/modules/utils/time-utils.js +440 -0
- mcp_ai_supervisor/web/static/js/modules/utils.js +557 -0
- mcp_ai_supervisor/web/static/js/modules/websocket-manager.js +875 -0
- mcp_ai_supervisor/web/static/js/modules/workbench-notification.js +103 -0
- mcp_ai_supervisor/web/static/js/vendor/marked.min.js +6 -0
- mcp_ai_supervisor/web/static/js/vendor/purify.min.js +3 -0
- mcp_ai_supervisor/web/templates/components/image-upload.html +43 -0
- mcp_ai_supervisor/web/templates/components/settings-card.html +58 -0
- mcp_ai_supervisor/web/templates/components/status-indicator.html +31 -0
- mcp_ai_supervisor/web/templates/components/toggle-switch.html +19 -0
- mcp_ai_supervisor/web/templates/feedback.html +1198 -0
- mcp_ai_supervisor/web/templates/index.html +378 -0
- mcp_ai_supervisor/web/utils/__init__.py +12 -0
- mcp_ai_supervisor/web/utils/browser.py +35 -0
- mcp_ai_supervisor/web/utils/compression_config.py +195 -0
- mcp_ai_supervisor/web/utils/compression_monitor.py +314 -0
- mcp_ai_supervisor/web/utils/ide_opener.py +286 -0
- mcp_ai_supervisor/web/utils/network.py +66 -0
- mcp_ai_supervisor/web/utils/port_manager.py +340 -0
- mcp_ai_supervisor/web/utils/session_cleanup_manager.py +543 -0
- mcp_ai_supervisor/web/utils/workbench_client.py +899 -0
- mcp_ai_supervisor/workbench/__init__.py +5 -0
- 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
|
+
"""
|