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.
Files changed (42) hide show
  1. agentfield/__init__.py +66 -0
  2. agentfield/agent.py +3569 -0
  3. agentfield/agent_ai.py +1125 -0
  4. agentfield/agent_cli.py +386 -0
  5. agentfield/agent_field_handler.py +494 -0
  6. agentfield/agent_mcp.py +534 -0
  7. agentfield/agent_registry.py +29 -0
  8. agentfield/agent_server.py +1185 -0
  9. agentfield/agent_utils.py +269 -0
  10. agentfield/agent_workflow.py +323 -0
  11. agentfield/async_config.py +278 -0
  12. agentfield/async_execution_manager.py +1227 -0
  13. agentfield/client.py +1447 -0
  14. agentfield/connection_manager.py +280 -0
  15. agentfield/decorators.py +527 -0
  16. agentfield/did_manager.py +337 -0
  17. agentfield/dynamic_skills.py +304 -0
  18. agentfield/execution_context.py +255 -0
  19. agentfield/execution_state.py +453 -0
  20. agentfield/http_connection_manager.py +429 -0
  21. agentfield/litellm_adapters.py +140 -0
  22. agentfield/logger.py +249 -0
  23. agentfield/mcp_client.py +204 -0
  24. agentfield/mcp_manager.py +340 -0
  25. agentfield/mcp_stdio_bridge.py +550 -0
  26. agentfield/memory.py +723 -0
  27. agentfield/memory_events.py +489 -0
  28. agentfield/multimodal.py +173 -0
  29. agentfield/multimodal_response.py +403 -0
  30. agentfield/pydantic_utils.py +227 -0
  31. agentfield/rate_limiter.py +280 -0
  32. agentfield/result_cache.py +441 -0
  33. agentfield/router.py +190 -0
  34. agentfield/status.py +70 -0
  35. agentfield/types.py +710 -0
  36. agentfield/utils.py +26 -0
  37. agentfield/vc_generator.py +464 -0
  38. agentfield/vision.py +198 -0
  39. agentfield-0.1.22rc2.dist-info/METADATA +102 -0
  40. agentfield-0.1.22rc2.dist-info/RECORD +42 -0
  41. agentfield-0.1.22rc2.dist-info/WHEEL +5 -0
  42. 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")