agent-framework-devui 1.0.0b251001__py3-none-any.whl → 1.0.0b251016__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.

@@ -4,7 +4,6 @@
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- import hashlib
8
7
  import importlib
9
8
  import importlib.util
10
9
  import logging
@@ -13,7 +12,6 @@ import uuid
13
12
  from pathlib import Path
14
13
  from typing import Any
15
14
 
16
- import httpx
17
15
  from dotenv import load_dotenv
18
16
 
19
17
  from .models._discovery_models import EntityInfo
@@ -33,7 +31,6 @@ class EntityDiscovery:
33
31
  self.entities_dir = entities_dir
34
32
  self._entities: dict[str, EntityInfo] = {}
35
33
  self._loaded_objects: dict[str, Any] = {}
36
- self._remote_cache_dir = Path.home() / ".agent_framework_devui" / "remote_cache"
37
34
 
38
35
  async def discover_entities(self) -> list[EntityInfo]:
39
36
  """Scan for Agent Framework entities.
@@ -73,6 +70,115 @@ class EntityDiscovery:
73
70
  """
74
71
  return self._loaded_objects.get(entity_id)
75
72
 
73
+ async def load_entity(self, entity_id: str) -> Any:
74
+ """Load entity on-demand (lazy loading).
75
+
76
+ This method implements lazy loading by importing the entity module only when needed.
77
+ In-memory entities are returned from cache immediately.
78
+
79
+ Args:
80
+ entity_id: Entity identifier
81
+
82
+ Returns:
83
+ Loaded entity object
84
+
85
+ Raises:
86
+ ValueError: If entity not found or cannot be loaded
87
+ """
88
+ # Check if already loaded (includes in-memory entities)
89
+ if entity_id in self._loaded_objects:
90
+ logger.debug(f"Entity {entity_id} already loaded (cache hit)")
91
+ return self._loaded_objects[entity_id]
92
+
93
+ # Get entity metadata
94
+ entity_info = self._entities.get(entity_id)
95
+ if not entity_info:
96
+ raise ValueError(f"Entity {entity_id} not found in registry")
97
+
98
+ # In-memory entities should never reach here (they're pre-loaded)
99
+ if entity_info.source == "in_memory":
100
+ raise ValueError(f"In-memory entity {entity_id} missing from loaded objects cache")
101
+
102
+ logger.info(f"Lazy loading entity: {entity_id} (source: {entity_info.source})")
103
+
104
+ # Load based on source - only directory and in-memory are supported
105
+ if entity_info.source == "directory":
106
+ entity_obj = await self._load_directory_entity(entity_id, entity_info)
107
+ else:
108
+ raise ValueError(
109
+ f"Unsupported entity source: {entity_info.source}. "
110
+ f"Only 'directory' and 'in_memory' sources are supported."
111
+ )
112
+
113
+ # Enrich metadata with actual entity data
114
+ # Don't pass entity_type if it's "unknown" - let inference determine the real type
115
+ enriched_info = await self.create_entity_info_from_object(
116
+ entity_obj,
117
+ entity_type=entity_info.type if entity_info.type != "unknown" else None,
118
+ source=entity_info.source,
119
+ )
120
+ # IMPORTANT: Preserve the original entity_id (enrichment generates a new one)
121
+ enriched_info.id = entity_id
122
+ # Preserve the original path from sparse metadata
123
+ if "path" in entity_info.metadata:
124
+ enriched_info.metadata["path"] = entity_info.metadata["path"]
125
+ enriched_info.metadata["lazy_loaded"] = True
126
+ self._entities[entity_id] = enriched_info
127
+
128
+ # Cache the loaded object
129
+ self._loaded_objects[entity_id] = entity_obj
130
+ logger.info(f"✅ Successfully loaded entity: {entity_id} (type: {enriched_info.type})")
131
+
132
+ return entity_obj
133
+
134
+ async def _load_directory_entity(self, entity_id: str, entity_info: EntityInfo) -> Any:
135
+ """Load entity from directory (imports module).
136
+
137
+ Args:
138
+ entity_id: Entity identifier
139
+ entity_info: Entity metadata
140
+
141
+ Returns:
142
+ Loaded entity object
143
+ """
144
+ # Get directory path from metadata
145
+ dir_path = Path(entity_info.metadata.get("path", ""))
146
+ if not dir_path.exists(): # noqa: ASYNC240
147
+ raise ValueError(f"Entity directory not found: {dir_path}")
148
+
149
+ # Load .env if it exists
150
+ if dir_path.is_dir(): # noqa: ASYNC240
151
+ self._load_env_for_entity(dir_path)
152
+ else:
153
+ self._load_env_for_entity(dir_path.parent)
154
+
155
+ # Import the module
156
+ if dir_path.is_dir(): # noqa: ASYNC240
157
+ # Directory-based entity - try different import patterns
158
+ import_patterns = [
159
+ entity_id,
160
+ f"{entity_id}.agent",
161
+ f"{entity_id}.workflow",
162
+ ]
163
+
164
+ for pattern in import_patterns:
165
+ module = self._load_module_from_pattern(pattern)
166
+ if module:
167
+ # Find entity in module - pass entity_id so registration uses correct ID
168
+ entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
169
+ if entity_obj:
170
+ return entity_obj
171
+
172
+ raise ValueError(f"No valid entity found in {dir_path}")
173
+ # File-based entity
174
+ module = self._load_module_from_file(dir_path, entity_id)
175
+ if module:
176
+ entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path))
177
+ if entity_obj:
178
+ return entity_obj
179
+
180
+ raise ValueError(f"No valid entity found in {dir_path}")
181
+
76
182
  def list_entities(self) -> list[EntityInfo]:
77
183
  """List all discovered entities.
78
184
 
@@ -81,6 +187,48 @@ class EntityDiscovery:
81
187
  """
82
188
  return list(self._entities.values())
83
189
 
190
+ def invalidate_entity(self, entity_id: str) -> None:
191
+ """Invalidate (clear cache for) an entity to enable hot reload.
192
+
193
+ This removes the entity from the loaded objects cache and clears its module
194
+ from Python's sys.modules cache. The entity metadata remains, so it will be
195
+ reimported on next access.
196
+
197
+ Args:
198
+ entity_id: Entity identifier to invalidate
199
+ """
200
+ # Remove from loaded objects cache
201
+ if entity_id in self._loaded_objects:
202
+ del self._loaded_objects[entity_id]
203
+ logger.info(f"Cleared loaded object cache for: {entity_id}")
204
+
205
+ # Clear from Python's module cache (including submodules)
206
+ keys_to_delete = [
207
+ module_name
208
+ for module_name in sys.modules
209
+ if module_name == entity_id or module_name.startswith(f"{entity_id}.")
210
+ ]
211
+ for key in keys_to_delete:
212
+ del sys.modules[key]
213
+ logger.debug(f"Cleared module cache: {key}")
214
+
215
+ # Reset lazy_loaded flag in metadata
216
+ entity_info = self._entities.get(entity_id)
217
+ if entity_info and "lazy_loaded" in entity_info.metadata:
218
+ entity_info.metadata["lazy_loaded"] = False
219
+
220
+ logger.info(f"♻️ Entity invalidated: {entity_id} (will reload on next access)")
221
+
222
+ def invalidate_all(self) -> None:
223
+ """Invalidate all cached entities.
224
+
225
+ Useful for forcing a complete reload of all entities.
226
+ """
227
+ entity_ids = list(self._loaded_objects.keys())
228
+ for entity_id in entity_ids:
229
+ self.invalidate_entity(entity_id)
230
+ logger.info(f"Invalidated {len(entity_ids)} entities")
231
+
84
232
  def register_entity(self, entity_id: str, entity_info: EntityInfo, entity_object: Any) -> None:
85
233
  """Register an entity with both metadata and object.
86
234
 
@@ -116,16 +264,9 @@ class EntityDiscovery:
116
264
  # Extract metadata with improved fallback naming
117
265
  name = getattr(entity_object, "name", None)
118
266
  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}"
267
+ # In-memory entities: use class name as it's more readable than UUID
268
+ class_name = entity_object.__class__.__name__
269
+ name = f"{entity_type.title()} {class_name}"
129
270
  description = getattr(entity_object, "description", "")
130
271
 
131
272
  # Generate entity ID using Agent Framework specific naming
@@ -134,6 +275,36 @@ class EntityDiscovery:
134
275
  # Extract tools/executors using Agent Framework specific logic
135
276
  tools_list = await self._extract_tools_from_object(entity_object, entity_type)
136
277
 
278
+ # Extract agent-specific fields (for agents only)
279
+ instructions = None
280
+ model = None
281
+ chat_client_type = None
282
+ context_providers_list = None
283
+ middleware_list = None
284
+
285
+ if entity_type == "agent":
286
+ from ._utils import extract_agent_metadata
287
+
288
+ agent_meta = extract_agent_metadata(entity_object)
289
+ instructions = agent_meta["instructions"]
290
+ model = agent_meta["model"]
291
+ chat_client_type = agent_meta["chat_client_type"]
292
+ context_providers_list = agent_meta["context_providers"]
293
+ middleware_list = agent_meta["middleware"]
294
+
295
+ # Log helpful info about agent capabilities (before creating EntityInfo)
296
+ if entity_type == "agent":
297
+ has_run_stream = hasattr(entity_object, "run_stream")
298
+ has_run = hasattr(entity_object, "run")
299
+
300
+ if not has_run_stream and has_run:
301
+ logger.info(
302
+ f"Agent '{entity_id}' only has run() (non-streaming). "
303
+ "DevUI will automatically convert to streaming."
304
+ )
305
+ elif not has_run_stream and not has_run:
306
+ logger.warning(f"Agent '{entity_id}' lacks both run() and run_stream() methods. May not work.")
307
+
137
308
  # Create EntityInfo with Agent Framework specifics
138
309
  return EntityInfo(
139
310
  id=entity_id,
@@ -142,6 +313,11 @@ class EntityDiscovery:
142
313
  type=entity_type,
143
314
  framework="agent_framework",
144
315
  tools=[str(tool) for tool in (tools_list or [])],
316
+ instructions=instructions,
317
+ model_id=model,
318
+ chat_client_type=chat_client_type,
319
+ context_providers=context_providers_list,
320
+ middleware=middleware_list,
145
321
  executors=tools_list if entity_type == "workflow" else [],
146
322
  input_schema={"type": "string"}, # Default schema
147
323
  start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None,
@@ -155,7 +331,10 @@ class EntityDiscovery:
155
331
  )
156
332
 
157
333
  async def _scan_entities_directory(self, entities_dir: Path) -> None:
158
- """Scan the entities directory for Agent Framework entities.
334
+ """Scan the entities directory for Agent Framework entities (lazy loading).
335
+
336
+ This method scans the filesystem WITHOUT importing modules, creating sparse
337
+ metadata that will be enriched on-demand when entities are accessed.
159
338
 
160
339
  Args:
161
340
  entities_dir: Directory to scan for entities
@@ -164,78 +343,120 @@ class EntityDiscovery:
164
343
  logger.warning(f"Entities directory not found: {entities_dir}")
165
344
  return
166
345
 
167
- logger.info(f"Scanning {entities_dir} for Agent Framework entities...")
346
+ logger.info(f"Scanning {entities_dir} for Agent Framework entities (lazy mode)...")
168
347
 
169
348
  # Add entities directory to Python path if not already there
170
349
  entities_dir_str = str(entities_dir)
171
350
  if entities_dir_str not in sys.path:
172
351
  sys.path.insert(0, entities_dir_str)
173
352
 
174
- # Scan for directories and Python files
353
+ # Scan for directories and Python files WITHOUT importing
175
354
  for item in entities_dir.iterdir(): # noqa: ASYNC240
176
355
  if item.name.startswith(".") or item.name == "__pycache__":
177
356
  continue
178
357
 
179
- if item.is_dir():
180
- # Directory-based entity
181
- await self._discover_entities_in_directory(item)
358
+ if item.is_dir() and self._looks_like_entity(item):
359
+ # Directory-based entity - create sparse metadata
360
+ self._register_sparse_entity(item)
182
361
  elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"):
183
- # Single file entity
184
- await self._discover_entities_in_file(item)
362
+ # Single file entity - create sparse metadata
363
+ self._register_sparse_file_entity(item)
185
364
 
186
- async def _discover_entities_in_directory(self, dir_path: Path) -> None:
187
- """Discover entities in a directory using module import.
365
+ def _looks_like_entity(self, dir_path: Path) -> bool:
366
+ """Check if directory contains an entity (without importing).
188
367
 
189
368
  Args:
190
- dir_path: Directory containing entity
191
- """
192
- entity_id = dir_path.name
193
- logger.debug(f"Scanning directory: {entity_id}")
369
+ dir_path: Directory to check
194
370
 
195
- try:
196
- # Load environment variables for this entity first
197
- self._load_env_for_entity(dir_path)
371
+ Returns:
372
+ True if directory appears to contain an entity
373
+ """
374
+ return (
375
+ (dir_path / "agent.py").exists()
376
+ or (dir_path / "workflow.py").exists()
377
+ or (dir_path / "__init__.py").exists()
378
+ )
198
379
 
199
- # Try different import patterns
200
- import_patterns = [
201
- entity_id, # Direct module import
202
- f"{entity_id}.agent", # agent.py submodule
203
- f"{entity_id}.workflow", # workflow.py submodule
204
- ]
380
+ def _detect_entity_type(self, dir_path: Path) -> str:
381
+ """Detect entity type from directory structure (without importing).
205
382
 
206
- for pattern in import_patterns:
207
- module = self._load_module_from_pattern(pattern)
208
- if module:
209
- entities_found = await self._find_entities_in_module(module, entity_id, str(dir_path))
210
- if entities_found:
211
- logger.debug(f"Found {len(entities_found)} entities in {pattern}")
212
- break
383
+ Uses filename conventions to determine entity type:
384
+ - workflow.py → "workflow"
385
+ - agent.py → "agent"
386
+ - both or neither "unknown"
213
387
 
214
- except Exception as e:
215
- logger.warning(f"Error scanning directory {entity_id}: {e}")
388
+ Args:
389
+ dir_path: Directory to analyze
216
390
 
217
- async def _discover_entities_in_file(self, file_path: Path) -> None:
218
- """Discover entities in a single Python file.
391
+ Returns:
392
+ Entity type: "workflow", "agent", or "unknown"
393
+ """
394
+ has_agent = (dir_path / "agent.py").exists()
395
+ has_workflow = (dir_path / "workflow.py").exists()
396
+
397
+ if has_agent and has_workflow:
398
+ # Both files exist - ambiguous, mark as unknown
399
+ return "unknown"
400
+ if has_workflow:
401
+ return "workflow"
402
+ if has_agent:
403
+ return "agent"
404
+ # Has __init__.py but no specific file
405
+ return "unknown"
406
+
407
+ def _register_sparse_entity(self, dir_path: Path) -> None:
408
+ """Register entity with sparse metadata (no import).
219
409
 
220
410
  Args:
221
- file_path: Python file to scan
411
+ dir_path: Entity directory
222
412
  """
223
- try:
224
- # Load environment variables for this entity's directory first
225
- self._load_env_for_entity(file_path.parent)
413
+ entity_id = dir_path.name
414
+ entity_type = self._detect_entity_type(dir_path)
226
415
 
227
- # Create module name from file path
228
- base_name = file_path.stem
416
+ entity_info = EntityInfo(
417
+ id=entity_id,
418
+ name=entity_id.replace("_", " ").title(),
419
+ type=entity_type,
420
+ framework="agent_framework",
421
+ tools=[], # Sparse - will be populated on load
422
+ description="", # Sparse - will be populated on load
423
+ source="directory",
424
+ metadata={
425
+ "path": str(dir_path),
426
+ "discovered": True,
427
+ "lazy_loaded": False,
428
+ },
429
+ )
229
430
 
230
- # Load the module directly from file
231
- module = self._load_module_from_file(file_path, base_name)
232
- if module:
233
- entities_found = await self._find_entities_in_module(module, base_name, str(file_path))
234
- if entities_found:
235
- logger.debug(f"Found {len(entities_found)} entities in {file_path.name}")
431
+ self._entities[entity_id] = entity_info
432
+ logger.debug(f"Registered sparse entity: {entity_id} (type: {entity_type})")
236
433
 
237
- except Exception as e:
238
- logger.warning(f"Error scanning file {file_path}: {e}")
434
+ def _register_sparse_file_entity(self, file_path: Path) -> None:
435
+ """Register file-based entity with sparse metadata (no import).
436
+
437
+ Args:
438
+ file_path: Entity Python file
439
+ """
440
+ entity_id = file_path.stem
441
+
442
+ # File-based entities are typically agents, but we can't know for sure without importing
443
+ entity_info = EntityInfo(
444
+ id=entity_id,
445
+ name=entity_id.replace("_", " ").title(),
446
+ type="unknown", # Will be determined on load
447
+ framework="agent_framework",
448
+ tools=[],
449
+ description="",
450
+ source="directory",
451
+ metadata={
452
+ "path": str(file_path),
453
+ "discovered": True,
454
+ "lazy_loaded": False,
455
+ },
456
+ )
457
+
458
+ self._entities[entity_id] = entity_info
459
+ logger.debug(f"Registered sparse file entity: {entity_id}")
239
460
 
240
461
  def _load_env_for_entity(self, entity_path: Path) -> bool:
241
462
  """Load .env file for an entity.
@@ -327,19 +548,17 @@ class EntityDiscovery:
327
548
  logger.warning(f"Error loading module from {file_path}: {e}")
328
549
  return None
329
550
 
330
- async def _find_entities_in_module(self, module: Any, base_id: str, module_path: str) -> list[str]:
331
- """Find agent and workflow entities in a loaded module.
551
+ async def _find_entity_in_module(self, module: Any, entity_id: str, module_path: str) -> Any:
552
+ """Find agent or workflow entity in a loaded module.
332
553
 
333
554
  Args:
334
555
  module: Loaded Python module
335
- base_id: Base identifier for entities
556
+ entity_id: Expected entity identifier to register with
336
557
  module_path: Path to module for metadata
337
558
 
338
559
  Returns:
339
- List of entity IDs that were found and registered
560
+ Loaded entity object, or None if not found
340
561
  """
341
- entities_found = []
342
-
343
562
  # Look for explicit variable names first
344
563
  candidates = [
345
564
  ("agent", getattr(module, "agent", None)),
@@ -351,11 +570,12 @@ class EntityDiscovery:
351
570
  continue
352
571
 
353
572
  if self._is_valid_entity(obj, obj_type):
354
- # Pass source as "directory" for directory-discovered entities
355
- await self._register_entity_from_object(obj, obj_type, module_path, source="directory")
356
- entities_found.append(obj_type)
573
+ # Register with the correct entity_id (from directory name)
574
+ # Store the object directly in _loaded_objects so we can return it
575
+ self._loaded_objects[entity_id] = obj
576
+ return obj
357
577
 
358
- return entities_found
578
+ return None
359
579
 
360
580
  def _is_valid_entity(self, obj: Any, expected_type: str) -> bool:
361
581
  """Check if object is a valid agent or workflow using duck typing.
@@ -393,7 +613,9 @@ class EntityDiscovery:
393
613
  pass
394
614
 
395
615
  # Fallback to duck typing for agent protocol
396
- if hasattr(obj, "run_stream") and hasattr(obj, "id") and hasattr(obj, "name"):
616
+ # Agent must have either run_stream() or run() method, plus id and name
617
+ has_execution_method = hasattr(obj, "run_stream") or hasattr(obj, "run")
618
+ if has_execution_method and hasattr(obj, "id") and hasattr(obj, "name"):
397
619
  return True
398
620
 
399
621
  except (TypeError, AttributeError):
@@ -431,13 +653,9 @@ class EntityDiscovery:
431
653
  # Extract metadata from the live object with improved fallback naming
432
654
  name = getattr(obj, "name", None)
433
655
  if not name:
434
- entity_id_raw = getattr(obj, "id", None)
435
- if entity_id_raw:
436
- # Truncate UUID to first 8 characters for readability
437
- short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw)
438
- name = f"{obj_type.title()} {short_id}"
439
- else:
440
- name = f"{obj_type.title()} {obj.__class__.__name__}"
656
+ # Use class name as it's more readable than UUID
657
+ class_name = obj.__class__.__name__
658
+ name = f"{obj_type.title()} {class_name}"
441
659
  description = getattr(obj, "description", None)
442
660
  tools = await self._extract_tools_from_object(obj, obj_type)
443
661
 
@@ -446,6 +664,23 @@ class EntityDiscovery:
446
664
  if tools:
447
665
  tools_union = [tool for tool in tools]
448
666
 
667
+ # Extract agent-specific fields (for agents only)
668
+ instructions = None
669
+ model = None
670
+ chat_client_type = None
671
+ context_providers_list = None
672
+ middleware_list = None
673
+
674
+ if obj_type == "agent":
675
+ from ._utils import extract_agent_metadata
676
+
677
+ agent_meta = extract_agent_metadata(obj)
678
+ instructions = agent_meta["instructions"]
679
+ model = agent_meta["model"]
680
+ chat_client_type = agent_meta["chat_client_type"]
681
+ context_providers_list = agent_meta["context_providers"]
682
+ middleware_list = agent_meta["middleware"]
683
+
449
684
  entity_info = EntityInfo(
450
685
  id=entity_id,
451
686
  type=obj_type,
@@ -453,6 +688,11 @@ class EntityDiscovery:
453
688
  framework="agent_framework",
454
689
  description=description,
455
690
  tools=tools_union,
691
+ instructions=instructions,
692
+ model_id=model,
693
+ chat_client_type=chat_client_type,
694
+ context_providers=context_providers_list,
695
+ middleware=middleware_list,
456
696
  metadata={
457
697
  "module_path": module_path,
458
698
  "entity_type": obj_type,
@@ -530,7 +770,7 @@ class EntityDiscovery:
530
770
  source: Source of entity (directory, in_memory, remote)
531
771
 
532
772
  Returns:
533
- Unique entity ID with format: {type}_{source}_{name}_{uuid8}
773
+ Unique entity ID with format: {type}_{source}_{name}_{uuid}
534
774
  """
535
775
  import re
536
776
 
@@ -546,179 +786,7 @@ class EntityDiscovery:
546
786
  else:
547
787
  base_name = "entity"
548
788
 
549
- # Generate short UUID (8 chars = 4 billion combinations)
550
- short_uuid = uuid.uuid4().hex[:8]
551
-
552
- return f"{entity_type}_{source}_{base_name}_{short_uuid}"
553
-
554
- async def fetch_remote_entity(
555
- self, url: str, metadata: dict[str, Any] | None = None
556
- ) -> tuple[EntityInfo | None, str | None]:
557
- """Fetch and register entity from URL.
558
-
559
- Args:
560
- url: URL to Python file containing entity
561
- metadata: Additional metadata (source, sampleId, etc.)
562
-
563
- Returns:
564
- Tuple of (EntityInfo if successful, error_message if failed)
565
- """
566
- try:
567
- normalized_url = self._normalize_url(url)
568
- logger.info(f"Normalized URL: {normalized_url}")
569
-
570
- content = await self._fetch_url_content(normalized_url)
571
- if not content:
572
- error_msg = "Failed to fetch content from URL. The file may not exist or is not accessible."
573
- logger.warning(error_msg)
574
- return None, error_msg
575
-
576
- if not self._validate_python_syntax(content):
577
- error_msg = "Invalid Python syntax in the file. Please check the file contains valid Python code."
578
- logger.warning(error_msg)
579
- return None, error_msg
580
-
581
- entity_object = await self._load_entity_from_content(content, url)
582
- if not entity_object:
583
- error_msg = (
584
- "No valid agent or workflow found in the file. "
585
- "Make sure the file contains an 'agent' or 'workflow' variable."
586
- )
587
- logger.warning(error_msg)
588
- return None, error_msg
589
-
590
- entity_info = await self.create_entity_info_from_object(
591
- entity_object,
592
- entity_type=None, # Auto-detect
593
- source="remote",
594
- )
595
-
596
- entity_info.source = metadata.get("source", "remote_gallery") if metadata else "remote_gallery"
597
- entity_info.original_url = url
598
- if metadata:
599
- entity_info.metadata.update(metadata)
600
-
601
- self.register_entity(entity_info.id, entity_info, entity_object)
789
+ # Generate full UUID for guaranteed uniqueness
790
+ full_uuid = uuid.uuid4().hex
602
791
 
603
- logger.info(f"Successfully added remote entity: {entity_info.id}")
604
- return entity_info, None
605
-
606
- except Exception as e:
607
- error_msg = f"Unexpected error: {e!s}"
608
- logger.error(f"Error fetching remote entity from {url}: {e}", exc_info=True)
609
- return None, error_msg
610
-
611
- def _normalize_url(self, url: str) -> str:
612
- """Convert various Git hosting URLs to raw content URLs."""
613
- # GitHub: blob -> raw
614
- if "github.com" in url and "/blob/" in url:
615
- return url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/")
616
-
617
- # GitLab: blob -> raw
618
- if "gitlab.com" in url and "/-/blob/" in url:
619
- return url.replace("/-/blob/", "/-/raw/")
620
-
621
- # Bitbucket: src -> raw
622
- if "bitbucket.org" in url and "/src/" in url:
623
- return url.replace("/src/", "/raw/")
624
-
625
- return url
626
-
627
- async def _fetch_url_content(self, url: str, max_size_mb: int = 10) -> str | None:
628
- """Fetch content from URL with size and timeout limits."""
629
- try:
630
- timeout = 30.0 # 30 second timeout
631
-
632
- async with httpx.AsyncClient(timeout=timeout) as client:
633
- response = await client.get(url)
634
-
635
- if response.status_code != 200:
636
- logger.warning(f"HTTP {response.status_code} for {url}")
637
- return None
638
-
639
- # Check content length
640
- content_length = response.headers.get("content-length")
641
- if content_length and int(content_length) > max_size_mb * 1024 * 1024:
642
- logger.warning(f"File too large: {content_length} bytes")
643
- return None
644
-
645
- # Read with size limit
646
- content = response.text
647
- if len(content.encode("utf-8")) > max_size_mb * 1024 * 1024:
648
- logger.warning("Content too large after reading")
649
- return None
650
-
651
- return content
652
-
653
- except Exception as e:
654
- logger.error(f"Error fetching {url}: {e}")
655
- return None
656
-
657
- def _validate_python_syntax(self, content: str) -> bool:
658
- """Validate that content is valid Python code."""
659
- try:
660
- compile(content, "<remote>", "exec")
661
- return True
662
- except SyntaxError as e:
663
- logger.warning(f"Python syntax error: {e}")
664
- return False
665
-
666
- async def _load_entity_from_content(self, content: str, source_url: str) -> Any | None:
667
- """Load entity object from Python content string using disk-based import.
668
-
669
- This method caches remote entities to disk and uses importlib for loading,
670
- making it consistent with local entity discovery and avoiding exec() security warnings.
671
- """
672
- try:
673
- # Create cache directory if it doesn't exist
674
- self._remote_cache_dir.mkdir(parents=True, exist_ok=True)
675
-
676
- # Generate a unique filename based on URL hash
677
- url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:16]
678
- module_name = f"remote_entity_{url_hash}"
679
- cached_file = self._remote_cache_dir / f"{module_name}.py"
680
-
681
- # Write content to cache file
682
- cached_file.write_text(content, encoding="utf-8")
683
- logger.debug(f"Cached remote entity to {cached_file}")
684
-
685
- # Load module from cached file using importlib (same as local scanning)
686
- module = self._load_module_from_file(cached_file, module_name)
687
- if not module:
688
- logger.warning(f"Failed to load module from cached file: {cached_file}")
689
- return None
690
-
691
- # Look for agent or workflow objects in the loaded module
692
- for name in dir(module):
693
- if name.startswith("_"):
694
- continue
695
-
696
- obj = getattr(module, name)
697
-
698
- # Check for explicitly named entities first
699
- if name in ["agent", "workflow"] and self._is_valid_entity(obj, name):
700
- return obj
701
-
702
- # Also check if any object looks like an agent/workflow
703
- if self._is_valid_agent(obj) or self._is_valid_workflow(obj):
704
- return obj
705
-
706
- return None
707
-
708
- except Exception as e:
709
- logger.error(f"Error loading entity from content: {e}")
710
- return None
711
-
712
- def remove_remote_entity(self, entity_id: str) -> bool:
713
- """Remove a remote entity by ID."""
714
- if entity_id in self._entities:
715
- entity_info = self._entities[entity_id]
716
- if entity_info.source in ["remote_gallery", "remote"]:
717
- del self._entities[entity_id]
718
- if entity_id in self._loaded_objects:
719
- del self._loaded_objects[entity_id]
720
- logger.info(f"Removed remote entity: {entity_id}")
721
- return True
722
- logger.warning(f"Cannot remove local entity: {entity_id}")
723
- return False
724
- return False
792
+ return f"{entity_type}_{source}_{base_name}_{full_uuid}"