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,724 @@
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
+ # Create EntityInfo with Agent Framework specifics
138
+ return EntityInfo(
139
+ id=entity_id,
140
+ name=name,
141
+ description=description,
142
+ type=entity_type,
143
+ framework="agent_framework",
144
+ tools=[str(tool) for tool in (tools_list or [])],
145
+ executors=tools_list if entity_type == "workflow" else [],
146
+ input_schema={"type": "string"}, # Default schema
147
+ start_executor_id=tools_list[0] if tools_list and entity_type == "workflow" else None,
148
+ metadata={
149
+ "source": "agent_framework_object",
150
+ "class_name": entity_object.__class__.__name__
151
+ if hasattr(entity_object, "__class__")
152
+ else str(type(entity_object)),
153
+ "has_run_stream": hasattr(entity_object, "run_stream"),
154
+ },
155
+ )
156
+
157
+ async def _scan_entities_directory(self, entities_dir: Path) -> None:
158
+ """Scan the entities directory for Agent Framework entities.
159
+
160
+ Args:
161
+ entities_dir: Directory to scan for entities
162
+ """
163
+ if not entities_dir.exists(): # noqa: ASYNC240
164
+ logger.warning(f"Entities directory not found: {entities_dir}")
165
+ return
166
+
167
+ logger.info(f"Scanning {entities_dir} for Agent Framework entities...")
168
+
169
+ # Add entities directory to Python path if not already there
170
+ entities_dir_str = str(entities_dir)
171
+ if entities_dir_str not in sys.path:
172
+ sys.path.insert(0, entities_dir_str)
173
+
174
+ # Scan for directories and Python files
175
+ for item in entities_dir.iterdir(): # noqa: ASYNC240
176
+ if item.name.startswith(".") or item.name == "__pycache__":
177
+ continue
178
+
179
+ if item.is_dir():
180
+ # Directory-based entity
181
+ await self._discover_entities_in_directory(item)
182
+ 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)
185
+
186
+ async def _discover_entities_in_directory(self, dir_path: Path) -> None:
187
+ """Discover entities in a directory using module import.
188
+
189
+ Args:
190
+ dir_path: Directory containing entity
191
+ """
192
+ entity_id = dir_path.name
193
+ logger.debug(f"Scanning directory: {entity_id}")
194
+
195
+ try:
196
+ # Load environment variables for this entity first
197
+ self._load_env_for_entity(dir_path)
198
+
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
+ ]
205
+
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
213
+
214
+ except Exception as e:
215
+ logger.warning(f"Error scanning directory {entity_id}: {e}")
216
+
217
+ async def _discover_entities_in_file(self, file_path: Path) -> None:
218
+ """Discover entities in a single Python file.
219
+
220
+ Args:
221
+ file_path: Python file to scan
222
+ """
223
+ try:
224
+ # Load environment variables for this entity's directory first
225
+ self._load_env_for_entity(file_path.parent)
226
+
227
+ # Create module name from file path
228
+ base_name = file_path.stem
229
+
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}")
236
+
237
+ except Exception as e:
238
+ logger.warning(f"Error scanning file {file_path}: {e}")
239
+
240
+ def _load_env_for_entity(self, entity_path: Path) -> bool:
241
+ """Load .env file for an entity.
242
+
243
+ Args:
244
+ entity_path: Path to entity directory
245
+
246
+ Returns:
247
+ True if .env was loaded successfully
248
+ """
249
+ # Check for .env in the entity folder first
250
+ env_file = entity_path / ".env"
251
+ if self._load_env_file(env_file):
252
+ return True
253
+
254
+ # Check one level up (the entities directory) for safety
255
+ if self.entities_dir:
256
+ entities_dir = Path(self.entities_dir).resolve()
257
+ entities_env = entities_dir / ".env"
258
+ if self._load_env_file(entities_env):
259
+ return True
260
+
261
+ return False
262
+
263
+ def _load_env_file(self, env_path: Path) -> bool:
264
+ """Load environment variables from .env file.
265
+
266
+ Args:
267
+ env_path: Path to .env file
268
+
269
+ Returns:
270
+ True if file was loaded successfully
271
+ """
272
+ if env_path.exists():
273
+ load_dotenv(env_path, override=True)
274
+ logger.debug(f"Loaded .env from {env_path}")
275
+ return True
276
+ return False
277
+
278
+ def _load_module_from_pattern(self, pattern: str) -> Any | None:
279
+ """Load module using import pattern.
280
+
281
+ Args:
282
+ pattern: Import pattern to try
283
+
284
+ Returns:
285
+ Loaded module or None if failed
286
+ """
287
+ try:
288
+ # Check if module exists first
289
+ spec = importlib.util.find_spec(pattern)
290
+ if spec is None:
291
+ return None
292
+
293
+ module = importlib.import_module(pattern)
294
+ logger.debug(f"Successfully imported {pattern}")
295
+ return module
296
+
297
+ except ModuleNotFoundError:
298
+ logger.debug(f"Import pattern {pattern} not found")
299
+ return None
300
+ except Exception as e:
301
+ logger.warning(f"Error importing {pattern}: {e}")
302
+ return None
303
+
304
+ def _load_module_from_file(self, file_path: Path, module_name: str) -> Any | None:
305
+ """Load module directly from file path.
306
+
307
+ Args:
308
+ file_path: Path to Python file
309
+ module_name: Name to assign to module
310
+
311
+ Returns:
312
+ Loaded module or None if failed
313
+ """
314
+ try:
315
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
316
+ if spec is None or spec.loader is None:
317
+ return None
318
+
319
+ module = importlib.util.module_from_spec(spec)
320
+ sys.modules[module_name] = module # Add to sys.modules for proper imports
321
+ spec.loader.exec_module(module)
322
+
323
+ logger.debug(f"Successfully loaded module from {file_path}")
324
+ return module
325
+
326
+ except Exception as e:
327
+ logger.warning(f"Error loading module from {file_path}: {e}")
328
+ return None
329
+
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.
332
+
333
+ Args:
334
+ module: Loaded Python module
335
+ base_id: Base identifier for entities
336
+ module_path: Path to module for metadata
337
+
338
+ Returns:
339
+ List of entity IDs that were found and registered
340
+ """
341
+ entities_found = []
342
+
343
+ # Look for explicit variable names first
344
+ candidates = [
345
+ ("agent", getattr(module, "agent", None)),
346
+ ("workflow", getattr(module, "workflow", None)),
347
+ ]
348
+
349
+ for obj_type, obj in candidates:
350
+ if obj is None:
351
+ continue
352
+
353
+ 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)
357
+
358
+ return entities_found
359
+
360
+ def _is_valid_entity(self, obj: Any, expected_type: str) -> bool:
361
+ """Check if object is a valid agent or workflow using duck typing.
362
+
363
+ Args:
364
+ obj: Object to validate
365
+ expected_type: Expected type ("agent" or "workflow")
366
+
367
+ Returns:
368
+ True if object is valid for the expected type
369
+ """
370
+ if expected_type == "agent":
371
+ return self._is_valid_agent(obj)
372
+ if expected_type == "workflow":
373
+ return self._is_valid_workflow(obj)
374
+ return False
375
+
376
+ def _is_valid_agent(self, obj: Any) -> bool:
377
+ """Check if object is a valid Agent Framework agent.
378
+
379
+ Args:
380
+ obj: Object to validate
381
+
382
+ Returns:
383
+ True if object appears to be a valid agent
384
+ """
385
+ try:
386
+ # Try to import AgentProtocol for proper type checking
387
+ try:
388
+ from agent_framework import AgentProtocol
389
+
390
+ if isinstance(obj, AgentProtocol):
391
+ return True
392
+ except ImportError:
393
+ pass
394
+
395
+ # Fallback to duck typing for agent protocol
396
+ if hasattr(obj, "run_stream") and hasattr(obj, "id") and hasattr(obj, "name"):
397
+ return True
398
+
399
+ except (TypeError, AttributeError):
400
+ pass
401
+
402
+ return False
403
+
404
+ def _is_valid_workflow(self, obj: Any) -> bool:
405
+ """Check if object is a valid Agent Framework workflow.
406
+
407
+ Args:
408
+ obj: Object to validate
409
+
410
+ Returns:
411
+ True if object appears to be a valid workflow
412
+ """
413
+ # Check for workflow - must have run_stream method and executors
414
+ return hasattr(obj, "run_stream") and (hasattr(obj, "executors") or hasattr(obj, "get_executors_list"))
415
+
416
+ async def _register_entity_from_object(
417
+ self, obj: Any, obj_type: str, module_path: str, source: str = "directory"
418
+ ) -> None:
419
+ """Register an entity from a live object.
420
+
421
+ Args:
422
+ obj: Entity object
423
+ obj_type: Type of entity ("agent" or "workflow")
424
+ module_path: Path to module for metadata
425
+ source: Source of entity (directory, in_memory, remote)
426
+ """
427
+ try:
428
+ # Generate entity ID with source information
429
+ entity_id = self._generate_entity_id(obj, obj_type, source)
430
+
431
+ # Extract metadata from the live object with improved fallback naming
432
+ name = getattr(obj, "name", None)
433
+ 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__}"
441
+ description = getattr(obj, "description", None)
442
+ tools = await self._extract_tools_from_object(obj, obj_type)
443
+
444
+ # Create EntityInfo
445
+ tools_union: list[str | dict[str, Any]] | None = None
446
+ if tools:
447
+ tools_union = [tool for tool in tools]
448
+
449
+ entity_info = EntityInfo(
450
+ id=entity_id,
451
+ type=obj_type,
452
+ name=name,
453
+ framework="agent_framework",
454
+ description=description,
455
+ tools=tools_union,
456
+ metadata={
457
+ "module_path": module_path,
458
+ "entity_type": obj_type,
459
+ "source": source,
460
+ "has_run_stream": hasattr(obj, "run_stream"),
461
+ "class_name": obj.__class__.__name__ if hasattr(obj, "__class__") else str(type(obj)),
462
+ },
463
+ )
464
+
465
+ # Register the entity
466
+ self.register_entity(entity_id, entity_info, obj)
467
+
468
+ except Exception as e:
469
+ logger.error(f"Error registering entity from {source}: {e}")
470
+
471
+ async def _extract_tools_from_object(self, obj: Any, obj_type: str) -> list[str]:
472
+ """Extract tool/executor names from a live object.
473
+
474
+ Args:
475
+ obj: Entity object
476
+ obj_type: Type of entity
477
+
478
+ Returns:
479
+ List of tool/executor names
480
+ """
481
+ tools = []
482
+
483
+ try:
484
+ if obj_type == "agent":
485
+ # For agents, check chat_options.tools first
486
+ chat_options = getattr(obj, "chat_options", None)
487
+ if chat_options and hasattr(chat_options, "tools"):
488
+ for tool in chat_options.tools:
489
+ if hasattr(tool, "__name__"):
490
+ tools.append(tool.__name__)
491
+ elif hasattr(tool, "name"):
492
+ tools.append(tool.name)
493
+ else:
494
+ tools.append(str(tool))
495
+ else:
496
+ # Fallback to direct tools attribute
497
+ agent_tools = getattr(obj, "tools", None)
498
+ if agent_tools:
499
+ for tool in agent_tools:
500
+ if hasattr(tool, "__name__"):
501
+ tools.append(tool.__name__)
502
+ elif hasattr(tool, "name"):
503
+ tools.append(tool.name)
504
+ else:
505
+ tools.append(str(tool))
506
+
507
+ elif obj_type == "workflow":
508
+ # For workflows, extract executor names
509
+ if hasattr(obj, "get_executors_list"):
510
+ executor_objects = obj.get_executors_list()
511
+ tools = [getattr(ex, "id", str(ex)) for ex in executor_objects]
512
+ elif hasattr(obj, "executors"):
513
+ executors = obj.executors
514
+ if isinstance(executors, list):
515
+ tools = [getattr(ex, "id", str(ex)) for ex in executors]
516
+ elif isinstance(executors, dict):
517
+ tools = list(executors.keys())
518
+
519
+ except Exception as e:
520
+ logger.debug(f"Error extracting tools from {obj_type} {type(obj)}: {e}")
521
+
522
+ return tools
523
+
524
+ def _generate_entity_id(self, entity: Any, entity_type: str, source: str = "directory") -> str:
525
+ """Generate unique entity ID with UUID suffix for collision resistance.
526
+
527
+ Args:
528
+ entity: Entity object
529
+ entity_type: Type of entity (agent, workflow, etc.)
530
+ source: Source of entity (directory, in_memory, remote)
531
+
532
+ Returns:
533
+ Unique entity ID with format: {type}_{source}_{name}_{uuid8}
534
+ """
535
+ import re
536
+
537
+ # Extract base name with priority: name -> id -> class_name
538
+ if hasattr(entity, "name") and entity.name:
539
+ base_name = str(entity.name).lower().replace(" ", "-").replace("_", "-")
540
+ elif hasattr(entity, "id") and entity.id:
541
+ base_name = str(entity.id).lower().replace(" ", "-").replace("_", "-")
542
+ elif hasattr(entity, "__class__"):
543
+ class_name = entity.__class__.__name__
544
+ # Convert CamelCase to kebab-case
545
+ base_name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", class_name).lower()
546
+ else:
547
+ base_name = "entity"
548
+
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)
602
+
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