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.
- agent_framework_devui/__init__.py +151 -0
- agent_framework_devui/_cli.py +143 -0
- agent_framework_devui/_discovery.py +822 -0
- agent_framework_devui/_executor.py +777 -0
- agent_framework_devui/_mapper.py +558 -0
- agent_framework_devui/_server.py +577 -0
- agent_framework_devui/_session.py +191 -0
- agent_framework_devui/_tracing.py +168 -0
- agent_framework_devui/_utils.py +421 -0
- agent_framework_devui/models/__init__.py +72 -0
- agent_framework_devui/models/_discovery_models.py +58 -0
- agent_framework_devui/models/_openai_custom.py +209 -0
- agent_framework_devui/ui/agentframework.svg +33 -0
- agent_framework_devui/ui/assets/index-D0SfShuZ.js +445 -0
- agent_framework_devui/ui/assets/index-WsCIE0bH.css +1 -0
- agent_framework_devui/ui/index.html +14 -0
- agent_framework_devui/ui/vite.svg +1 -0
- agent_framework_devui-1.0.0b251007.dist-info/METADATA +172 -0
- agent_framework_devui-1.0.0b251007.dist-info/RECORD +22 -0
- {agent_framework_devui-0.0.1a0.dist-info → agent_framework_devui-1.0.0b251007.dist-info}/WHEEL +1 -2
- agent_framework_devui-1.0.0b251007.dist-info/entry_points.txt +3 -0
- agent_framework_devui-1.0.0b251007.dist-info/licenses/LICENSE +21 -0
- agent_framework_devui-0.0.1a0.dist-info/METADATA +0 -18
- agent_framework_devui-0.0.1a0.dist-info/RECORD +0 -5
- agent_framework_devui-0.0.1a0.dist-info/licenses/LICENSE +0 -9
- agent_framework_devui-0.0.1a0.dist-info/top_level.txt +0 -1
|
@@ -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
|