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.
- agent_framework_devui/_conversations.py +473 -0
- agent_framework_devui/_discovery.py +323 -255
- agent_framework_devui/_executor.py +146 -281
- agent_framework_devui/_mapper.py +307 -128
- agent_framework_devui/_server.py +232 -192
- agent_framework_devui/_session.py +3 -3
- agent_framework_devui/_utils.py +548 -0
- agent_framework_devui/models/__init__.py +15 -10
- agent_framework_devui/models/_discovery_models.py +8 -2
- agent_framework_devui/models/_openai_custom.py +45 -90
- agent_framework_devui/ui/agentframework.svg +33 -0
- agent_framework_devui/ui/assets/index-CE4pGoXh.css +1 -0
- agent_framework_devui/ui/assets/index-DmL7WSFa.js +577 -0
- agent_framework_devui/ui/index.html +3 -3
- agent_framework_devui-1.0.0b251016.dist-info/METADATA +286 -0
- agent_framework_devui-1.0.0b251016.dist-info/RECORD +23 -0
- agent_framework_devui/ui/assets/index-D1AmQWga.css +0 -1
- agent_framework_devui/ui/assets/index-DPEaaIdK.js +0 -435
- agent_framework_devui-1.0.0b251001.dist-info/METADATA +0 -172
- agent_framework_devui-1.0.0b251001.dist-info/RECORD +0 -20
- {agent_framework_devui-1.0.0b251001.dist-info → agent_framework_devui-1.0.0b251016.dist-info}/WHEEL +0 -0
- {agent_framework_devui-1.0.0b251001.dist-info → agent_framework_devui-1.0.0b251016.dist-info}/entry_points.txt +0 -0
- {agent_framework_devui-1.0.0b251001.dist-info → agent_framework_devui-1.0.0b251016.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
+
# Single file entity - create sparse metadata
|
|
363
|
+
self._register_sparse_file_entity(item)
|
|
185
364
|
|
|
186
|
-
|
|
187
|
-
"""
|
|
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
|
|
191
|
-
"""
|
|
192
|
-
entity_id = dir_path.name
|
|
193
|
-
logger.debug(f"Scanning directory: {entity_id}")
|
|
369
|
+
dir_path: Directory to check
|
|
194
370
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
|
|
388
|
+
Args:
|
|
389
|
+
dir_path: Directory to analyze
|
|
216
390
|
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
411
|
+
dir_path: Entity directory
|
|
222
412
|
"""
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
331
|
-
"""Find agent
|
|
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
|
-
|
|
556
|
+
entity_id: Expected entity identifier to register with
|
|
336
557
|
module_path: Path to module for metadata
|
|
337
558
|
|
|
338
559
|
Returns:
|
|
339
|
-
|
|
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
|
-
#
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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}_{
|
|
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
|
|
550
|
-
|
|
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
|
-
|
|
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}"
|