castrel-proxy 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.
- castrel_proxy/__init__.py +22 -0
- castrel_proxy/cli/__init__.py +5 -0
- castrel_proxy/cli/commands.py +608 -0
- castrel_proxy/core/__init__.py +18 -0
- castrel_proxy/core/client_id.py +94 -0
- castrel_proxy/core/config.py +158 -0
- castrel_proxy/core/daemon.py +206 -0
- castrel_proxy/core/executor.py +166 -0
- castrel_proxy/data/__init__.py +1 -0
- castrel_proxy/data/default_whitelist.txt +229 -0
- castrel_proxy/mcp/__init__.py +8 -0
- castrel_proxy/mcp/manager.py +278 -0
- castrel_proxy/network/__init__.py +13 -0
- castrel_proxy/network/api_client.py +284 -0
- castrel_proxy/network/websocket_client.py +1148 -0
- castrel_proxy/operations/__init__.py +17 -0
- castrel_proxy/operations/document.py +343 -0
- castrel_proxy/security/__init__.py +17 -0
- castrel_proxy/security/whitelist.py +403 -0
- castrel_proxy-0.1.0.dist-info/METADATA +302 -0
- castrel_proxy-0.1.0.dist-info/RECORD +24 -0
- castrel_proxy-0.1.0.dist-info/WHEEL +4 -0
- castrel_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- castrel_proxy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket Client Module
|
|
3
|
+
|
|
4
|
+
Establishes WebSocket connection with server, receives commands and returns execution results
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from ..operations import document
|
|
18
|
+
from ..core.executor import CommandExecutor
|
|
19
|
+
from ..mcp.manager import get_mcp_manager
|
|
20
|
+
from ..security.whitelist import get_whitelist_file_path, is_command_allowed
|
|
21
|
+
|
|
22
|
+
# Configure logging
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WebSocketClient:
|
|
27
|
+
"""WebSocket client"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
server_url: str,
|
|
32
|
+
client_id: str,
|
|
33
|
+
verification_code: str,
|
|
34
|
+
workspace_id: str,
|
|
35
|
+
reconnect_interval: float = 5.0,
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
初始化 WebSocket client
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
server_url: Server URL
|
|
42
|
+
client_id: Client unique identifier
|
|
43
|
+
verification_code: Verification code
|
|
44
|
+
workspace_id: Workspace ID
|
|
45
|
+
reconnect_interval: Reconnect interval (seconds)
|
|
46
|
+
"""
|
|
47
|
+
self.server_url = server_url
|
|
48
|
+
self.client_id = client_id
|
|
49
|
+
self.verification_code = verification_code
|
|
50
|
+
self.workspace_id = workspace_id
|
|
51
|
+
self.reconnect_interval = reconnect_interval
|
|
52
|
+
self.mcp_manager = get_mcp_manager()
|
|
53
|
+
self.running = False
|
|
54
|
+
self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
|
55
|
+
self.session: Optional[aiohttp.ClientSession] = None
|
|
56
|
+
self.heartbeat_task: Optional[asyncio.Task] = None
|
|
57
|
+
self.heartbeat_interval = 30.0 # 30seconds,Ensure enough heartbeats before server timeout
|
|
58
|
+
|
|
59
|
+
def _get_ws_url(self) -> str:
|
|
60
|
+
"""Get WebSocket URL"""
|
|
61
|
+
# Convert http/https to ws/wss
|
|
62
|
+
ws_url = self.server_url.replace("https://", "wss://").replace("http://", "ws://")
|
|
63
|
+
ws_url = ws_url.rstrip("/")
|
|
64
|
+
|
|
65
|
+
# Add client authentication parameters
|
|
66
|
+
return f"{ws_url}/api/v1/bridge/ws?client_id={self.client_id}&workspace_id={self.workspace_id}&verification_code={self.verification_code}"
|
|
67
|
+
|
|
68
|
+
def _log_operation(
|
|
69
|
+
self,
|
|
70
|
+
session_id: str,
|
|
71
|
+
operation_type: str,
|
|
72
|
+
operation: str,
|
|
73
|
+
arguments: any = None,
|
|
74
|
+
result: any = None,
|
|
75
|
+
success: bool = True,
|
|
76
|
+
elapsed: float = 0.0,
|
|
77
|
+
error: str = None,
|
|
78
|
+
):
|
|
79
|
+
"""
|
|
80
|
+
Log operation to terminal.log
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
session_id: Session ID
|
|
84
|
+
operation_type: Operation type(MCP_TOOL, DOC_READ, DOC_WRITE, DOC_EDIT)
|
|
85
|
+
operation: Operation name
|
|
86
|
+
arguments: Arguments
|
|
87
|
+
result: Result
|
|
88
|
+
success: Whether successful
|
|
89
|
+
elapsed: Execution time
|
|
90
|
+
error: Error message
|
|
91
|
+
"""
|
|
92
|
+
from datetime import datetime
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Create session directory
|
|
96
|
+
home_dir = os.path.expanduser("~")
|
|
97
|
+
session_dir = os.path.join(home_dir, ".castrel", session_id)
|
|
98
|
+
os.makedirs(session_dir, exist_ok=True)
|
|
99
|
+
log_file = os.path.join(session_dir, "terminal.log")
|
|
100
|
+
|
|
101
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
102
|
+
|
|
103
|
+
log_entry = f"""[{timestamp}] {operation_type}: {operation}
|
|
104
|
+
SUCCESS: {success}
|
|
105
|
+
DURATION: {elapsed:.2f}s
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
if arguments:
|
|
109
|
+
import json
|
|
110
|
+
|
|
111
|
+
log_entry += f" ARGUMENTS:\n {json.dumps(arguments, ensure_ascii=False, indent=2).replace(chr(10), chr(10) + ' ')}\n"
|
|
112
|
+
|
|
113
|
+
if result:
|
|
114
|
+
import json
|
|
115
|
+
|
|
116
|
+
result_str = json.dumps(result, ensure_ascii=False, indent=2)[:500] # Limit length
|
|
117
|
+
log_entry += f" RESULT:\n {result_str.replace(chr(10), chr(10) + ' ')}\n"
|
|
118
|
+
|
|
119
|
+
if error:
|
|
120
|
+
log_entry += f" ERROR:\n {error}\n"
|
|
121
|
+
|
|
122
|
+
log_entry += "---\n\n"
|
|
123
|
+
|
|
124
|
+
# Append to log file
|
|
125
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
126
|
+
f.write(log_entry)
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
# Logging failure should not affect operation execution
|
|
130
|
+
logger.warning(f"Failed to log operation: {e}")
|
|
131
|
+
|
|
132
|
+
async def _handle_command(self, message: dict) -> Optional[dict]:
|
|
133
|
+
"""
|
|
134
|
+
Handle server commands
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
message: Server message
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Optional[dict]: Response message, return None if no response needed None
|
|
141
|
+
"""
|
|
142
|
+
message_id = message.get("id")
|
|
143
|
+
message_type = message.get("type")
|
|
144
|
+
timestamp = message.get("timestamp")
|
|
145
|
+
|
|
146
|
+
logger.info(
|
|
147
|
+
f"[CLIENT-MSG-RECV] Received message: message_id={message_id}, "
|
|
148
|
+
f"message_type={message_type}, timestamp={timestamp}, client_id={self.client_id}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if message_type == "connected":
|
|
152
|
+
# Connection success message
|
|
153
|
+
session_id = message.get("session_id", "")
|
|
154
|
+
msg = message.get("message", "")
|
|
155
|
+
logger.info(
|
|
156
|
+
f"[CLIENT-CONNECTED] Connection established: session_id={session_id}, "
|
|
157
|
+
f"message={msg}, client_id={self.client_id}"
|
|
158
|
+
)
|
|
159
|
+
# No response needed
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
elif message_type == "local_tool_call":
|
|
163
|
+
# Handle local command call
|
|
164
|
+
data = message.get("data", {})
|
|
165
|
+
command = data.get("command", "")
|
|
166
|
+
args = data.get("args", [])
|
|
167
|
+
cwd = data.get("cwd")
|
|
168
|
+
timeout = data.get("timeout", 300)
|
|
169
|
+
session_id = data.get("session_id", "")
|
|
170
|
+
|
|
171
|
+
logger.info(
|
|
172
|
+
f"[CLIENT-LOCAL-CALL] Local tool call received: message_id={message_id}, "
|
|
173
|
+
f"command={command}, args={args}, cwd={cwd}, session_id={session_id}, timeout={timeout}s, "
|
|
174
|
+
f"client_id={self.client_id}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return await self._execute_local_command(
|
|
178
|
+
message_id=message_id,
|
|
179
|
+
command=command,
|
|
180
|
+
args=args,
|
|
181
|
+
cwd=cwd,
|
|
182
|
+
session_id=session_id,
|
|
183
|
+
timeout=timeout,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
elif message_type == "mcp_tool_call":
|
|
187
|
+
# Handle MCP tool call
|
|
188
|
+
data = message.get("data", {})
|
|
189
|
+
server_name = data.get("server_name", "")
|
|
190
|
+
tool_name = data.get("tool_name", "")
|
|
191
|
+
arguments = data.get("arguments", {})
|
|
192
|
+
session_id = data.get("session_id", "")
|
|
193
|
+
|
|
194
|
+
logger.info(
|
|
195
|
+
f"[CLIENT-MCP-CALL] MCP tool call received: message_id={message_id}, "
|
|
196
|
+
f"server={server_name}, tool={tool_name}, session_id={session_id}, arguments={arguments}, "
|
|
197
|
+
f"client_id={self.client_id}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return await self._execute_mcp_tool(
|
|
201
|
+
message_id=message_id,
|
|
202
|
+
server_name=server_name,
|
|
203
|
+
tool_name=tool_name,
|
|
204
|
+
session_id=session_id,
|
|
205
|
+
arguments=arguments,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
elif message_type == "doc_read_call":
|
|
209
|
+
# Handle document read call
|
|
210
|
+
data = message.get("data", {})
|
|
211
|
+
file_path = data.get("file_path", "")
|
|
212
|
+
encoding = data.get("encoding")
|
|
213
|
+
session_id = data.get("session_id", "")
|
|
214
|
+
|
|
215
|
+
logger.info(
|
|
216
|
+
f"[CLIENT-DOC-READ-CALL] Doc read call received: message_id={message_id}, "
|
|
217
|
+
f"file_path={file_path}, session_id={session_id}, encoding={encoding}, client_id={self.client_id}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return await self._execute_doc_read(
|
|
221
|
+
message_id=message_id,
|
|
222
|
+
file_path=file_path,
|
|
223
|
+
session_id=session_id,
|
|
224
|
+
encoding=encoding,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
elif message_type == "doc_write_call":
|
|
228
|
+
# Handle document write call
|
|
229
|
+
data = message.get("data", {})
|
|
230
|
+
file_path = data.get("file_path", "")
|
|
231
|
+
content = data.get("content", "")
|
|
232
|
+
encoding = data.get("encoding", "utf-8")
|
|
233
|
+
create_dirs = data.get("create_dirs", True)
|
|
234
|
+
session_id = data.get("session_id", "")
|
|
235
|
+
|
|
236
|
+
logger.info(
|
|
237
|
+
f"[CLIENT-DOC-WRITE-CALL] Doc write call received: message_id={message_id}, "
|
|
238
|
+
f"file_path={file_path}, content_len={len(content)}, session_id={session_id}, encoding={encoding}, "
|
|
239
|
+
f"create_dirs={create_dirs}, client_id={self.client_id}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return await self._execute_doc_write(
|
|
243
|
+
message_id=message_id,
|
|
244
|
+
file_path=file_path,
|
|
245
|
+
session_id=session_id,
|
|
246
|
+
content=content,
|
|
247
|
+
encoding=encoding,
|
|
248
|
+
create_dirs=create_dirs,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
elif message_type == "doc_edit_call":
|
|
252
|
+
# Handle document edit call
|
|
253
|
+
data = message.get("data", {})
|
|
254
|
+
file_path = data.get("file_path", "")
|
|
255
|
+
operation = data.get("operation", "")
|
|
256
|
+
new_content = data.get("new_content", "")
|
|
257
|
+
old_content = data.get("old_content")
|
|
258
|
+
encoding = data.get("encoding")
|
|
259
|
+
session_id = data.get("session_id", "")
|
|
260
|
+
|
|
261
|
+
logger.info(
|
|
262
|
+
f"[CLIENT-DOC-EDIT-CALL] Doc edit call received: message_id={message_id}, "
|
|
263
|
+
f"file_path={file_path}, operation={operation}, session_id={session_id}, encoding={encoding}, client_id={self.client_id}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return await self._execute_doc_edit(
|
|
267
|
+
message_id=message_id,
|
|
268
|
+
file_path=file_path,
|
|
269
|
+
session_id=session_id,
|
|
270
|
+
operation=operation,
|
|
271
|
+
new_content=new_content,
|
|
272
|
+
old_content=old_content,
|
|
273
|
+
encoding=encoding,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
elif message_type == "ping":
|
|
277
|
+
# Heartbeat from server, response needed
|
|
278
|
+
logger.debug(f"[CLIENT-PING-RECV] Received ping: message_id={message_id}, client_id={self.client_id}")
|
|
279
|
+
return {"id": message_id, "type": "pong"}
|
|
280
|
+
|
|
281
|
+
elif message_type == "pong":
|
|
282
|
+
# Server response to client heartbeat
|
|
283
|
+
logger.debug(f"[CLIENT-PONG-RECV] Received pong: message_id={message_id}, client_id={self.client_id}")
|
|
284
|
+
# No response needed
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
else:
|
|
288
|
+
# Unknown command type
|
|
289
|
+
logger.warning(
|
|
290
|
+
f"[CLIENT-MSG-UNKNOWN] Unknown message type: message_id={message_id}, "
|
|
291
|
+
f"message_type={message_type}, client_id={self.client_id}"
|
|
292
|
+
)
|
|
293
|
+
return {
|
|
294
|
+
"id": message_id,
|
|
295
|
+
"type": "error",
|
|
296
|
+
"error": f"Unknown message type: {message_type}",
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async def _send_heartbeat(self):
|
|
300
|
+
"""Send heartbeat periodically"""
|
|
301
|
+
logger.info(
|
|
302
|
+
f"[CLIENT-HEARTBEAT-START] Heartbeat task started: interval={self.heartbeat_interval}s, "
|
|
303
|
+
f"client_id={self.client_id}"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
while self.running and self.ws and not self.ws.closed:
|
|
307
|
+
try:
|
|
308
|
+
# Send heartbeat message
|
|
309
|
+
heartbeat_msg = {
|
|
310
|
+
"id": str(uuid.uuid4()),
|
|
311
|
+
"type": "ping",
|
|
312
|
+
"timestamp": int(time.time() * 1000),
|
|
313
|
+
}
|
|
314
|
+
logger.debug(
|
|
315
|
+
f"[CLIENT-HEARTBEAT-SEND] Sending heartbeat: message_id={heartbeat_msg['id']}, "
|
|
316
|
+
f"client_id={self.client_id}"
|
|
317
|
+
)
|
|
318
|
+
await self.ws.send_json(heartbeat_msg)
|
|
319
|
+
logger.debug(
|
|
320
|
+
f"[CLIENT-HEARTBEAT-SENT] Heartbeat sent: message_id={heartbeat_msg['id']}, "
|
|
321
|
+
f"client_id={self.client_id}"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Wait for next heartbeat
|
|
325
|
+
await asyncio.sleep(self.heartbeat_interval)
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.error(
|
|
329
|
+
f"[CLIENT-HEARTBEAT-ERROR] Failed to send heartbeat: error={e}, client_id={self.client_id}",
|
|
330
|
+
exc_info=True,
|
|
331
|
+
)
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
logger.info(f"[CLIENT-HEARTBEAT-STOP] Heartbeat task stopped: client_id={self.client_id}")
|
|
335
|
+
|
|
336
|
+
async def _execute_local_command(
|
|
337
|
+
self,
|
|
338
|
+
message_id: str,
|
|
339
|
+
command: str,
|
|
340
|
+
session_id: str,
|
|
341
|
+
args: list = None,
|
|
342
|
+
cwd: Optional[str] = None,
|
|
343
|
+
timeout: int = 300,
|
|
344
|
+
) -> dict:
|
|
345
|
+
"""
|
|
346
|
+
Execute local command
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
message_id: 消息ID
|
|
350
|
+
command: 命令名称
|
|
351
|
+
session_id: 聊天Session ID(required)
|
|
352
|
+
args: 命令Arguments列表
|
|
353
|
+
cwd: 工作目录
|
|
354
|
+
timeout: 超时时间(seconds)
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
dict: 响应消息
|
|
358
|
+
"""
|
|
359
|
+
start_time = time.time()
|
|
360
|
+
try:
|
|
361
|
+
# 验证 session_id
|
|
362
|
+
if not session_id:
|
|
363
|
+
logger.error(f"[CLIENT-LOCAL-EXEC-ERROR] session_id is required: message_id={message_id}")
|
|
364
|
+
return {
|
|
365
|
+
"id": message_id,
|
|
366
|
+
"type": "local_tool_result",
|
|
367
|
+
"success": False,
|
|
368
|
+
"data": {
|
|
369
|
+
"exit_code": -1,
|
|
370
|
+
"stdout": "",
|
|
371
|
+
"stderr": "session_id is required for command execution",
|
|
372
|
+
"execution_time": 0.0,
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if args is None:
|
|
377
|
+
args = []
|
|
378
|
+
|
|
379
|
+
# 展开Arguments中的 ~ 路径和环境变量
|
|
380
|
+
expanded_args = []
|
|
381
|
+
for arg in args:
|
|
382
|
+
# 只对看起来像路径的Arguments进行展开(包含 ~ 或 $)
|
|
383
|
+
if "~" in arg or "$" in arg:
|
|
384
|
+
expanded_args.append(os.path.expanduser(os.path.expandvars(arg)))
|
|
385
|
+
else:
|
|
386
|
+
expanded_args.append(arg)
|
|
387
|
+
|
|
388
|
+
# 构建完整命令
|
|
389
|
+
if expanded_args:
|
|
390
|
+
full_command = f"{command} {' '.join(expanded_args)}"
|
|
391
|
+
else:
|
|
392
|
+
full_command = command
|
|
393
|
+
|
|
394
|
+
# Whitelist check
|
|
395
|
+
is_allowed, blocked_commands = is_command_allowed(full_command)
|
|
396
|
+
if not is_allowed:
|
|
397
|
+
whitelist_path = get_whitelist_file_path()
|
|
398
|
+
blocked_list = ", ".join(blocked_commands) if blocked_commands else command
|
|
399
|
+
error_msg = (
|
|
400
|
+
f"Command execution rejected。Following commands not in whitelist: {blocked_list}\n"
|
|
401
|
+
f"Please add required commands to whitelist configuration file: {whitelist_path}"
|
|
402
|
+
)
|
|
403
|
+
logger.warning(
|
|
404
|
+
f"[CLIENT-LOCAL-EXEC-BLOCKED] Commands not in whitelist: message_id={message_id}, "
|
|
405
|
+
f"blocked_commands={blocked_commands}, full_command={full_command[:200]}, "
|
|
406
|
+
f"whitelist_path={whitelist_path}, client_id={self.client_id}"
|
|
407
|
+
)
|
|
408
|
+
return {
|
|
409
|
+
"id": message_id,
|
|
410
|
+
"type": "local_tool_result",
|
|
411
|
+
"success": False,
|
|
412
|
+
"data": {
|
|
413
|
+
"exit_code": -3,
|
|
414
|
+
"stdout": "",
|
|
415
|
+
"stderr": error_msg,
|
|
416
|
+
"execution_time": 0.0,
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
logger.info(
|
|
421
|
+
f"[CLIENT-LOCAL-EXEC-START] Executing local command: message_id={message_id}, "
|
|
422
|
+
f"command={full_command}, session_id={session_id}, cwd={cwd}, timeout={timeout}s, "
|
|
423
|
+
f"client_id={self.client_id}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Create session-specific executor
|
|
427
|
+
executor = CommandExecutor(session_id=session_id, timeout=timeout)
|
|
428
|
+
|
|
429
|
+
# Execute command(Pass cwd to execute method if specified)
|
|
430
|
+
result = await executor.execute(full_command, cwd=cwd)
|
|
431
|
+
|
|
432
|
+
elapsed = time.time() - start_time
|
|
433
|
+
logger.info(
|
|
434
|
+
f"[CLIENT-LOCAL-EXEC-SUCCESS] Local command completed: message_id={message_id}, "
|
|
435
|
+
f"exit_code={result.exit_code}, elapsed={elapsed:.2f}s, "
|
|
436
|
+
f"stdout_len={len(result.stdout)}, stderr_len={len(result.stderr)}, client_id={self.client_id}"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
"id": message_id,
|
|
441
|
+
"type": "local_tool_result",
|
|
442
|
+
"success": result.exit_code == 0,
|
|
443
|
+
"data": result.to_dict(),
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
except Exception as e:
|
|
447
|
+
elapsed = time.time() - start_time
|
|
448
|
+
logger.error(
|
|
449
|
+
f"[CLIENT-LOCAL-EXEC-ERROR] Local command execution failed: message_id={message_id}, "
|
|
450
|
+
f"error={e}, elapsed={elapsed:.2f}s, client_id={self.client_id}",
|
|
451
|
+
exc_info=True,
|
|
452
|
+
)
|
|
453
|
+
return {
|
|
454
|
+
"id": message_id,
|
|
455
|
+
"type": "local_tool_result",
|
|
456
|
+
"success": False,
|
|
457
|
+
"data": {
|
|
458
|
+
"exit_code": -1,
|
|
459
|
+
"stdout": "",
|
|
460
|
+
"stderr": f"Execute local command失败: {str(e)}",
|
|
461
|
+
"execution_time": 0.0,
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async def _execute_mcp_tool(
|
|
466
|
+
self,
|
|
467
|
+
message_id: str,
|
|
468
|
+
server_name: str,
|
|
469
|
+
tool_name: str,
|
|
470
|
+
session_id: str,
|
|
471
|
+
arguments: dict,
|
|
472
|
+
) -> dict:
|
|
473
|
+
"""
|
|
474
|
+
Execute MCP tool
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
message_id: 消息ID
|
|
478
|
+
server_name: MCP 服务器名称
|
|
479
|
+
tool_name: 工具名称
|
|
480
|
+
session_id: 聊天Session ID(required)
|
|
481
|
+
arguments: 工具Arguments
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
dict: 响应消息
|
|
485
|
+
"""
|
|
486
|
+
start_time = time.time()
|
|
487
|
+
try:
|
|
488
|
+
# 验证 session_id
|
|
489
|
+
if not session_id:
|
|
490
|
+
logger.error(f"[CLIENT-MCP-EXEC-ERROR] session_id is required: message_id={message_id}")
|
|
491
|
+
return {
|
|
492
|
+
"id": message_id,
|
|
493
|
+
"type": "mcp_tool_result",
|
|
494
|
+
"success": False,
|
|
495
|
+
"data": {
|
|
496
|
+
"server_name": server_name,
|
|
497
|
+
"tool_name": tool_name,
|
|
498
|
+
"result": None,
|
|
499
|
+
"error": "session_id is required for MCP tool execution",
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
logger.info(
|
|
504
|
+
f"[CLIENT-MCP-EXEC-START] Executing MCP tool: message_id={message_id}, "
|
|
505
|
+
f"server={server_name}, tool={tool_name}, session_id={session_id}, arguments={arguments}, "
|
|
506
|
+
f"client_id={self.client_id}"
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# Call MCP tool
|
|
510
|
+
if not self.mcp_manager.client:
|
|
511
|
+
logger.error(
|
|
512
|
+
f"[CLIENT-MCP-EXEC-ERROR] MCP client not initialized: message_id={message_id}, "
|
|
513
|
+
f"client_id={self.client_id}"
|
|
514
|
+
)
|
|
515
|
+
return {
|
|
516
|
+
"id": message_id,
|
|
517
|
+
"type": "mcp_tool_result",
|
|
518
|
+
"success": False,
|
|
519
|
+
"data": {
|
|
520
|
+
"server_name": server_name,
|
|
521
|
+
"tool_name": tool_name,
|
|
522
|
+
"result": None,
|
|
523
|
+
"error": "MCP 客户端未初始化",
|
|
524
|
+
},
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
# 执行工具
|
|
528
|
+
tools = await self.mcp_manager.client.get_tools(server_name=server_name)
|
|
529
|
+
current_tool = None
|
|
530
|
+
# Iterate through tool_name to get corresponding tool
|
|
531
|
+
for tool in tools:
|
|
532
|
+
if tool.name == tool_name:
|
|
533
|
+
current_tool = tool
|
|
534
|
+
break
|
|
535
|
+
if not current_tool:
|
|
536
|
+
logger.error(
|
|
537
|
+
f"[CLIENT-MCP-EXEC-ERROR] MCP tool not found: message_id={message_id}, "
|
|
538
|
+
f"server={server_name}, tool={tool_name}, available_tools={[t.name for t in tools]}, "
|
|
539
|
+
f"client_id={self.client_id}"
|
|
540
|
+
)
|
|
541
|
+
return {
|
|
542
|
+
"id": message_id,
|
|
543
|
+
"type": "mcp_tool_result",
|
|
544
|
+
"success": False,
|
|
545
|
+
"data": {
|
|
546
|
+
"server_name": server_name,
|
|
547
|
+
"tool_name": tool_name,
|
|
548
|
+
"result": None,
|
|
549
|
+
"error": "Execute MCP tool失败: tool不存在",
|
|
550
|
+
},
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
result = await current_tool.ainvoke(input=arguments)
|
|
554
|
+
|
|
555
|
+
elapsed = time.time() - start_time
|
|
556
|
+
logger.info(
|
|
557
|
+
f"[CLIENT-MCP-EXEC-SUCCESS] MCP tool completed: message_id={message_id}, "
|
|
558
|
+
f"server={server_name}, tool={tool_name}, elapsed={elapsed:.2f}s, client_id={self.client_id}"
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Log to terminal.log
|
|
562
|
+
self._log_operation(
|
|
563
|
+
session_id=session_id,
|
|
564
|
+
operation_type="MCP_TOOL",
|
|
565
|
+
operation=f"{server_name}:{tool_name}",
|
|
566
|
+
arguments=arguments,
|
|
567
|
+
result=result,
|
|
568
|
+
success=True,
|
|
569
|
+
elapsed=elapsed,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
"id": message_id,
|
|
574
|
+
"type": "mcp_tool_result",
|
|
575
|
+
"success": True,
|
|
576
|
+
"data": {
|
|
577
|
+
"server_name": server_name,
|
|
578
|
+
"tool_name": tool_name,
|
|
579
|
+
"result": result,
|
|
580
|
+
},
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
except Exception as e:
|
|
584
|
+
elapsed = time.time() - start_time
|
|
585
|
+
logger.error(
|
|
586
|
+
f"[CLIENT-MCP-EXEC-ERROR] MCP tool execution failed: message_id={message_id}, "
|
|
587
|
+
f"server={server_name}, tool={tool_name}, error={e}, elapsed={elapsed:.2f}s, "
|
|
588
|
+
f"client_id={self.client_id}",
|
|
589
|
+
exc_info=True,
|
|
590
|
+
)
|
|
591
|
+
return {
|
|
592
|
+
"id": message_id,
|
|
593
|
+
"type": "mcp_tool_result",
|
|
594
|
+
"success": False,
|
|
595
|
+
"data": {
|
|
596
|
+
"server_name": server_name,
|
|
597
|
+
"tool_name": tool_name,
|
|
598
|
+
"result": None,
|
|
599
|
+
"error": f"Execute MCP tool失败: {str(e)}",
|
|
600
|
+
},
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async def _execute_doc_read(
|
|
604
|
+
self,
|
|
605
|
+
message_id: str,
|
|
606
|
+
file_path: str,
|
|
607
|
+
session_id: str,
|
|
608
|
+
encoding: Optional[str] = None,
|
|
609
|
+
) -> dict:
|
|
610
|
+
"""
|
|
611
|
+
Execute document read
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
message_id: 消息ID
|
|
615
|
+
file_path: 文件路径
|
|
616
|
+
session_id: 聊天Session ID
|
|
617
|
+
encoding: 文件编码
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
dict: 响应消息
|
|
621
|
+
"""
|
|
622
|
+
start_time = time.time()
|
|
623
|
+
try:
|
|
624
|
+
logger.info(
|
|
625
|
+
f"[CLIENT-DOC-READ-EXEC-START] Executing doc read: message_id={message_id}, "
|
|
626
|
+
f"file_path={file_path}, session_id={session_id}, encoding={encoding}, client_id={self.client_id}"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# Call document.read_document
|
|
630
|
+
result = document.read_document(file_path=file_path, encoding=encoding)
|
|
631
|
+
|
|
632
|
+
elapsed = time.time() - start_time
|
|
633
|
+
logger.info(
|
|
634
|
+
f"[CLIENT-DOC-READ-EXEC-SUCCESS] Doc read completed: message_id={message_id}, "
|
|
635
|
+
f"success={result.get('success')}, elapsed={elapsed:.2f}s, client_id={self.client_id}"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
# Log to terminal.log
|
|
639
|
+
self._log_operation(
|
|
640
|
+
session_id=session_id,
|
|
641
|
+
operation_type="DOC_READ",
|
|
642
|
+
operation=file_path,
|
|
643
|
+
arguments={"encoding": encoding},
|
|
644
|
+
result={"size": result.get("size"), "encoding": result.get("encoding")},
|
|
645
|
+
success=result.get("success", False),
|
|
646
|
+
elapsed=elapsed,
|
|
647
|
+
error=result.get("error"),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
"id": message_id,
|
|
652
|
+
"type": "doc_read_result",
|
|
653
|
+
"success": result.get("success", False),
|
|
654
|
+
"data": {
|
|
655
|
+
"content": result.get("content"),
|
|
656
|
+
"encoding": result.get("encoding"),
|
|
657
|
+
"size": result.get("size"),
|
|
658
|
+
"error": result.get("error"),
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
except Exception as e:
|
|
663
|
+
elapsed = time.time() - start_time
|
|
664
|
+
logger.error(
|
|
665
|
+
f"[CLIENT-DOC-READ-EXEC-ERROR] Doc read execution failed: message_id={message_id}, "
|
|
666
|
+
f"error={e}, elapsed={elapsed:.2f}s, client_id={self.client_id}",
|
|
667
|
+
exc_info=True,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# Log error to terminal.log
|
|
671
|
+
self._log_operation(
|
|
672
|
+
session_id=session_id,
|
|
673
|
+
operation_type="DOC_READ",
|
|
674
|
+
operation=file_path,
|
|
675
|
+
arguments={"encoding": encoding},
|
|
676
|
+
result=None,
|
|
677
|
+
success=False,
|
|
678
|
+
elapsed=elapsed,
|
|
679
|
+
error=str(e),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
"id": message_id,
|
|
684
|
+
"type": "doc_read_result",
|
|
685
|
+
"success": False,
|
|
686
|
+
"data": {
|
|
687
|
+
"content": None,
|
|
688
|
+
"encoding": None,
|
|
689
|
+
"size": None,
|
|
690
|
+
"error": f"Execute document read失败: {str(e)}",
|
|
691
|
+
},
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async def _execute_doc_write(
|
|
695
|
+
self,
|
|
696
|
+
message_id: str,
|
|
697
|
+
file_path: str,
|
|
698
|
+
session_id: str,
|
|
699
|
+
content: str,
|
|
700
|
+
encoding: str = "utf-8",
|
|
701
|
+
create_dirs: bool = True,
|
|
702
|
+
) -> dict:
|
|
703
|
+
"""
|
|
704
|
+
Execute document write
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
message_id: 消息ID
|
|
708
|
+
file_path: 文件路径
|
|
709
|
+
session_id: 聊天Session ID
|
|
710
|
+
content: 文件内容
|
|
711
|
+
encoding: 文件编码
|
|
712
|
+
create_dirs: 是否创建父目录
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
dict: 响应消息
|
|
716
|
+
"""
|
|
717
|
+
start_time = time.time()
|
|
718
|
+
try:
|
|
719
|
+
logger.info(
|
|
720
|
+
f"[CLIENT-DOC-WRITE-EXEC-START] Executing doc write: message_id={message_id}, "
|
|
721
|
+
f"file_path={file_path}, content_len={len(content)}, session_id={session_id}, encoding={encoding}, "
|
|
722
|
+
f"create_dirs={create_dirs}, client_id={self.client_id}"
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
# Call document.write_document
|
|
726
|
+
result = document.write_document(
|
|
727
|
+
file_path=file_path,
|
|
728
|
+
content=content,
|
|
729
|
+
encoding=encoding,
|
|
730
|
+
create_dirs=create_dirs,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
elapsed = time.time() - start_time
|
|
734
|
+
logger.info(
|
|
735
|
+
f"[CLIENT-DOC-WRITE-EXEC-SUCCESS] Doc write completed: message_id={message_id}, "
|
|
736
|
+
f"success={result.get('success')}, elapsed={elapsed:.2f}s, client_id={self.client_id}"
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
# Log to terminal.log
|
|
740
|
+
self._log_operation(
|
|
741
|
+
session_id=session_id,
|
|
742
|
+
operation_type="DOC_WRITE",
|
|
743
|
+
operation=file_path,
|
|
744
|
+
arguments={
|
|
745
|
+
"content_length": len(content),
|
|
746
|
+
"encoding": encoding,
|
|
747
|
+
"create_dirs": create_dirs,
|
|
748
|
+
},
|
|
749
|
+
result={"size": result.get("size"), "path": result.get("path")},
|
|
750
|
+
success=result.get("success", False),
|
|
751
|
+
elapsed=elapsed,
|
|
752
|
+
error=result.get("error"),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
"id": message_id,
|
|
757
|
+
"type": "doc_write_result",
|
|
758
|
+
"success": result.get("success", False),
|
|
759
|
+
"data": {
|
|
760
|
+
"path": result.get("path"),
|
|
761
|
+
"size": result.get("size"),
|
|
762
|
+
"error": result.get("error"),
|
|
763
|
+
},
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
except Exception as e:
|
|
767
|
+
elapsed = time.time() - start_time
|
|
768
|
+
logger.error(
|
|
769
|
+
f"[CLIENT-DOC-WRITE-EXEC-ERROR] Doc write execution failed: message_id={message_id}, "
|
|
770
|
+
f"error={e}, elapsed={elapsed:.2f}s, client_id={self.client_id}",
|
|
771
|
+
exc_info=True,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
# Log error to terminal.log
|
|
775
|
+
self._log_operation(
|
|
776
|
+
session_id=session_id,
|
|
777
|
+
operation_type="DOC_WRITE",
|
|
778
|
+
operation=file_path,
|
|
779
|
+
arguments={
|
|
780
|
+
"content_length": len(content),
|
|
781
|
+
"encoding": encoding,
|
|
782
|
+
"create_dirs": create_dirs,
|
|
783
|
+
},
|
|
784
|
+
result=None,
|
|
785
|
+
success=False,
|
|
786
|
+
elapsed=elapsed,
|
|
787
|
+
error=str(e),
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
"id": message_id,
|
|
792
|
+
"type": "doc_write_result",
|
|
793
|
+
"success": False,
|
|
794
|
+
"data": {
|
|
795
|
+
"path": None,
|
|
796
|
+
"size": None,
|
|
797
|
+
"error": f"Execute document write失败: {str(e)}",
|
|
798
|
+
},
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async def _execute_doc_edit(
|
|
802
|
+
self,
|
|
803
|
+
message_id: str,
|
|
804
|
+
file_path: str,
|
|
805
|
+
session_id: str,
|
|
806
|
+
operation: str,
|
|
807
|
+
new_content: str,
|
|
808
|
+
old_content: Optional[str] = None,
|
|
809
|
+
encoding: Optional[str] = None,
|
|
810
|
+
) -> dict:
|
|
811
|
+
"""
|
|
812
|
+
Execute document edit
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
message_id: 消息ID
|
|
816
|
+
file_path: 文件路径
|
|
817
|
+
session_id: 聊天Session ID
|
|
818
|
+
operation: Operation type(replace, append, prepend)
|
|
819
|
+
new_content: 新内容
|
|
820
|
+
old_content: 旧内容(Only needed for replace operation)
|
|
821
|
+
encoding: 文件编码
|
|
822
|
+
|
|
823
|
+
Returns:
|
|
824
|
+
dict: 响应消息
|
|
825
|
+
"""
|
|
826
|
+
start_time = time.time()
|
|
827
|
+
try:
|
|
828
|
+
logger.info(
|
|
829
|
+
f"[CLIENT-DOC-EDIT-EXEC-START] Executing doc edit: message_id={message_id}, "
|
|
830
|
+
f"file_path={file_path}, operation={operation}, session_id={session_id}, encoding={encoding}, client_id={self.client_id}"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# Call document.edit_document
|
|
834
|
+
result = document.edit_document(
|
|
835
|
+
file_path=file_path,
|
|
836
|
+
operation=operation,
|
|
837
|
+
new_content=new_content,
|
|
838
|
+
old_content=old_content,
|
|
839
|
+
encoding=encoding,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
elapsed = time.time() - start_time
|
|
843
|
+
logger.info(
|
|
844
|
+
f"[CLIENT-DOC-EDIT-EXEC-SUCCESS] Doc edit completed: message_id={message_id}, "
|
|
845
|
+
f"success={result.get('success')}, operation={operation}, elapsed={elapsed:.2f}s, "
|
|
846
|
+
f"client_id={self.client_id}"
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
# Log to terminal.log
|
|
850
|
+
self._log_operation(
|
|
851
|
+
session_id=session_id,
|
|
852
|
+
operation_type="DOC_EDIT",
|
|
853
|
+
operation=f"{operation}:{file_path}",
|
|
854
|
+
arguments={
|
|
855
|
+
"new_content_length": len(new_content),
|
|
856
|
+
"old_content_length": len(old_content) if old_content else 0,
|
|
857
|
+
"encoding": encoding,
|
|
858
|
+
},
|
|
859
|
+
result={"size": result.get("size"), "path": result.get("path")},
|
|
860
|
+
success=result.get("success", False),
|
|
861
|
+
elapsed=elapsed,
|
|
862
|
+
error=result.get("error"),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
return {
|
|
866
|
+
"id": message_id,
|
|
867
|
+
"type": "doc_edit_result",
|
|
868
|
+
"success": result.get("success", False),
|
|
869
|
+
"data": {
|
|
870
|
+
"operation": result.get("operation"),
|
|
871
|
+
"size": result.get("size"),
|
|
872
|
+
"path": result.get("path"),
|
|
873
|
+
"error": result.get("error"),
|
|
874
|
+
},
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
except Exception as e:
|
|
878
|
+
elapsed = time.time() - start_time
|
|
879
|
+
logger.error(
|
|
880
|
+
f"[CLIENT-DOC-EDIT-EXEC-ERROR] Doc edit execution failed: message_id={message_id}, "
|
|
881
|
+
f"error={e}, elapsed={elapsed:.2f}s, client_id={self.client_id}",
|
|
882
|
+
exc_info=True,
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
# Log error to terminal.log
|
|
886
|
+
self._log_operation(
|
|
887
|
+
session_id=session_id,
|
|
888
|
+
operation_type="DOC_EDIT",
|
|
889
|
+
operation=f"{operation}:{file_path}",
|
|
890
|
+
arguments={
|
|
891
|
+
"new_content_length": len(new_content),
|
|
892
|
+
"old_content_length": len(old_content) if old_content else 0,
|
|
893
|
+
"encoding": encoding,
|
|
894
|
+
},
|
|
895
|
+
result=None,
|
|
896
|
+
success=False,
|
|
897
|
+
elapsed=elapsed,
|
|
898
|
+
error=str(e),
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
return {
|
|
902
|
+
"id": message_id,
|
|
903
|
+
"type": "doc_edit_result",
|
|
904
|
+
"success": False,
|
|
905
|
+
"data": {
|
|
906
|
+
"operation": operation,
|
|
907
|
+
"size": None,
|
|
908
|
+
"path": None,
|
|
909
|
+
"error": f"Execute document edit失败: {str(e)}",
|
|
910
|
+
},
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async def _listen(self):
|
|
914
|
+
"""监听Server message"""
|
|
915
|
+
try:
|
|
916
|
+
logger.info(f"[CLIENT-LISTEN-START] Started listening for messages: client_id={self.client_id}")
|
|
917
|
+
|
|
918
|
+
async for msg in self.ws:
|
|
919
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
920
|
+
try:
|
|
921
|
+
# Parse message
|
|
922
|
+
data = json.loads(msg.data)
|
|
923
|
+
logger.debug(f"[CLIENT-WS-RECV] Received raw message: data={data}, client_id={self.client_id}")
|
|
924
|
+
|
|
925
|
+
# Put command processing in background task to avoid blocking message receive loop
|
|
926
|
+
# This ensures other messages (like ping/pong) can still be received and processed during long tasks
|
|
927
|
+
asyncio.create_task(self._handle_and_respond(data))
|
|
928
|
+
|
|
929
|
+
except json.JSONDecodeError as e:
|
|
930
|
+
logger.error(
|
|
931
|
+
f"[CLIENT-MSG-PARSE-ERROR] Failed to parse message: error={e}, "
|
|
932
|
+
f"raw_data={msg.data}, client_id={self.client_id}"
|
|
933
|
+
)
|
|
934
|
+
except Exception as e:
|
|
935
|
+
logger.error(
|
|
936
|
+
f"[CLIENT-MSG-HANDLE-ERROR] Error handling message: error={e}, client_id={self.client_id}",
|
|
937
|
+
exc_info=True,
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
941
|
+
logger.error(
|
|
942
|
+
f"[CLIENT-WS-ERROR] WebSocket error: exception={self.ws.exception()}, "
|
|
943
|
+
f"client_id={self.client_id}"
|
|
944
|
+
)
|
|
945
|
+
break
|
|
946
|
+
|
|
947
|
+
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
|
948
|
+
logger.info(f"[CLIENT-WS-CLOSED] WebSocket connection closed: client_id={self.client_id}")
|
|
949
|
+
break
|
|
950
|
+
|
|
951
|
+
except Exception as e:
|
|
952
|
+
logger.error(
|
|
953
|
+
f"[CLIENT-LISTEN-ERROR] Error in message listener: error={e}, client_id={self.client_id}",
|
|
954
|
+
exc_info=True,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
async def _handle_and_respond(self, data: dict):
|
|
958
|
+
"""
|
|
959
|
+
Handle command and send response (run as background task)
|
|
960
|
+
|
|
961
|
+
Put command handling and response sending in separate task to avoid blocking _listen() loop。
|
|
962
|
+
这样可以确保在长任务执行期间:
|
|
963
|
+
1. Message receive loop continues running, can receive server PING/PONG messages
|
|
964
|
+
2. Heartbeat task can send normally
|
|
965
|
+
3. WebSocket Connection stays active
|
|
966
|
+
|
|
967
|
+
Args:
|
|
968
|
+
data: Received message data
|
|
969
|
+
"""
|
|
970
|
+
try:
|
|
971
|
+
# Handle command
|
|
972
|
+
response = await self._handle_command(data)
|
|
973
|
+
|
|
974
|
+
# Send response(if any)
|
|
975
|
+
if response is not None:
|
|
976
|
+
logger.info(
|
|
977
|
+
f"[CLIENT-RESPONSE-SEND] Sending response: message_id={response.get('id')}, "
|
|
978
|
+
f"type={response.get('type')}, success={response.get('success')}, "
|
|
979
|
+
f"client_id={self.client_id}"
|
|
980
|
+
)
|
|
981
|
+
await self.ws.send_json(response)
|
|
982
|
+
logger.info(
|
|
983
|
+
f"[CLIENT-RESPONSE-SENT] Response sent successfully: message_id={response.get('id')}, "
|
|
984
|
+
f"client_id={self.client_id}"
|
|
985
|
+
)
|
|
986
|
+
else:
|
|
987
|
+
logger.debug(
|
|
988
|
+
f"[CLIENT-NO-RESPONSE] No response needed for message: "
|
|
989
|
+
f"message_id={data.get('id')}, type={data.get('type')}, client_id={self.client_id}"
|
|
990
|
+
)
|
|
991
|
+
except Exception as e:
|
|
992
|
+
logger.error(
|
|
993
|
+
f"[CLIENT-HANDLE-RESPOND-ERROR] Error in handle_and_respond: error={e}, "
|
|
994
|
+
f"message_id={data.get('id')}, client_id={self.client_id}",
|
|
995
|
+
exc_info=True,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
async def connect(self):
|
|
999
|
+
"""Establish WebSocket connection"""
|
|
1000
|
+
ws_url = self._get_ws_url()
|
|
1001
|
+
logger.info(
|
|
1002
|
+
f"[CLIENT-CONNECT-START] Attempting to connect to server: url={ws_url}, "
|
|
1003
|
+
f"client_id={self.client_id}, workspace_id={self.workspace_id}"
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
try:
|
|
1007
|
+
# Don't set global timeout when creating ClientSession
|
|
1008
|
+
# This ensures long-running commands won't be interrupted by timeout
|
|
1009
|
+
self.session = aiohttp.ClientSession()
|
|
1010
|
+
logger.debug(f"[CLIENT-SESSION-CREATED] aiohttp session created: client_id={self.client_id}")
|
|
1011
|
+
|
|
1012
|
+
# Establish WebSocket connection,配置适合长时间任务的Arguments
|
|
1013
|
+
self.ws = await self.session.ws_connect(
|
|
1014
|
+
ws_url,
|
|
1015
|
+
heartbeat=self.heartbeat_interval, # Enable WebSocket protocol-level heartbeat(PING/PONG)
|
|
1016
|
+
timeout=900.0, # 15minute timeout,Support long-running commands
|
|
1017
|
+
autoclose=False, # Don't auto-close connection
|
|
1018
|
+
autoping=True, # Auto-respond to server PING
|
|
1019
|
+
)
|
|
1020
|
+
logger.info(
|
|
1021
|
+
f"[CLIENT-CONNECT-SUCCESS] WebSocket connection established: client_id={self.client_id}, "
|
|
1022
|
+
f"ws_closed={self.ws.closed}, heartbeat={self.heartbeat_interval}s"
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
# Start heartbeat task
|
|
1026
|
+
self.heartbeat_task = asyncio.create_task(self._send_heartbeat())
|
|
1027
|
+
logger.info(
|
|
1028
|
+
f"[CLIENT-HEARTBEAT-TASK] Heartbeat task started: interval={self.heartbeat_interval}s, "
|
|
1029
|
+
f"client_id={self.client_id}"
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
return True
|
|
1033
|
+
|
|
1034
|
+
except Exception as e:
|
|
1035
|
+
logger.error(
|
|
1036
|
+
f"[CLIENT-CONNECT-ERROR] Failed to connect: error={e}, client_id={self.client_id}",
|
|
1037
|
+
exc_info=True,
|
|
1038
|
+
)
|
|
1039
|
+
if self.session:
|
|
1040
|
+
await self.session.close()
|
|
1041
|
+
return False
|
|
1042
|
+
|
|
1043
|
+
async def disconnect(self):
|
|
1044
|
+
"""Disconnect WebSocket connection"""
|
|
1045
|
+
logger.info(f"[CLIENT-DISCONNECT-START] Starting disconnect process: client_id={self.client_id}")
|
|
1046
|
+
|
|
1047
|
+
# Stop heartbeat task
|
|
1048
|
+
if self.heartbeat_task and not self.heartbeat_task.done():
|
|
1049
|
+
logger.debug(f"[CLIENT-DISCONNECT-HEARTBEAT] Cancelling heartbeat task: client_id={self.client_id}")
|
|
1050
|
+
self.heartbeat_task.cancel()
|
|
1051
|
+
try:
|
|
1052
|
+
await self.heartbeat_task
|
|
1053
|
+
except asyncio.CancelledError:
|
|
1054
|
+
pass
|
|
1055
|
+
logger.info(f"[CLIENT-DISCONNECT-HEARTBEAT] Heartbeat task stopped: client_id={self.client_id}")
|
|
1056
|
+
|
|
1057
|
+
if self.ws and not self.ws.closed:
|
|
1058
|
+
logger.debug(f"[CLIENT-DISCONNECT-WS] Closing WebSocket connection: client_id={self.client_id}")
|
|
1059
|
+
await self.ws.close()
|
|
1060
|
+
logger.info(f"[CLIENT-DISCONNECT-WS] WebSocket closed: client_id={self.client_id}")
|
|
1061
|
+
|
|
1062
|
+
if self.session:
|
|
1063
|
+
logger.debug(f"[CLIENT-DISCONNECT-SESSION] Closing aiohttp session: client_id={self.client_id}")
|
|
1064
|
+
await self.session.close()
|
|
1065
|
+
logger.info(f"[CLIENT-DISCONNECT-SESSION] Session closed: client_id={self.client_id}")
|
|
1066
|
+
|
|
1067
|
+
logger.info(f"[CLIENT-DISCONNECT-COMPLETE] Disconnect completed: client_id={self.client_id}")
|
|
1068
|
+
|
|
1069
|
+
async def run(self):
|
|
1070
|
+
"""Run client (with automatic reconnection)"""
|
|
1071
|
+
self.running = True
|
|
1072
|
+
logger.info(
|
|
1073
|
+
f"[CLIENT-RUN-START] Starting client: client_id={self.client_id}, "
|
|
1074
|
+
f"server_url={self.server_url}, workspace_id={self.workspace_id}"
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
# Connect to MCP services
|
|
1078
|
+
try:
|
|
1079
|
+
logger.info(f"[CLIENT-MCP-CONNECT] Connecting to MCP services: client_id={self.client_id}")
|
|
1080
|
+
mcp_count = await self.mcp_manager.connect_all()
|
|
1081
|
+
if mcp_count > 0:
|
|
1082
|
+
logger.info(
|
|
1083
|
+
f"[CLIENT-MCP-CONNECTED] Successfully connected to MCP services: count={mcp_count}, "
|
|
1084
|
+
f"client_id={self.client_id}"
|
|
1085
|
+
)
|
|
1086
|
+
else:
|
|
1087
|
+
logger.warning(f"[CLIENT-MCP-NONE] No MCP services configured or connected: client_id={self.client_id}")
|
|
1088
|
+
except Exception as e:
|
|
1089
|
+
logger.error(
|
|
1090
|
+
f"[CLIENT-MCP-ERROR] Error connecting to MCP services: error={e}, client_id={self.client_id}",
|
|
1091
|
+
exc_info=True,
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
reconnect_count = 0
|
|
1095
|
+
while self.running:
|
|
1096
|
+
# Attempting to connect
|
|
1097
|
+
logger.info(
|
|
1098
|
+
f"[CLIENT-RECONNECT] Attempting to connect: attempt={reconnect_count + 1}, client_id={self.client_id}"
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
if await self.connect():
|
|
1102
|
+
reconnect_count = 0 # Reset reconnection count
|
|
1103
|
+
try:
|
|
1104
|
+
# Listen for messages
|
|
1105
|
+
await self._listen()
|
|
1106
|
+
except Exception as e:
|
|
1107
|
+
logger.error(
|
|
1108
|
+
f"[CLIENT-RUN-ERROR] Error during client run: error={e}, client_id={self.client_id}",
|
|
1109
|
+
exc_info=True,
|
|
1110
|
+
)
|
|
1111
|
+
finally:
|
|
1112
|
+
# Disconnect
|
|
1113
|
+
await self.disconnect()
|
|
1114
|
+
else:
|
|
1115
|
+
reconnect_count += 1
|
|
1116
|
+
logger.warning(
|
|
1117
|
+
f"[CLIENT-CONNECT-FAILED] Failed to establish connection: attempts={reconnect_count}, "
|
|
1118
|
+
f"client_id={self.client_id}"
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
# If still running, wait then reconnect
|
|
1122
|
+
if self.running:
|
|
1123
|
+
logger.info(
|
|
1124
|
+
f"[CLIENT-RECONNECT-WAIT] Waiting for reconnection: delay={self.reconnect_interval}s, "
|
|
1125
|
+
f"client_id={self.client_id}"
|
|
1126
|
+
)
|
|
1127
|
+
await asyncio.sleep(self.reconnect_interval)
|
|
1128
|
+
|
|
1129
|
+
async def stop(self):
|
|
1130
|
+
"""Stop client"""
|
|
1131
|
+
logger.info(f"[CLIENT-STOP-START] Stopping client: client_id={self.client_id}")
|
|
1132
|
+
|
|
1133
|
+
self.running = False
|
|
1134
|
+
await self.disconnect()
|
|
1135
|
+
|
|
1136
|
+
# Disconnect from MCP services
|
|
1137
|
+
try:
|
|
1138
|
+
logger.info(f"[CLIENT-MCP-DISCONNECT] Disconnecting from MCP services: client_id={self.client_id}")
|
|
1139
|
+
await self.mcp_manager.disconnect_all()
|
|
1140
|
+
logger.info(f"[CLIENT-MCP-DISCONNECTED] MCP services disconnected: client_id={self.client_id}")
|
|
1141
|
+
except Exception as e:
|
|
1142
|
+
logger.error(
|
|
1143
|
+
f"[CLIENT-MCP-DISCONNECT-ERROR] Error disconnecting from MCP services: error={e}, "
|
|
1144
|
+
f"client_id={self.client_id}",
|
|
1145
|
+
exc_info=True,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
logger.info(f"[CLIENT-STOP-COMPLETE] Client stopped: client_id={self.client_id}")
|