From f1fc502470bec6491f12ede8769444764c2879bd Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Wed, 7 Oct 2026 05:29:45 +0530 Subject: [PATCH 1/3] Python: discover DevUI entities exported with an annotated assignment --- .../devui/agent_framework_devui/_discovery.py | 12 +++++++++--- python/packages/devui/tests/devui/test_discovery.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index 70447f2624a..4767d050402 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -558,9 +558,15 @@ def _has_entity_exports(self, file_path: Path) -> bool: # Look for module-level assignments of 'agent' or 'workflow' for node in ast.walk(tree): if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Name) and target.id in ("agent", "workflow"): - return True + targets = node.targets + elif isinstance(node, ast.AnnAssign) and node.value is not None: + # Annotated assignment, e.g. `agent: Agent = Agent(...)` + targets = [node.target] + else: + continue + for target in targets: + if isinstance(target, ast.Name) and target.id in ("agent", "workflow"): + return True except Exception as e: logger.debug(f"Could not parse {file_path} for entity exports: {e}") return False diff --git a/python/packages/devui/tests/devui/test_discovery.py b/python/packages/devui/tests/devui/test_discovery.py index f7cd22e7be5..5679acefc94 100644 --- a/python/packages/devui/tests/devui/test_discovery.py +++ b/python/packages/devui/tests/devui/test_discovery.py @@ -56,6 +56,18 @@ async def test_empty_directory(): assert len(entities) == 0 +async def test_discover_file_entity_with_annotated_export(): + """A file exporting `agent: Agent = ...` is discovered like `agent = ...`.""" + with tempfile.TemporaryDirectory() as temp_dir: + (Path(temp_dir) / "typed_agent.py").write_text("agent: object = object()\n") + (Path(temp_dir) / "declared_only.py").write_text("agent: object\n") + + discovery = EntityDiscovery(temp_dir) + entities = await discovery.discover_entities() + + assert [entity.id for entity in entities] == ["typed_agent"] + + async def test_discovery_accepts_agents_with_only_run(): """Test that discovery accepts agents with only run() method. From da92d0e51f9bb26caf5f23b8ec367a554f84c8b7 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Wed, 7 Oct 2026 14:16:34 +0530 Subject: [PATCH 2/3] Python: skip function and class bodies when scanning DevUI entity exports --- .../packages/devui/agent_framework_devui/_discovery.py | 10 ++++++++-- python/packages/devui/tests/devui/test_discovery.py | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index 4767d050402..1fa3413f403 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -555,8 +555,14 @@ def _has_entity_exports(self, file_path: Path) -> bool: source = file_path.read_text(encoding="utf-8") tree = ast.parse(source, filename=str(file_path)) - # Look for module-level assignments of 'agent' or 'workflow' - for node in ast.walk(tree): + # Look for module-level assignments of 'agent' or 'workflow'. Function and class + # bodies are skipped since their assignments do not create module attributes. + pending: list[ast.AST] = list(tree.body) + while pending: + node = pending.pop() + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + pending.extend(ast.iter_child_nodes(node)) if isinstance(node, ast.Assign): targets = node.targets elif isinstance(node, ast.AnnAssign) and node.value is not None: diff --git a/python/packages/devui/tests/devui/test_discovery.py b/python/packages/devui/tests/devui/test_discovery.py index 5679acefc94..b408c9efecb 100644 --- a/python/packages/devui/tests/devui/test_discovery.py +++ b/python/packages/devui/tests/devui/test_discovery.py @@ -61,11 +61,15 @@ async def test_discover_file_entity_with_annotated_export(): with tempfile.TemporaryDirectory() as temp_dir: (Path(temp_dir) / "typed_agent.py").write_text("agent: object = object()\n") (Path(temp_dir) / "declared_only.py").write_text("agent: object\n") + (Path(temp_dir) / "conditional_agent.py").write_text("if True:\n agent: object = object()\n") + (Path(temp_dir) / "local_only.py").write_text( + "def build():\n agent: object = object()\n return agent\n\n\nclass Holder:\n workflow = object()\n" + ) discovery = EntityDiscovery(temp_dir) entities = await discovery.discover_entities() - assert [entity.id for entity in entities] == ["typed_agent"] + assert sorted(entity.id for entity in entities) == ["conditional_agent", "typed_agent"] async def test_discovery_accepts_agents_with_only_run(): From 3ca6c3093ef369c6b092277786bfdc5b9443c4d2 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Thu, 15 Oct 2026 03:22:52 +0530 Subject: [PATCH 3/3] Python: keep DevUI exports assigned in a function or class that declares them global --- .../devui/agent_framework_devui/_discovery.py | 29 +++++++++++++++---- .../devui/tests/devui/test_discovery.py | 9 ++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index 1fa3413f403..bb20f80e312 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -20,6 +20,19 @@ logger = logging.getLogger(__name__) +def _global_names(scope: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> set[str]: + """Return the names a function or class body declares ``global``, not counting nested scopes.""" + names: set[str] = set() + pending: list[ast.AST] = list(scope.body) + while pending: + node = pending.pop() + if isinstance(node, ast.Global): + names.update(node.names) + elif not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + pending.extend(ast.iter_child_nodes(node)) + return names + + class EntityDiscovery: """Discovery for Agent Framework entities - agents and workflows.""" @@ -555,14 +568,18 @@ def _has_entity_exports(self, file_path: Path) -> bool: source = file_path.read_text(encoding="utf-8") tree = ast.parse(source, filename=str(file_path)) - # Look for module-level assignments of 'agent' or 'workflow'. Function and class - # bodies are skipped since their assignments do not create module attributes. - pending: list[ast.AST] = list(tree.body) + # Look for assignments of 'agent' or 'workflow' that create module attributes. In a + # function or class body that is only the case for names the scope declares global; + # otherwise they are locals or class attributes. + exports = {"agent", "workflow"} + pending: list[tuple[ast.AST, set[str]]] = [(node, exports) for node in tree.body] while pending: - node = pending.pop() + node, names = pending.pop() if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + scope_names = exports & _global_names(node) + pending.extend((child, scope_names) for child in node.body) continue - pending.extend(ast.iter_child_nodes(node)) + pending.extend((child, names) for child in ast.iter_child_nodes(node)) if isinstance(node, ast.Assign): targets = node.targets elif isinstance(node, ast.AnnAssign) and node.value is not None: @@ -571,7 +588,7 @@ def _has_entity_exports(self, file_path: Path) -> bool: else: continue for target in targets: - if isinstance(target, ast.Name) and target.id in ("agent", "workflow"): + if isinstance(target, ast.Name) and target.id in names: return True except Exception as e: logger.debug(f"Could not parse {file_path} for entity exports: {e}") diff --git a/python/packages/devui/tests/devui/test_discovery.py b/python/packages/devui/tests/devui/test_discovery.py index b408c9efecb..e36659cb608 100644 --- a/python/packages/devui/tests/devui/test_discovery.py +++ b/python/packages/devui/tests/devui/test_discovery.py @@ -63,13 +63,18 @@ async def test_discover_file_entity_with_annotated_export(): (Path(temp_dir) / "declared_only.py").write_text("agent: object\n") (Path(temp_dir) / "conditional_agent.py").write_text("if True:\n agent: object = object()\n") (Path(temp_dir) / "local_only.py").write_text( - "def build():\n agent: object = object()\n return agent\n\n\nclass Holder:\n workflow = object()\n" + "def build():\n agent: object = object()\n return agent\n\n\n" + "class Holder:\n workflow = object()\n\n\n" + "def other():\n global name\n agent = object()\n" + ) + (Path(temp_dir) / "global_factory.py").write_text( + "def build():\n global agent\n if True:\n agent = object()\n\n\nbuild()\n" ) discovery = EntityDiscovery(temp_dir) entities = await discovery.discover_entities() - assert sorted(entity.id for entity in entities) == ["conditional_agent", "typed_agent"] + assert sorted(entity.id for entity in entities) == ["conditional_agent", "global_factory", "typed_agent"] async def test_discovery_accepts_agents_with_only_run():