agentfield 0.1.22rc2__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.
- agentfield/__init__.py +66 -0
- agentfield/agent.py +3569 -0
- agentfield/agent_ai.py +1125 -0
- agentfield/agent_cli.py +386 -0
- agentfield/agent_field_handler.py +494 -0
- agentfield/agent_mcp.py +534 -0
- agentfield/agent_registry.py +29 -0
- agentfield/agent_server.py +1185 -0
- agentfield/agent_utils.py +269 -0
- agentfield/agent_workflow.py +323 -0
- agentfield/async_config.py +278 -0
- agentfield/async_execution_manager.py +1227 -0
- agentfield/client.py +1447 -0
- agentfield/connection_manager.py +280 -0
- agentfield/decorators.py +527 -0
- agentfield/did_manager.py +337 -0
- agentfield/dynamic_skills.py +304 -0
- agentfield/execution_context.py +255 -0
- agentfield/execution_state.py +453 -0
- agentfield/http_connection_manager.py +429 -0
- agentfield/litellm_adapters.py +140 -0
- agentfield/logger.py +249 -0
- agentfield/mcp_client.py +204 -0
- agentfield/mcp_manager.py +340 -0
- agentfield/mcp_stdio_bridge.py +550 -0
- agentfield/memory.py +723 -0
- agentfield/memory_events.py +489 -0
- agentfield/multimodal.py +173 -0
- agentfield/multimodal_response.py +403 -0
- agentfield/pydantic_utils.py +227 -0
- agentfield/rate_limiter.py +280 -0
- agentfield/result_cache.py +441 -0
- agentfield/router.py +190 -0
- agentfield/status.py +70 -0
- agentfield/types.py +710 -0
- agentfield/utils.py +26 -0
- agentfield/vc_generator.py +464 -0
- agentfield/vision.py +198 -0
- agentfield-0.1.22rc2.dist-info/METADATA +102 -0
- agentfield-0.1.22rc2.dist-info/RECORD +42 -0
- agentfield-0.1.22rc2.dist-info/WHEEL +5 -0
- agentfield-0.1.22rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
from fastapi import FastAPI, HTTPException
|
|
10
|
+
|
|
11
|
+
from .logger import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PendingRequest:
|
|
18
|
+
"""Represents a pending request waiting for response"""
|
|
19
|
+
|
|
20
|
+
future: asyncio.Future
|
|
21
|
+
timestamp: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StdioMCPBridge:
|
|
25
|
+
"""
|
|
26
|
+
Bridge that converts stdio-based MCP servers to HTTP endpoints.
|
|
27
|
+
|
|
28
|
+
This bridge starts a stdio MCP server process and provides HTTP endpoints
|
|
29
|
+
that translate HTTP requests to JSON-RPC over stdio and back.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, server_config: dict, port: int, dev_mode: bool = False):
|
|
33
|
+
self.server_config = server_config
|
|
34
|
+
self.port = port
|
|
35
|
+
self.dev_mode = dev_mode
|
|
36
|
+
|
|
37
|
+
# Process management
|
|
38
|
+
self.process: Optional[asyncio.subprocess.Process] = None
|
|
39
|
+
self.stdin_writer: Optional[asyncio.StreamWriter] = None
|
|
40
|
+
self.stdout_reader: Optional[asyncio.StreamReader] = None
|
|
41
|
+
self.stderr_reader: Optional[asyncio.StreamReader] = None
|
|
42
|
+
|
|
43
|
+
# Request correlation
|
|
44
|
+
self.pending_requests: Dict[str, PendingRequest] = {}
|
|
45
|
+
self.request_timeout = 30.0 # seconds
|
|
46
|
+
|
|
47
|
+
# Server state
|
|
48
|
+
self.initialized = False
|
|
49
|
+
self.running = False
|
|
50
|
+
self.app: Optional[FastAPI] = None
|
|
51
|
+
self.server_task: Optional[asyncio.Task] = None
|
|
52
|
+
self.stdio_reader_task: Optional[asyncio.Task] = None
|
|
53
|
+
|
|
54
|
+
# Request ID counter for JSON-RPC
|
|
55
|
+
self._request_id_counter = 0
|
|
56
|
+
|
|
57
|
+
def _get_next_request_id(self) -> int:
|
|
58
|
+
"""Get next request ID for JSON-RPC"""
|
|
59
|
+
self._request_id_counter += 1
|
|
60
|
+
return self._request_id_counter
|
|
61
|
+
|
|
62
|
+
async def start(self) -> bool:
|
|
63
|
+
"""Start the stdio MCP server and HTTP bridge"""
|
|
64
|
+
try:
|
|
65
|
+
if self.dev_mode:
|
|
66
|
+
logger.debug(
|
|
67
|
+
f"Starting stdio MCP bridge for {self.server_config.get('alias', 'unknown')} "
|
|
68
|
+
f"on port {self.port}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Start the stdio MCP server process
|
|
72
|
+
if not await self._start_stdio_process():
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# Start stdio response reader BEFORE initializing MCP session
|
|
76
|
+
self.running = True
|
|
77
|
+
self.stdio_reader_task = asyncio.create_task(self._read_stdio_responses())
|
|
78
|
+
|
|
79
|
+
# Give the reader task a moment to start
|
|
80
|
+
await asyncio.sleep(0.1)
|
|
81
|
+
|
|
82
|
+
# Initialize MCP session
|
|
83
|
+
if not await self._initialize_mcp_session():
|
|
84
|
+
await self.stop()
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# Setup HTTP server
|
|
88
|
+
self._setup_http_server()
|
|
89
|
+
|
|
90
|
+
# Start HTTP server
|
|
91
|
+
if self.app is None:
|
|
92
|
+
raise RuntimeError("HTTP server not properly initialized")
|
|
93
|
+
|
|
94
|
+
config = uvicorn.Config(
|
|
95
|
+
app=self.app,
|
|
96
|
+
host="localhost",
|
|
97
|
+
port=self.port,
|
|
98
|
+
log_level="error" if not self.dev_mode else "info",
|
|
99
|
+
access_log=self.dev_mode,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
server = uvicorn.Server(config)
|
|
103
|
+
self.server_task = asyncio.create_task(server.serve())
|
|
104
|
+
|
|
105
|
+
if self.dev_mode:
|
|
106
|
+
logger.debug(
|
|
107
|
+
f"Stdio MCP bridge started successfully on port {self.port}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"Failed to start stdio MCP bridge: {e}")
|
|
114
|
+
await self.stop()
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
async def stop(self) -> None:
|
|
118
|
+
"""Stop the bridge and cleanup resources"""
|
|
119
|
+
if self.dev_mode:
|
|
120
|
+
logger.debug("Stopping stdio MCP bridge...")
|
|
121
|
+
|
|
122
|
+
self.running = False
|
|
123
|
+
|
|
124
|
+
# Cancel pending requests
|
|
125
|
+
for request_id, pending in self.pending_requests.items():
|
|
126
|
+
if not pending.future.done():
|
|
127
|
+
pending.future.set_exception(Exception("Bridge shutting down"))
|
|
128
|
+
self.pending_requests.clear()
|
|
129
|
+
|
|
130
|
+
# Stop HTTP server
|
|
131
|
+
if self.server_task and not self.server_task.done():
|
|
132
|
+
self.server_task.cancel()
|
|
133
|
+
try:
|
|
134
|
+
await self.server_task
|
|
135
|
+
except asyncio.CancelledError:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# Stop stdio reader
|
|
139
|
+
if self.stdio_reader_task and not self.stdio_reader_task.done():
|
|
140
|
+
self.stdio_reader_task.cancel()
|
|
141
|
+
try:
|
|
142
|
+
await self.stdio_reader_task
|
|
143
|
+
except asyncio.CancelledError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
# Close stdio streams
|
|
147
|
+
if self.stdin_writer:
|
|
148
|
+
self.stdin_writer.close()
|
|
149
|
+
await self.stdin_writer.wait_closed()
|
|
150
|
+
|
|
151
|
+
# Terminate process
|
|
152
|
+
if self.process:
|
|
153
|
+
try:
|
|
154
|
+
self.process.terminate()
|
|
155
|
+
try:
|
|
156
|
+
await asyncio.wait_for(
|
|
157
|
+
asyncio.create_task(self._wait_for_process()), timeout=5.0
|
|
158
|
+
)
|
|
159
|
+
except asyncio.TimeoutError:
|
|
160
|
+
self.process.kill()
|
|
161
|
+
await asyncio.create_task(self._wait_for_process())
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(f"Error stopping process: {e}")
|
|
164
|
+
|
|
165
|
+
if self.dev_mode:
|
|
166
|
+
logger.debug("Stdio MCP bridge stopped")
|
|
167
|
+
|
|
168
|
+
async def _wait_for_process(self):
|
|
169
|
+
"""Wait for process to terminate"""
|
|
170
|
+
if self.process:
|
|
171
|
+
await self.process.wait()
|
|
172
|
+
|
|
173
|
+
async def health_check(self) -> bool:
|
|
174
|
+
"""Check if bridge and stdio process are healthy"""
|
|
175
|
+
if not self.running or not self.process:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
# Check if process is still running
|
|
179
|
+
if self.process.returncode is not None:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
# Try a simple tools/list request to verify communication
|
|
183
|
+
try:
|
|
184
|
+
await asyncio.wait_for(
|
|
185
|
+
self._send_stdio_request("tools/list", {}), timeout=5.0
|
|
186
|
+
)
|
|
187
|
+
return True
|
|
188
|
+
except Exception:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
async def _start_stdio_process(self) -> bool:
|
|
192
|
+
"""Start the stdio MCP server process"""
|
|
193
|
+
try:
|
|
194
|
+
run_command = self.server_config.get("run", "")
|
|
195
|
+
if not run_command:
|
|
196
|
+
raise ValueError("No run command specified in server config")
|
|
197
|
+
|
|
198
|
+
working_dir = self.server_config.get("working_dir", ".")
|
|
199
|
+
env = os.environ.copy()
|
|
200
|
+
env.update(self.server_config.get("environment", {}))
|
|
201
|
+
|
|
202
|
+
if self.dev_mode:
|
|
203
|
+
logger.debug(f"Starting process: {run_command}")
|
|
204
|
+
logger.debug(f"Working directory: {working_dir}")
|
|
205
|
+
|
|
206
|
+
# Start process
|
|
207
|
+
self.process = await asyncio.create_subprocess_shell(
|
|
208
|
+
run_command,
|
|
209
|
+
stdin=asyncio.subprocess.PIPE,
|
|
210
|
+
stdout=asyncio.subprocess.PIPE,
|
|
211
|
+
stderr=asyncio.subprocess.PIPE,
|
|
212
|
+
cwd=working_dir,
|
|
213
|
+
env=env,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if (
|
|
217
|
+
self.process.stdin is None
|
|
218
|
+
or self.process.stdout is None
|
|
219
|
+
or self.process.stderr is None
|
|
220
|
+
):
|
|
221
|
+
raise RuntimeError("Failed to create stdio pipes for process")
|
|
222
|
+
|
|
223
|
+
self.stdin_writer = self.process.stdin
|
|
224
|
+
self.stdout_reader = self.process.stdout
|
|
225
|
+
self.stderr_reader = self.process.stderr
|
|
226
|
+
|
|
227
|
+
# Give process time to start
|
|
228
|
+
await asyncio.sleep(1.0)
|
|
229
|
+
|
|
230
|
+
# Check if process started successfully
|
|
231
|
+
if self.process.returncode is not None:
|
|
232
|
+
stderr_output = ""
|
|
233
|
+
if self.stderr_reader:
|
|
234
|
+
try:
|
|
235
|
+
stderr_data = await asyncio.wait_for(
|
|
236
|
+
self.stderr_reader.read(1024), timeout=1.0
|
|
237
|
+
)
|
|
238
|
+
stderr_output = stderr_data.decode("utf-8", errors="ignore")
|
|
239
|
+
except asyncio.TimeoutError:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
raise RuntimeError(
|
|
243
|
+
f"Process failed to start. Exit code: {self.process.returncode}. Stderr: {stderr_output}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.error(f"Failed to start stdio process: {e}")
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
async def _initialize_mcp_session(self) -> bool:
|
|
253
|
+
"""Initialize MCP session with handshake"""
|
|
254
|
+
try:
|
|
255
|
+
if self.dev_mode:
|
|
256
|
+
logger.debug("Initializing MCP session...")
|
|
257
|
+
|
|
258
|
+
# Send initialize request
|
|
259
|
+
init_params = {
|
|
260
|
+
"protocolVersion": "2024-11-05",
|
|
261
|
+
"capabilities": {"roots": {"listChanged": True}},
|
|
262
|
+
"clientInfo": {"name": "agentfield-stdio-bridge", "version": "1.0.0"},
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
response = await self._send_stdio_request("initialize", init_params)
|
|
266
|
+
|
|
267
|
+
if "error" in response:
|
|
268
|
+
raise RuntimeError(f"Initialize failed: {response['error']}")
|
|
269
|
+
|
|
270
|
+
# Send initialized notification (no response expected)
|
|
271
|
+
await self._send_stdio_notification("notifications/initialized", {})
|
|
272
|
+
|
|
273
|
+
self.initialized = True
|
|
274
|
+
|
|
275
|
+
if self.dev_mode:
|
|
276
|
+
logger.debug("MCP session initialized successfully")
|
|
277
|
+
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.error(f"Failed to initialize MCP session: {e}")
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
def _setup_http_server(self) -> None:
|
|
285
|
+
"""Setup FastAPI HTTP server with MCP endpoints"""
|
|
286
|
+
|
|
287
|
+
@asynccontextmanager
|
|
288
|
+
async def lifespan(app: FastAPI):
|
|
289
|
+
# Startup
|
|
290
|
+
yield
|
|
291
|
+
# Shutdown
|
|
292
|
+
await self.stop()
|
|
293
|
+
|
|
294
|
+
self.app = FastAPI(
|
|
295
|
+
title="MCP Stdio Bridge",
|
|
296
|
+
description="HTTP bridge for stdio-based MCP servers",
|
|
297
|
+
lifespan=lifespan,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
@self.app.get("/health")
|
|
301
|
+
async def health_endpoint():
|
|
302
|
+
"""Health check endpoint"""
|
|
303
|
+
is_healthy = await self.health_check()
|
|
304
|
+
if is_healthy:
|
|
305
|
+
return {"status": "healthy", "bridge": "running", "process": "running"}
|
|
306
|
+
else:
|
|
307
|
+
raise HTTPException(
|
|
308
|
+
status_code=503, detail="Bridge or process not healthy"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
@self.app.post("/mcp/tools/list")
|
|
312
|
+
async def list_tools_endpoint():
|
|
313
|
+
"""List available tools from stdio MCP server"""
|
|
314
|
+
try:
|
|
315
|
+
response = await self._handle_list_tools({})
|
|
316
|
+
return response
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.error(f"Error listing tools: {e}")
|
|
319
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
320
|
+
|
|
321
|
+
@self.app.post("/mcp/tools/call")
|
|
322
|
+
async def call_tool_endpoint(request: dict):
|
|
323
|
+
"""Call a specific tool on stdio MCP server"""
|
|
324
|
+
try:
|
|
325
|
+
response = await self._handle_call_tool(request)
|
|
326
|
+
return response
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.error(f"Error calling tool: {e}")
|
|
329
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
330
|
+
|
|
331
|
+
# Also support the standard MCP v1 endpoint format
|
|
332
|
+
@self.app.post("/mcp/v1")
|
|
333
|
+
async def mcp_v1_endpoint(request: dict):
|
|
334
|
+
"""Standard MCP v1 JSON-RPC endpoint"""
|
|
335
|
+
try:
|
|
336
|
+
method = request.get("method", "")
|
|
337
|
+
params = request.get("params", {})
|
|
338
|
+
|
|
339
|
+
if method == "tools/list":
|
|
340
|
+
result = await self._handle_list_tools(params)
|
|
341
|
+
return {
|
|
342
|
+
"jsonrpc": "2.0",
|
|
343
|
+
"id": request.get("id", 1),
|
|
344
|
+
"result": result,
|
|
345
|
+
}
|
|
346
|
+
elif method == "tools/call":
|
|
347
|
+
result = await self._handle_call_tool(params)
|
|
348
|
+
return {
|
|
349
|
+
"jsonrpc": "2.0",
|
|
350
|
+
"id": request.get("id", 1),
|
|
351
|
+
"result": result,
|
|
352
|
+
}
|
|
353
|
+
else:
|
|
354
|
+
raise HTTPException(
|
|
355
|
+
status_code=400, detail=f"Unsupported method: {method}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.error(f"Error in MCP v1 endpoint: {e}")
|
|
360
|
+
return {
|
|
361
|
+
"jsonrpc": "2.0",
|
|
362
|
+
"id": request.get("id", 1),
|
|
363
|
+
"error": {"code": -32603, "message": str(e)},
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async def _handle_list_tools(self, request: dict) -> dict:
|
|
367
|
+
"""Handle tools/list request"""
|
|
368
|
+
try:
|
|
369
|
+
response = await self._send_stdio_request("tools/list", {})
|
|
370
|
+
|
|
371
|
+
if "error" in response:
|
|
372
|
+
raise RuntimeError(f"Tools list failed: {response['error']}")
|
|
373
|
+
|
|
374
|
+
result = response.get("result", {})
|
|
375
|
+
tools = result.get("tools", [])
|
|
376
|
+
|
|
377
|
+
return {"tools": tools}
|
|
378
|
+
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.error(f"Failed to list tools: {e}")
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
async def _handle_call_tool(self, request: dict) -> dict:
|
|
384
|
+
"""Handle tools/call request"""
|
|
385
|
+
try:
|
|
386
|
+
tool_name = request.get("name")
|
|
387
|
+
arguments = request.get("arguments", {})
|
|
388
|
+
|
|
389
|
+
if not tool_name:
|
|
390
|
+
raise ValueError("Tool name is required")
|
|
391
|
+
|
|
392
|
+
params = {"name": tool_name, "arguments": arguments}
|
|
393
|
+
|
|
394
|
+
response = await self._send_stdio_request("tools/call", params)
|
|
395
|
+
|
|
396
|
+
if "error" in response:
|
|
397
|
+
raise RuntimeError(f"Tool call failed: {response['error']}")
|
|
398
|
+
|
|
399
|
+
return response.get("result", {})
|
|
400
|
+
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.error(f"Failed to call tool: {e}")
|
|
403
|
+
raise
|
|
404
|
+
|
|
405
|
+
async def _send_stdio_request(self, method: str, params: dict) -> dict:
|
|
406
|
+
"""Send JSON-RPC request to stdio process and wait for response"""
|
|
407
|
+
if not self.stdin_writer:
|
|
408
|
+
raise RuntimeError("Stdio process not initialized")
|
|
409
|
+
|
|
410
|
+
request_id = self._get_next_request_id()
|
|
411
|
+
|
|
412
|
+
request = {
|
|
413
|
+
"jsonrpc": "2.0",
|
|
414
|
+
"id": request_id,
|
|
415
|
+
"method": method,
|
|
416
|
+
"params": params,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
# Create future for response
|
|
420
|
+
future = asyncio.Future()
|
|
421
|
+
self.pending_requests[str(request_id)] = PendingRequest(
|
|
422
|
+
future=future, timestamp=asyncio.get_event_loop().time()
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
# Send request
|
|
427
|
+
request_json = json.dumps(request) + "\n"
|
|
428
|
+
self.stdin_writer.write(request_json.encode("utf-8"))
|
|
429
|
+
await self.stdin_writer.drain()
|
|
430
|
+
|
|
431
|
+
if self.dev_mode:
|
|
432
|
+
logger.debug(f"Sent request: {method} (id: {request_id})")
|
|
433
|
+
|
|
434
|
+
# Wait for response with timeout
|
|
435
|
+
response = await asyncio.wait_for(future, timeout=self.request_timeout)
|
|
436
|
+
return response
|
|
437
|
+
|
|
438
|
+
except asyncio.TimeoutError:
|
|
439
|
+
# Clean up pending request
|
|
440
|
+
self.pending_requests.pop(str(request_id), None)
|
|
441
|
+
raise RuntimeError(f"Request timeout for {method}")
|
|
442
|
+
except Exception as e:
|
|
443
|
+
# Clean up pending request
|
|
444
|
+
self.pending_requests.pop(str(request_id), None)
|
|
445
|
+
raise RuntimeError(f"Request failed for {method}: {e}")
|
|
446
|
+
|
|
447
|
+
async def _send_stdio_notification(self, method: str, params: dict) -> None:
|
|
448
|
+
"""Send JSON-RPC notification to stdio process (no response expected)"""
|
|
449
|
+
if not self.stdin_writer:
|
|
450
|
+
raise RuntimeError("Stdio process not initialized")
|
|
451
|
+
|
|
452
|
+
notification = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
453
|
+
|
|
454
|
+
notification_json = json.dumps(notification) + "\n"
|
|
455
|
+
self.stdin_writer.write(notification_json.encode("utf-8"))
|
|
456
|
+
await self.stdin_writer.drain()
|
|
457
|
+
|
|
458
|
+
if self.dev_mode:
|
|
459
|
+
logger.debug(f"Sent notification: {method}")
|
|
460
|
+
|
|
461
|
+
async def _read_stdio_responses(self) -> None:
|
|
462
|
+
"""Read responses from stdio process and correlate with pending requests"""
|
|
463
|
+
if not self.stdout_reader:
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
while self.running:
|
|
468
|
+
try:
|
|
469
|
+
# Read line from stdout
|
|
470
|
+
line = await asyncio.wait_for(
|
|
471
|
+
self.stdout_reader.readline(), timeout=1.0
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
if not line:
|
|
475
|
+
# EOF reached
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
line_str = line.decode("utf-8").strip()
|
|
479
|
+
if not line_str:
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
# Parse JSON response
|
|
483
|
+
try:
|
|
484
|
+
response = json.loads(line_str)
|
|
485
|
+
except json.JSONDecodeError:
|
|
486
|
+
if self.dev_mode:
|
|
487
|
+
logger.warning(
|
|
488
|
+
f"Failed to parse JSON response: {line_str[:100]}..."
|
|
489
|
+
)
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
# Handle response
|
|
493
|
+
await self._handle_stdio_response(response)
|
|
494
|
+
|
|
495
|
+
except asyncio.TimeoutError:
|
|
496
|
+
# Check for expired requests
|
|
497
|
+
await self._cleanup_expired_requests()
|
|
498
|
+
continue
|
|
499
|
+
except Exception as e:
|
|
500
|
+
if self.running:
|
|
501
|
+
logger.error(f"Error reading stdio response: {e}")
|
|
502
|
+
break
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
if self.running:
|
|
506
|
+
logger.error(f"Stdio reader task failed: {e}")
|
|
507
|
+
finally:
|
|
508
|
+
# Cancel all pending requests
|
|
509
|
+
for pending in self.pending_requests.values():
|
|
510
|
+
if not pending.future.done():
|
|
511
|
+
pending.future.set_exception(Exception("Stdio reader stopped"))
|
|
512
|
+
self.pending_requests.clear()
|
|
513
|
+
|
|
514
|
+
async def _handle_stdio_response(self, response: dict) -> None:
|
|
515
|
+
"""Handle a response from stdio process"""
|
|
516
|
+
response_id = response.get("id")
|
|
517
|
+
|
|
518
|
+
if response_id is None:
|
|
519
|
+
# This might be a notification, ignore
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
request_id = str(response_id)
|
|
523
|
+
pending = self.pending_requests.pop(request_id, None)
|
|
524
|
+
|
|
525
|
+
if pending and not pending.future.done():
|
|
526
|
+
pending.future.set_result(response)
|
|
527
|
+
|
|
528
|
+
if self.dev_mode:
|
|
529
|
+
logger.debug(f"Received response for request {request_id}")
|
|
530
|
+
elif self.dev_mode:
|
|
531
|
+
logger.warning(f"Received response for unknown request {request_id}")
|
|
532
|
+
|
|
533
|
+
async def _cleanup_expired_requests(self) -> None:
|
|
534
|
+
"""Clean up expired pending requests"""
|
|
535
|
+
current_time = asyncio.get_event_loop().time()
|
|
536
|
+
expired_ids = []
|
|
537
|
+
|
|
538
|
+
for request_id, pending in self.pending_requests.items():
|
|
539
|
+
if current_time - pending.timestamp > self.request_timeout:
|
|
540
|
+
expired_ids.append(request_id)
|
|
541
|
+
if not pending.future.done():
|
|
542
|
+
pending.future.set_exception(
|
|
543
|
+
asyncio.TimeoutError("Request expired")
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
for request_id in expired_ids:
|
|
547
|
+
self.pending_requests.pop(request_id, None)
|
|
548
|
+
|
|
549
|
+
if expired_ids and self.dev_mode:
|
|
550
|
+
logger.warning(f"Cleaned up {len(expired_ids)} expired requests")
|