agent-framework-devui 0.0.1a0__py3-none-any.whl → 1.0.0b251001__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.

Potentially problematic release.


This version of agent-framework-devui might be problematic. Click here for more details.

@@ -0,0 +1,530 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """FastAPI server implementation."""
4
+
5
+ import inspect
6
+ import json
7
+ import logging
8
+ from collections.abc import AsyncGenerator
9
+ from contextlib import asynccontextmanager
10
+ from typing import Any
11
+
12
+ from fastapi import FastAPI, HTTPException, Request
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import JSONResponse, StreamingResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+
17
+ from ._discovery import EntityDiscovery
18
+ from ._executor import AgentFrameworkExecutor
19
+ from ._mapper import MessageMapper
20
+ from .models import AgentFrameworkRequest, OpenAIError
21
+ from .models._discovery_models import DiscoveryResponse, EntityInfo
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class DevServer:
27
+ """Development Server - OpenAI compatible API server for debugging agents."""
28
+
29
+ def __init__(
30
+ self,
31
+ entities_dir: str | None = None,
32
+ port: int = 8080,
33
+ host: str = "127.0.0.1",
34
+ cors_origins: list[str] | None = None,
35
+ ui_enabled: bool = True,
36
+ ) -> None:
37
+ """Initialize the development server.
38
+
39
+ Args:
40
+ entities_dir: Directory to scan for entities
41
+ port: Port to run server on
42
+ host: Host to bind server to
43
+ cors_origins: List of allowed CORS origins
44
+ ui_enabled: Whether to enable the UI
45
+ """
46
+ self.entities_dir = entities_dir
47
+ self.port = port
48
+ self.host = host
49
+ self.cors_origins = cors_origins or ["*"]
50
+ self.ui_enabled = ui_enabled
51
+ self.executor: AgentFrameworkExecutor | None = None
52
+ self._app: FastAPI | None = None
53
+ self._pending_entities: list[Any] | None = None
54
+
55
+ async def _ensure_executor(self) -> AgentFrameworkExecutor:
56
+ """Ensure executor is initialized."""
57
+ if self.executor is None:
58
+ logger.info("Initializing Agent Framework executor...")
59
+
60
+ # Create components directly
61
+ entity_discovery = EntityDiscovery(self.entities_dir)
62
+ message_mapper = MessageMapper()
63
+ self.executor = AgentFrameworkExecutor(entity_discovery, message_mapper)
64
+
65
+ # Discover entities from directory
66
+ discovered_entities = await self.executor.discover_entities()
67
+ logger.info(f"Discovered {len(discovered_entities)} entities from directory")
68
+
69
+ # Register any pending in-memory entities
70
+ if self._pending_entities:
71
+ discovery = self.executor.entity_discovery
72
+ for entity in self._pending_entities:
73
+ try:
74
+ entity_info = await discovery.create_entity_info_from_object(entity, source="in-memory")
75
+ discovery.register_entity(entity_info.id, entity_info, entity)
76
+ logger.info(f"Registered in-memory entity: {entity_info.id}")
77
+ except Exception as e:
78
+ logger.error(f"Failed to register in-memory entity: {e}")
79
+ self._pending_entities = None # Clear after registration
80
+
81
+ # Get the final entity count after all registration
82
+ all_entities = self.executor.entity_discovery.list_entities()
83
+ logger.info(f"Total entities available: {len(all_entities)}")
84
+
85
+ return self.executor
86
+
87
+ async def _cleanup_entities(self) -> None:
88
+ """Cleanup entity resources (close clients, credentials, etc.)."""
89
+ if not self.executor:
90
+ return
91
+
92
+ logger.info("Cleaning up entity resources...")
93
+ entities = self.executor.entity_discovery.list_entities()
94
+ closed_count = 0
95
+
96
+ for entity_info in entities:
97
+ try:
98
+ entity_obj = self.executor.entity_discovery.get_entity_object(entity_info.id)
99
+ if entity_obj and hasattr(entity_obj, "chat_client"):
100
+ client = entity_obj.chat_client
101
+ if hasattr(client, "close") and callable(client.close):
102
+ if inspect.iscoroutinefunction(client.close):
103
+ await client.close()
104
+ else:
105
+ client.close()
106
+ closed_count += 1
107
+ logger.debug(f"Closed client for entity: {entity_info.id}")
108
+ except Exception as e:
109
+ logger.warning(f"Error closing entity {entity_info.id}: {e}")
110
+
111
+ if closed_count > 0:
112
+ logger.info(f"Closed {closed_count} entity client(s)")
113
+
114
+ def create_app(self) -> FastAPI:
115
+ """Create the FastAPI application."""
116
+
117
+ @asynccontextmanager
118
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
119
+ # Startup
120
+ logger.info("Starting Agent Framework Server")
121
+ await self._ensure_executor()
122
+ yield
123
+ # Shutdown
124
+ logger.info("Shutting down Agent Framework Server")
125
+
126
+ # Cleanup entity resources (e.g., close credentials, clients)
127
+ if self.executor:
128
+ await self._cleanup_entities()
129
+
130
+ app = FastAPI(
131
+ title="Agent Framework Server",
132
+ description="OpenAI-compatible API server for Agent Framework and other AI frameworks",
133
+ version="1.0.0",
134
+ lifespan=lifespan,
135
+ )
136
+
137
+ # Add CORS middleware
138
+ app.add_middleware(
139
+ CORSMiddleware,
140
+ allow_origins=self.cors_origins,
141
+ allow_credentials=True,
142
+ allow_methods=["*"],
143
+ allow_headers=["*"],
144
+ )
145
+
146
+ self._register_routes(app)
147
+ self._mount_ui(app)
148
+
149
+ return app
150
+
151
+ def _register_routes(self, app: FastAPI) -> None:
152
+ """Register API routes."""
153
+
154
+ @app.get("/health")
155
+ async def health_check() -> dict[str, Any]:
156
+ """Health check endpoint."""
157
+ executor = await self._ensure_executor()
158
+ # Use list_entities() to avoid re-discovering and re-registering entities
159
+ entities = executor.entity_discovery.list_entities()
160
+
161
+ return {"status": "healthy", "entities_count": len(entities), "framework": "agent_framework"}
162
+
163
+ @app.get("/v1/entities", response_model=DiscoveryResponse)
164
+ async def discover_entities() -> DiscoveryResponse:
165
+ """List all registered entities."""
166
+ try:
167
+ executor = await self._ensure_executor()
168
+ # Use list_entities() instead of discover_entities() to get already-registered entities
169
+ entities = executor.entity_discovery.list_entities()
170
+ return DiscoveryResponse(entities=entities)
171
+ except Exception as e:
172
+ logger.error(f"Error listing entities: {e}")
173
+ raise HTTPException(status_code=500, detail=f"Entity listing failed: {e!s}") from e
174
+
175
+ @app.get("/v1/entities/{entity_id}/info", response_model=EntityInfo)
176
+ async def get_entity_info(entity_id: str) -> EntityInfo:
177
+ """Get detailed information about a specific entity."""
178
+ try:
179
+ executor = await self._ensure_executor()
180
+ entity_info = executor.get_entity_info(entity_id)
181
+
182
+ if not entity_info:
183
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
184
+
185
+ # For workflows, populate additional detailed information
186
+ if entity_info.type == "workflow":
187
+ entity_obj = executor.entity_discovery.get_entity_object(entity_id)
188
+ if entity_obj:
189
+ # Get workflow structure
190
+ workflow_dump = None
191
+ if hasattr(entity_obj, "to_dict") and callable(getattr(entity_obj, "to_dict", None)):
192
+ try:
193
+ workflow_dump = entity_obj.to_dict() # type: ignore[attr-defined]
194
+ except Exception:
195
+ workflow_dump = None
196
+ elif hasattr(entity_obj, "to_json") and callable(getattr(entity_obj, "to_json", None)):
197
+ try:
198
+ raw_dump = entity_obj.to_json() # type: ignore[attr-defined]
199
+ except Exception:
200
+ workflow_dump = None
201
+ else:
202
+ if isinstance(raw_dump, (bytes, bytearray)):
203
+ try:
204
+ raw_dump = raw_dump.decode()
205
+ except Exception:
206
+ raw_dump = raw_dump.decode(errors="replace")
207
+ if isinstance(raw_dump, str):
208
+ try:
209
+ parsed_dump = json.loads(raw_dump)
210
+ except Exception:
211
+ workflow_dump = raw_dump
212
+ else:
213
+ workflow_dump = parsed_dump if isinstance(parsed_dump, dict) else raw_dump
214
+ else:
215
+ workflow_dump = raw_dump
216
+ elif hasattr(entity_obj, "__dict__"):
217
+ workflow_dump = {k: v for k, v in entity_obj.__dict__.items() if not k.startswith("_")}
218
+
219
+ # Get input schema information
220
+ input_schema = {}
221
+ input_type_name = "Unknown"
222
+ start_executor_id = ""
223
+
224
+ try:
225
+ start_executor = entity_obj.get_start_executor()
226
+ if start_executor and hasattr(start_executor, "_handlers"):
227
+ message_types = list(start_executor._handlers.keys())
228
+ if message_types:
229
+ input_type = message_types[0]
230
+ input_type_name = getattr(input_type, "__name__", str(input_type))
231
+
232
+ # Basic schema generation for common types
233
+ if input_type is str:
234
+ input_schema = {"type": "string"}
235
+ elif input_type is dict:
236
+ input_schema = {"type": "object"}
237
+ elif hasattr(input_type, "model_json_schema"):
238
+ input_schema = input_type.model_json_schema()
239
+
240
+ start_executor_id = getattr(start_executor, "executor_id", "")
241
+ except Exception as e:
242
+ logger.debug(f"Could not extract input info for workflow {entity_id}: {e}")
243
+
244
+ # Get executor list
245
+ executor_list = []
246
+ if hasattr(entity_obj, "executors") and entity_obj.executors:
247
+ executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors]
248
+
249
+ # Create copy of entity info and populate workflow-specific fields
250
+ update_payload: dict[str, Any] = {
251
+ "workflow_dump": workflow_dump,
252
+ "input_schema": input_schema,
253
+ "input_type_name": input_type_name,
254
+ "start_executor_id": start_executor_id,
255
+ }
256
+ if executor_list:
257
+ update_payload["executors"] = executor_list
258
+ return entity_info.model_copy(update=update_payload)
259
+
260
+ # For non-workflow entities, return as-is
261
+ return entity_info
262
+
263
+ except HTTPException:
264
+ raise
265
+ except Exception as e:
266
+ logger.error(f"Error getting entity info for {entity_id}: {e}")
267
+ raise HTTPException(status_code=500, detail=f"Failed to get entity info: {e!s}") from e
268
+
269
+ @app.post("/v1/entities/add")
270
+ async def add_entity(request: dict[str, Any]) -> dict[str, Any]:
271
+ """Add entity from URL."""
272
+ try:
273
+ url = request.get("url")
274
+ metadata = request.get("metadata", {})
275
+
276
+ if not url:
277
+ raise HTTPException(status_code=400, detail="URL is required")
278
+
279
+ logger.info(f"Attempting to add entity from URL: {url}")
280
+ executor = await self._ensure_executor()
281
+ entity_info, error_msg = await executor.entity_discovery.fetch_remote_entity(url, metadata)
282
+
283
+ if not entity_info:
284
+ # Sanitize error message - only return safe, user-friendly errors
285
+ logger.error(f"Failed to fetch or validate entity from {url}: {error_msg}")
286
+ safe_error = error_msg if error_msg else "Failed to fetch or validate entity"
287
+ raise HTTPException(status_code=400, detail=safe_error)
288
+
289
+ logger.info(f"Successfully added entity: {entity_info.id}")
290
+ return {"success": True, "entity": entity_info.model_dump()}
291
+
292
+ except HTTPException:
293
+ raise
294
+ except Exception as e:
295
+ logger.error(f"Error adding entity: {e}", exc_info=True)
296
+ # Don't expose internal error details to client
297
+ raise HTTPException(
298
+ status_code=500, detail="An unexpected error occurred while adding the entity"
299
+ ) from e
300
+
301
+ @app.delete("/v1/entities/{entity_id}")
302
+ async def remove_entity(entity_id: str) -> dict[str, Any]:
303
+ """Remove entity by ID."""
304
+ try:
305
+ executor = await self._ensure_executor()
306
+
307
+ # Cleanup entity resources before removal
308
+ try:
309
+ entity_obj = executor.entity_discovery.get_entity_object(entity_id)
310
+ if entity_obj and hasattr(entity_obj, "chat_client"):
311
+ client = entity_obj.chat_client
312
+ if hasattr(client, "close") and callable(client.close):
313
+ if inspect.iscoroutinefunction(client.close):
314
+ await client.close()
315
+ else:
316
+ client.close()
317
+ logger.info(f"Closed client for entity: {entity_id}")
318
+ except Exception as e:
319
+ logger.warning(f"Error closing entity {entity_id} during removal: {e}")
320
+
321
+ # Remove entity from registry
322
+ success = executor.entity_discovery.remove_remote_entity(entity_id)
323
+
324
+ if success:
325
+ return {"success": True}
326
+ raise HTTPException(status_code=404, detail="Entity not found or cannot be removed")
327
+
328
+ except HTTPException:
329
+ raise
330
+ except Exception as e:
331
+ logger.error(f"Error removing entity {entity_id}: {e}")
332
+ raise HTTPException(status_code=500, detail=f"Failed to remove entity: {e!s}") from e
333
+
334
+ @app.post("/v1/responses")
335
+ async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any:
336
+ """OpenAI Responses API endpoint."""
337
+ try:
338
+ raw_body = await raw_request.body()
339
+ logger.info(f"Raw request body: {raw_body.decode()}")
340
+ logger.info(f"Parsed request: model={request.model}, extra_body={request.extra_body}")
341
+
342
+ # Get entity_id using the new method
343
+ entity_id = request.get_entity_id()
344
+ logger.info(f"Extracted entity_id: {entity_id}")
345
+
346
+ if not entity_id:
347
+ error = OpenAIError.create(f"Missing entity_id. Request extra_body: {request.extra_body}")
348
+ return JSONResponse(status_code=400, content=error.to_dict())
349
+
350
+ # Get executor and validate entity exists
351
+ executor = await self._ensure_executor()
352
+ try:
353
+ entity_info = executor.get_entity_info(entity_id)
354
+ logger.info(f"Found entity: {entity_info.name} ({entity_info.type})")
355
+ except Exception:
356
+ error = OpenAIError.create(f"Entity not found: {entity_id}")
357
+ return JSONResponse(status_code=404, content=error.to_dict())
358
+
359
+ # Execute request
360
+ if request.stream:
361
+ return StreamingResponse(
362
+ self._stream_execution(executor, request),
363
+ media_type="text/event-stream",
364
+ headers={
365
+ "Cache-Control": "no-cache",
366
+ "Connection": "keep-alive",
367
+ "Access-Control-Allow-Origin": "*",
368
+ },
369
+ )
370
+ return await executor.execute_sync(request)
371
+
372
+ except Exception as e:
373
+ logger.error(f"Error executing request: {e}")
374
+ error = OpenAIError.create(f"Execution failed: {e!s}")
375
+ return JSONResponse(status_code=500, content=error.to_dict())
376
+
377
+ @app.post("/v1/threads")
378
+ async def create_thread(request_data: dict[str, Any]) -> dict[str, Any]:
379
+ """Create a new thread for an agent."""
380
+ try:
381
+ agent_id = request_data.get("agent_id")
382
+ if not agent_id:
383
+ raise HTTPException(status_code=400, detail="agent_id is required")
384
+
385
+ executor = await self._ensure_executor()
386
+ thread_id = executor.create_thread(agent_id)
387
+
388
+ return {
389
+ "id": thread_id,
390
+ "object": "thread",
391
+ "created_at": int(__import__("time").time()),
392
+ "metadata": {"agent_id": agent_id},
393
+ }
394
+ except HTTPException:
395
+ raise
396
+ except Exception as e:
397
+ logger.error(f"Error creating thread: {e}")
398
+ raise HTTPException(status_code=500, detail=f"Failed to create thread: {e!s}") from e
399
+
400
+ @app.get("/v1/threads")
401
+ async def list_threads(agent_id: str) -> dict[str, Any]:
402
+ """List threads for an agent."""
403
+ try:
404
+ executor = await self._ensure_executor()
405
+ thread_ids = executor.list_threads_for_agent(agent_id)
406
+
407
+ # Convert thread IDs to thread objects
408
+ threads = []
409
+ for thread_id in thread_ids:
410
+ threads.append({"id": thread_id, "object": "thread", "agent_id": agent_id})
411
+
412
+ return {"object": "list", "data": threads}
413
+ except Exception as e:
414
+ logger.error(f"Error listing threads: {e}")
415
+ raise HTTPException(status_code=500, detail=f"Failed to list threads: {e!s}") from e
416
+
417
+ @app.get("/v1/threads/{thread_id}")
418
+ async def get_thread(thread_id: str) -> dict[str, Any]:
419
+ """Get thread information."""
420
+ try:
421
+ executor = await self._ensure_executor()
422
+
423
+ # Check if thread exists
424
+ thread = executor.get_thread(thread_id)
425
+ if not thread:
426
+ raise HTTPException(status_code=404, detail="Thread not found")
427
+
428
+ # Get the agent that owns this thread
429
+ agent_id = executor.get_agent_for_thread(thread_id)
430
+
431
+ return {"id": thread_id, "object": "thread", "agent_id": agent_id}
432
+ except HTTPException:
433
+ raise
434
+ except Exception as e:
435
+ logger.error(f"Error getting thread {thread_id}: {e}")
436
+ raise HTTPException(status_code=500, detail=f"Failed to get thread: {e!s}") from e
437
+
438
+ @app.delete("/v1/threads/{thread_id}")
439
+ async def delete_thread(thread_id: str) -> dict[str, Any]:
440
+ """Delete a thread."""
441
+ try:
442
+ executor = await self._ensure_executor()
443
+ success = executor.delete_thread(thread_id)
444
+
445
+ if not success:
446
+ raise HTTPException(status_code=404, detail="Thread not found")
447
+
448
+ return {"id": thread_id, "object": "thread.deleted", "deleted": True}
449
+ except HTTPException:
450
+ raise
451
+ except Exception as e:
452
+ logger.error(f"Error deleting thread {thread_id}: {e}")
453
+ raise HTTPException(status_code=500, detail=f"Failed to delete thread: {e!s}") from e
454
+
455
+ @app.get("/v1/threads/{thread_id}/messages")
456
+ async def get_thread_messages(thread_id: str) -> dict[str, Any]:
457
+ """Get messages from a thread."""
458
+ try:
459
+ executor = await self._ensure_executor()
460
+
461
+ # Check if thread exists
462
+ thread = executor.get_thread(thread_id)
463
+ if not thread:
464
+ raise HTTPException(status_code=404, detail="Thread not found")
465
+
466
+ # Get messages from thread
467
+ messages = await executor.get_thread_messages(thread_id)
468
+
469
+ return {"object": "list", "data": messages, "thread_id": thread_id}
470
+ except HTTPException:
471
+ raise
472
+ except Exception as e:
473
+ logger.error(f"Error getting messages for thread {thread_id}: {e}")
474
+ raise HTTPException(status_code=500, detail=f"Failed to get thread messages: {e!s}") from e
475
+
476
+ async def _stream_execution(
477
+ self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest
478
+ ) -> AsyncGenerator[str, None]:
479
+ """Stream execution directly through executor."""
480
+ try:
481
+ # Direct call to executor - simple and clean
482
+ async for event in executor.execute_streaming(request):
483
+ # IMPORTANT: Check model_dump_json FIRST because to_json() can have newlines (pretty-printing)
484
+ # which breaks SSE format. model_dump_json() returns single-line JSON.
485
+ if hasattr(event, "model_dump_json"):
486
+ payload = event.model_dump_json() # type: ignore[attr-defined]
487
+ elif hasattr(event, "to_json") and callable(getattr(event, "to_json", None)):
488
+ payload = event.to_json() # type: ignore[attr-defined]
489
+ # Strip newlines from pretty-printed JSON for SSE compatibility
490
+ payload = payload.replace("\n", "").replace("\r", "")
491
+ elif isinstance(event, dict):
492
+ # Handle plain dict events (e.g., error events from executor)
493
+ payload = json.dumps(event)
494
+ elif hasattr(event, "to_dict") and callable(getattr(event, "to_dict", None)):
495
+ payload = json.dumps(event.to_dict()) # type: ignore[attr-defined]
496
+ else:
497
+ payload = json.dumps(str(event))
498
+ yield f"data: {payload}\n\n"
499
+
500
+ # Send final done event
501
+ yield "data: [DONE]\n\n"
502
+
503
+ except Exception as e:
504
+ logger.error(f"Error in streaming execution: {e}")
505
+ error_event = {"id": "error", "object": "error", "error": {"message": str(e), "type": "execution_error"}}
506
+ yield f"data: {json.dumps(error_event)}\n\n"
507
+
508
+ def _mount_ui(self, app: FastAPI) -> None:
509
+ """Mount the UI as static files."""
510
+ from pathlib import Path
511
+
512
+ ui_dir = Path(__file__).parent / "ui"
513
+ if ui_dir.exists() and ui_dir.is_dir() and self.ui_enabled:
514
+ app.mount("/", StaticFiles(directory=str(ui_dir), html=True), name="ui")
515
+
516
+ def register_entities(self, entities: list[Any]) -> None:
517
+ """Register entities to be discovered when server starts.
518
+
519
+ Args:
520
+ entities: List of entity objects to register
521
+ """
522
+ if self._pending_entities is None:
523
+ self._pending_entities = []
524
+ self._pending_entities.extend(entities)
525
+
526
+ def get_app(self) -> FastAPI:
527
+ """Get the FastAPI application instance."""
528
+ if self._app is None:
529
+ self._app = self.create_app()
530
+ return self._app