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