From 92e34272f1cf0719c87b2d6d4f643af342d4e6f4 Mon Sep 17 00:00:00 2001 From: nandanadileep <116407139+nandanadileep@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:30:41 +0000 Subject: [PATCH] fix(examples): serve workers in 63b/63d so 63c/63e --- examples/agents/63b_serve.py | 35 +++--- examples/agents/63d_serve_from_package.py | 25 ++-- examples/agents/run_all_examples.py | 6 +- tests/unit/ai/test_example_63_serve.py | 135 ++++++++++++++++++++++ 4 files changed, 167 insertions(+), 34 deletions(-) create mode 100644 tests/unit/ai/test_example_63_serve.py diff --git a/examples/agents/63b_serve.py b/examples/agents/63b_serve.py index c4b7e736..07041be6 100644 --- a/examples/agents/63b_serve.py +++ b/examples/agents/63b_serve.py @@ -1,23 +1,24 @@ """Serve — keep tool workers running as a persistent service. Demonstrates: - - runtime.serve() to register Python workers and block until interrupted + - runtime.serve() to register agents + Python workers and block until interrupted - Serving multiple agents in a single process - - Decoupled from deploy: workers only, no workflow registration + - The deploy/serve/run separation: this process owns the workers for a + pre-deployed agent while another process runs it by name -serve() registers the Python tool functions (tools, custom guardrails, -callbacks, handoff checks) as Conductor workers and starts polling for -tasks. The workflow must already exist on the server (from a prior -deploy() or run() call, possibly in a different process). +serve() = deploy() + serve: it registers each agent on the server (compiles +the workflow and task definitions), registers the local Python tool functions +(tools, custom guardrails, callbacks, handoff checks) as Conductor workers, +and starts polling for tasks. It blocks until Ctrl+C / SIGTERM. -Start this in a long-running process (systemd, Docker, k8s pod). -Press Ctrl+C to stop. +Run this in one terminal and run the matching run-by-name client +(63c_run_by_name.py) in another. Without a process serving the workers the +run-by-name client schedules tool tasks that nothing ever polls, so it hangs. python 63b_serve.py Requirements: - Conductor server running - - Agents already deployed (run 63_deploy.py first) - CONDUCTOR_SERVER_URL=http://localhost:8080/api in .env or environment - CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini in .env or environment """ @@ -70,14 +71,8 @@ def check_status(service: str) -> str: if __name__ == "__main__": with AgentRuntime() as runtime: - result = runtime.run(ops_bot, "Check the status of the API gateway.") - result.print_result() - - # Production pattern: - # 1. Deploy once during CI/CD: - # runtime.deploy(doc_assistant, ops_bot) - # CLI alternative: - # runtime.deploy(agent) from a release script - # - # 2. In a separate long-lived worker process: - # runtime.serve(doc_assistant, ops_bot) + # serve() registers doc_assistant/ops_bot on the server, then serves + # their tool workers and blocks until Ctrl+C. Run this in one terminal + # so that 63c_run_by_name.py (and any other run-by-name client) finds + # a live process polling the agents' task queues. + runtime.serve(doc_assistant, ops_bot) diff --git a/examples/agents/63d_serve_from_package.py b/examples/agents/63d_serve_from_package.py index 99b467cc..0afe54f0 100644 --- a/examples/agents/63d_serve_from_package.py +++ b/examples/agents/63d_serve_from_package.py @@ -9,12 +9,18 @@ collects all module-level Agent instances. This avoids the need to explicitly list every agent when serving a large codebase. +serve() registers each agent on the server (compile + register, like +deploy()) and then serves its Python tool workers, blocking until Ctrl+C. +Run this in one terminal and run the matching run-by-name client +(63e_run_monitoring.py) in another; without a serving process the client +schedules tool tasks that nothing ever polls, so it hangs. + python 63d_serve_from_package.py Requirements: - Conductor server running - CONDUCTOR_SERVER_URL=http://localhost:8080/api in .env or environment - - A Python package with Agent instances at module level + - CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini in .env or environment """ from conductor.ai.agents import Agent, AgentRuntime, discover_agents, tool @@ -51,14 +57,11 @@ def health_check() -> str: if __name__ == "__main__": with AgentRuntime() as runtime: - result = runtime.run(monitoring_agent, "Is everything healthy? Run a full check.") - result.print_result() - - # Production pattern: - # 1. Deploy once during CI/CD: - # runtime.deploy(monitoring_agent, *discover_agents(["myapp.agents"])) - # CLI alternative: - # runtime.deploy(agent) from a release script + # serve() registers monitoring on the server, serves its tool workers, + # and blocks until Ctrl+C. Run this in one terminal so that + # 63e_run_monitoring.py (and any other run-by-name client) finds a live + # process polling the monitoring agent's task queue. # - # 2. In a separate long-lived worker process: - # runtime.serve(monitoring_agent, packages=["myapp.agents"]) + # Add packages=["myapp.agents"] to auto-discover and serve every + # module-level Agent in that package alongside the explicit agent. + runtime.serve(monitoring_agent) diff --git a/examples/agents/run_all_examples.py b/examples/agents/run_all_examples.py index 78417bcb..5ec0f7f9 100644 --- a/examples/agents/run_all_examples.py +++ b/examples/agents/run_all_examples.py @@ -49,9 +49,9 @@ # Long-running by design or interactive — would legitimately never terminate. DAEMON_SKIP = { "12_long_running.py": "long-running by design", - "63b_serve.py": "serve() worker daemon (blocks forever)", - "63d_serve_from_package.py": "serve() worker daemon (blocks forever)", - "63e_run_monitoring.py": "live monitoring loop", + "63b_serve.py": "serve() worker daemon (blocks forever; serves 63c's agents)", + "63d_serve_from_package.py": "serve() worker daemon (blocks forever; serves 63e's agent)", + "63e_run_monitoring.py": "run-by-name client; needs 63d_serve_from_package.py serving in another terminal", "77_kafka_consumer_agent.py": "kafka consumer daemon (+ needs Kafka)", "79_agent_message_bus.py": "message-bus daemon", "80_live_dashboard.py": "interactive live dashboard", diff --git a/tests/unit/ai/test_example_63_serve.py b/tests/unit/ai/test_example_63_serve.py new file mode 100644 index 00000000..12a17c3f --- /dev/null +++ b/tests/unit/ai/test_example_63_serve.py @@ -0,0 +1,135 @@ +"""Regression tests for examples 63b/63d: serve the workers that 63c/63e need. + +Issue #487: ``63c_run_by_name.py`` and ``63e_run_monitoring.py`` execute a +pre-deployed agent *by name* (``runtime.run("doc_assistant", ...)`` / +``runtime.run("monitoring", ...)``). A run-by-name client never sees the +agent object, so it cannot start the agent's tool workers. Some other +process must be serving them. ``63b_serve.py`` and ``63d_serve_from_package.py`` +are the examples meant to be that process, but their executable bodies called +``runtime.run(, ...)`` instead of ``runtime.serve(...)`` — so no +shipped example ever served workers and the run-by-name clients hung forever. + +These tests pin the example *executable bodies* (the ``__main__`` block) to the +serving behaviour, using only the stdlib ``ast`` module so they fail on the +original, un-fixed examples. They do not import the examples (which would +execute their module-level code and require a live server). +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +EXAMPLES = Path(__file__).resolve().parents[3] / "examples" / "agents" + + +def _main_block_calls(path: Path) -> list[ast.Call]: + """Return every ``runtime.(...)`` call inside the ``__main__`` block. + + Only calls whose receiver is the ``AgentRuntime`` context-manager binding + (``with AgentRuntime() as runtime:``) are returned, so module-level helper + calls in the example are ignored. + """ + tree = ast.parse(path.read_text(encoding="utf-8")) + + main_body: list[ast.stmt] = [] + for node in tree.body: + if not isinstance(node, ast.If): + continue + test = node.test + # ``if __name__ == "__main__":`` + if ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "__name__" + and len(test.comparators) == 1 + and isinstance(test.comparators[0], ast.Constant) + and test.comparators[0].value == "__main__" + ): + main_body = node.body + break + + runtime_names: set[str] = set() + for node in ast.walk(ast.Module(body=main_body, type_ignores=[])): + if isinstance(node, ast.With): + for item in node.items: + ctx = item.context_expr + if ( + isinstance(ctx, ast.Call) + and isinstance(ctx.func, ast.Name) + and ctx.func.id == "AgentRuntime" + and isinstance(item.optional_vars, ast.Name) + ): + runtime_names.add(item.optional_vars.id) + + calls: list[ast.Call] = [] + for node in ast.walk(ast.Module(body=main_body, type_ignores=[])): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id in runtime_names + ): + calls.append(node) + return calls + + +def _method_name(call: ast.Call) -> str: + assert isinstance(call.func, ast.Attribute) + return call.func.attr + + +def _positional_names(call: ast.Call) -> list[str]: + return [a.id for a in call.args if isinstance(a, ast.Name)] + + +def test_63b_serves_doc_assistant_and_ops_bot(): + calls = _main_block_calls(EXAMPLES / "63b_serve.py") + serve_calls = [c for c in calls if _method_name(c) == "serve"] + assert serve_calls, ( + "63b_serve.py must call runtime.serve(...) in its __main__ block so a " + "process serves the workers that 63c_run_by_name.py needs; it currently " + "does not serve any agent." + ) + served = set() + for call in serve_calls: + served.update(_positional_names(call)) + assert {"doc_assistant", "ops_bot"} <= served, ( + f"63b must serve both agents by name so 63c can run 'doc_assistant'; " + f"got served={sorted(served)}" + ) + + +def test_63b_does_not_run_an_agent_object(): + calls = _main_block_calls(EXAMPLES / "63b_serve.py") + run_calls = [c for c in calls if _method_name(c) == "run"] + assert not run_calls, ( + "63b_serve.py is the serving daemon for 63c; its __main__ block must " + "not execute an agent with runtime.run(, ...)." + ) + + +def test_63d_serves_monitoring_agent(): + calls = _main_block_calls(EXAMPLES / "63d_serve_from_package.py") + serve_calls = [c for c in calls if _method_name(c) == "serve"] + assert serve_calls, ( + "63d_serve_from_package.py must call runtime.serve(...) in its __main__ " + "block so a process serves the workers that 63e_run_monitoring.py needs; " + "it currently does not serve any agent." + ) + served = set() + for call in serve_calls: + served.update(_positional_names(call)) + assert "monitoring_agent" in served, ( + f"63d must serve the monitoring agent so 63e can run 'monitoring'; " + f"got served={sorted(served)}" + ) + + +def test_63d_does_not_run_an_agent_object(): + calls = _main_block_calls(EXAMPLES / "63d_serve_from_package.py") + run_calls = [c for c in calls if _method_name(c) == "run"] + assert not run_calls, ( + "63d_serve_from_package.py is the serving daemon for 63e; its __main__ " + "block must not execute an agent with runtime.run(, ...)." + )