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,822 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Agent Framework entity discovery implementation."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import importlib
9
+ import importlib.util
10
+ import logging
11
+ import sys
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+ from dotenv import load_dotenv
18
+
19
+ from .models._discovery_models import EntityInfo
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class EntityDiscovery:
25
+ """Discovery for Agent Framework entities - agents and workflows."""
26
+
27
+ def __init__(self, entities_dir: str | None = None):
28
+ """Initialize entity discovery.
29
+
30
+ Args:
31
+ entities_dir: Directory to scan for entities (optional)
32
+ """
33
+ self.entities_dir = entities_dir
34
+ self._entities: dict[str, EntityInfo] = {}
35
+ self._loaded_objects: dict[str, Any] = {}
36
+ self._remote_cache_dir = Path.home() / ".agent_framework_devui" / "remote_cache"
37
+
38
+ async def discover_entities(self) -> list[EntityInfo]:
39
+ """Scan for Agent Framework entities.
40
+
41
+ Returns:
42
+ List of discovered entities
43
+ """
44
+ if not self.entities_dir:
45
+ logger.info("No Agent Framework entities directory configured")
46
+ return []
47
+
48
+ entities_dir = Path(self.entities_dir).resolve() # noqa: ASYNC240
49
+ await self._scan_entities_directory(entities_dir)
50
+
51
+ logger.info(f"Discovered {len(self._entities)} Agent Framework entities")
52
+ return self.list_entities()
53
+
54
+ def get_entity_info(self, entity_id: str) -> EntityInfo | None:
55
+ """Get entity metadata.
56
+
57
+ Args:
58
+ entity_id: Entity identifier
59
+
60
+ Returns:
61
+ Entity information or None if not found
62
+ """
63
+ return self._entities.get(entity_id)
64
+
65
+ def get_entity_object(self, entity_id: str) -> Any | None:
66
+ """Get the actual loaded entity object.
67
+
68
+ Args:
69
+ entity_id: Entity identifier
70
+
71
+ Returns:
72
+ Entity object or None if not found
73
+ """
74
+ return self._loaded_objects.get(entity_id)
75
+
76
+ def list_entities(self) -> list[EntityInfo]:
77
+ """List all discovered entities.
78
+
79
+ Returns:
80
+ List of all entity information
81
+ """
82
+ return list(self._entities.values())
83
+
84
+ def register_entity(self, entity_id: str, entity_info: EntityInfo, entity_object: Any) -> None:
85
+ """Register an entity with both metadata and object.
86
+
87
+ Args:
88
+ entity_id: Unique entity identifier
89
+ entity_info: Entity metadata
90
+ entity_object: Actual entity object for execution
91
+ """
92
+ self._entities[entity_id] = entity_info
93
+ self._loaded_objects[entity_id] = entity_object
94
+ logger.debug(f"Registered entity: {entity_id} ({entity_info.type})")
95
+
96
+ async def create_entity_info_from_object(
97
+ self, entity_object: Any, entity_type: str | None = None, source: str = "in_memory"
98
+ ) -> EntityInfo:
99
+ """Create EntityInfo from Agent Framework entity object.
100
+
101
+ Args:
102
+ entity_object: Agent Framework entity object
103
+ entity_type: Optional entity type override
104
+ source: Source of entity (directory, in_memory, remote)
105
+
106
+ Returns:
107
+ EntityInfo with Agent Framework specific metadata
108
+ """
109
+ # Determine entity type if not provided
110
+ if entity_type is None:
111
+ entity_type = "agent"
112
+ # Check if it's a workflow
113
+ if hasattr(entity_object, "get_executors_list") or hasattr(entity_object, "executors"):
114
+ entity_type = "workflow"
115
+
116
+ # Extract metadata with improved fallback naming
117
+ name = getattr(entity_object, "name", None)
118
+ if not name:
119
+ # In-memory entities: use ID with entity type prefix since no directory name available
120
+ entity_id_raw = getattr(entity_object, "id", None)
121
+ if entity_id_raw:
122
+ # Truncate UUID to first 8 characters for readability
123
+ short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw)
124
+ name = f"{entity_type.title()} {short_id}"
125
+ else:
126
+ # Fallback to class name with entity type
127
+ class_name = entity_object.__class__.__name__
128
+ name = f"{entity_type.title()} {class_name}"
129
+ description = getattr(entity_object, "description", "")
130
+
131
+ # Generate entity ID using Agent Framework specific naming
132
+ entity_id = self._generate_entity_id(entity_object, entity_type, source)
133
+
134
+ # Extract tools/executors using Agent Framework specific logic
135
+ tools_list = await self._extract_tools_from_object(entity_object, entity_type)
136
+
137
+ # Extract agent-specific fields (for agents only)
138
+ instructions = None
139
+ model = None
140
+ chat_client_type = None
141
+ context_providers_list = None
142
+ middleware_list = None
143
+
144
+ if entity_type == "agent":
145
+ # Try to get instructions
146
+ if hasattr(entity_object, "chat_options") and hasattr(entity_object.chat_options, "instructions"):
147
+ instructions = entity_object.chat_options.instructions
148
+
149
+ # Try to get model - check both chat_options and chat_client
150
+ if (
151
+ hasattr(entity_object, "chat_options")
152
+ and hasattr(entity_object.chat_options, "model_id")
153
+ and entity_object.chat_options.model_id
154
+ ):
155
+ model = entity_object.chat_options.model_id
156
+ elif hasattr(entity_object, "chat_client") and hasattr(entity_object.chat_client, "model_id"):
157
+ model = entity_object.chat_client.model_id
158
+
159
+ # Try to get chat client type
160
+ if hasattr(entity_object, "chat_client"):
161
+ chat_client_type = entity_object.chat_client.__class__.__name__
162
+
163
+ # Try to get context providers
164
+ if (
165
+ hasattr(entity_object, "context_provider")
166
+ and entity_object.context_provider
167
+ and hasattr(entity_object.context_provider, "__class__")
168
+ ):
169
+ context_providers_list = [entity_object.context_provider.__class__.__name__]
170
+
171
+ # Try to get middleware
172
+ if hasattr(entity_object, "middleware") and entity_object.middleware:
173
+ middleware_list = []
174
+ for m in entity_object.middleware:
175
+ # Try multiple ways to get a good name for middleware
176
+ if hasattr(m, "__name__"): # Function or callable
177
+ middleware_list.append(m.__name__)
178
+ elif hasattr(m, "__class__"): # Class instance
179
+ middleware_list.append(m.__class__.__name__)
180
+ else:
181
+ middleware_list.append(str(m))
182
+
183
+ # Create EntityInfo with Agent Framework specifics
184
+ return EntityInfo(
185
+ id=entity_id,
186
+ name=name,
187
+ description=description,
188
+ type=entity_type,
189
+ framework="agent_framework",
190
+ tools=[str(tool) for tool in (tools_list or [])],
191
+ instructions=instructions,
192
+ model_id=model,
193
+ chat_client_type=chat_client_type,
194
+ context_providers=context_providers_list,
195
+ middleware=middleware_list,
196
+ executors=tools_list if entity_type == "workflow" else [],
197
+ input_schema={"type": "string"}, # Default schema
198
+ start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None,
199
+ metadata={
200
+ "source": "agent_framework_object",
201
+ "class_name": entity_object.__class__.__name__
202
+ if hasattr(entity_object, "__class__")
203
+ else str(type(entity_object)),
204
+ "has_run_stream": hasattr(entity_object, "run_stream"),
205
+ },
206
+ )
207
+
208
+ async def _scan_entities_directory(self, entities_dir: Path) -> None:
209
+ """Scan the entities directory for Agent Framework entities.
210
+
211
+ Args:
212
+ entities_dir: Directory to scan for entities
213
+ """
214
+ if not entities_dir.exists(): # noqa: ASYNC240
215
+ logger.warning(f"Entities directory not found: {entities_dir}")
216
+ return
217
+
218
+ logger.info(f"Scanning {entities_dir} for Agent Framework entities...")
219
+
220
+ # Add entities directory to Python path if not already there
221
+ entities_dir_str = str(entities_dir)
222
+ if entities_dir_str not in sys.path:
223
+ sys.path.insert(0, entities_dir_str)
224
+
225
+ # Scan for directories and Python files
226
+ for item in entities_dir.iterdir(): # noqa: ASYNC240
227
+ if item.name.startswith(".") or item.name == "__pycache__":
228
+ continue
229
+
230
+ if item.is_dir():
231
+ # Directory-based entity
232
+ await self._discover_entities_in_directory(item)
233
+ elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"):
234
+ # Single file entity
235
+ await self._discover_entities_in_file(item)
236
+
237
+ async def _discover_entities_in_directory(self, dir_path: Path) -> None:
238
+ """Discover entities in a directory using module import.
239
+
240
+ Args:
241
+ dir_path: Directory containing entity
242
+ """
243
+ entity_id = dir_path.name
244
+ logger.debug(f"Scanning directory: {entity_id}")
245
+
246
+ try:
247
+ # Load environment variables for this entity first
248
+ self._load_env_for_entity(dir_path)
249
+
250
+ # Try different import patterns
251
+ import_patterns = [
252
+ entity_id, # Direct module import
253
+ f"{entity_id}.agent", # agent.py submodule
254
+ f"{entity_id}.workflow", # workflow.py submodule
255
+ ]
256
+
257
+ for pattern in import_patterns:
258
+ module = self._load_module_from_pattern(pattern)
259
+ if module:
260
+ entities_found = await self._find_entities_in_module(module, entity_id, str(dir_path))
261
+ if entities_found:
262
+ logger.debug(f"Found {len(entities_found)} entities in {pattern}")
263
+ break
264
+
265
+ except Exception as e:
266
+ logger.warning(f"Error scanning directory {entity_id}: {e}")
267
+
268
+ async def _discover_entities_in_file(self, file_path: Path) -> None:
269
+ """Discover entities in a single Python file.
270
+
271
+ Args:
272
+ file_path: Python file to scan
273
+ """
274
+ try:
275
+ # Load environment variables for this entity's directory first
276
+ self._load_env_for_entity(file_path.parent)
277
+
278
+ # Create module name from file path
279
+ base_name = file_path.stem
280
+
281
+ # Load the module directly from file
282
+ module = self._load_module_from_file(file_path, base_name)
283
+ if module:
284
+ entities_found = await self._find_entities_in_module(module, base_name, str(file_path))
285
+ if entities_found:
286
+ logger.debug(f"Found {len(entities_found)} entities in {file_path.name}")
287
+
288
+ except Exception as e:
289
+ logger.warning(f"Error scanning file {file_path}: {e}")
290
+
291
+ def _load_env_for_entity(self, entity_path: Path) -> bool:
292
+ """Load .env file for an entity.
293
+
294
+ Args:
295
+ entity_path: Path to entity directory
296
+
297
+ Returns:
298
+ True if .env was loaded successfully
299
+ """
300
+ # Check for .env in the entity folder first
301
+ env_file = entity_path / ".env"
302
+ if self._load_env_file(env_file):
303
+ return True
304
+
305
+ # Check one level up (the entities directory) for safety
306
+ if self.entities_dir:
307
+ entities_dir = Path(self.entities_dir).resolve()
308
+ entities_env = entities_dir / ".env"
309
+ if self._load_env_file(entities_env):
310
+ return True
311
+
312
+ return False
313
+
314
+ def _load_env_file(self, env_path: Path) -> bool:
315
+ """Load environment variables from .env file.
316
+
317
+ Args:
318
+ env_path: Path to .env file
319
+
320
+ Returns:
321
+ True if file was loaded successfully
322
+ """
323
+ if env_path.exists():
324
+ load_dotenv(env_path, override=True)
325
+ logger.debug(f"Loaded .env from {env_path}")
326
+ return True
327
+ return False
328
+
329
+ def _load_module_from_pattern(self, pattern: str) -> Any | None:
330
+ """Load module using import pattern.
331
+
332
+ Args:
333
+ pattern: Import pattern to try
334
+
335
+ Returns:
336
+ Loaded module or None if failed
337
+ """
338
+ try:
339
+ # Check if module exists first
340
+ spec = importlib.util.find_spec(pattern)
341
+ if spec is None:
342
+ return None
343
+
344
+ module = importlib.import_module(pattern)
345
+ logger.debug(f"Successfully imported {pattern}")
346
+ return module
347
+
348
+ except ModuleNotFoundError:
349
+ logger.debug(f"Import pattern {pattern} not found")
350
+ return None
351
+ except Exception as e:
352
+ logger.warning(f"Error importing {pattern}: {e}")
353
+ return None
354
+
355
+ def _load_module_from_file(self, file_path: Path, module_name: str) -> Any | None:
356
+ """Load module directly from file path.
357
+
358
+ Args:
359
+ file_path: Path to Python file
360
+ module_name: Name to assign to module
361
+
362
+ Returns:
363
+ Loaded module or None if failed
364
+ """
365
+ try:
366
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
367
+ if spec is None or spec.loader is None:
368
+ return None
369
+
370
+ module = importlib.util.module_from_spec(spec)
371
+ sys.modules[module_name] = module # Add to sys.modules for proper imports
372
+ spec.loader.exec_module(module)
373
+
374
+ logger.debug(f"Successfully loaded module from {file_path}")
375
+ return module
376
+
377
+ except Exception as e:
378
+ logger.warning(f"Error loading module from {file_path}: {e}")
379
+ return None
380
+
381
+ async def _find_entities_in_module(self, module: Any, base_id: str, module_path: str) -> list[str]:
382
+ """Find agent and workflow entities in a loaded module.
383
+
384
+ Args:
385
+ module: Loaded Python module
386
+ base_id: Base identifier for entities
387
+ module_path: Path to module for metadata
388
+
389
+ Returns:
390
+ List of entity IDs that were found and registered
391
+ """
392
+ entities_found = []
393
+
394
+ # Look for explicit variable names first
395
+ candidates = [
396
+ ("agent", getattr(module, "agent", None)),
397
+ ("workflow", getattr(module, "workflow", None)),
398
+ ]
399
+
400
+ for obj_type, obj in candidates:
401
+ if obj is None:
402
+ continue
403
+
404
+ if self._is_valid_entity(obj, obj_type):
405
+ # Pass source as "directory" for directory-discovered entities
406
+ await self._register_entity_from_object(obj, obj_type, module_path, source="directory")
407
+ entities_found.append(obj_type)
408
+
409
+ return entities_found
410
+
411
+ def _is_valid_entity(self, obj: Any, expected_type: str) -> bool:
412
+ """Check if object is a valid agent or workflow using duck typing.
413
+
414
+ Args:
415
+ obj: Object to validate
416
+ expected_type: Expected type ("agent" or "workflow")
417
+
418
+ Returns:
419
+ True if object is valid for the expected type
420
+ """
421
+ if expected_type == "agent":
422
+ return self._is_valid_agent(obj)
423
+ if expected_type == "workflow":
424
+ return self._is_valid_workflow(obj)
425
+ return False
426
+
427
+ def _is_valid_agent(self, obj: Any) -> bool:
428
+ """Check if object is a valid Agent Framework agent.
429
+
430
+ Args:
431
+ obj: Object to validate
432
+
433
+ Returns:
434
+ True if object appears to be a valid agent
435
+ """
436
+ try:
437
+ # Try to import AgentProtocol for proper type checking
438
+ try:
439
+ from agent_framework import AgentProtocol
440
+
441
+ if isinstance(obj, AgentProtocol):
442
+ return True
443
+ except ImportError:
444
+ pass
445
+
446
+ # Fallback to duck typing for agent protocol
447
+ if hasattr(obj, "run_stream") and hasattr(obj, "id") and hasattr(obj, "name"):
448
+ return True
449
+
450
+ except (TypeError, AttributeError):
451
+ pass
452
+
453
+ return False
454
+
455
+ def _is_valid_workflow(self, obj: Any) -> bool:
456
+ """Check if object is a valid Agent Framework workflow.
457
+
458
+ Args:
459
+ obj: Object to validate
460
+
461
+ Returns:
462
+ True if object appears to be a valid workflow
463
+ """
464
+ # Check for workflow - must have run_stream method and executors
465
+ return hasattr(obj, "run_stream") and (hasattr(obj, "executors") or hasattr(obj, "get_executors_list"))
466
+
467
+ async def _register_entity_from_object(
468
+ self, obj: Any, obj_type: str, module_path: str, source: str = "directory"
469
+ ) -> None:
470
+ """Register an entity from a live object.
471
+
472
+ Args:
473
+ obj: Entity object
474
+ obj_type: Type of entity ("agent" or "workflow")
475
+ module_path: Path to module for metadata
476
+ source: Source of entity (directory, in_memory, remote)
477
+ """
478
+ try:
479
+ # Generate entity ID with source information
480
+ entity_id = self._generate_entity_id(obj, obj_type, source)
481
+
482
+ # Extract metadata from the live object with improved fallback naming
483
+ name = getattr(obj, "name", None)
484
+ if not name:
485
+ entity_id_raw = getattr(obj, "id", None)
486
+ if entity_id_raw:
487
+ # Truncate UUID to first 8 characters for readability
488
+ short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw)
489
+ name = f"{obj_type.title()} {short_id}"
490
+ else:
491
+ name = f"{obj_type.title()} {obj.__class__.__name__}"
492
+ description = getattr(obj, "description", None)
493
+ tools = await self._extract_tools_from_object(obj, obj_type)
494
+
495
+ # Create EntityInfo
496
+ tools_union: list[str | dict[str, Any]] | None = None
497
+ if tools:
498
+ tools_union = [tool for tool in tools]
499
+
500
+ # Extract agent-specific fields (for agents only)
501
+ instructions = None
502
+ model = None
503
+ chat_client_type = None
504
+ context_providers_list = None
505
+ middleware_list = None
506
+
507
+ if obj_type == "agent":
508
+ # Try to get instructions
509
+ if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "instructions"):
510
+ instructions = obj.chat_options.instructions
511
+
512
+ # Try to get model - check both chat_options and chat_client
513
+ if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "model_id") and obj.chat_options.model_id:
514
+ model = obj.chat_options.model_id
515
+ elif hasattr(obj, "chat_client") and hasattr(obj.chat_client, "model_id"):
516
+ model = obj.chat_client.model_id
517
+
518
+ # Try to get chat client type
519
+ if hasattr(obj, "chat_client"):
520
+ chat_client_type = obj.chat_client.__class__.__name__
521
+
522
+ # Try to get context providers
523
+ if (
524
+ hasattr(obj, "context_provider")
525
+ and obj.context_provider
526
+ and hasattr(obj.context_provider, "__class__")
527
+ ):
528
+ context_providers_list = [obj.context_provider.__class__.__name__]
529
+
530
+ # Try to get middleware
531
+ if hasattr(obj, "middleware") and obj.middleware:
532
+ middleware_list = []
533
+ for m in obj.middleware:
534
+ # Try multiple ways to get a good name for middleware
535
+ if hasattr(m, "__name__"): # Function or callable
536
+ middleware_list.append(m.__name__)
537
+ elif hasattr(m, "__class__"): # Class instance
538
+ middleware_list.append(m.__class__.__name__)
539
+ else:
540
+ middleware_list.append(str(m))
541
+
542
+ entity_info = EntityInfo(
543
+ id=entity_id,
544
+ type=obj_type,
545
+ name=name,
546
+ framework="agent_framework",
547
+ description=description,
548
+ tools=tools_union,
549
+ instructions=instructions,
550
+ model_id=model,
551
+ chat_client_type=chat_client_type,
552
+ context_providers=context_providers_list,
553
+ middleware=middleware_list,
554
+ metadata={
555
+ "module_path": module_path,
556
+ "entity_type": obj_type,
557
+ "source": source,
558
+ "has_run_stream": hasattr(obj, "run_stream"),
559
+ "class_name": obj.__class__.__name__ if hasattr(obj, "__class__") else str(type(obj)),
560
+ },
561
+ )
562
+
563
+ # Register the entity
564
+ self.register_entity(entity_id, entity_info, obj)
565
+
566
+ except Exception as e:
567
+ logger.error(f"Error registering entity from {source}: {e}")
568
+
569
+ async def _extract_tools_from_object(self, obj: Any, obj_type: str) -> list[str]:
570
+ """Extract tool/executor names from a live object.
571
+
572
+ Args:
573
+ obj: Entity object
574
+ obj_type: Type of entity
575
+
576
+ Returns:
577
+ List of tool/executor names
578
+ """
579
+ tools = []
580
+
581
+ try:
582
+ if obj_type == "agent":
583
+ # For agents, check chat_options.tools first
584
+ chat_options = getattr(obj, "chat_options", None)
585
+ if chat_options and hasattr(chat_options, "tools"):
586
+ for tool in chat_options.tools:
587
+ if hasattr(tool, "__name__"):
588
+ tools.append(tool.__name__)
589
+ elif hasattr(tool, "name"):
590
+ tools.append(tool.name)
591
+ else:
592
+ tools.append(str(tool))
593
+ else:
594
+ # Fallback to direct tools attribute
595
+ agent_tools = getattr(obj, "tools", None)
596
+ if agent_tools:
597
+ for tool in agent_tools:
598
+ if hasattr(tool, "__name__"):
599
+ tools.append(tool.__name__)
600
+ elif hasattr(tool, "name"):
601
+ tools.append(tool.name)
602
+ else:
603
+ tools.append(str(tool))
604
+
605
+ elif obj_type == "workflow":
606
+ # For workflows, extract executor names
607
+ if hasattr(obj, "get_executors_list"):
608
+ executor_objects = obj.get_executors_list()
609
+ tools = [getattr(ex, "id", str(ex)) for ex in executor_objects]
610
+ elif hasattr(obj, "executors"):
611
+ executors = obj.executors
612
+ if isinstance(executors, list):
613
+ tools = [getattr(ex, "id", str(ex)) for ex in executors]
614
+ elif isinstance(executors, dict):
615
+ tools = list(executors.keys())
616
+
617
+ except Exception as e:
618
+ logger.debug(f"Error extracting tools from {obj_type} {type(obj)}: {e}")
619
+
620
+ return tools
621
+
622
+ def _generate_entity_id(self, entity: Any, entity_type: str, source: str = "directory") -> str:
623
+ """Generate unique entity ID with UUID suffix for collision resistance.
624
+
625
+ Args:
626
+ entity: Entity object
627
+ entity_type: Type of entity (agent, workflow, etc.)
628
+ source: Source of entity (directory, in_memory, remote)
629
+
630
+ Returns:
631
+ Unique entity ID with format: {type}_{source}_{name}_{uuid8}
632
+ """
633
+ import re
634
+
635
+ # Extract base name with priority: name -> id -> class_name
636
+ if hasattr(entity, "name") and entity.name:
637
+ base_name = str(entity.name).lower().replace(" ", "-").replace("_", "-")
638
+ elif hasattr(entity, "id") and entity.id:
639
+ base_name = str(entity.id).lower().replace(" ", "-").replace("_", "-")
640
+ elif hasattr(entity, "__class__"):
641
+ class_name = entity.__class__.__name__
642
+ # Convert CamelCase to kebab-case
643
+ base_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
644
+ else:
645
+ base_name = "entity"
646
+
647
+ # Generate short UUID (8 chars = 4 billion combinations)
648
+ short_uuid = uuid.uuid4().hex[:8]
649
+
650
+ return f"{entity_type}_{source}_{base_name}_{short_uuid}"
651
+
652
+ async def fetch_remote_entity(
653
+ self, url: str, metadata: dict[str, Any] | None = None
654
+ ) -> tuple[EntityInfo | None, str | None]:
655
+ """Fetch and register entity from URL.
656
+
657
+ Args:
658
+ url: URL to Python file containing entity
659
+ metadata: Additional metadata (source, sampleId, etc.)
660
+
661
+ Returns:
662
+ Tuple of (EntityInfo if successful, error_message if failed)
663
+ """
664
+ try:
665
+ normalized_url = self._normalize_url(url)
666
+ logger.info(f"Normalized URL: {normalized_url}")
667
+
668
+ content = await self._fetch_url_content(normalized_url)
669
+ if not content:
670
+ error_msg = "Failed to fetch content from URL. The file may not exist or is not accessible."
671
+ logger.warning(error_msg)
672
+ return None, error_msg
673
+
674
+ if not self._validate_python_syntax(content):
675
+ error_msg = "Invalid Python syntax in the file. Please check the file contains valid Python code."
676
+ logger.warning(error_msg)
677
+ return None, error_msg
678
+
679
+ entity_object = await self._load_entity_from_content(content, url)
680
+ if not entity_object:
681
+ error_msg = (
682
+ "No valid agent or workflow found in the file. "
683
+ "Make sure the file contains an 'agent' or 'workflow' variable."
684
+ )
685
+ logger.warning(error_msg)
686
+ return None, error_msg
687
+
688
+ entity_info = await self.create_entity_info_from_object(
689
+ entity_object,
690
+ entity_type=None, # Auto-detect
691
+ source="remote",
692
+ )
693
+
694
+ entity_info.source = metadata.get("source", "remote_gallery") if metadata else "remote_gallery"
695
+ entity_info.original_url = url
696
+ if metadata:
697
+ entity_info.metadata.update(metadata)
698
+
699
+ self.register_entity(entity_info.id, entity_info, entity_object)
700
+
701
+ logger.info(f"Successfully added remote entity: {entity_info.id}")
702
+ return entity_info, None
703
+
704
+ except Exception as e:
705
+ error_msg = f"Unexpected error: {e!s}"
706
+ logger.error(f"Error fetching remote entity from {url}: {e}", exc_info=True)
707
+ return None, error_msg
708
+
709
+ def _normalize_url(self, url: str) -> str:
710
+ """Convert various Git hosting URLs to raw content URLs."""
711
+ # GitHub: blob -> raw
712
+ if "github.com" in url and "/blob/" in url:
713
+ return url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/")
714
+
715
+ # GitLab: blob -> raw
716
+ if "gitlab.com" in url and "/-/blob/" in url:
717
+ return url.replace("/-/blob/", "/-/raw/")
718
+
719
+ # Bitbucket: src -> raw
720
+ if "bitbucket.org" in url and "/src/" in url:
721
+ return url.replace("/src/", "/raw/")
722
+
723
+ return url
724
+
725
+ async def _fetch_url_content(self, url: str, max_size_mb: int = 10) -> str | None:
726
+ """Fetch content from URL with size and timeout limits."""
727
+ try:
728
+ timeout = 30.0 # 30 second timeout
729
+
730
+ async with httpx.AsyncClient(timeout=timeout) as client:
731
+ response = await client.get(url)
732
+
733
+ if response.status_code != 200:
734
+ logger.warning(f"HTTP {response.status_code} for {url}")
735
+ return None
736
+
737
+ # Check content length
738
+ content_length = response.headers.get("content-length")
739
+ if content_length and int(content_length) > max_size_mb * 1024 * 1024:
740
+ logger.warning(f"File too large: {content_length} bytes")
741
+ return None
742
+
743
+ # Read with size limit
744
+ content = response.text
745
+ if len(content.encode("utf-8")) > max_size_mb * 1024 * 1024:
746
+ logger.warning("Content too large after reading")
747
+ return None
748
+
749
+ return content
750
+
751
+ except Exception as e:
752
+ logger.error(f"Error fetching {url}: {e}")
753
+ return None
754
+
755
+ def _validate_python_syntax(self, content: str) -> bool:
756
+ """Validate that content is valid Python code."""
757
+ try:
758
+ compile(content, "<remote>", "exec")
759
+ return True
760
+ except SyntaxError as e:
761
+ logger.warning(f"Python syntax error: {e}")
762
+ return False
763
+
764
+ async def _load_entity_from_content(self, content: str, source_url: str) -> Any | None:
765
+ """Load entity object from Python content string using disk-based import.
766
+
767
+ This method caches remote entities to disk and uses importlib for loading,
768
+ making it consistent with local entity discovery and avoiding exec() security warnings.
769
+ """
770
+ try:
771
+ # Create cache directory if it doesn't exist
772
+ self._remote_cache_dir.mkdir(parents=True, exist_ok=True)
773
+
774
+ # Generate a unique filename based on URL hash
775
+ url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:16]
776
+ module_name = f"remote_entity_{url_hash}"
777
+ cached_file = self._remote_cache_dir / f"{module_name}.py"
778
+
779
+ # Write content to cache file
780
+ cached_file.write_text(content, encoding="utf-8")
781
+ logger.debug(f"Cached remote entity to {cached_file}")
782
+
783
+ # Load module from cached file using importlib (same as local scanning)
784
+ module = self._load_module_from_file(cached_file, module_name)
785
+ if not module:
786
+ logger.warning(f"Failed to load module from cached file: {cached_file}")
787
+ return None
788
+
789
+ # Look for agent or workflow objects in the loaded module
790
+ for name in dir(module):
791
+ if name.startswith("_"):
792
+ continue
793
+
794
+ obj = getattr(module, name)
795
+
796
+ # Check for explicitly named entities first
797
+ if name in ["agent", "workflow"] and self._is_valid_entity(obj, name):
798
+ return obj
799
+
800
+ # Also check if any object looks like an agent/workflow
801
+ if self._is_valid_agent(obj) or self._is_valid_workflow(obj):
802
+ return obj
803
+
804
+ return None
805
+
806
+ except Exception as e:
807
+ logger.error(f"Error loading entity from content: {e}")
808
+ return None
809
+
810
+ def remove_remote_entity(self, entity_id: str) -> bool:
811
+ """Remove a remote entity by ID."""
812
+ if entity_id in self._entities:
813
+ entity_info = self._entities[entity_id]
814
+ if entity_info.source in ["remote_gallery", "remote"]:
815
+ del self._entities[entity_id]
816
+ if entity_id in self._loaded_objects:
817
+ del self._loaded_objects[entity_id]
818
+ logger.info(f"Removed remote entity: {entity_id}")
819
+ return True
820
+ logger.warning(f"Cannot remove local entity: {entity_id}")
821
+ return False
822
+ return False