From a3a2be5a7227ce398f81279f7a545d9f58858d72 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 28 Apr 2026 20:51:30 -0700 Subject: [PATCH 01/44] feat: add agent health endpoints Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 105 ++++++++++++++++++++- dapr_agents/workflow/runners/base.py | 8 +- dapr_agents/workflow/utils/registration.py | 26 +++-- dapr_agents/workflow/utils/subscription.py | 27 +++++- 4 files changed, 149 insertions(+), 17 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index d479a8722..763d5a886 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -417,7 +417,7 @@ def _wire_pubsub_routes( ) deduper = None - closers = register_message_routes( + closers, status_functions = register_message_routes( routes=specs, dapr_client=self._dapr_client, delivery_mode=delivery_mode, @@ -430,6 +430,7 @@ def _wire_pubsub_routes( deduper=deduper, ) self._pubsub_closers.extend(closers) + self._pubsub_consumer_status_functions.extend(status_functions) self._wired_pubsub = True def _wire_http_routes( @@ -506,6 +507,8 @@ def serve( expose_entry: bool = True, entry_path: str = "/agent/run", status_path: str = "/agent/instances/{instance_id}", + health_check_path: str = "/livez", + readiness_check_path: str = "/readyz", workflow_component: str = "dapr", fetch_status_payloads: bool = True, delivery_mode: Literal["sync", "async"] = "sync", @@ -523,6 +526,8 @@ def serve( expose_entry: Mount a default POST endpoint that schedules the workflow entry. entry_path: HTTP path for the default POST endpoint. status_path: HTTP path for the status endpoint (must include `{instance_id}`). + health_check_path: HTTP path for the endpoint for Kubernetes liveness probes. + readiness_check_path: HTTP path for the endpoint for Dapr health /Kubernetes readiness probes. workflow_component: Workflow component name used in the returned status URL. fetch_status_payloads: Include payloads when fetching workflow status. delivery_mode: Delivery mode forwarded to `subscribe`. @@ -562,6 +567,16 @@ def serve( fetch_status_payloads=fetch_status_payloads, ) + self._mount_health_endpoints( + fastapi_app=fastapi_app, + agent=agent, + health_check_path=health_check_path, + readiness_check_path=readiness_check_path, + expose_entry=expose_entry, + entry_path=entry_path, + status_path=status_path, + ) + auto_run = app is None if auto_run: try: @@ -597,6 +612,94 @@ def _normalize_path(path: str) -> str: path = f"/{path}" return path + def _mount_health_endpoints( + self, + *, + fastapi_app: FastAPI, + agent: DurableAgent, + health_check_path: str, + readiness_check_path: str, + expose_entry: bool, + entry_path: str, + status_path: str, + ) -> None: + health_check_path = self._normalize_path(health_check_path) + readiness_check_path = self._normalize_path(readiness_check_path) + + if health_check_path not in self._default_http_paths: + self._default_http_paths.add(health_check_path) + + async def _get_health_status() -> dict[str, str]: + return {"status": "ok"} + + fastapi_app.add_api_route( + health_check_path, + _get_health_status, + methods=["GET"], + summary="Get agent health", + tags=["health"], + ) + + logger.info("Mounted health endpoint at %s", health_check_path) + else: + logger.debug("Health endpoint already mounted at %s", health_check_path) + + if readiness_check_path in self._default_http_paths: + logger.debug( + "Readiness endpoint already mounted at %s", readiness_check_path + ) + return + + self._default_http_paths.add(readiness_check_path) + + def _is_ready( + agent: DurableAgent, expose_entry: bool, entry_path: str, status_path: str + ) -> bool: + # Ensure agent is not shutting down + if self.is_shutdown_requested(): + return False + + # Ensure workflow runtime is started and agent workflows/activites are registered + if not agent.is_started: + return False + + # Ensure subscription consumers are running and able to process messages + if getattr(agent, "pubsub", None): + if not self._wired_pubsub or not all( + is_ready() for is_ready in self._pubsub_consumer_status_functions + ): + return False + + # Ensure agent routes are mounted + if not self._wired_http: + return False + + # Ensure default service routes are mounted if given (other routes are the caller's responsibility) + if expose_entry and not ( + entry_path in self._default_http_paths + and status_path in self._default_http_paths + ): + return False + + return True + + async def _get_ready_status() -> dict[str, str]: + if _is_ready(agent, expose_entry, entry_path, status_path): + return {"status": "ok"} + raise HTTPException( + status_code=503, detail="Agent is not ready, check logs for details" + ) + + fastapi_app.add_api_route( + readiness_check_path, + _get_ready_status, + methods=["GET"], + summary="Get agent readiness", + tags=["health"], + ) + + logger.info("Mounted readiness endpoint at %s", readiness_check_path) + def _mount_service_routes( self, *, diff --git a/dapr_agents/workflow/runners/base.py b/dapr_agents/workflow/runners/base.py index 14ef63c2a..d9e5a6471 100644 --- a/dapr_agents/workflow/runners/base.py +++ b/dapr_agents/workflow/runners/base.py @@ -89,6 +89,7 @@ def __init__( self._dapr_client: Optional[DaprClient] = dapr_client self._dapr_client_owned: bool = dapr_client is None self._pubsub_closers: List[Callable[[], None]] = [] + self._pubsub_consumer_status_functions: List[Callable[[], bool]] = [] self._wired_pubsub = False self._wired_http = False @@ -290,7 +291,7 @@ def register_routes( # ---- Discovery mode (targets) ---- if use_targets: if not self._wired_pubsub and self._dapr_client is not None: - closers = register_message_routes( + closers, status_functions = register_message_routes( dapr_client=self._dapr_client, targets=targets or [], routes=None, @@ -303,6 +304,7 @@ def register_routes( log_outcome=log_outcome, ) self._pubsub_closers.extend(closers) + self._pubsub_consumer_status_functions.extend(status_functions) self._wired_pubsub = True if fastapi_app is not None and not self._wired_http: @@ -320,7 +322,7 @@ def register_routes( http_specs = [r for r in specs if isinstance(r, HttpRouteSpec)] if pubsub_specs and not self._wired_pubsub and self._dapr_client is not None: - closers = register_message_routes( + closers, status_functions = register_message_routes( routes=pubsub_specs, dapr_client=self._dapr_client, delivery_mode=delivery_mode, @@ -332,6 +334,7 @@ def register_routes( log_outcome=log_outcome, ) self._pubsub_closers.extend(closers) + self._pubsub_consumer_status_functions.extend(status_functions) self._wired_pubsub = True if http_specs and fastapi_app is not None and not self._wired_http: @@ -354,6 +357,7 @@ def unwire_pubsub(self) -> None: except Exception: logger.exception("Error while closing subscription") self._pubsub_closers.clear() + self._pubsub_consumer_status_functions.clear() self._wired_pubsub = False # -------------------- workflow scheduling APIs ---------------------- diff --git a/dapr_agents/workflow/utils/registration.py b/dapr_agents/workflow/utils/registration.py index de2bbac60..4b1a42a42 100644 --- a/dapr_agents/workflow/utils/registration.py +++ b/dapr_agents/workflow/utils/registration.py @@ -257,11 +257,12 @@ def _mount_http_bindings( *, app: FastAPI, loop: Optional[asyncio.AbstractEventLoop], -) -> List[Callable[[], None]]: +) -> tuple[List[Callable[[], None]], List[Callable[[], bool]]]: if not bindings: - return [] + return [], [] closers: List[Callable[[], None]] = [] + status_functions: List[Callable[[], bool]] = [] async def _invoke(bound_handler: Callable[..., Any], parsed: Any) -> Any: result = bound_handler(parsed) @@ -359,9 +360,11 @@ async def endpoint(body: Any = Body(...)) -> Any: ) closers.append(lambda: None) + status_functions.append(lambda: True) + logger.info("Mounted HTTP route %s %s -> %s", _method, _path, _name) - return closers + return closers, status_functions def register_message_routes( @@ -379,7 +382,7 @@ def register_message_routes( await_timeout: Optional[int] = None, fetch_payloads: bool = True, log_outcome: bool = True, -) -> List[Callable[[], None]]: +) -> tuple[List[Callable[[], None]], List[Callable[[], bool]]]: """ Register workflow-backed pub/sub routes via decorator discovery and/or explicit specs. @@ -397,9 +400,10 @@ def register_message_routes( await_timeout: Optional wait timeout in seconds. fetch_payloads: Include workflow payloads when waiting for completion. log_outcome: Log COMPLETED/FAILED status (either inline or via detached task). - Returns: - List of closers that unsubscribe handlers and cancel async workers. + A tuple of lists: + - List of closers that unsubscribe handlers and cancel async workers. + - List of status functions indicating pub/sub consumer readiness. """ if targets is None and routes is None: raise ValueError( @@ -409,7 +413,7 @@ def register_message_routes( bindings = _collect_message_bindings(targets=targets, routes=routes) if not bindings: logger.info("No message routes discovered.") - return [] + return [], [] # Validate that required PubSub components are available before subscribing pubsub_names: Set[str] = set() @@ -444,7 +448,7 @@ def register_http_routes( targets: Optional[Iterable[Any]] = None, routes: Optional[Iterable[HttpRouteSpec]] = None, loop: Optional[asyncio.AbstractEventLoop] = None, -) -> List[Callable[[], None]]: +) -> tuple[List[Callable[[], None]], List[Callable[[], bool]]]: """ Mount FastAPI endpoints from `@http_router` targets and/or explicit `HttpRouteSpec` entries. @@ -455,7 +459,9 @@ def register_http_routes( loop: Optional loop reference (retained for symmetry/future async needs). Returns: - List of no-op closers (API symmetry with message registrar). + A tuple of lists (API symmetry with message registrar): + - List of no-op closers. + - List of no-op status functions. """ if targets is None and routes is None: raise ValueError( @@ -465,6 +471,6 @@ def register_http_routes( bindings = _collect_http_bindings(targets=targets, routes=routes) if not bindings: logger.info("No HTTP routes discovered.") - return [] + return [], [] return _mount_http_bindings(bindings, app=app, loop=loop) diff --git a/dapr_agents/workflow/utils/subscription.py b/dapr_agents/workflow/utils/subscription.py index 9deb85f11..8d07fdbd8 100644 --- a/dapr_agents/workflow/utils/subscription.py +++ b/dapr_agents/workflow/utils/subscription.py @@ -330,7 +330,7 @@ def _subscribe_message_bindings( await_timeout: Optional[int], fetch_payloads: bool, log_outcome: bool, -) -> List[Callable[[], None]]: +) -> tuple[List[Callable[[], None]], List[Callable[[], bool]]]: """Internal implementation of streaming subscriptions. This function sets up streaming subscriptions for all bindings, @@ -437,6 +437,7 @@ async def _async_worker() -> None: bindings_by_topic_key = _group_bindings_by_topic(bindings) closers: List[Callable[[], None]] = [] + consumer_status_functions: List[Callable[[], bool]] = [] for (pubsub_name, topic_name), topic_bindings in bindings_by_topic_key.items(): binding_schema_pairs = _build_binding_schema_pairs(topic_bindings) @@ -635,6 +636,22 @@ def _close() -> None: closers.append( _make_closer(subscription, consumer_thread, pubsub_name, topic_name) ) + + def _make_status_func(subscription: Any) -> Callable[[], bool]: + def _is_ready() -> bool: + """Check if a stream consumer is able to process messages. + + Returns: + True if a consumer's stream is active. + False if a non-recoverable error or caller-initiated shutdown occurred, + or if the stream is currently reconnecting. + """ + return subscription._is_stream_active() + + return _is_ready + + consumer_status_functions.append(_make_status_func(subscription)) + logger.debug( f"Subscribed streaming to pubsub={pubsub_name} topic={topic_name} " f"(delivery={delivery_mode} await={await_result})" @@ -654,7 +671,7 @@ def _cancel() -> None: closers.append(_make_cancel_all(worker_tasks)) - return closers + return closers, consumer_status_functions def subscribe_message_bindings( @@ -671,7 +688,7 @@ def subscribe_message_bindings( await_timeout: Optional[int], fetch_payloads: bool, log_outcome: bool, -) -> List[Callable[[], None]]: +) -> tuple[List[Callable[[], None]], List[Callable[[], bool]]]: """Set up streaming subscriptions for message route bindings. Args: @@ -689,7 +706,9 @@ def subscribe_message_bindings( log_outcome: Log workflow completion status. Returns: - List of closer functions to unsubscribe and cleanup resources. + A tuple of lists: + - List of closer functions to unsubscribe and cleanup resources. + - List of status functions indicating stream consumer readiness. Raises: ValueError: If delivery_mode is invalid or dead_letter_topics conflict. From 60e52fee7ccdacf6294fed7e577564d53552afaa Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 28 Apr 2026 20:52:32 -0700 Subject: [PATCH 02/44] test: add agent health endpoint tests Signed-off-by: Jeffrey Zhang --- tests/workflow/test_agent_runner.py | 302 ++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 tests/workflow/test_agent_runner.py diff --git a/tests/workflow/test_agent_runner.py b/tests/workflow/test_agent_runner.py new file mode 100644 index 000000000..cb5ecdd8f --- /dev/null +++ b/tests/workflow/test_agent_runner.py @@ -0,0 +1,302 @@ +# +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from dapr_agents.workflow.runners.agent import AgentRunner + +# --------------------------------------------------------------------------- +# AgentRunner health endpoint checks +# --------------------------------------------------------------------------- + + +def _make_agent_runner() -> AgentRunner: + return AgentRunner(name="test-agent-runner", wf_client=MagicMock()) + + +def _make_mock_agent(is_started: bool, pubsub: Any = None) -> MagicMock: + agent = MagicMock() + agent.name = "mock-agent" + agent.is_started = is_started + + if pubsub is None: + agent.pubsub = None + else: + agent.pubsub = MagicMock() + + return agent + + +@dataclass +class _MockResponse: + status_code: int + detail: Any + + def json(self) -> Any: + return self.detail + + +class _MockFastAPI: + def __init__(self) -> None: + self.routes: list[dict[str, Any]] = [] + + def add_api_route( + self, + path: str, + endpoint: Any, + methods: list[str], + summary: str | None = None, + tags: list[str] | None = None, + ) -> None: + self.routes.append( + { + "path": path, + "endpoint": endpoint, + "methods": set(methods), + "summary": summary, + "tags": tags or [], + } + ) + + +@pytest.mark.asyncio +class _MockClient: + def __init__(self, app: _MockFastAPI) -> None: + self.app = app + + async def get(self, path: str) -> _MockResponse: + route = next( + (r for r in self.app.routes if r["path"] == path and "GET" in r["methods"]), + None, + ) + if route is None: + return _MockResponse(status_code=404, detail={"detail": "Not Found"}) + + endpoint = route["endpoint"] + + try: + result = await endpoint() + return _MockResponse(status_code=200, detail=result) + except Exception as exc: # noqa: BLE001 + status_code = int(getattr(exc, "status_code", 500)) + detail = getattr(exc, "detail", "Internal Server Error") + return _MockResponse(status_code=status_code, detail={"detail": detail}) + + +def _make_mock_fastapi_app() -> _MockFastAPI: + return _MockFastAPI() + + +@pytest.mark.asyncio +async def test_livez_ok(): + """When the agent is started, livez reports 'ok'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True) + + with patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=False) + + client = _MockClient(mock_fastapi_app) + livez = await client.get("/livez") + + assert livez.status_code == 200 + + +@pytest.mark.asyncio +async def test_readyz_ok(): + """When the agent is started, readyz reports 'ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True) + + with patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=False) + + client = _MockClient(mock_fastapi_app) + readyz = await client.get("/readyz") + + assert readyz.status_code == 200 + + +@pytest.mark.asyncio +async def test_readyz_agent_not_started(): + """When the agent is not (yet) started, readyz reports 'not ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=False) + + with patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=False) + + client = _MockClient(mock_fastapi_app) + readyz = await client.get("/readyz") + + assert readyz.status_code == 503 + + +@pytest.mark.asyncio +async def test_readyz_agent_pubsub_not_wired(): + """When pub/sub routes are not (yet) wired, readyz reports 'not ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True, pubsub=object()) + + with ( + patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ), + patch( + "dapr_agents.workflow.runners.agent.register_message_routes", + return_value=([MagicMock()], [MagicMock()]), + ), + patch.object(runner, "_build_pubsub_specs", return_value=[]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=False) + + client = _MockClient(mock_fastapi_app) + + readyz = await client.get("/readyz") + assert readyz.status_code == 503 + + +@pytest.mark.asyncio +async def test_readyz_agent_pubsub_wired_and_ready(): + """When pub/sub consumers are ready, readyz reports 'ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True, pubsub=object()) + + with ( + patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ), + patch( + "dapr_agents.workflow.runners.agent.register_message_routes", + return_value=([MagicMock()], [lambda: True]), + ), + patch.object(runner, "_build_pubsub_specs", return_value=[MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=False) + + client = _MockClient(mock_fastapi_app) + + readyz = await client.get("/readyz") + assert readyz.status_code == 200 + + +@pytest.mark.asyncio +async def test_readyz_agent_pubsub_wired_but_not_ready(): + """When pub/sub consumers are not ready, readyz reports 'not ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True, pubsub=object()) + + with ( + patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ), + patch( + "dapr_agents.workflow.runners.agent.register_message_routes", + return_value=([MagicMock()], [lambda: False]), + ), + patch.object(runner, "_build_pubsub_specs", return_value=[MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=False) + + client = _MockClient(mock_fastapi_app) + + readyz = await client.get("/readyz") + assert readyz.status_code == 503 + + +@pytest.mark.asyncio +async def test_readyz_agent_service_routes_mounted(): + """When default service routes are mounted, readyz reports 'ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True) + + with patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=True) + + client = _MockClient(mock_fastapi_app) + + readyz = await client.get("/readyz") + assert readyz.status_code == 200 + + +@pytest.mark.asyncio +async def test_readyz_agent_service_routes_not_mounted(): + """When default service routes are not (yet) mounted, readyz reports 'not ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True) + + with patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app) + + runner._default_http_paths.clear() + + client = _MockClient(mock_fastapi_app) + + readyz_not_ready = await client.get("/readyz") + assert readyz_not_ready.status_code == 503 + + +@pytest.mark.asyncio +async def test_readyz_agent_shutting_down(): + """When shutdown is in progress, readyz reports 'not ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True) + + with patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=False) + + runner.install_signal_handlers() + + if runner._shutdown_event is not None: + runner._shutdown_event.set() + + client = _MockClient(mock_fastapi_app) + + readyz = await client.get("/readyz") + assert readyz.status_code == 503 From 384a5897fd658f580876b8eb4170e543b6e72300 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 28 Apr 2026 21:01:22 -0700 Subject: [PATCH 03/44] test: update failing message router tests Signed-off-by: Jeffrey Zhang --- tests/workflow/test_message_router.py | 45 +++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/tests/workflow/test_message_router.py b/tests/workflow/test_message_router.py index f30dc20c8..4d43677e5 100644 --- a/tests/workflow/test_message_router.py +++ b/tests/workflow/test_message_router.py @@ -587,7 +587,7 @@ def handle_order(message: OrderCreated): loop = asyncio.new_event_loop() try: with patch(_PATCH_TARGET, return_value=mock_client): - closers = register_message_routes( + closers, status_functions = register_message_routes( dapr_client=mock_client, targets=[handle_order], loop=loop ) finally: @@ -596,6 +596,7 @@ def handle_order(message: OrderCreated): # Should create one subscription assert mock_client.subscribe.call_count == 1 assert len(closers) == 1 + assert len(status_functions) == 1 # Verify subscription parameters call_args = mock_client.subscribe.call_args @@ -621,7 +622,7 @@ def handle_cancelled(self, message: OrderCancelled): loop = asyncio.new_event_loop() try: with patch(_PATCH_TARGET, return_value=mock_client): - closers = register_message_routes( + closers, status_functions = register_message_routes( dapr_client=mock_client, targets=[handler], loop=loop ) finally: @@ -630,6 +631,7 @@ def handle_cancelled(self, message: OrderCancelled): # Should create two subscriptions assert mock_client.subscribe.call_count == 2 assert len(closers) == 2 + assert len(status_functions) == 2 # Verify both topics were registered topics = [call.kwargs["topic"] for call in mock_client.subscribe.call_args_list] @@ -654,7 +656,7 @@ def handle_cancelled(self, message: OrderCancelled): loop = asyncio.new_event_loop() try: with patch(_PATCH_TARGET, return_value=mock_client): - closers = register_message_routes( + closers, status_functions = register_message_routes( dapr_client=mock_client, targets=[handler], loop=loop ) finally: @@ -663,6 +665,7 @@ def handle_cancelled(self, message: OrderCancelled): # Should create only one subscription (grouped by pubsub+topic) assert mock_client.subscribe.call_count == 1 assert len(closers) == 1 + assert len(status_functions) == 1 # Verify the subscription was created for the shared topic call_args = mock_client.subscribe.call_args @@ -690,7 +693,7 @@ def regular_method(self, message: OrderCreated): loop = asyncio.new_event_loop() try: with patch(_PATCH_TARGET, return_value=mock_client): - closers = register_message_routes( + closers, status_functions = register_message_routes( dapr_client=mock_client, targets=[handler], loop=loop ) finally: @@ -699,6 +702,7 @@ def regular_method(self, message: OrderCreated): # Should only create one subscription (for decorated method) assert mock_client.subscribe.call_count == 1 assert len(closers) == 1 + assert len(status_functions) == 1 def test_register_message_handlers_handles_multiple_targets(): @@ -718,7 +722,7 @@ def handle_shipment(self, message: ShipmentCreated): loop = asyncio.new_event_loop() try: with patch(_PATCH_TARGET, return_value=mock_client): - closers = register_message_routes( + closers, status_functions = register_message_routes( dapr_client=mock_client, targets=[standalone_handler, handler_instance], loop=loop, @@ -729,6 +733,7 @@ def handle_shipment(self, message: ShipmentCreated): # Should create two subscriptions assert mock_client.subscribe.call_count == 2 assert len(closers) == 2 + assert len(status_functions) == 2 def test_register_message_handlers_returns_closers(): @@ -746,7 +751,7 @@ def handle_cancelled(message: OrderCancelled): loop = asyncio.new_event_loop() try: with patch(_PATCH_TARGET, return_value=mock_client): - closers = register_message_routes( + closers, _ = register_message_routes( dapr_client=mock_client, targets=[handle_created, handle_cancelled], loop=loop, @@ -759,6 +764,34 @@ def handle_cancelled(message: OrderCancelled): assert all(callable(closer) for closer in closers) +def test_register_message_handlers_returns_status_functions(): + """Test that status functions are returned for each subscription.""" + mock_client = create_mock_dapr_client(["messagepubsub"]) + + @message_router(pubsub="messagepubsub", topic="orders.created") + def handle_created(message: OrderCreated): + pass + + @message_router(pubsub="messagepubsub", topic="orders.cancelled") + def handle_cancelled(message: OrderCancelled): + pass + + loop = asyncio.new_event_loop() + try: + with patch(_PATCH_TARGET, return_value=mock_client): + _, status_functions = register_message_routes( + dapr_client=mock_client, + targets=[handle_created, handle_cancelled], + loop=loop, + ) + finally: + loop.close() + + # Should return two status functions + assert len(status_functions) == 2 + assert all(callable(status_function) for status_function in status_functions) + + class TestTTLDedupeBackend: def test_unseen_key_returns_false(self) -> None: backend = TTLDedupeBackend(maxsize=8, ttl=1.0) From eda8f2246e9302a18626a83e5dc8682074852e7e Mon Sep 17 00:00:00 2001 From: Casper Nielsen Date: Fri, 1 May 2026 10:28:20 +0200 Subject: [PATCH 04/44] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Casper Nielsen --- dapr_agents/workflow/runners/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index 763d5a886..3fd03c8e1 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -659,7 +659,7 @@ def _is_ready( if self.is_shutdown_requested(): return False - # Ensure workflow runtime is started and agent workflows/activites are registered + # Ensure workflow runtime is started and agent workflows/activities are registered if not agent.is_started: return False From b6b2218f747371d7b037dde7fe2f2fe85efeece4 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 1 May 2026 12:03:39 -0700 Subject: [PATCH 05/44] fix: address copilot feedback Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 11 +++++------ dapr_agents/workflow/utils/subscription.py | 22 +++++++++++++++++----- tests/workflow/test_agent_runner.py | 13 +++++++------ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index 3fd03c8e1..440a98317 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -538,6 +538,11 @@ def serve( """ fastapi_app = app or FastAPI(title="Dapr Agent Service", version="1.0.0") + + entry_path = self._normalize_path(entry_path) + status_path = self._normalize_path(status_path) + health_check_path = self._normalize_path(health_check_path) + readiness_check_path = self._normalize_path(readiness_check_path) try: agent.start() @@ -623,9 +628,6 @@ def _mount_health_endpoints( entry_path: str, status_path: str, ) -> None: - health_check_path = self._normalize_path(health_check_path) - readiness_check_path = self._normalize_path(readiness_check_path) - if health_check_path not in self._default_http_paths: self._default_http_paths.add(health_check_path) @@ -710,9 +712,6 @@ def _mount_service_routes( workflow_component: str, fetch_status_payloads: bool, ) -> None: - entry_path = self._normalize_path(entry_path) - status_path = self._normalize_path(status_path) - if "{instance_id}" not in status_path: raise ValueError("status_path must include '{instance_id}'.") diff --git a/dapr_agents/workflow/utils/subscription.py b/dapr_agents/workflow/utils/subscription.py index 8d07fdbd8..4aa95dace 100644 --- a/dapr_agents/workflow/utils/subscription.py +++ b/dapr_agents/workflow/utils/subscription.py @@ -637,20 +637,32 @@ def _close() -> None: _make_closer(subscription, consumer_thread, pubsub_name, topic_name) ) - def _make_status_func(subscription: Any) -> Callable[[], bool]: + def _make_status_func(subscription: Any, ps_name: str, t_name: str) -> Callable[[], bool]: def _is_ready() -> bool: """Check if a stream consumer is able to process messages. Returns: True if a consumer's stream is active. False if a non-recoverable error or caller-initiated shutdown occurred, - or if the stream is currently reconnecting. + if the stream is currently reconnecting, or if the stream's status cannot be determined. """ - return subscription._is_stream_active() + is_stream_active = getattr(subscription, "_is_stream_active", None) + + if not callable(is_stream_active): + return False + + try: + return bool(is_stream_active()) + except Exception: + logger.exception( + f"Error checking stream consumer {ps_name}:{t_name} status.", + exc_info=True, + ) + return False return _is_ready - consumer_status_functions.append(_make_status_func(subscription)) + consumer_status_functions.append(_make_status_func(subscription, pubsub_name, topic_name)) logger.debug( f"Subscribed streaming to pubsub={pubsub_name} topic={topic_name} " @@ -715,7 +727,7 @@ def subscribe_message_bindings( RuntimeError: If async mode is used without a running event loop. """ if not bindings: - return [] + return [], [] _validate_delivery_mode(delivery_mode) _validate_dead_letter_topics(bindings) diff --git a/tests/workflow/test_agent_runner.py b/tests/workflow/test_agent_runner.py index cb5ecdd8f..b1e3c2651 100644 --- a/tests/workflow/test_agent_runner.py +++ b/tests/workflow/test_agent_runner.py @@ -292,11 +292,12 @@ async def test_readyz_agent_shutting_down(): runner.serve(agent, app=mock_fastapi_app, expose_entry=False) runner.install_signal_handlers() - - if runner._shutdown_event is not None: + try: runner._shutdown_event.set() + + client = _MockClient(mock_fastapi_app) - client = _MockClient(mock_fastapi_app) - - readyz = await client.get("/readyz") - assert readyz.status_code == 503 + readyz = await client.get("/readyz") + assert readyz.status_code == 503 + finally: + runner.remove_signal_handlers() From b9c2231edbfe5fb4510d5ad9ca8c10a90c353b53 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 1 May 2026 12:07:02 -0700 Subject: [PATCH 06/44] test: add test for partial consumer readiness Signed-off-by: Jeffrey Zhang --- tests/workflow/test_agent_runner.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/workflow/test_agent_runner.py b/tests/workflow/test_agent_runner.py index b1e3c2651..a3644cf47 100644 --- a/tests/workflow/test_agent_runner.py +++ b/tests/workflow/test_agent_runner.py @@ -238,6 +238,32 @@ async def test_readyz_agent_pubsub_wired_but_not_ready(): assert readyz.status_code == 503 +@pytest.mark.asyncio +async def test_readyz_agent_pubsub_wired_but_not_all_ready(): + """When some pub/sub consumers are not ready, readyz reports 'not ready'.""" + runner = _make_agent_runner() + mock_fastapi_app = _make_mock_fastapi_app() + agent = _make_mock_agent(is_started=True, pubsub=object()) + + with ( + patch( + "dapr_agents.workflow.runners.agent.register_http_routes", + return_value=([MagicMock()], [MagicMock()]), + ), + patch( + "dapr_agents.workflow.runners.agent.register_message_routes", + return_value=([MagicMock()], [lambda: True, lambda: False, lambda: True]), + ), + patch.object(runner, "_build_pubsub_specs", return_value=[MagicMock()]), + ): + runner.serve(agent, app=mock_fastapi_app, expose_entry=False) + + client = _MockClient(mock_fastapi_app) + + readyz = await client.get("/readyz") + assert readyz.status_code == 503 + + @pytest.mark.asyncio async def test_readyz_agent_service_routes_mounted(): """When default service routes are mounted, readyz reports 'ready'.""" From 1baa6c415742ff7bf72a42a4d325e1290960caa4 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 1 May 2026 12:14:50 -0700 Subject: [PATCH 07/44] style: lint fixes Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 2 +- dapr_agents/workflow/utils/subscription.py | 12 +++++++++--- tests/workflow/test_agent_runner.py | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index 440a98317..7dad8ed89 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -538,7 +538,7 @@ def serve( """ fastapi_app = app or FastAPI(title="Dapr Agent Service", version="1.0.0") - + entry_path = self._normalize_path(entry_path) status_path = self._normalize_path(status_path) health_check_path = self._normalize_path(health_check_path) diff --git a/dapr_agents/workflow/utils/subscription.py b/dapr_agents/workflow/utils/subscription.py index 4aa95dace..62812d5dc 100644 --- a/dapr_agents/workflow/utils/subscription.py +++ b/dapr_agents/workflow/utils/subscription.py @@ -637,7 +637,11 @@ def _close() -> None: _make_closer(subscription, consumer_thread, pubsub_name, topic_name) ) - def _make_status_func(subscription: Any, ps_name: str, t_name: str) -> Callable[[], bool]: + def _make_status_func( + subscription: Any, + ps_name: str, + t_name: str, + ) -> Callable[[], bool]: def _is_ready() -> bool: """Check if a stream consumer is able to process messages. @@ -650,7 +654,7 @@ def _is_ready() -> bool: if not callable(is_stream_active): return False - + try: return bool(is_stream_active()) except Exception: @@ -662,7 +666,9 @@ def _is_ready() -> bool: return _is_ready - consumer_status_functions.append(_make_status_func(subscription, pubsub_name, topic_name)) + consumer_status_functions.append( + _make_status_func(subscription, pubsub_name, topic_name) + ) logger.debug( f"Subscribed streaming to pubsub={pubsub_name} topic={topic_name} " diff --git a/tests/workflow/test_agent_runner.py b/tests/workflow/test_agent_runner.py index a3644cf47..ca1d8e467 100644 --- a/tests/workflow/test_agent_runner.py +++ b/tests/workflow/test_agent_runner.py @@ -320,7 +320,7 @@ async def test_readyz_agent_shutting_down(): runner.install_signal_handlers() try: runner._shutdown_event.set() - + client = _MockClient(mock_fastapi_app) readyz = await client.get("/readyz") From 0a95ef87f6cc83aa68ce8fac49c8805048e4e04f Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 1 May 2026 14:51:53 -0700 Subject: [PATCH 08/44] style: formatting fix Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index 7dad8ed89..d39463c55 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -527,7 +527,7 @@ def serve( entry_path: HTTP path for the default POST endpoint. status_path: HTTP path for the status endpoint (must include `{instance_id}`). health_check_path: HTTP path for the endpoint for Kubernetes liveness probes. - readiness_check_path: HTTP path for the endpoint for Dapr health /Kubernetes readiness probes. + readiness_check_path: HTTP path for the endpoint for Dapr health/Kubernetes readiness probes. workflow_component: Workflow component name used in the returned status URL. fetch_status_payloads: Include payloads when fetching workflow status. delivery_mode: Delivery mode forwarded to `subscribe`. From adec6bda1140ab54ee0f9e7edf17441924e588b7 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Mon, 4 May 2026 16:43:31 -0700 Subject: [PATCH 09/44] style: rename status function factory for consistency Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/utils/subscription.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dapr_agents/workflow/utils/subscription.py b/dapr_agents/workflow/utils/subscription.py index 62812d5dc..b58acc85b 100644 --- a/dapr_agents/workflow/utils/subscription.py +++ b/dapr_agents/workflow/utils/subscription.py @@ -637,7 +637,7 @@ def _close() -> None: _make_closer(subscription, consumer_thread, pubsub_name, topic_name) ) - def _make_status_func( + def _make_status_function( subscription: Any, ps_name: str, t_name: str, @@ -667,7 +667,7 @@ def _is_ready() -> bool: return _is_ready consumer_status_functions.append( - _make_status_func(subscription, pubsub_name, topic_name) + _make_status_function(subscription, pubsub_name, topic_name) ) logger.debug( From 5b72c6c10c39f7905ba6f923aa9ff3d995d4d6ab Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Mon, 4 May 2026 17:11:34 -0700 Subject: [PATCH 10/44] style: use f-strings for agent serve logs Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index d39463c55..e27ddae00 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -642,13 +642,13 @@ async def _get_health_status() -> dict[str, str]: tags=["health"], ) - logger.info("Mounted health endpoint at %s", health_check_path) + logger.info(f"Mounted health endpoint at {health_check_path}") else: - logger.debug("Health endpoint already mounted at %s", health_check_path) + logger.debug(f"Health endpoint already mounted at {health_check_path}") if readiness_check_path in self._default_http_paths: logger.debug( - "Readiness endpoint already mounted at %s", readiness_check_path + f"Readiness endpoint already mounted at {readiness_check_path}" ) return @@ -700,7 +700,7 @@ async def _get_ready_status() -> dict[str, str]: tags=["health"], ) - logger.info("Mounted readiness endpoint at %s", readiness_check_path) + logger.info(f"Mounted readiness endpoint at {readiness_check_path}") def _mount_service_routes( self, @@ -783,12 +783,12 @@ async def _purge_workflow(instance_id: str) -> dict[str, str]: tags=["agent"], ) - logger.info("Mounted default agent run endpoint at %s", entry_path) + logger.info(f"Mounted default agent run endpoint at {entry_path}") else: - logger.debug("Workflow entry endpoint already mounted at %s", entry_path) + logger.debug(f"Workflow entry endpoint already mounted at {entry_path}") if status_path in self._default_http_paths: - logger.debug("Workflow status endpoint already mounted at %s", status_path) + logger.debug(f"Workflow status endpoint already mounted at {status_path}") return self._default_http_paths.add(status_path) @@ -821,7 +821,7 @@ async def _get_status(instance_id: str) -> dict: summary="Get workflow status", tags=["workflow"], ) - logger.info("Mounted default workflow status endpoint at %s", status_path) + logger.info(f"Mounted default workflow status endpoint at {status_path}") def shutdown(self, agent: Optional[DurableAgent] = None) -> None: """ From 032a2b49664d64fd78ee529100e3511bfa23517c Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Mon, 4 May 2026 18:01:21 -0700 Subject: [PATCH 11/44] fix: check fastapi app existence in agent routes check Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index e27ddae00..db2c93bb6 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -655,7 +655,7 @@ async def _get_health_status() -> dict[str, str]: self._default_http_paths.add(readiness_check_path) def _is_ready( - agent: DurableAgent, expose_entry: bool, entry_path: str, status_path: str + fastapi_app: FastAPI, agent: DurableAgent, expose_entry: bool, entry_path: str, status_path: str ) -> bool: # Ensure agent is not shutting down if self.is_shutdown_requested(): @@ -673,7 +673,7 @@ def _is_ready( return False # Ensure agent routes are mounted - if not self._wired_http: + if fastapi_app and not self._wired_http: return False # Ensure default service routes are mounted if given (other routes are the caller's responsibility) @@ -686,7 +686,7 @@ def _is_ready( return True async def _get_ready_status() -> dict[str, str]: - if _is_ready(agent, expose_entry, entry_path, status_path): + if _is_ready(fastapi_app, agent, expose_entry, entry_path, status_path): return {"status": "ok"} raise HTTPException( status_code=503, detail="Agent is not ready, check logs for details" From aa08f792bbfb0cbbd01f47c7809bf5a9642f52cd Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Mon, 4 May 2026 19:04:35 -0700 Subject: [PATCH 12/44] style: lint fix Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index db2c93bb6..5fbd911fb 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -655,7 +655,11 @@ async def _get_health_status() -> dict[str, str]: self._default_http_paths.add(readiness_check_path) def _is_ready( - fastapi_app: FastAPI, agent: DurableAgent, expose_entry: bool, entry_path: str, status_path: str + fastapi_app: FastAPI, + agent: DurableAgent, + expose_entry: bool, + entry_path: str, + status_path: str, ) -> bool: # Ensure agent is not shutting down if self.is_shutdown_requested(): From 8c8fe42dc4618f7942a486a4cebf8d1a84d471e6 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 5 May 2026 17:04:41 -0700 Subject: [PATCH 13/44] style: remove redundant stack trace flag Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/utils/subscription.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dapr_agents/workflow/utils/subscription.py b/dapr_agents/workflow/utils/subscription.py index b58acc85b..d2dda76a7 100644 --- a/dapr_agents/workflow/utils/subscription.py +++ b/dapr_agents/workflow/utils/subscription.py @@ -659,8 +659,7 @@ def _is_ready() -> bool: return bool(is_stream_active()) except Exception: logger.exception( - f"Error checking stream consumer {ps_name}:{t_name} status.", - exc_info=True, + f"Error checking stream consumer {ps_name}:{t_name} status." ) return False From e29bb08fc8271358b50e590ae43bbb8fbcfc1196 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 13:36:56 -0700 Subject: [PATCH 14/44] style: f-string logging for registration methods Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/utils/registration.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/dapr_agents/workflow/utils/registration.py b/dapr_agents/workflow/utils/registration.py index 4b1a42a42..6a49c5112 100644 --- a/dapr_agents/workflow/utils/registration.py +++ b/dapr_agents/workflow/utils/registration.py @@ -302,8 +302,7 @@ async def endpoint(body: Any = Body(...)) -> Any: break except Exception: logger.debug( - "HTTP schema %r did not match; trying next.", - model, + f"HTTP schema {model!r} did not match; trying next.", exc_info=True, ) @@ -317,10 +316,8 @@ async def endpoint(body: Any = Body(...)) -> Any: if matched_model is not None: logger.debug( - "Validated HTTP request for %s %s with model=%s", - method_b, - path_b, - getattr(matched_model, "__name__", str(matched_model)), + f"Validated HTTP request for {method_b} {path_b} " + f"with model={getattr(matched_model, '__name__', str(matched_model))}" ) result = await _invoke(bound_handler, parsed) @@ -334,7 +331,7 @@ async def endpoint(body: Any = Body(...)) -> Any: return JSONResponse(content=result) except Exception: - logger.exception("HTTP handler error for %s %s.", method_b, path_b) + logger.exception(f"HTTP handler error for {method_b} {path_b}.") return JSONResponse( status_code=500, content={"detail": "Internal Server Error"} ) @@ -362,7 +359,7 @@ async def endpoint(body: Any = Body(...)) -> Any: closers.append(lambda: None) status_functions.append(lambda: True) - logger.info("Mounted HTTP route %s %s -> %s", _method, _path, _name) + logger.info(f"Mounted HTTP route {_method} {_path} -> {_name}") return closers, status_functions From ad1d1621af77db13dc8684c98140900793f8545b Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 13:53:29 -0700 Subject: [PATCH 15/44] chore: expose readiness details Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index 5fbd911fb..91b9e9879 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -660,40 +660,42 @@ def _is_ready( expose_entry: bool, entry_path: str, status_path: str, - ) -> bool: + ) -> tuple[bool, str]: # Ensure agent is not shutting down if self.is_shutdown_requested(): - return False + return False, "Agent is shutting down" # Ensure workflow runtime is started and agent workflows/activities are registered if not agent.is_started: - return False + return False, "Agent workflow runtime is not started" # Ensure subscription consumers are running and able to process messages if getattr(agent, "pubsub", None): if not self._wired_pubsub or not all( is_ready() for is_ready in self._pubsub_consumer_status_functions ): - return False + return False, "Agent subscription consumers are not running or are unable to process messages" # Ensure agent routes are mounted if fastapi_app and not self._wired_http: - return False + return False, "Agent HTTP routes are not mounted" # Ensure default service routes are mounted if given (other routes are the caller's responsibility) if expose_entry and not ( entry_path in self._default_http_paths and status_path in self._default_http_paths ): - return False + return False, "Agent default HTTP service routes are not mounted" - return True + return True, "Agent is ready" async def _get_ready_status() -> dict[str, str]: - if _is_ready(fastapi_app, agent, expose_entry, entry_path, status_path): - return {"status": "ok"} + is_ready, detail = _is_ready(fastapi_app, agent, expose_entry, entry_path, status_path) + + if is_ready: + return {"status": detail} raise HTTPException( - status_code=503, detail="Agent is not ready, check logs for details" + status_code=503, detail=f"{detail}, check logs for details" ) fastapi_app.add_api_route( From 6ac182358ca464a6c41ab9c76c852381e530d141 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 13:59:12 -0700 Subject: [PATCH 16/44] refactor: rename internal agent readiness check Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index 91b9e9879..fe7a28623 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -654,7 +654,7 @@ async def _get_health_status() -> dict[str, str]: self._default_http_paths.add(readiness_check_path) - def _is_ready( + def _get_agent_readiness( fastapi_app: FastAPI, agent: DurableAgent, expose_entry: bool, @@ -690,7 +690,7 @@ def _is_ready( return True, "Agent is ready" async def _get_ready_status() -> dict[str, str]: - is_ready, detail = _is_ready(fastapi_app, agent, expose_entry, entry_path, status_path) + is_ready, detail = _get_agent_readiness(fastapi_app, agent, expose_entry, entry_path, status_path) if is_ready: return {"status": detail} From ab2c1535a46304450fa9214236d77cbffb7809e7 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 14:04:46 -0700 Subject: [PATCH 17/44] style: formatting fix Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index fe7a28623..398d3dc10 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -674,7 +674,10 @@ def _get_agent_readiness( if not self._wired_pubsub or not all( is_ready() for is_ready in self._pubsub_consumer_status_functions ): - return False, "Agent subscription consumers are not running or are unable to process messages" + return ( + False, + "Agent subscription consumers are not running or are unable to process messages", + ) # Ensure agent routes are mounted if fastapi_app and not self._wired_http: @@ -690,7 +693,9 @@ def _get_agent_readiness( return True, "Agent is ready" async def _get_ready_status() -> dict[str, str]: - is_ready, detail = _get_agent_readiness(fastapi_app, agent, expose_entry, entry_path, status_path) + is_ready, detail = _get_agent_readiness( + fastapi_app, agent, expose_entry, entry_path, status_path + ) if is_ready: return {"status": detail} From 167a29fd610f5d147be254be03232db36d8c20f2 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 19:59:02 -0700 Subject: [PATCH 18/44] feat: add agent execution config resolution logic Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 126 +++++++++++++++++- dapr_agents/agents/configs.py | 78 ++++++++++- .../durableagent/test_tool_execution_mode.py | 3 +- 3 files changed, 199 insertions(+), 8 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 49e241c5f..0628c6c67 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -16,6 +16,7 @@ import asyncio import json import logging +from os import getenv import re from importlib.metadata import version from datetime import datetime, timezone @@ -43,7 +44,10 @@ AgentExecutionConfig, AgentTracingExporter, ConfigFieldDescriptor, + OrchestrationMode, RuntimeConfigKey, + ToolChoice, + ToolExecutionMode, LLMMetadata, MemoryMetadata, MemoryStoreMetadata, @@ -474,7 +478,7 @@ def __init__( ) self.instrumentor: Optional[DaprAgentsInstrumentor] = None - self._setup_agent_runtime_configuration() + self._setup_agent_observability_runtime_configuration() # ----------------------------- # Registry wiring @@ -540,6 +544,8 @@ def __init__( # Execution config # ----------------------------- self.execution = execution or AgentExecutionConfig() + self._setup_agent_execution_runtime_configuration() + try: self.execution.max_iterations = max(1, int(self.execution.max_iterations)) except Exception: @@ -547,13 +553,11 @@ def __init__( if not self.tools: if self.execution.tool_choice is not None: logger.debug( - "No tools configured for agent '%s'; ignoring tool_choice=%r.", - self.name, - self.execution.tool_choice, + f"No tools configured for agent '{self.name}'; ignoring tool_choice={self.execution.tool_choice!r}." ) self.execution.tool_choice = None elif self.execution.tool_choice is None: - self.execution.tool_choice = "auto" + self.execution.tool_choice = ToolChoice.AUTO # ----------------------------- # Agent metadata & registry registration @@ -1723,6 +1727,116 @@ def _coerce_datetime(value: Optional[Any]) -> datetime: except ValueError: pass return datetime.now(timezone.utc) + + def _resolve_execution_config(self) -> AgentExecutionConfig: + """ + Resolve the execution configuration for the agent in the following order: + 1. Passed through instantiation (highest priority) + 2. Environment variables + 3. Default statestore runtime config (lowest priority) + + Args: + agent_execution: Optional execution config provided during initialization. + Returns: + Resolved AgentExecutionConfig instance. + """ + + config = self._load_execution_from_statestore() + logger.debug(f"Statestore execution config: {config}") + + env_config = AgentExecutionConfig.from_env() + logger.debug(f"Env execution config: {env_config}") + + config = self._merge_execution_configs(config, env_config) + logger.debug(f"Merged execution config: {config}") + + if self.execution: + config = self._merge_execution_configs(config, self.execution) + logger.debug(f"Final execution config with override: {config}") + return config + + def _load_execution_from_statestore(self) -> AgentExecutionConfig: + """ + Load execution configuration from the state store. + + Returns: + AgentExecutionConfig instance loaded from state store. + """ + + try: + max_iterations: Optional[int] = None + if max_iter_str := self._runtime_conf.get("MAX_ITERATIONS"): + try: + max_iterations = max(1, int(max_iter_str)) + except ValueError: + max_iterations = 10 + + tool_choice: Optional[ToolChoice] = None + if tool_choice_str := self._runtime_conf.get("TOOL_CHOICE"): + try: + tool_choice = ToolChoice(tool_choice_str) + except (ValueError, KeyError): + tool_choice = ToolChoice.AUTO + + tool_execution_mode: Optional[ToolExecutionMode] = None + orchestration_mode: Optional[OrchestrationMode] = None + app_health_check_enabled: Optional[bool] = None + app_ready_check_enabled: Optional[bool] = None + + return AgentExecutionConfig( + max_iterations=max_iterations, + tool_choice=tool_choice, + tool_execution_mode=tool_execution_mode, + orchestration_mode=orchestration_mode, + app_health_check_enabled=app_health_check_enabled, + app_ready_check_enabled=app_ready_check_enabled, + ) + except Exception as e: + logger.debug(f"Could not load execution config from statestore: {e}") + return AgentExecutionConfig() + + def _merge_execution_configs( + self, base: AgentExecutionConfig, override: AgentExecutionConfig + ) -> AgentExecutionConfig: + """ + Merge two execution configurations, with the override taking precedence. + Only override if the override value is not None. + + Args: + base: Base execution configuration. + override: Override execution configuration. + Returns: + Merged AgentExecutionConfig instance. + """ + + orchestration_mode = ( + override.orchestration_mode + if override.orchestration_mode is not None + else base.orchestration_mode + ) + app_health_check_enabled = ( + override.app_health_check_enabled + if override.app_health_check_enabled is not None + else base.app_health_check_enabled + ) + app_ready_check_enabled = ( + override.app_ready_check_enabled + if override.app_ready_check_enabled is not None + else base.app_ready_check_enabled + ) + + merged_config = AgentExecutionConfig( + max_iterations=override.max_iterations or base.max_iterations, + tool_choice=override.tool_choice or base.tool_choice, + tool_execution_mode=override.tool_execution_mode or base.tool_execution_mode, + orchestration_mode=orchestration_mode, + app_health_check_enabled=app_health_check_enabled, + app_ready_check_enabled=app_ready_check_enabled, + ) + return merged_config + + def _setup_agent_execution_runtime_configuration(self) -> None: + self.execution = self._resolve_execution_config() def _resolve_observability_config(self) -> AgentObservabilityConfig: """ @@ -1855,7 +1969,7 @@ def _merge_observability_configs( ) return merged_config - def _setup_agent_runtime_configuration(self) -> None: + def _setup_agent_observability_runtime_configuration(self) -> None: self._agent_observability = self._resolve_observability_config() self._setup_agent_observability(self._agent_observability) diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index f251dc567..06b6c1166 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -369,6 +369,18 @@ class AgentProfileConfig: module_overrides: Dict[str, PromptSection] = field(default_factory=dict) +class ToolChoice(StrEnum): + """ + Enumeration of supported tool choice strategies for durable agents. + + AUTO: The agent decides when to use tools based on the prompt and context. + This is the default and recommended setting for most use cases, + as it allows the agent to leverage tools when beneficial while avoiding unnecessary calls. + """ + + AUTO = "auto" + + class ToolExecutionMode(StrEnum): """ Enumeration of supported tool execution modes for durable agents. @@ -403,15 +415,79 @@ class OrchestrationMode(StrEnum): class AgentExecutionConfig: """ Dials to configure the agent execution. + + Attributes: + max_iterations: Maximum number of turns allowed for the agent to produce a final response. + tool_choice: Tool choice strategy for the agent. + tool_execution_mode: Tool execution mode for the agent. + orchestration_mode: Orchestration strategy for the agent. + app_health_check_enabled: Enable/disable Kubernetes liveness probes. + app_ready_check_enabled: Enable/disable Dapr health/Kubernetes readiness probes. """ # TODO: add a forceFinalAnswer field in case max_iterations is near/reached. Or do we have a conclusion baked in by default? Do we want this to derive a conclusion by default? # TODO: add stop_at_tokens max_iterations: int = 10 - tool_choice: Optional[str] = "auto" + tool_choice: Optional[ToolChoice] = ToolChoice.AUTO tool_execution_mode: ToolExecutionMode = ToolExecutionMode.PARALLEL orchestration_mode: Optional[OrchestrationMode] = None + app_health_check_enabled: Optional[bool] = None + app_ready_check_enabled: Optional[bool] = None + + @classmethod + def from_env(cls) -> "AgentExecutionConfig": + """Create execution config from environment variables.""" + + max_iterations: Optional[int] = None + if max_iterations := getenv("MAX_ITERATIONS"): + try: + max_iterations = max(1, int(max_iterations)) + except ValueError: + max_iterations = 10 + + tool_choice: Optional[ToolChoice] = None + if tool_choice_str := getenv("TOOL_CHOICE"): + try: + tool_choice = ToolChoice(tool_choice_str) + except (ValueError, KeyError): + tool_choice = ToolChoice.AUTO + + tool_execution_mode: Optional[ToolExecutionMode] = None + if tool_execution_mode_str := getenv("TOOL_EXECUTION_MODE"): + try: + tool_execution_mode = ToolExecutionMode(tool_execution_mode_str) + except (ValueError, KeyError): + tool_execution_mode = ToolExecutionMode.PARALLEL + + orchestration_mode: Optional[OrchestrationMode] = None + if orchestration_mode_str := getenv("ORCHESTRATION_MODE"): + try: + orchestration_mode = OrchestrationMode(orchestration_mode_str) + except (ValueError, KeyError): + orchestration_mode = None + + app_health_check_enabled: Optional[bool] = None + if getenv("ENABLE_APP_HEALTH_CHECK") is not None: + app_health_check_enabled = ( + getenv("ENABLE_APP_HEALTH_CHECK", "false").lower() == "true" + ) + + app_ready_check_enabled: Optional[bool] = None + if getenv("ENABLE_APP_READY_CHECK") is not None: + app_ready_check_enabled = ( + getenv("ENABLE_APP_READY_CHECK", "false").lower() == "true" + ) + + return cls( + max_iterations=max_iterations, + tool_choice=tool_choice, + tool_execution_mode=tool_execution_mode, + orchestration_mode=orchestration_mode, + app_health_check_enabled=app_health_check_enabled, + app_ready_check_enabled=app_ready_check_enabled, + ) + @dataclass class WorkflowRetryPolicy: diff --git a/tests/agents/durableagent/test_tool_execution_mode.py b/tests/agents/durableagent/test_tool_execution_mode.py index 67db2f4bb..3d8559ae8 100644 --- a/tests/agents/durableagent/test_tool_execution_mode.py +++ b/tests/agents/durableagent/test_tool_execution_mode.py @@ -13,6 +13,7 @@ AgentPubSubConfig, AgentRegistryConfig, AgentStateConfig, + ToolChoice, ToolExecutionMode, ) from dapr_agents.agents.durable import DurableAgent @@ -119,7 +120,7 @@ def test_set_parallel_explicit(self): def test_other_defaults_unchanged(self): config = AgentExecutionConfig(tool_execution_mode=ToolExecutionMode.SEQUENTIAL) assert config.max_iterations == 10 - assert config.tool_choice == "auto" + assert config.tool_choice == ToolChoice.AUTO assert config.orchestration_mode is None From 43c42b228f3da64d78f2cc39a74a7e1ee5e05cfb Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 20:40:50 -0700 Subject: [PATCH 19/44] style: formatting fix Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 0628c6c67..d1764c35b 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -1727,7 +1727,7 @@ def _coerce_datetime(value: Optional[Any]) -> datetime: except ValueError: pass return datetime.now(timezone.utc) - + def _resolve_execution_config(self) -> AgentExecutionConfig: """ Resolve the execution configuration for the agent in the following order: @@ -1828,7 +1828,8 @@ def _merge_execution_configs( merged_config = AgentExecutionConfig( max_iterations=override.max_iterations or base.max_iterations, tool_choice=override.tool_choice or base.tool_choice, - tool_execution_mode=override.tool_execution_mode or base.tool_execution_mode, + tool_execution_mode=override.tool_execution_mode + or base.tool_execution_mode, orchestration_mode=orchestration_mode, app_health_check_enabled=app_health_check_enabled, app_ready_check_enabled=app_ready_check_enabled, From 3a6668f4c54f856a32e2c8876fd500ce17b7e5ac Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 20:42:12 -0700 Subject: [PATCH 20/44] feat: use agent execution config resolution for health endpoints Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 112 +++++++++++++++++--------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index 398d3dc10..ca07e30fe 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -507,8 +507,10 @@ def serve( expose_entry: bool = True, entry_path: str = "/agent/run", status_path: str = "/agent/instances/{instance_id}", - health_check_path: str = "/livez", - readiness_check_path: str = "/readyz", + enable_app_health_check: bool = False, + app_health_check_path: str = "/livez", + enable_app_ready_check: bool = False, + app_ready_check_path: str = "/readyz", workflow_component: str = "dapr", fetch_status_payloads: bool = True, delivery_mode: Literal["sync", "async"] = "sync", @@ -526,8 +528,18 @@ def serve( expose_entry: Mount a default POST endpoint that schedules the workflow entry. entry_path: HTTP path for the default POST endpoint. status_path: HTTP path for the status endpoint (must include `{instance_id}`). - health_check_path: HTTP path for the endpoint for Kubernetes liveness probes. - readiness_check_path: HTTP path for the endpoint for Dapr health/Kubernetes readiness probes. + enable_app_health_check: Whether to mount a health endpoint for Kubernetes liveness probes. + Resolved in the following order (highest to lowest): + 1. The `app_health_check_enabled` attribute of the agent execution config + 2. The `ENABLE_APP_HEALTH_CHECK` environment variable + 3. The `enable_app_health_check` argument + app_health_check_path: HTTP path for the health endpoint for Kubernetes liveness probes. + enable_app_ready_check: Whether to mount a readiness endpoint for Dapr health/Kubernetes readiness probes. + Resolved in the following order (highest to lowest): + 1. The `app_ready_check_enabled` attribute of the agent execution config + 2. The `ENABLE_APP_READY_CHECK` environment variable + 3. The `enable_app_ready_check` argument + app_ready_check_path: HTTP path for the readiness endpoint for Dapr health/Kubernetes readiness probes. workflow_component: Workflow component name used in the returned status URL. fetch_status_payloads: Include payloads when fetching workflow status. delivery_mode: Delivery mode forwarded to `subscribe`. @@ -541,8 +553,8 @@ def serve( entry_path = self._normalize_path(entry_path) status_path = self._normalize_path(status_path) - health_check_path = self._normalize_path(health_check_path) - readiness_check_path = self._normalize_path(readiness_check_path) + app_health_check_path = self._normalize_path(app_health_check_path) + app_ready_check_path = self._normalize_path(app_ready_check_path) try: agent.start() @@ -572,15 +584,28 @@ def serve( fetch_status_payloads=fetch_status_payloads, ) - self._mount_health_endpoints( - fastapi_app=fastapi_app, - agent=agent, - health_check_path=health_check_path, - readiness_check_path=readiness_check_path, - expose_entry=expose_entry, - entry_path=entry_path, - status_path=status_path, + app_health_check_enabled = ( + agent.execution.app_health_check_enabled or enable_app_health_check + ) + if app_health_check_enabled: + self._mount_health_endpoint( + fastapi_app=fastapi_app, + agent=agent, + app_health_check_path=app_health_check_path, + ) + + app_ready_check_enabled = ( + agent.execution.app_ready_check_enabled or enable_app_ready_check ) + if app_ready_check_enabled: + self._mount_ready_endpoint( + fastapi_app=fastapi_app, + agent=agent, + app_ready_check_path=app_ready_check_path, + expose_entry=expose_entry, + entry_path=entry_path, + status_path=status_path, + ) auto_run = app is None if auto_run: @@ -617,42 +642,49 @@ def _normalize_path(path: str) -> str: path = f"/{path}" return path - def _mount_health_endpoints( + def _mount_health_endpoint( self, *, fastapi_app: FastAPI, agent: DurableAgent, - health_check_path: str, - readiness_check_path: str, - expose_entry: bool, - entry_path: str, - status_path: str, - ) -> None: - if health_check_path not in self._default_http_paths: - self._default_http_paths.add(health_check_path) + app_health_check_path: str, + ): + if app_health_check_path in self._default_http_paths: + logger.debug(f"Health endpoint already mounted at {app_health_check_path}") + return - async def _get_health_status() -> dict[str, str]: - return {"status": "ok"} + self._default_http_paths.add(app_health_check_path) - fastapi_app.add_api_route( - health_check_path, - _get_health_status, - methods=["GET"], - summary="Get agent health", - tags=["health"], - ) + async def _get_health_status() -> dict[str, str]: + return {"status": "ok"} - logger.info(f"Mounted health endpoint at {health_check_path}") - else: - logger.debug(f"Health endpoint already mounted at {health_check_path}") + fastapi_app.add_api_route( + app_health_check_path, + _get_health_status, + methods=["GET"], + summary="Get agent health", + tags=["health"], + ) + + logger.info(f"Mounted health endpoint at {app_health_check_path}") - if readiness_check_path in self._default_http_paths: + def _mount_ready_endpoint( + self, + *, + fastapi_app: FastAPI, + agent: DurableAgent, + app_ready_check_path: str, + expose_entry: bool, + entry_path: str, + status_path: str, + ): + if app_ready_check_path in self._default_http_paths: logger.debug( - f"Readiness endpoint already mounted at {readiness_check_path}" + f"Readiness endpoint already mounted at {app_ready_check_path}" ) return - self._default_http_paths.add(readiness_check_path) + self._default_http_paths.add(app_ready_check_path) def _get_agent_readiness( fastapi_app: FastAPI, @@ -704,14 +736,14 @@ async def _get_ready_status() -> dict[str, str]: ) fastapi_app.add_api_route( - readiness_check_path, + app_ready_check_path, _get_ready_status, methods=["GET"], summary="Get agent readiness", tags=["health"], ) - logger.info(f"Mounted readiness endpoint at {readiness_check_path}") + logger.info(f"Mounted readiness endpoint at {app_ready_check_path}") def _mount_service_routes( self, From 3d171158eedfc59127d5953d3127dfb17b147713 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 21:05:36 -0700 Subject: [PATCH 21/44] refactor: remove redundant falsy handling for orchestration mode Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index d1764c35b..f023659c7 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -1809,11 +1809,6 @@ def _merge_execution_configs( Merged AgentExecutionConfig instance. """ - orchestration_mode = ( - override.orchestration_mode - if override.orchestration_mode is not None - else base.orchestration_mode - ) app_health_check_enabled = ( override.app_health_check_enabled if override.app_health_check_enabled is not None @@ -1830,7 +1825,7 @@ def _merge_execution_configs( tool_choice=override.tool_choice or base.tool_choice, tool_execution_mode=override.tool_execution_mode or base.tool_execution_mode, - orchestration_mode=orchestration_mode, + orchestration_mode=override.orchestration_mode or base.orchestration_mode, app_health_check_enabled=app_health_check_enabled, app_ready_check_enabled=app_ready_check_enabled, ) From f29d59687dfe9b9c7a6ad3d1db2239c36305be3b Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 21:26:41 -0700 Subject: [PATCH 22/44] fix: disallow overriding of health and ready endpoint paths Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index ca07e30fe..e36eb01b3 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -508,9 +508,7 @@ def serve( entry_path: str = "/agent/run", status_path: str = "/agent/instances/{instance_id}", enable_app_health_check: bool = False, - app_health_check_path: str = "/livez", enable_app_ready_check: bool = False, - app_ready_check_path: str = "/readyz", workflow_component: str = "dapr", fetch_status_payloads: bool = True, delivery_mode: Literal["sync", "async"] = "sync", @@ -533,13 +531,11 @@ def serve( 1. The `app_health_check_enabled` attribute of the agent execution config 2. The `ENABLE_APP_HEALTH_CHECK` environment variable 3. The `enable_app_health_check` argument - app_health_check_path: HTTP path for the health endpoint for Kubernetes liveness probes. enable_app_ready_check: Whether to mount a readiness endpoint for Dapr health/Kubernetes readiness probes. Resolved in the following order (highest to lowest): 1. The `app_ready_check_enabled` attribute of the agent execution config 2. The `ENABLE_APP_READY_CHECK` environment variable 3. The `enable_app_ready_check` argument - app_ready_check_path: HTTP path for the readiness endpoint for Dapr health/Kubernetes readiness probes. workflow_component: Workflow component name used in the returned status URL. fetch_status_payloads: Include payloads when fetching workflow status. delivery_mode: Delivery mode forwarded to `subscribe`. @@ -553,8 +549,6 @@ def serve( entry_path = self._normalize_path(entry_path) status_path = self._normalize_path(status_path) - app_health_check_path = self._normalize_path(app_health_check_path) - app_ready_check_path = self._normalize_path(app_ready_check_path) try: agent.start() @@ -591,7 +585,6 @@ def serve( self._mount_health_endpoint( fastapi_app=fastapi_app, agent=agent, - app_health_check_path=app_health_check_path, ) app_ready_check_enabled = ( @@ -601,7 +594,6 @@ def serve( self._mount_ready_endpoint( fastapi_app=fastapi_app, agent=agent, - app_ready_check_path=app_ready_check_path, expose_entry=expose_entry, entry_path=entry_path, status_path=status_path, @@ -647,8 +639,8 @@ def _mount_health_endpoint( *, fastapi_app: FastAPI, agent: DurableAgent, - app_health_check_path: str, ): + app_health_check_path = "/livez" if app_health_check_path in self._default_http_paths: logger.debug(f"Health endpoint already mounted at {app_health_check_path}") return @@ -673,11 +665,11 @@ def _mount_ready_endpoint( *, fastapi_app: FastAPI, agent: DurableAgent, - app_ready_check_path: str, expose_entry: bool, entry_path: str, status_path: str, ): + app_ready_check_path = "/readyz" if app_ready_check_path in self._default_http_paths: logger.debug( f"Readiness endpoint already mounted at {app_ready_check_path}" From 5772f6a0e0dd2ef987761de2fa6872358481ad95 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Thu, 7 May 2026 22:10:36 -0700 Subject: [PATCH 23/44] chore: removed unused import Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index f023659c7..f8329cdfe 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -16,7 +16,6 @@ import asyncio import json import logging -from os import getenv import re from importlib.metadata import version from datetime import datetime, timezone From cc61f81d884d4241d74241f3a5596fef7b50c360 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 8 May 2026 10:29:17 -0700 Subject: [PATCH 24/44] fix: fix max iterations env var parsing Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 4 ++-- dapr_agents/agents/configs.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index f8329cdfe..7e5a4e04c 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -1764,9 +1764,9 @@ def _load_execution_from_statestore(self) -> AgentExecutionConfig: try: max_iterations: Optional[int] = None - if max_iter_str := self._runtime_conf.get("MAX_ITERATIONS"): + if max_iterations_str := self._runtime_conf.get("MAX_ITERATIONS"): try: - max_iterations = max(1, int(max_iter_str)) + max_iterations = max(1, int(max_iterations_str)) except ValueError: max_iterations = 10 diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 06b6c1166..ac4d1ef8b 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -440,9 +440,9 @@ def from_env(cls) -> "AgentExecutionConfig": """Create execution config from environment variables.""" max_iterations: Optional[int] = None - if max_iterations := getenv("MAX_ITERATIONS"): + if max_iterations_str := getenv("MAX_ITERATIONS"): try: - max_iterations = max(1, int(max_iterations)) + max_iterations = max(1, int(max_iterations_str)) except ValueError: max_iterations = 10 From a2219d5d8b789dd225f417f4d01390d5f251cfb6 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 8 May 2026 11:35:51 -0700 Subject: [PATCH 25/44] refactor: add constants for agent execution defaults Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 10 ++++++---- dapr_agents/agents/configs.py | 18 ++++++++++++------ dapr_agents/agents/durable.py | 5 +++-- .../agents/durableagent/test_durable_agent.py | 3 ++- .../durableagent/test_tool_execution_mode.py | 9 ++++++--- tests/agents/test_base.py | 9 ++++++--- 6 files changed, 35 insertions(+), 19 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 7e5a4e04c..641346abb 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -33,6 +33,8 @@ from dapr_agents.agents.components import DaprInfra from dapr_agents.agents.configs import ( + AGENT_DEFAULT_MAX_ITERATIONS, + AGENT_DEFAULT_TOOL_CHOICE, AgentLoggingExporter, AgentMemoryConfig, AgentMetadata, @@ -548,7 +550,7 @@ def __init__( try: self.execution.max_iterations = max(1, int(self.execution.max_iterations)) except Exception: - self.execution.max_iterations = 10 + self.execution.max_iterations = AGENT_DEFAULT_MAX_ITERATIONS if not self.tools: if self.execution.tool_choice is not None: logger.debug( @@ -556,7 +558,7 @@ def __init__( ) self.execution.tool_choice = None elif self.execution.tool_choice is None: - self.execution.tool_choice = ToolChoice.AUTO + self.execution.tool_choice = AGENT_DEFAULT_TOOL_CHOICE # ----------------------------- # Agent metadata & registry registration @@ -1768,14 +1770,14 @@ def _load_execution_from_statestore(self) -> AgentExecutionConfig: try: max_iterations = max(1, int(max_iterations_str)) except ValueError: - max_iterations = 10 + max_iterations = AGENT_DEFAULT_MAX_ITERATIONS tool_choice: Optional[ToolChoice] = None if tool_choice_str := self._runtime_conf.get("TOOL_CHOICE"): try: tool_choice = ToolChoice(tool_choice_str) except (ValueError, KeyError): - tool_choice = ToolChoice.AUTO + tool_choice = AGENT_DEFAULT_TOOL_CHOICE tool_execution_mode: Optional[ToolExecutionMode] = None orchestration_mode: Optional[OrchestrationMode] = None diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index ac4d1ef8b..b9b7e8751 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -378,6 +378,7 @@ class ToolChoice(StrEnum): as it allows the agent to leverage tools when beneficial while avoiding unnecessary calls. """ + # TODO: This enum does not supports dicts, which some LLM providers allow when forcing a specific tool AUTO = "auto" @@ -411,6 +412,11 @@ class OrchestrationMode(StrEnum): ROUNDROBIN = "roundrobin" +AGENT_DEFAULT_MAX_ITERATIONS = 10 +AGENT_DEFAULT_TOOL_CHOICE = ToolChoice.AUTO +AGENT_DEFAULT_TOOL_EXECUTION_MODE = ToolExecutionMode.PARALLEL + + @dataclass class AgentExecutionConfig: """ @@ -427,9 +433,9 @@ class AgentExecutionConfig: # TODO: add a forceFinalAnswer field in case max_iterations is near/reached. Or do we have a conclusion baked in by default? Do we want this to derive a conclusion by default? # TODO: add stop_at_tokens - max_iterations: int = 10 - tool_choice: Optional[ToolChoice] = ToolChoice.AUTO - tool_execution_mode: ToolExecutionMode = ToolExecutionMode.PARALLEL + max_iterations: int = AGENT_DEFAULT_MAX_ITERATIONS + tool_choice: Optional[ToolChoice] = AGENT_DEFAULT_TOOL_CHOICE + tool_execution_mode: ToolExecutionMode = AGENT_DEFAULT_TOOL_EXECUTION_MODE orchestration_mode: Optional[OrchestrationMode] = None app_health_check_enabled: Optional[bool] = None @@ -444,21 +450,21 @@ def from_env(cls) -> "AgentExecutionConfig": try: max_iterations = max(1, int(max_iterations_str)) except ValueError: - max_iterations = 10 + max_iterations = AGENT_DEFAULT_MAX_ITERATIONS tool_choice: Optional[ToolChoice] = None if tool_choice_str := getenv("TOOL_CHOICE"): try: tool_choice = ToolChoice(tool_choice_str) except (ValueError, KeyError): - tool_choice = ToolChoice.AUTO + tool_choice = AGENT_DEFAULT_TOOL_CHOICE tool_execution_mode: Optional[ToolExecutionMode] = None if tool_execution_mode_str := getenv("TOOL_EXECUTION_MODE"): try: tool_execution_mode = ToolExecutionMode(tool_execution_mode_str) except (ValueError, KeyError): - tool_execution_mode = ToolExecutionMode.PARALLEL + tool_execution_mode = AGENT_DEFAULT_TOOL_EXECUTION_MODE orchestration_mode: Optional[OrchestrationMode] = None if orchestration_mode_str := getenv("ORCHESTRATION_MODE"): diff --git a/dapr_agents/agents/durable.py b/dapr_agents/agents/durable.py index a03011f70..c60928a4c 100644 --- a/dapr_agents/agents/durable.py +++ b/dapr_agents/agents/durable.py @@ -51,6 +51,7 @@ from dapr_agents.agents.base import AgentBase from dapr_agents.agents.configs import ( + AGENT_DEFAULT_TOOL_CHOICE, OrchestrationMode, ToolExecutionMode, AgentExecutionConfig, @@ -302,7 +303,7 @@ def __init__( # Re-enable tool_choice if AgentBase cleared it due to an empty tools list # but we've now registered agent-as-tool entries into the executor. if self._agents_as_tools and self.execution.tool_choice is None: - self.execution.tool_choice = "auto" + self.execution.tool_choice = AGENT_DEFAULT_TOOL_CHOICE grpc_options = getattr(self, "workflow_grpc_options", None) apply_grpc_options(grpc_options) @@ -2146,7 +2147,7 @@ def load_tools(self, ctx: wf.WorkflowActivityContext) -> List[str]: registered, ) if self.execution.tool_choice is None: - self.execution.tool_choice = "auto" + self.execution.tool_choice = AGENT_DEFAULT_TOOL_CHOICE return registered diff --git a/tests/agents/durableagent/test_durable_agent.py b/tests/agents/durableagent/test_durable_agent.py index 154eed7a6..ebabe88f7 100644 --- a/tests/agents/durableagent/test_durable_agent.py +++ b/tests/agents/durableagent/test_durable_agent.py @@ -25,6 +25,7 @@ from dapr_agents.agents.durable import DurableAgent from dapr_agents.agents.configs import ( + AGENT_DEFAULT_MAX_ITERATIONS, AgentPubSubConfig, AgentStateConfig, AgentRegistryConfig, @@ -244,7 +245,7 @@ def test_durable_agent_initialization(self, mock_llm): assert agent.prompting_helper.role == "Test Durable Assistant" assert agent.prompting_helper.goal == "Help with testing" assert agent.prompting_helper.instructions == ["Be helpful"] - assert agent.execution.max_iterations == 10 # default value + assert agent.execution.max_iterations == AGENT_DEFAULT_MAX_ITERATIONS assert agent.tool_history == [] assert agent.pubsub.pubsub_name == "testpubsub" assert agent.pubsub.agent_topic == "TestDurableAgent" diff --git a/tests/agents/durableagent/test_tool_execution_mode.py b/tests/agents/durableagent/test_tool_execution_mode.py index 3d8559ae8..b501c4f12 100644 --- a/tests/agents/durableagent/test_tool_execution_mode.py +++ b/tests/agents/durableagent/test_tool_execution_mode.py @@ -8,12 +8,13 @@ import pytest from dapr_agents.agents.configs import ( + AGENT_DEFAULT_MAX_ITERATIONS, + AGENT_DEFAULT_TOOL_CHOICE, AgentExecutionConfig, AgentMemoryConfig, AgentPubSubConfig, AgentRegistryConfig, AgentStateConfig, - ToolChoice, ToolExecutionMode, ) from dapr_agents.agents.durable import DurableAgent @@ -119,9 +120,11 @@ def test_set_parallel_explicit(self): def test_other_defaults_unchanged(self): config = AgentExecutionConfig(tool_execution_mode=ToolExecutionMode.SEQUENTIAL) - assert config.max_iterations == 10 - assert config.tool_choice == ToolChoice.AUTO + assert config.max_iterations == AGENT_DEFAULT_MAX_ITERATIONS + assert config.tool_choice == AGENT_DEFAULT_TOOL_CHOICE assert config.orchestration_mode is None + assert config.app_health_check_enabled is None + assert config.app_ready_check_enabled is None # --------------------------------------------------------------------------- diff --git a/tests/agents/test_base.py b/tests/agents/test_base.py index 64bed5c61..238fc4b25 100644 --- a/tests/agents/test_base.py +++ b/tests/agents/test_base.py @@ -15,7 +15,10 @@ from unittest.mock import Mock, patch from dapr_agents.agents.base import AgentBase -from dapr_agents.agents.configs import AgentMemoryConfig +from dapr_agents.agents.configs import ( + AGENT_DEFAULT_MAX_ITERATIONS, + AgentMemoryConfig, +) from dapr_agents.memory import ConversationListMemory from dapr_agents.llm import OpenAIChatClient from dapr_agents.prompt import ChatPromptTemplate @@ -94,7 +97,7 @@ def test_agent_creation_with_all_fields(self, basic_agent): "Test instruction 1", "Test instruction 2", ] - assert basic_agent.execution.max_iterations == 10 + assert basic_agent.execution.max_iterations == AGENT_DEFAULT_MAX_ITERATIONS assert basic_agent.prompting_helper.template_format == "jinja2" assert isinstance(basic_agent.memory, ConversationListMemory) assert basic_agent.llm is not None @@ -399,7 +402,7 @@ def test_template_format_validation(self, mock_llm_client): def test_max_iterations_default(self, minimal_agent): """Test default max iterations.""" - assert minimal_agent.execution.max_iterations == 10 + assert minimal_agent.execution.max_iterations == AGENT_DEFAULT_MAX_ITERATIONS def test_max_iterations_custom(self, mock_llm_client): """Test custom max iterations.""" From d5a26f82000aad574841ad7fe7a0e0c3356ee118 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 15 May 2026 17:23:41 -0700 Subject: [PATCH 26/44] fix: merge approval config Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 641346abb..2373ee948 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -1827,6 +1827,7 @@ def _merge_execution_configs( tool_execution_mode=override.tool_execution_mode or base.tool_execution_mode, orchestration_mode=override.orchestration_mode or base.orchestration_mode, + approval=override.approval if override.approval else base.approval, app_health_check_enabled=app_health_check_enabled, app_ready_check_enabled=app_ready_check_enabled, ) From e86f2be8b1a946ae2252ee2aa7760718138bfc92 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 15 May 2026 19:33:20 -0700 Subject: [PATCH 27/44] chore: change log level to warning on already mounted health endpoints Signed-off-by: Jeffrey Zhang --- dapr_agents/workflow/runners/agent.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dapr_agents/workflow/runners/agent.py b/dapr_agents/workflow/runners/agent.py index 8311c768b..563c01ebe 100644 --- a/dapr_agents/workflow/runners/agent.py +++ b/dapr_agents/workflow/runners/agent.py @@ -604,6 +604,7 @@ def serve( entry_path=entry_path, status_path=status_path, ) + self._mount_hitl_routes(fastapi_app=fastapi_app, agent=agent) auto_run = app is None @@ -649,7 +650,9 @@ def _mount_health_endpoint( ): app_health_check_path = "/livez" if app_health_check_path in self._default_http_paths: - logger.debug(f"Health endpoint already mounted at {app_health_check_path}") + logger.warning( + f"Health endpoint already mounted at {app_health_check_path}" + ) return self._default_http_paths.add(app_health_check_path) @@ -678,7 +681,7 @@ def _mount_ready_endpoint( ): app_ready_check_path = "/readyz" if app_ready_check_path in self._default_http_paths: - logger.debug( + logger.warning( f"Readiness endpoint already mounted at {app_ready_check_path}" ) return From 267e26e6719b29a009391ec6c53af642eb5197e9 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 15 May 2026 20:06:25 -0700 Subject: [PATCH 28/44] fix: change agent execution config priority Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 29 ++++++++++++++++------------- dapr_agents/agents/configs.py | 1 + 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 2373ee948..3b57519a3 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -35,6 +35,7 @@ from dapr_agents.agents.configs import ( AGENT_DEFAULT_MAX_ITERATIONS, AGENT_DEFAULT_TOOL_CHOICE, + AgentApprovalConfig, AgentLoggingExporter, AgentMemoryConfig, AgentMetadata, @@ -1732,9 +1733,9 @@ def _coerce_datetime(value: Optional[Any]) -> datetime: def _resolve_execution_config(self) -> AgentExecutionConfig: """ Resolve the execution configuration for the agent in the following order: - 1. Passed through instantiation (highest priority) - 2. Environment variables - 3. Default statestore runtime config (lowest priority) + 1. Statestore runtime config (highest priority) + 2. Passed through instantiation + 3. Environment variables (lowest priority) Args: agent_execution: Optional execution config provided during initialization. @@ -1742,18 +1743,18 @@ def _resolve_execution_config(self) -> AgentExecutionConfig: Resolved AgentExecutionConfig instance. """ - config = self._load_execution_from_statestore() - logger.debug(f"Statestore execution config: {config}") - - env_config = AgentExecutionConfig.from_env() - logger.debug(f"Env execution config: {env_config}") - - config = self._merge_execution_configs(config, env_config) - logger.debug(f"Merged execution config: {config}") + config = AgentExecutionConfig.from_env() + logger.debug(f"Env execution config: {config}") if self.execution: config = self._merge_execution_configs(config, self.execution) - logger.debug(f"Final execution config with override: {config}") + logger.debug(f"Merged execution config: {config}") + + statestore_config = self._load_execution_from_statestore() + logger.debug(f"Statestore execution config: {statestore_config}") + + config = self._merge_execution_configs(config, statestore_config) + logger.debug(f"Final execution config with statestore override: {config}") return config def _load_execution_from_statestore(self) -> AgentExecutionConfig: @@ -1781,6 +1782,7 @@ def _load_execution_from_statestore(self) -> AgentExecutionConfig: tool_execution_mode: Optional[ToolExecutionMode] = None orchestration_mode: Optional[OrchestrationMode] = None + approval: Optional[AgentApprovalConfig] = None app_health_check_enabled: Optional[bool] = None app_ready_check_enabled: Optional[bool] = None @@ -1789,6 +1791,7 @@ def _load_execution_from_statestore(self) -> AgentExecutionConfig: tool_choice=tool_choice, tool_execution_mode=tool_execution_mode, orchestration_mode=orchestration_mode, + approval=approval, app_health_check_enabled=app_health_check_enabled, app_ready_check_enabled=app_ready_check_enabled, ) @@ -1827,7 +1830,7 @@ def _merge_execution_configs( tool_execution_mode=override.tool_execution_mode or base.tool_execution_mode, orchestration_mode=override.orchestration_mode or base.orchestration_mode, - approval=override.approval if override.approval else base.approval, + approval=override.approval or base.approval, app_health_check_enabled=app_health_check_enabled, app_ready_check_enabled=app_ready_check_enabled, ) diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 7f8aa2c82..7cf070d37 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -460,6 +460,7 @@ class AgentExecutionConfig: tool_execution_mode: Tool execution mode for the agent. orchestration_mode: Orchestration strategy for the agent. app_health_check_enabled: Enable/disable Kubernetes liveness probes. + approval: Human-in-the-loop configuration for the agent. app_ready_check_enabled: Enable/disable Dapr health/Kubernetes readiness probes. """ From 3d5fbd10f6ac5bd09a86a993a0c71b62bbe21015 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 15 May 2026 21:47:28 -0700 Subject: [PATCH 29/44] refactor: move agent execution enums to types to avoid circular imports Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 4 +- dapr_agents/agents/configs.py | 62 ++++--------------- dapr_agents/agents/constants.py | 33 ++++++++++ dapr_agents/agents/durable.py | 2 +- dapr_agents/types/__init__.py | 12 +++- dapr_agents/types/agent.py | 49 ++++++++++++++- mypy.ini | 2 +- .../agents/durableagent/test_durable_agent.py | 2 +- .../durableagent/test_tool_execution_mode.py | 6 +- tests/agents/test_base.py | 2 +- 10 files changed, 112 insertions(+), 62 deletions(-) create mode 100644 dapr_agents/agents/constants.py diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 3b57519a3..61b86dd76 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -32,9 +32,11 @@ ) from dapr_agents.agents.components import DaprInfra -from dapr_agents.agents.configs import ( +from dapr_agents.agents.constants import ( AGENT_DEFAULT_MAX_ITERATIONS, AGENT_DEFAULT_TOOL_CHOICE, +) +from dapr_agents.agents.configs import ( AgentApprovalConfig, AgentLoggingExporter, AgentMemoryConfig, diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 7cf070d37..5dd70c95f 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -32,6 +32,12 @@ from pydantic import BaseModel, Field +from dapr_agents.types.agent import ToolChoice, ToolExecutionMode, OrchestrationMode +from dapr_agents.agents.constants import ( + AGENT_DEFAULT_MAX_ITERATIONS, + AGENT_DEFAULT_TOOL_CHOICE, + AGENT_DEFAULT_TOOL_EXECUTION_MODE, +) from dapr_agents.agents.schemas import ( AgentWorkflowEntry, AgentWorkflowMessage, @@ -248,11 +254,13 @@ def validate_max_iterations(v: int) -> int: def validate_tool_choice(v: str) -> str: """Warn if tool_choice is non-standard, but allow it.""" - allowed = {"auto", "none", "required"} - if v.lower() not in allowed: + try: + ToolChoice(v.lower()) + except (ValueError, KeyError): _config_logger.warning( - "tool_choice '%s' not in standard set %s; allowing anyway.", v, allowed + f"tool_choice {v} not in standard set {set([tc.value for tc in ToolChoice])}; allowing anyway." ) + return v @@ -369,54 +377,6 @@ class AgentProfileConfig: module_overrides: Dict[str, PromptSection] = field(default_factory=dict) -class ToolChoice(StrEnum): - """ - Enumeration of supported tool choice strategies for durable agents. - - AUTO: The agent decides when to use tools based on the prompt and context. - This is the default and recommended setting for most use cases, - as it allows the agent to leverage tools when beneficial while avoiding unnecessary calls. - """ - - # TODO: This enum does not supports dicts, which some LLM providers allow when forcing a specific tool - AUTO = "auto" - - -class ToolExecutionMode(StrEnum): - """ - Enumeration of supported tool execution modes for durable agents. - - PARALLEL: All tool calls returned by the LLM in a single turn are executed - concurrently via ``wf.when_all``. This is the default behaviour and - provides the best latency when tools are independent. - SEQUENTIAL: Tool calls are executed one after another in the order they - were returned by the LLM. Use this when tools have side-effects that - depend on the results of earlier calls in the same turn. - """ - - PARALLEL = "parallel" - SEQUENTIAL = "sequential" - - -class OrchestrationMode(StrEnum): - """ - Enumeration of supported orchestration strategies for durable agents. - - AGENT: Orchestration is driven by an LLM-generated plan that determines the next steps and agent interactions. - RANDOM: Orchestration randomly selects agents or actions at each decision point, without a predetermined plan. - ROUNDROBIN: Orchestration cycles through available agents or actions in a fixed order, ensuring equal opportunity for each participant. - """ - - AGENT = "agent" - RANDOM = "random" - ROUNDROBIN = "roundrobin" - - -AGENT_DEFAULT_MAX_ITERATIONS = 10 -AGENT_DEFAULT_TOOL_CHOICE = ToolChoice.AUTO -AGENT_DEFAULT_TOOL_EXECUTION_MODE = ToolExecutionMode.PARALLEL - - @dataclass class AgentApprovalConfig: """ diff --git a/dapr_agents/agents/constants.py b/dapr_agents/agents/constants.py new file mode 100644 index 000000000..45e8164e4 --- /dev/null +++ b/dapr_agents/agents/constants.py @@ -0,0 +1,33 @@ +# +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from dapr_agents.types.agent import ToolChoice, ToolExecutionMode + +# ============================================================================ +# Agent Execution Defaults +# ============================================================================ + +AGENT_DEFAULT_MAX_ITERATIONS = 10 +AGENT_DEFAULT_TOOL_CHOICE = ToolChoice.AUTO +AGENT_DEFAULT_TOOL_EXECUTION_MODE = ToolExecutionMode.PARALLEL + + +# ============================================================================ +# Public API Exports +# ============================================================================ + +__all__ = [ + "AGENT_DEFAULT_MAX_ITERATIONS", + "AGENT_DEFAULT_TOOL_CHOICE", + "AGENT_DEFAULT_TOOL_EXECUTION_MODE", +] diff --git a/dapr_agents/agents/durable.py b/dapr_agents/agents/durable.py index 40fe21dec..d680ac2c7 100644 --- a/dapr_agents/agents/durable.py +++ b/dapr_agents/agents/durable.py @@ -50,8 +50,8 @@ ) from dapr_agents.agents.base import AgentBase +from dapr_agents.agents.constants import AGENT_DEFAULT_TOOL_CHOICE from dapr_agents.agents.configs import ( - AGENT_DEFAULT_TOOL_CHOICE, OrchestrationMode, ToolExecutionMode, AgentApprovalConfig, diff --git a/dapr_agents/types/__init__.py b/dapr_agents/types/__init__.py index 7716fdb3f..688daf4ca 100644 --- a/dapr_agents/types/__init__.py +++ b/dapr_agents/types/__init__.py @@ -11,7 +11,14 @@ # limitations under the License. # -from .agent import AgentStatus, AgentTaskEntry, AgentTaskStatus +from .agent import ( + AgentStatus, + AgentTaskEntry, + AgentTaskStatus, + ToolChoice, + ToolExecutionMode, + OrchestrationMode, +) from .workflow import DaprWorkflowStatus from .exceptions import ( AgentError, @@ -52,6 +59,9 @@ "AgentStatus", "AgentTaskEntry", "AgentTaskStatus", + "ToolChoice", + "ToolExecutionMode", + "OrchestrationMode", "DaprWorkflowStatus", "AgentError", "AgentToolExecutorError", diff --git a/dapr_agents/types/agent.py b/dapr_agents/types/agent.py index 9c9d9b678..6c6e9b0ec 100644 --- a/dapr_agents/types/agent.py +++ b/dapr_agents/types/agent.py @@ -14,11 +14,54 @@ from pydantic import BaseModel, Field from typing import Optional from datetime import datetime -from enum import Enum +from enum import StrEnum import uuid -class AgentStatus(str, Enum): +class ToolChoice(StrEnum): + """ + Enumeration of supported tool choice strategies for durable agents. + + AUTO: The agent decides when to use tools based on the prompt and context. + This is the default and recommended choice for most use cases, + as it allows the agent to leverage tools when beneficial while avoiding unnecessary calls. + """ + + # TODO: This enum does not support dicts, which some LLM providers allow when forcing a specific tool + AUTO = "auto" + + +class ToolExecutionMode(StrEnum): + """ + Enumeration of supported tool execution modes for durable agents. + + PARALLEL: All tool calls returned by the LLM in a single turn are executed + concurrently via ``wf.when_all``. This is the default behaviour and + provides the best latency when tools are independent. + SEQUENTIAL: Tool calls are executed one after another in the order they + were returned by the LLM. Use this when tools have side-effects that + depend on the results of earlier calls in the same turn. + """ + + PARALLEL = "parallel" + SEQUENTIAL = "sequential" + + +class OrchestrationMode(StrEnum): + """ + Enumeration of supported orchestration strategies for durable agents. + + AGENT: Orchestration is driven by an LLM-generated plan that determines the next steps and agent interactions. + RANDOM: Orchestration randomly selects agents or actions at each decision point, without a predetermined plan. + ROUNDROBIN: Orchestration cycles through available agents or actions in a fixed order, ensuring equal opportunity for each participant. + """ + + AGENT = "agent" + RANDOM = "random" + ROUNDROBIN = "roundrobin" + + +class AgentStatus(StrEnum): """Enumeration of possible agent statuses for standardized tracking.""" ACTIVE = "active" # The agent is actively working on tasks @@ -28,7 +71,7 @@ class AgentStatus(str, Enum): ERROR = "error" # The agent encountered an error and needs attention -class AgentTaskStatus(str, Enum): +class AgentTaskStatus(StrEnum): """Enumeration of possible task statuses for standardizing task tracking.""" IN_PROGRESS = "in-progress" # Task is currently in progress diff --git a/mypy.ini b/mypy.ini index 146cfb30b..e439f8bfd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -12,7 +12,7 @@ # [mypy] -python_version = 3.10 +python_version = 3.11 warn_unused_configs = True warn_redundant_casts = True show_error_codes = True diff --git a/tests/agents/durableagent/test_durable_agent.py b/tests/agents/durableagent/test_durable_agent.py index ebabe88f7..7453feb9a 100644 --- a/tests/agents/durableagent/test_durable_agent.py +++ b/tests/agents/durableagent/test_durable_agent.py @@ -24,8 +24,8 @@ from dapr.ext.workflow import DaprWorkflowContext from dapr_agents.agents.durable import DurableAgent +from dapr_agents.agents.constants import AGENT_DEFAULT_MAX_ITERATIONS from dapr_agents.agents.configs import ( - AGENT_DEFAULT_MAX_ITERATIONS, AgentPubSubConfig, AgentStateConfig, AgentRegistryConfig, diff --git a/tests/agents/durableagent/test_tool_execution_mode.py b/tests/agents/durableagent/test_tool_execution_mode.py index b501c4f12..d241e1800 100644 --- a/tests/agents/durableagent/test_tool_execution_mode.py +++ b/tests/agents/durableagent/test_tool_execution_mode.py @@ -7,21 +7,23 @@ import pytest -from dapr_agents.agents.configs import ( +from dapr_agents.agents.constants import ( AGENT_DEFAULT_MAX_ITERATIONS, AGENT_DEFAULT_TOOL_CHOICE, +) +from dapr_agents.agents.configs import ( AgentExecutionConfig, AgentMemoryConfig, AgentPubSubConfig, AgentRegistryConfig, AgentStateConfig, - ToolExecutionMode, ) from dapr_agents.agents.durable import DurableAgent from dapr_agents.llm import OpenAIChatClient from dapr_agents.memory import ConversationDaprStateMemory from dapr_agents.storage.daprstores.stateservice import StateStoreService from dapr_agents.tool.base import AgentTool +from dapr_agents.types.agent import ToolExecutionMode # --------------------------------------------------------------------------- diff --git a/tests/agents/test_base.py b/tests/agents/test_base.py index 238fc4b25..d903feb20 100644 --- a/tests/agents/test_base.py +++ b/tests/agents/test_base.py @@ -15,8 +15,8 @@ from unittest.mock import Mock, patch from dapr_agents.agents.base import AgentBase +from dapr_agents.agents.constants import AGENT_DEFAULT_MAX_ITERATIONS from dapr_agents.agents.configs import ( - AGENT_DEFAULT_MAX_ITERATIONS, AgentMemoryConfig, ) from dapr_agents.memory import ConversationListMemory From a2be6fb716aa16994a2f3f2aa5f12447c23c34de Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Fri, 15 May 2026 21:52:35 -0700 Subject: [PATCH 30/44] feat: add any, required, none tool choices Signed-off-by: Jeffrey Zhang --- dapr_agents/types/agent.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dapr_agents/types/agent.py b/dapr_agents/types/agent.py index 6c6e9b0ec..2424903c8 100644 --- a/dapr_agents/types/agent.py +++ b/dapr_agents/types/agent.py @@ -25,10 +25,18 @@ class ToolChoice(StrEnum): AUTO: The agent decides when to use tools based on the prompt and context. This is the default and recommended choice for most use cases, as it allows the agent to leverage tools when beneficial while avoiding unnecessary calls. + ANY: The agent must use at least one tool in each response. + Support for this choice is provider-dependent and is functionally equivalent to ``REQUIRED``. + REQUIRED: The agent must use at least one tool in each response. + Support for this choice is provider-dependent and is functionally equivalent to ``ANY``. + NONE: The agent ignores tools; tool calling is explicitly disabled. """ # TODO: This enum does not support dicts, which some LLM providers allow when forcing a specific tool AUTO = "auto" + ANY = "any" + REQUIRED = "required" + NONE = "none" class ToolExecutionMode(StrEnum): From 5a75f7d0b01e94f020339ae8dd995e50d51e37dc Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 19 May 2026 16:01:16 -0700 Subject: [PATCH 31/44] refactor: create process and apply config update methods Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 105 ++++++------------------ dapr_agents/agents/configs.py | 137 +++++++++++++++++++++++++++++++- tests/agents/test_hot_reload.py | 33 ++++---- 3 files changed, 176 insertions(+), 99 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 61b86dd76..84ce44b72 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -61,6 +61,8 @@ WorkflowGrpcOptions, DEFAULT_AGENT_WORKFLOW_BUNDLE, AgentObservabilityConfig, + apply_config_update, + process_config_update, validate_max_iterations, validate_non_empty_string, validate_tool_choice, @@ -826,109 +828,48 @@ def _apply_config_update(self, key: str, value: Any) -> bool: descriptor = self._CONFIG_FIELD_MAP.get(normalized_key) if descriptor is None: - logger.debug( - "Agent %s ignoring unrecognized config key: %s", self.name, key - ) + logger.debug(f"Agent {self.name} ignoring unrecognized config key: {key}") return False safe_value = "***" if descriptor.sensitive else value - logger.info( - 'Agent %s applying config update: %s="%s"', self.name, key, safe_value - ) - # Type coercion + logger.info(f"Agent {self.name} applying config update: {key}={safe_value!r}") + try: - coerced_value = self._coerce_config_value(value, descriptor.target_type) - except (ValueError, TypeError) as e: - logger.warning( - "Agent %s: invalid value for key '%s': %s. Skipping update.", - self.name, - key, - e, + processed_value = process_config_update( + key=normalized_key, value=value, descriptor=descriptor ) + except Exception as e: + # Skip update for coercion/validation/transformation/other failures + logger.warning(f"Agent {self.name}: {e} Skipping update.") return False - # Validation - if descriptor.validator is not None: - try: - coerced_value = descriptor.validator(coerced_value) - except Exception as e: - logger.warning( - "Agent %s: validation failed for key '%s': %s. Skipping update.", - self.name, - key, - e, - ) - return False - - # Apply via setter callback try: - descriptor.setter(self, coerced_value) - except (AttributeError, TypeError): - logger.debug(f"Could not apply setter for key '{key}' (likely read-only)") + applied_value = apply_config_update( + target_obj=self, + key=normalized_key, + value=processed_value, + descriptor=descriptor, + ) + except RuntimeError as e: + # Fall through if the agent could not be updated but the value is otherwise valid + logger.debug(f"Agent {self.name}: {e}") + applied_value = None + + resolved_value = applied_value or processed_value # Rebuild prompt template if a profile key changed if descriptor.rebuilds_prompt: self._rebuild_prompt_after_config_update() # Fire user callbacks - self._fire_config_change_callbacks(normalized_key, coerced_value) + self._fire_config_change_callbacks(normalized_key, resolved_value) # Re-register metadata self._sync_metadata_after_config_update() return descriptor.triggers_otel_reload - @staticmethod - def _coerce_config_value(value: Any, target_type: Type) -> Any: - """Coerce a configuration value (usually a string) to the target Python type.""" - if isinstance(value, target_type): - return value - - if target_type is str: - return str(value) - - if target_type is int: - return int(float(value)) - - if target_type is float: - return float(value) - - if target_type is bool: - if isinstance(value, str): - if value.lower() in ("true", "1", "yes"): - return True - if value.lower() in ("false", "0", "no"): - return False - raise ValueError(f"Cannot coerce {value!r} to bool") - - if target_type is list: - if isinstance(value, str): - try: - parsed = json.loads(value) - if isinstance(parsed, list): - return parsed - except (json.JSONDecodeError, TypeError): - pass - return [value] - if isinstance(value, (list, tuple)): - return list(value) - return [value] - - if target_type is dict: - if isinstance(value, str): - parsed = json.loads(value) - if isinstance(parsed, dict): - return parsed - raise ValueError( - f"JSON parsed to {type(parsed).__name__}, expected dict" - ) - if isinstance(value, dict): - return value - raise ValueError(f"Cannot coerce {type(value).__name__} to dict") - - raise ValueError(f"Unsupported target type: {target_type}") - def _rebuild_prompt_after_config_update(self) -> None: """Rebuild the prompt template after a profile field change.""" try: diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 5dd70c95f..2ea738ac7 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -13,6 +13,7 @@ from __future__ import annotations +import json import logging import re from os import getenv @@ -218,13 +219,15 @@ class ConfigFieldDescriptor: Attributes: target_type: Expected Python type for the coerced value. setter: Callable ``(agent, value) -> None`` that applies the value. + getter: Optional callable ``() -> Any`` that retrieves the value. sensitive: If ``True``, the value is redacted in log output. - validator: Optional callable to validate/transform the coerced value. + validator: Optional idempotent callable ``(value) -> Any`` to validate/transform the coerced value. rebuilds_prompt: If ``True``, the prompt template is rebuilt after update. """ target_type: Type setter: Callable[..., None] + getter: Optional[Callable[[], Any]] = None sensitive: bool = False validator: Optional[Callable[..., Any]] = None rebuilds_prompt: bool = False @@ -288,6 +291,138 @@ def validate_otel_exporter_logging(v: str) -> str: return v +def apply_config_update( + target_obj: Any, + key: str, + value: Any, + descriptor: ConfigFieldDescriptor, +) -> Any: + """ + Process and apply a configuration update to an object. + + Args: + target_obj: The object to be updated. + key: The configuration key. + value: Optional raw value to coerce/validate/transform and apply. + Falls back to the descriptor's getter if not provided. + descriptor: An object describing how to process a value for a particular key. + + Returns: + The final applied value. + + Raises: + ValueError: If no value can be retrieved or coercion/validation fails. + RuntimeError: If the value cannot be applied. + """ + + processed_value = process_config_update(key, value, descriptor) + + # Apply via setter callback + try: + descriptor.setter(target_obj, processed_value) + except (AttributeError, TypeError): + raise RuntimeError( + f"Could not apply setter for key '{key}' (likely read-only)." + ) + + return processed_value + + +def process_config_update( + key: str, + value: Any, + descriptor: ConfigFieldDescriptor, +) -> Any: + """ + Process a configuration update by coercing, validating, and transforming a raw value. + + Args: + key: The configuration key. + value: Optional raw value to coerce/validate/transform and apply. + Falls back to the descriptor's getter if not provided. + descriptor: An object describing how to process a value for a particular key. + + Returns: + The processed value. + + Raises: + ValueError: If no value can be retrieved or coercion/validation fails. + """ + + if not descriptor: + raise ValueError(f"Unrecognized config key: {key}.") + + # Retrieve value using getter callback as a fallback + if not value and descriptor.getter: + try: + value = descriptor.getter() + except Exception as e: + raise ValueError(f"Unable to retrieve value for key '{key}': {e}.") + + # Type coercion + try: + processed_value = coerce_config_value(value, descriptor.target_type) + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid value for key '{key}': {e}.") + + # Validation/transformation + if descriptor.validator is not None: + try: + processed_value = descriptor.validator(processed_value) + except Exception as e: + raise ValueError(f"Validation failed for key '{key}': {e}.") + + return processed_value + + +def coerce_config_value(value: Any, target_type: Type) -> Any: + """Coerce a configuration value (usually a string) to the target Python type.""" + if isinstance(value, target_type): + return value + + if target_type is str: + return str(value) + + if target_type is int: + return int(float(value)) + + if target_type is float: + return float(value) + + if target_type is bool: + if isinstance(value, str): + if value.lower() in ("true", "1", "yes"): + return True + if value.lower() in ("false", "0", "no"): + return False + raise ValueError(f"Cannot coerce {value!r} to bool") + + if target_type is list: + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, list): + return parsed + except (json.JSONDecodeError, TypeError): + pass + return [value] + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + if target_type is dict: + if isinstance(value, str): + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + raise ValueError(f"JSON parsed to {type(parsed).__name__}, expected dict") + if isinstance(value, dict): + return value + raise ValueError(f"Cannot coerce {type(value).__name__} to dict") + + raise ValueError(f"Unsupported target type: {target_type}") + + @dataclass class RuntimeSubscriptionConfig: """Configuration for subscribing to a Dapr Configuration Store at runtime. diff --git a/tests/agents/test_hot_reload.py b/tests/agents/test_hot_reload.py index f0fa7e73a..1d1f64c48 100644 --- a/tests/agents/test_hot_reload.py +++ b/tests/agents/test_hot_reload.py @@ -24,6 +24,7 @@ AgentObservabilityConfig, LLMMetadata, RuntimeSubscriptionConfig, + coerce_config_value, ) from dapr_agents.observability.instrumentor import DaprAgentsInstrumentor from .mocks.llm_client import MockLLMClient @@ -376,62 +377,62 @@ def test_stop_minimal_agent_no_error(self, mock_llm_client): class TestCoerceConfigValue: - """Tests for _coerce_config_value type coercion.""" + """Tests for coerce_config_value type coercion.""" def test_str_passthrough(self): - assert AgentBase._coerce_config_value("hello", str) == "hello" + assert coerce_config_value("hello", str) == "hello" def test_str_from_int(self): - assert AgentBase._coerce_config_value(42, str) == "42" + assert coerce_config_value(42, str) == "42" def test_int_from_string(self): - assert AgentBase._coerce_config_value("42", int) == 42 + assert coerce_config_value("42", int) == 42 def test_int_from_float_string(self): - assert AgentBase._coerce_config_value("10.0", int) == 10 + assert coerce_config_value("10.0", int) == 10 def test_int_already_int(self): - assert AgentBase._coerce_config_value(7, int) == 7 + assert coerce_config_value(7, int) == 7 def test_int_invalid_raises(self): with pytest.raises((ValueError, TypeError)): - AgentBase._coerce_config_value("not_a_number", int) + coerce_config_value("not_a_number", int) def test_bool_true_variants(self): for v in ("true", "True", "1", "yes"): - assert AgentBase._coerce_config_value(v, bool) is True + assert coerce_config_value(v, bool) is True def test_bool_false_variants(self): for v in ("false", "False", "0", "no"): - assert AgentBase._coerce_config_value(v, bool) is False + assert coerce_config_value(v, bool) is False def test_bool_invalid_raises(self): with pytest.raises(ValueError): - AgentBase._coerce_config_value("maybe", bool) + coerce_config_value("maybe", bool) def test_list_from_json(self): - result = AgentBase._coerce_config_value('["a", "b"]', list) + result = coerce_config_value('["a", "b"]', list) assert result == ["a", "b"] def test_list_wraps_single_string(self): - result = AgentBase._coerce_config_value("single", list) + result = coerce_config_value("single", list) assert result == ["single"] def test_list_already_list(self): - result = AgentBase._coerce_config_value(["already"], list) + result = coerce_config_value(["already"], list) assert result == ["already"] def test_dict_from_json(self): - result = AgentBase._coerce_config_value('{"key": "val"}', dict) + result = coerce_config_value('{"key": "val"}', dict) assert result == {"key": "val"} def test_dict_already_dict(self): - result = AgentBase._coerce_config_value({"key": "val"}, dict) + result = coerce_config_value({"key": "val"}, dict) assert result == {"key": "val"} def test_dict_non_dict_json_raises(self): with pytest.raises(ValueError): - AgentBase._coerce_config_value("[1, 2]", dict) + coerce_config_value("[1, 2]", dict) class TestLoadInitialConfiguration: From 00663b5f5481c3d3bb827a7f3313e6e554830d84 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 19 May 2026 16:07:54 -0700 Subject: [PATCH 32/44] chore: update docstring Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/configs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 2ea738ac7..a73da7fc7 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -311,7 +311,7 @@ def apply_config_update( The final applied value. Raises: - ValueError: If no value can be retrieved or coercion/validation fails. + ValueError: If no value can be retrieved or coercion/validation/transformation fails. RuntimeError: If the value cannot be applied. """ @@ -346,7 +346,7 @@ def process_config_update( The processed value. Raises: - ValueError: If no value can be retrieved or coercion/validation fails. + ValueError: If no value can be retrieved or coercion/validation/transformation fails. """ if not descriptor: From 54894899b66f4a0af46f0f5047e2a7f57237f71c Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 19 May 2026 16:10:38 -0700 Subject: [PATCH 33/44] chore: make agent default constant naming consistent Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 4 ++-- dapr_agents/agents/components.py | 4 ++-- dapr_agents/agents/configs.py | 2 +- tests/agents/test_agent_state_config.py | 14 +++++++------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index ea54695f2..6e525fbae 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -59,7 +59,7 @@ RuntimeSubscriptionConfig, ToolMetadata, WorkflowGrpcOptions, - DEFAULT_AGENT_WORKFLOW_BUNDLE, + AGENT_DEFAULT_WORKFLOW_BUNDLE, AgentObservabilityConfig, apply_config_update, process_config_update, @@ -498,7 +498,7 @@ def __init__( registry=registry, base_metadata=base_metadata, max_etag_attempts=max_etag_attempts, - default_bundle=DEFAULT_AGENT_WORKFLOW_BUNDLE, + default_bundle=AGENT_DEFAULT_WORKFLOW_BUNDLE, workflow_grpc_options=workflow_grpc, ) diff --git a/dapr_agents/agents/components.py b/dapr_agents/agents/components.py index 24b998f4d..40a6766e5 100644 --- a/dapr_agents/agents/components.py +++ b/dapr_agents/agents/components.py @@ -28,7 +28,7 @@ AgentPubSubConfig, AgentRegistryConfig, AgentStateConfig, - DEFAULT_AGENT_WORKFLOW_BUNDLE, + AGENT_DEFAULT_WORKFLOW_BUNDLE, WorkflowGrpcOptions, StateModelBundle, ) @@ -119,7 +119,7 @@ def __init__( "No state bundle for %s; using default agent workflow entry schema", self.name, ) - bundle = DEFAULT_AGENT_WORKFLOW_BUNDLE + bundle = AGENT_DEFAULT_WORKFLOW_BUNDLE self._entry_model_cls = bundle.entry_model_cls self._message_model_cls = bundle.message_model_cls diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index a73da7fc7..61f49dd12 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -90,7 +90,7 @@ class StateModelBundle: message_coercer: Optional[MessageCoercer] = None -DEFAULT_AGENT_WORKFLOW_BUNDLE = StateModelBundle( +AGENT_DEFAULT_WORKFLOW_BUNDLE = StateModelBundle( entry_model_cls=AgentWorkflowEntry, message_model_cls=AgentWorkflowMessage, ) diff --git a/tests/agents/test_agent_state_config.py b/tests/agents/test_agent_state_config.py index 4690d546e..d693d302d 100644 --- a/tests/agents/test_agent_state_config.py +++ b/tests/agents/test_agent_state_config.py @@ -20,7 +20,7 @@ from dapr_agents.agents.configs import ( AgentStateConfig, - DEFAULT_AGENT_WORKFLOW_BUNDLE, + AGENT_DEFAULT_WORKFLOW_BUNDLE, StateModelBundle, ) @@ -41,11 +41,11 @@ def custom_message_coercer(payload: Dict[str, Any]) -> Dict[str, Any]: message_coercer=custom_message_coercer, ) - config.ensure_bundle(DEFAULT_AGENT_WORKFLOW_BUNDLE) + config.ensure_bundle(AGENT_DEFAULT_WORKFLOW_BUNDLE) bundle = config.get_state_model_bundle() - assert bundle.entry_model_cls is DEFAULT_AGENT_WORKFLOW_BUNDLE.entry_model_cls - assert bundle.message_model_cls is DEFAULT_AGENT_WORKFLOW_BUNDLE.message_model_cls + assert bundle.entry_model_cls is AGENT_DEFAULT_WORKFLOW_BUNDLE.entry_model_cls + assert bundle.message_model_cls is AGENT_DEFAULT_WORKFLOW_BUNDLE.message_model_cls assert bundle.entry_factory is custom_entry_factory assert bundle.message_coercer is custom_message_coercer @@ -55,16 +55,16 @@ def test_ensure_bundle_is_idempotent() -> None: store.store_name = "state" config = AgentStateConfig(store=store) - config.ensure_bundle(DEFAULT_AGENT_WORKFLOW_BUNDLE) + config.ensure_bundle(AGENT_DEFAULT_WORKFLOW_BUNDLE) # second injection with same bundle should be a no-op - config.ensure_bundle(DEFAULT_AGENT_WORKFLOW_BUNDLE) + config.ensure_bundle(AGENT_DEFAULT_WORKFLOW_BUNDLE) def test_ensure_bundle_rejects_mismatched_schema() -> None: store = Mock() store.store_name = "state" config = AgentStateConfig(store=store) - config.ensure_bundle(DEFAULT_AGENT_WORKFLOW_BUNDLE) + config.ensure_bundle(AGENT_DEFAULT_WORKFLOW_BUNDLE) class OtherEntry(BaseModel): value: int = 0 From 3740d596efe419c3971ab8f6f1cb0cb9155dd972 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 19 May 2026 16:49:22 -0700 Subject: [PATCH 34/44] chore: update docstrings Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 2 +- dapr_agents/agents/configs.py | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 6e525fbae..092d5b4c9 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -874,7 +874,7 @@ def _apply_config_update(self, key: str, value: Any) -> bool: value=processed_value, descriptor=descriptor, ) - except RuntimeError as e: + except Exception as e: # Fall through if the agent could not be updated but the value is otherwise valid logger.debug(f"Agent {self.name}: {e}") applied_value = None diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 61f49dd12..c7ea79aaf 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -299,19 +299,20 @@ def apply_config_update( ) -> Any: """ Process and apply a configuration update to an object. + This function is guaranteed to be idempotent if the processing logic is idempotent. Args: target_obj: The object to be updated. key: The configuration key. - value: Optional raw value to coerce/validate/transform and apply. - Falls back to the descriptor's getter if not provided. + value: Optional value to process and apply. + Falls back to the descriptor's getter if not provided (may not be idempotent). descriptor: An object describing how to process a value for a particular key. Returns: The final applied value. Raises: - ValueError: If no value can be retrieved or coercion/validation/transformation fails. + ValueError: If no value can be retrieved or processing fails. RuntimeError: If the value cannot be applied. """ @@ -334,19 +335,20 @@ def process_config_update( descriptor: ConfigFieldDescriptor, ) -> Any: """ - Process a configuration update by coercing, validating, and transforming a raw value. + Process a configuration update by coercing, validating, and transforming a value. + This function is guaranteed to be idempotent if the processing logic is idempotent. Args: key: The configuration key. - value: Optional raw value to coerce/validate/transform and apply. - Falls back to the descriptor's getter if not provided. + value: Optional value to process. + Falls back to the descriptor's getter if not provided (may not be idempotent). descriptor: An object describing how to process a value for a particular key. Returns: The processed value. Raises: - ValueError: If no value can be retrieved or coercion/validation/transformation fails. + ValueError: If no value can be retrieved or processing fails. """ if not descriptor: From f97e1685f982105c6c60e4ae2ac5a22478dd331d Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 19 May 2026 16:57:02 -0700 Subject: [PATCH 35/44] fix: add apply config update guards Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 092d5b4c9..1125697e0 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -874,10 +874,14 @@ def _apply_config_update(self, key: str, value: Any) -> bool: value=processed_value, descriptor=descriptor, ) - except Exception as e: + except RuntimeError as e: # Fall through if the agent could not be updated but the value is otherwise valid logger.debug(f"Agent {self.name}: {e}") applied_value = None + except Exception as e: + # Should not get here assuming value is valid + logger.warning(f"Agent {self.name}: {e} Skipping update.") + return False resolved_value = applied_value or processed_value From 8bc0af3ce683ac574e3a52c6aab797de5a3ab4ff Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 19 May 2026 18:32:20 -0700 Subject: [PATCH 36/44] style: add whitespace Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 1125697e0..18e9fd121 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -860,7 +860,9 @@ def _apply_config_update(self, key: str, value: Any) -> bool: try: processed_value = process_config_update( - key=normalized_key, value=value, descriptor=descriptor + key=normalized_key, + value=value, + descriptor=descriptor, ) except Exception as e: # Skip update for coercion/validation/transformation/other failures From 84ab1d97b2bfe930b2369a4c8153d82f57d7b1dd Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Tue, 19 May 2026 18:40:44 -0700 Subject: [PATCH 37/44] chore: add defensive check comment Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 18e9fd121..5ea4fe3f5 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -881,7 +881,7 @@ def _apply_config_update(self, key: str, value: Any) -> bool: logger.debug(f"Agent {self.name}: {e}") applied_value = None except Exception as e: - # Should not get here assuming value is valid + # Defensive check: we shouldn't get here assuming value is valid logger.warning(f"Agent {self.name}: {e} Skipping update.") return False From e7f92d25a3a809c50543d16621a068b0f4cf73a1 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 20 May 2026 16:06:43 -0700 Subject: [PATCH 38/44] refactor: create merge config method Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 11 ++- dapr_agents/agents/configs.py | 72 ++++++++++++++++- dapr_agents/agents/utils/models.py | 77 +++++++++++++++++++ .../durableagent/test_observability_config.py | 7 +- 4 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 dapr_agents/agents/utils/models.py diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 5ea4fe3f5..510c6b2fc 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -62,6 +62,7 @@ AGENT_DEFAULT_WORKFLOW_BUNDLE, AgentObservabilityConfig, apply_config_update, + merge_configs, process_config_update, validate_max_iterations, validate_non_empty_string, @@ -1742,13 +1743,13 @@ def _resolve_execution_config(self) -> AgentExecutionConfig: logger.debug(f"Env execution config: {config}") if self.execution: - config = self._merge_execution_configs(config, self.execution) + config = merge_configs(config, self.execution) logger.debug(f"Merged execution config: {config}") statestore_config = self._load_execution_from_statestore() logger.debug(f"Statestore execution config: {statestore_config}") - config = self._merge_execution_configs(config, statestore_config) + config = merge_configs(config, statestore_config) logger.debug(f"Final execution config with statestore override: {config}") return config @@ -1853,13 +1854,11 @@ def _resolve_observability_config(self) -> AgentObservabilityConfig: env_config = AgentObservabilityConfig.from_env() logger.debug(f"Env observability config: {env_config}") - config = self._merge_observability_configs(config, env_config) + config = merge_configs(config, env_config) logger.debug(f"Merged observability config: {config}") if self._agent_observability: - config = self._merge_observability_configs( - config, self._agent_observability - ) + config = merge_configs(config, self._agent_observability) logger.debug(f"Final observability config with override: {config}") return config diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index c7ea79aaf..967d1d99e 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -18,7 +18,7 @@ import re from os import getenv from enum import StrEnum -from dataclasses import dataclass, field +from dataclasses import dataclass, field, is_dataclass from typing import ( Any, Callable, @@ -28,11 +28,17 @@ Optional, Sequence, Type, + TypeVar, Union, ) from pydantic import BaseModel, Field +from dapr_agents.agents.utils.models import ( + get_model_factory, + get_model_fields, + is_supported_config_model, +) from dapr_agents.types.agent import ToolChoice, ToolExecutionMode, OrchestrationMode from dapr_agents.agents.constants import ( AGENT_DEFAULT_MAX_ITERATIONS, @@ -68,6 +74,8 @@ def _empty_headers() -> Dict[str, str]: MessageCoercer = Callable[[Dict[str, Any]], Any] EntryContainerGetter = Callable[[BaseModel], Optional[MutableMapping[str, Any]]] +T = TypeVar("T") + @dataclass class StateModelBundle: @@ -291,6 +299,11 @@ def validate_otel_exporter_logging(v: str) -> str: return v +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- + + def apply_config_update( target_obj: Any, key: str, @@ -425,6 +438,63 @@ def coerce_config_value(value: Any, target_type: Type) -> Any: raise ValueError(f"Unsupported target type: {target_type}") +def merge_configs(base: T, override: T) -> T: + """ + Merge two configuration models of the same type, with override taking precedence. + Only override if the override value is not None. + + Args: + base: The original configuration model. + override: The new configuration model with potential override values. + + Returns: + The merged configuration model. + + Raises: + TypeError: If models are of incompatible types or unsupported type. + ValueError: If merging fails. + """ + # NOTE: this implementation doesn't handle override values that are explicitly None + + if not is_supported_config_model(type(base)): + raise TypeError(f"Unsupported model type: {base!r}") + + if not is_supported_config_model(type(override)): + raise TypeError(f"Unsupported model type: {override!r}") + + if type(base) != type(override): + raise TypeError( + f"Cannot merge models of different types: {base!r} and {override!r}" + ) + + try: + # Infer model type from the base + model_fields = get_model_fields(base) + model_factory = get_model_factory(base) + + if not model_fields or not model_factory: + raise TypeError(f"Unsupported model type: {base!r}") + + merged_values: Dict[str, Any] = {} + + for field in model_fields: + base_val = getattr(base, field) + override_val = getattr(override, field) + + if isinstance(base_val, dict) and isinstance(override_val, dict): + # Shallow merge dicts + merged_values[field] = {**base_val, **override_val} + else: + merged_values[field] = ( + override_val if override_val is not None else base_val + ) + + return model_factory(merged_values) # type: ignore + + except Exception as e: + raise ValueError(f"Configuration merge failed: {e}") from e + + @dataclass class RuntimeSubscriptionConfig: """Configuration for subscribing to a Dapr Configuration Store at runtime. diff --git a/dapr_agents/agents/utils/models.py b/dapr_agents/agents/utils/models.py new file mode 100644 index 000000000..dc93d9348 --- /dev/null +++ b/dapr_agents/agents/utils/models.py @@ -0,0 +1,77 @@ +# +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from dataclasses import is_dataclass +from typing import Any, Callable, Callable + +from pydantic import BaseModel + + +def is_pydantic_model(obj: Any) -> bool: + """Check if the given object is a subclass of Pydantic's BaseModel.""" + return isinstance(obj, type) and issubclass(obj, BaseModel) + + +def is_supported_config_model(obj: Any) -> bool: + """Checks if an object is a supported configuration model (Pydantic, dataclass, or dict).""" + return obj is dict or is_dataclass(obj) or is_pydantic_model(obj) + + +def get_model_fields(model: Any) -> Any | None: + """ + Extract field names from a config model. + + Returns: + Iterable of field names, or None if unsupported type. + """ + if type(model) is dict: + return model.keys() + + if is_dataclass(model): + from dataclasses import fields as dataclass_fields + + return [f.name for f in dataclass_fields(model)] + + if hasattr(model, "model_validate"): + # Pydantic v2 + return model.model_fields.keys() + + if hasattr(model, "parse_obj"): + # Pydantic v1 + return model.__fields__.keys() + + return None + + +def get_model_factory(model: Any) -> Callable[..., Any] | None: + """ + Get the factory function for creating instances of a config model. + + Returns: + Callable that takes a dict and returns an instance. + """ + if type(model) is dict: + return dict # type: ignore + + if is_dataclass(model): + return lambda vals: type(model)(**vals) + + if hasattr(model, "model_validate"): + # Pydantic v2 + return lambda vals: type(model).model_validate(vals) + + if hasattr(model, "parse_obj"): + # Pydantic v1 + return lambda vals: type(model).parse_obj(vals) + + return None diff --git a/tests/agents/durableagent/test_observability_config.py b/tests/agents/durableagent/test_observability_config.py index a3829c86e..504344575 100644 --- a/tests/agents/durableagent/test_observability_config.py +++ b/tests/agents/durableagent/test_observability_config.py @@ -26,6 +26,7 @@ AgentObservabilityConfig, AgentTracingExporter, AgentLoggingExporter, + merge_configs, ) from dapr_agents.llm import OpenAIChatClient from dapr_agents.storage.daprstores.stateservice import StateStoreService @@ -905,7 +906,7 @@ def test_merge_none_values_dont_override(self, mock_llm): endpoint=None, # Should not override ) - merged = agent._merge_observability_configs(base, override) + merged = merge_configs(base, override) assert merged.enabled is True # From base assert merged.service_name == "override-service" # From override @@ -941,7 +942,7 @@ def test_merge_boolean_fields_correctly(self, mock_llm): tracing_enabled=True, ) - merged = agent._merge_observability_configs(base, override) + merged = merge_configs(base, override) assert merged.enabled is False # Override wins assert merged.logging_enabled is True # Base wins (override is None) @@ -968,7 +969,7 @@ def test_merge_empty_configs(self, mock_llm): base = AgentObservabilityConfig() override = AgentObservabilityConfig() - merged = agent._merge_observability_configs(base, override) + merged = merge_configs(base, override) assert merged.enabled is None assert merged.headers == {} From 40e247f195895cb1a335377eb57874d82bf1c0ce Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 20 May 2026 21:58:09 -0700 Subject: [PATCH 39/44] refactor: move config resolution logic out of agent Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 44 ++--- dapr_agents/agents/configs.py | 159 ++++++++++++++++++ .../durableagent/test_observability_config.py | 32 ++-- 3 files changed, 197 insertions(+), 38 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 510c6b2fc..4faacaa05 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -382,9 +382,6 @@ def __init__( self._runtime_secrets: Dict[str, str] = {} self._runtime_conf: Dict[str, str] = {} - self._agent_observability = agent_observability or AgentObservabilityConfig() - self._otel_logging_handler = None - self.instrumentor = None self.configuration = configuration self._subscription_id: Optional[str] = None self.appid = ( @@ -503,8 +500,15 @@ def __init__( workflow_grpc_options=workflow_grpc, ) + # ----------------------------- + # Observability wiring + # ----------------------------- self.instrumentor: Optional[DaprAgentsInstrumentor] = None - self._setup_agent_observability_runtime_configuration() + self._otel_logging_handler = None + self._agent_observability = agent_observability or AgentObservabilityConfig() + self._agent_observability = self._agent_observability.resolve_config(self._runtime_conf) + + self._setup_agent_observability() # ----------------------------- # Registry wiring @@ -574,7 +578,7 @@ def __init__( # Execution config # ----------------------------- self.execution = execution or AgentExecutionConfig() - self._setup_agent_execution_runtime_configuration() + self.execution = self.execution.resolve_config(self._runtime_conf) try: self.execution.max_iterations = max(1, int(self.execution.max_iterations)) @@ -1726,7 +1730,7 @@ def _coerce_datetime(value: Optional[Any]) -> datetime: pass return datetime.now(timezone.utc) - def _resolve_execution_config(self) -> AgentExecutionConfig: + def _resolve_execution_config(self, execution: Optional[AgentExecutionConfig]) -> AgentExecutionConfig: """ Resolve the execution configuration for the agent in the following order: 1. Statestore runtime config (highest priority) @@ -1742,15 +1746,16 @@ def _resolve_execution_config(self) -> AgentExecutionConfig: config = AgentExecutionConfig.from_env() logger.debug(f"Env execution config: {config}") - if self.execution: - config = merge_configs(config, self.execution) + if execution: + config = merge_configs(config, execution) logger.debug(f"Merged execution config: {config}") - statestore_config = self._load_execution_from_statestore() + statestore_config = AgentExecutionConfig.from_statestore(self._runtime_conf) logger.debug(f"Statestore execution config: {statestore_config}") config = merge_configs(config, statestore_config) logger.debug(f"Final execution config with statestore override: {config}") + return config def _load_execution_from_statestore(self) -> AgentExecutionConfig: @@ -1832,10 +1837,7 @@ def _merge_execution_configs( ) return merged_config - def _setup_agent_execution_runtime_configuration(self) -> None: - self.execution = self._resolve_execution_config() - - def _resolve_observability_config(self) -> AgentObservabilityConfig: + def _resolve_observability_config(self, observability: Optional[AgentObservabilityConfig]) -> AgentObservabilityConfig: """ Resolve the observability configuration for the agent in the following order: 1. Passed through instantiation (highest priority) @@ -1843,12 +1845,12 @@ def _resolve_observability_config(self) -> AgentObservabilityConfig: 3. Default statestore runtime config (lowest priority) Args: - agent_observability: Optional observability config provided during initialization. + observability: Optional observability config provided during initialization. Returns: Resolved AgentObservabilityConfig instance. """ - config = self._load_observability_from_statestore() + config = AgentObservabilityConfig.from_statestore(self._runtime_conf) logger.debug(f"Statestore observability config: {config}") env_config = AgentObservabilityConfig.from_env() @@ -1857,9 +1859,10 @@ def _resolve_observability_config(self) -> AgentObservabilityConfig: config = merge_configs(config, env_config) logger.debug(f"Merged observability config: {config}") - if self._agent_observability: - config = merge_configs(config, self._agent_observability) + if observability: + config = merge_configs(config, observability) logger.debug(f"Final observability config with override: {config}") + return config def _load_observability_from_statestore(self) -> AgentObservabilityConfig: @@ -1964,12 +1967,9 @@ def _merge_observability_configs( ) return merged_config - def _setup_agent_observability_runtime_configuration(self) -> None: - self._agent_observability = self._resolve_observability_config() - self._setup_agent_observability(self._agent_observability) - - def _setup_agent_observability(self, config: AgentObservabilityConfig) -> None: + def _setup_agent_observability(self) -> None: """Setup agent runtime configuration.""" + config = self._agent_observability self._otel_logging_handler = None if config.enabled: diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 967d1d99e..6cc12de3c 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -76,6 +76,7 @@ def _empty_headers() -> Dict[str, str]: T = TypeVar("T") +logger = logging.getLogger(__name__) @dataclass class StateModelBundle: @@ -695,6 +696,74 @@ def from_env(cls) -> "AgentExecutionConfig": app_ready_check_enabled=app_ready_check_enabled, ) + @classmethod + def from_statestore(cls, config: Dict[str, Any]) -> "AgentExecutionConfig": + """ + Load execution configuration from the state store. + + Returns: + AgentExecutionConfig instance loaded from state store. + """ + + try: + max_iterations: Optional[int] = None + if max_iterations_str := config.get("MAX_ITERATIONS"): + try: + max_iterations = max(1, int(max_iterations_str)) + except ValueError: + max_iterations = AGENT_DEFAULT_MAX_ITERATIONS + + tool_choice: Optional[ToolChoice] = None + if tool_choice_str := config.get("TOOL_CHOICE"): + try: + tool_choice = ToolChoice(tool_choice_str) + except (ValueError, KeyError): + tool_choice = AGENT_DEFAULT_TOOL_CHOICE + + tool_execution_mode: Optional[ToolExecutionMode] = None + orchestration_mode: Optional[OrchestrationMode] = None + approval: Optional[AgentApprovalConfig] = None + app_health_check_enabled: Optional[bool] = None + app_ready_check_enabled: Optional[bool] = None + + return AgentExecutionConfig( + max_iterations=max_iterations, + tool_choice=tool_choice, + tool_execution_mode=tool_execution_mode, + orchestration_mode=orchestration_mode, + approval=approval, + app_health_check_enabled=app_health_check_enabled, + app_ready_check_enabled=app_ready_check_enabled, + ) + except Exception as e: + return AgentExecutionConfig() + + def resolve_config(self, runtime_config: Dict[str, Any]) -> AgentExecutionConfig: + """ + Resolve the execution configuration for the agent in the following order: + 1. Statestore runtime config (highest priority) + 2. Passed through instantiation + 3. Environment variables (lowest priority) + + Args: + runtime_conf: Runtime configuration. + Returns: + Resolved AgentExecutionConfig instance. + """ + + config = AgentExecutionConfig.from_env() + logger.debug(f"Env execution config: {config}") + + config = merge_configs(config, self) + logger.debug(f"Merged execution config: {config}") + + statestore_config = AgentExecutionConfig.from_statestore(runtime_config) + logger.debug(f"Statestore execution config: {statestore_config}") + + config = merge_configs(config, statestore_config) + logger.debug(f"Final execution config with statestore override: {config}") + + return config @dataclass class WorkflowRetryPolicy: @@ -863,6 +932,96 @@ def from_env(cls) -> "AgentObservabilityConfig": tracing_exporter=tracing_exporter, ) + @classmethod + def from_statestore(cls, config: Dict[str, Any]) -> "AgentObservabilityConfig": + """ + Load observability configuration from the state store. + + Returns: + AgentObservabilityConfig instance loaded from state store. + """ + + try: + # Use standard OTEL env var names in statestore config + sdk_disabled = config.get("OTEL_SDK_DISABLED", "true").lower() + enabled = sdk_disabled != "true" + auth_token = ( + config.get("OTEL_EXPORTER_OTLP_HEADERS") + or config.get("OTEL_EXPORTER_OTLP_HEADERS") + or None + ) + endpoint = config.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None + service_name = config.get("OTEL_SERVICE_NAME") or None + logging_enabled = ( + config.get("OTEL_LOGGING_ENABLED", "false").lower() + == "true" + ) + tracing_enabled = ( + config.get("OTEL_TRACING_ENABLED", "false").lower() + == "true" + ) + + logging_exporter: Optional[AgentLoggingExporter] = None + logging_exporter_str = config.get( + "OTEL_LOGS_EXPORTER", "console" + ) + if logging_exporter_str: + try: + logging_exporter = AgentLoggingExporter(logging_exporter_str) + except (ValueError, KeyError): + logging_exporter = AgentLoggingExporter.CONSOLE + + tracing_exporter: Optional[AgentTracingExporter] = None + tracing_exporter_str = config.get( + "OTEL_TRACES_EXPORTER", "console" + ) + if tracing_exporter_str: + try: + tracing_exporter = AgentTracingExporter(tracing_exporter_str) + except (ValueError, KeyError): + tracing_exporter = AgentTracingExporter.CONSOLE + + return AgentObservabilityConfig( + enabled=enabled, + auth_token=auth_token, + endpoint=endpoint, + service_name=service_name, + logging_enabled=logging_enabled, + logging_exporter=logging_exporter, + tracing_enabled=tracing_enabled, + tracing_exporter=tracing_exporter, + ) + except Exception as e: + return AgentObservabilityConfig() + + def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentObservabilityConfig": + """ + Resolve the observability configuration for the agent in the following order: + 1. Passed through instantiation (highest priority) + 2. Environment variables + 3. Default statestore runtime config (lowest priority) + + Args: + runtime_conf: Runtime configuration. + Returns: + Resolved AgentObservabilityConfig instance. + """ + + config = AgentObservabilityConfig.from_statestore(runtime_config) + logger.debug(f"Statestore observability config: {config}") + + env_config = AgentObservabilityConfig.from_env() + logger.debug(f"Env observability config: {env_config}") + + config = merge_configs(config, env_config) + logger.debug(f"Merged observability config: {config}") + + config = merge_configs(config, self) + logger.debug(f"Final observability config with override: {config}") + + return config + + class AgentMetadata(BaseModel): """Metadata about an agent's configuration and capabilities.""" diff --git a/tests/agents/durableagent/test_observability_config.py b/tests/agents/durableagent/test_observability_config.py index 504344575..695cc1b28 100644 --- a/tests/agents/durableagent/test_observability_config.py +++ b/tests/agents/durableagent/test_observability_config.py @@ -105,7 +105,7 @@ def test_observability_config_from_instantiation_all_fields(self, mock_llm): agent_observability=obs_config, ) - resolved_config = agent._resolve_observability_config() + resolved_config = obs_config.resolve_config(agent._runtime_conf) assert resolved_config.enabled is True assert resolved_config.headers == {"Authorization": "Bearer token123"} @@ -142,7 +142,7 @@ def test_observability_config_from_instantiation_partial_fields(self, mock_llm): agent_observability=obs_config, ) - resolved_config = agent._resolve_observability_config() + resolved_config = obs_config.resolve_config(agent._runtime_conf) assert resolved_config.enabled is True assert resolved_config.tracing_enabled is True @@ -174,7 +174,7 @@ def test_observability_config_disabled_from_instantiation(self, mock_llm): agent_observability=obs_config, ) - resolved_config = agent._resolve_observability_config() + resolved_config = obs_config.resolve_config(agent._runtime_conf) assert resolved_config.enabled is False @@ -249,7 +249,7 @@ def test_observability_config_from_env_all_fields(self, mock_llm, monkeypatch): ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) assert resolved_config.enabled is True assert resolved_config.headers == {"Authorization": "Bearer env-token"} @@ -281,7 +281,7 @@ def test_observability_config_from_env_partial_fields(self, mock_llm, monkeypatc ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) assert resolved_config.enabled is True assert resolved_config.service_name == "partial-service" @@ -310,7 +310,7 @@ def test_observability_config_from_env_disabled(self, mock_llm, monkeypatch): ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) assert resolved_config.enabled is False def test_observability_config_from_env_invalid_exporter( @@ -336,7 +336,7 @@ def test_observability_config_from_env_invalid_exporter( ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) # Should default to CONSOLE for invalid values assert resolved_config.tracing_exporter == AgentTracingExporter.CONSOLE @@ -431,7 +431,7 @@ def test_observability_config_from_statestore_all_fields( ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) assert resolved_config.enabled is True assert resolved_config.auth_token == "statestore-token" @@ -470,7 +470,7 @@ def test_observability_config_from_statestore_partial_fields( ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) assert resolved_config.enabled is True assert resolved_config.service_name == "partial-statestore-service" @@ -502,7 +502,7 @@ def test_observability_config_from_statestore_disabled(self, mock_llm, monkeypat ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) assert resolved_config.enabled is False def test_observability_config_statestore_invalid_exporter( @@ -534,7 +534,7 @@ def test_observability_config_statestore_invalid_exporter( ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) # Should default to CONSOLE for invalid values assert resolved_config.tracing_exporter == AgentTracingExporter.CONSOLE @@ -631,7 +631,7 @@ def test_precedence_instantiation_over_env(self, mock_llm, monkeypatch): agent_observability=obs_config, ) - resolved_config = agent._resolve_observability_config() + resolved_config = obs_config.resolve_config(agent._runtime_conf) # Instantiation should win assert resolved_config.enabled is False @@ -674,7 +674,7 @@ def test_precedence_env_over_statestore(self, mock_llm, monkeypatch): ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) # Environment should win assert resolved_config.enabled is False @@ -726,7 +726,7 @@ def test_precedence_full_hierarchy(self, mock_llm, monkeypatch): agent_observability=obs_config, ) - resolved_config = agent._resolve_observability_config() + resolved_config = obs_config.resolve_config(agent._runtime_conf) # Instantiation wins for service_name and tracing_exporter assert resolved_config.service_name == "instantiation-service" @@ -775,7 +775,7 @@ def test_merge_configs_with_headers(self, mock_llm, monkeypatch): agent_observability=obs_config, ) - resolved_config = agent._resolve_observability_config() + resolved_config = obs_config.resolve_config(agent._runtime_conf) # Headers should be merged with instantiation taking precedence assert "X-Custom-Header" in resolved_config.headers @@ -804,7 +804,7 @@ def test_no_config_sources_returns_defaults(self, mock_llm, monkeypatch): ), ) - resolved_config = agent._resolve_observability_config() + resolved_config = AgentObservabilityConfig().resolve_config(agent._runtime_conf) # Values come from statestore defaults (False for booleans, console for exporters) assert resolved_config.enabled is False From db217bf50b82cb39b80894428d492b36da725c38 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 20 May 2026 22:19:46 -0700 Subject: [PATCH 40/44] refactor: mutate config during resolution Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 4 ++-- dapr_agents/agents/configs.py | 22 +++++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 4faacaa05..631185216 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -506,7 +506,7 @@ def __init__( self.instrumentor: Optional[DaprAgentsInstrumentor] = None self._otel_logging_handler = None self._agent_observability = agent_observability or AgentObservabilityConfig() - self._agent_observability = self._agent_observability.resolve_config(self._runtime_conf) + self._agent_observability.resolve_config(self._runtime_conf) self._setup_agent_observability() @@ -578,7 +578,7 @@ def __init__( # Execution config # ----------------------------- self.execution = execution or AgentExecutionConfig() - self.execution = self.execution.resolve_config(self._runtime_conf) + self.execution.resolve_config(self._runtime_conf) try: self.execution.max_iterations = max(1, int(self.execution.max_iterations)) diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 6cc12de3c..656611932 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -738,7 +738,7 @@ def from_statestore(cls, config: Dict[str, Any]) -> "AgentExecutionConfig": except Exception as e: return AgentExecutionConfig() - def resolve_config(self, runtime_config: Dict[str, Any]) -> AgentExecutionConfig: + def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentExecutionConfig": """ Resolve the execution configuration for the agent in the following order: 1. Statestore runtime config (highest priority) @@ -746,9 +746,10 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> AgentExecutionConfig 3. Environment variables (lowest priority) Args: - runtime_conf: Runtime configuration. + runtime_config: Runtime configuration. + Returns: - Resolved AgentExecutionConfig instance. + Resolved AgentExecutionConfig instance for fluent chaining. """ config = AgentExecutionConfig.from_env() @@ -763,7 +764,11 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> AgentExecutionConfig config = merge_configs(config, statestore_config) logger.debug(f"Final execution config with statestore override: {config}") - return config + for k, v in config.__dict__.items(): + setattr(self, k, v) + + return self + @dataclass class WorkflowRetryPolicy: @@ -1002,9 +1007,9 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentObservabilityC 3. Default statestore runtime config (lowest priority) Args: - runtime_conf: Runtime configuration. + runtime_config: Runtime configuration. Returns: - Resolved AgentObservabilityConfig instance. + Resolved AgentObservabilityConfig instance for fluent chaining. """ config = AgentObservabilityConfig.from_statestore(runtime_config) @@ -1019,7 +1024,10 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentObservabilityC config = merge_configs(config, self) logger.debug(f"Final observability config with override: {config}") - return config + for k, v in config.__dict__.items(): + setattr(self, k, v) + + return self From 7b841e0b1e3e204e7729789e935b5a3be625551e Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 20 May 2026 22:24:04 -0700 Subject: [PATCH 41/44] refactor: remove redundant resolution logic Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/base.py | 237 ------------------------------------- 1 file changed, 237 deletions(-) diff --git a/dapr_agents/agents/base.py b/dapr_agents/agents/base.py index 631185216..93fa9e55e 100644 --- a/dapr_agents/agents/base.py +++ b/dapr_agents/agents/base.py @@ -1730,243 +1730,6 @@ def _coerce_datetime(value: Optional[Any]) -> datetime: pass return datetime.now(timezone.utc) - def _resolve_execution_config(self, execution: Optional[AgentExecutionConfig]) -> AgentExecutionConfig: - """ - Resolve the execution configuration for the agent in the following order: - 1. Statestore runtime config (highest priority) - 2. Passed through instantiation - 3. Environment variables (lowest priority) - - Args: - agent_execution: Optional execution config provided during initialization. - Returns: - Resolved AgentExecutionConfig instance. - """ - - config = AgentExecutionConfig.from_env() - logger.debug(f"Env execution config: {config}") - - if execution: - config = merge_configs(config, execution) - logger.debug(f"Merged execution config: {config}") - - statestore_config = AgentExecutionConfig.from_statestore(self._runtime_conf) - logger.debug(f"Statestore execution config: {statestore_config}") - - config = merge_configs(config, statestore_config) - logger.debug(f"Final execution config with statestore override: {config}") - - return config - - def _load_execution_from_statestore(self) -> AgentExecutionConfig: - """ - Load execution configuration from the state store. - - Returns: - AgentExecutionConfig instance loaded from state store. - """ - - try: - max_iterations: Optional[int] = None - if max_iterations_str := self._runtime_conf.get("MAX_ITERATIONS"): - try: - max_iterations = max(1, int(max_iterations_str)) - except ValueError: - max_iterations = AGENT_DEFAULT_MAX_ITERATIONS - - tool_choice: Optional[ToolChoice] = None - if tool_choice_str := self._runtime_conf.get("TOOL_CHOICE"): - try: - tool_choice = ToolChoice(tool_choice_str) - except (ValueError, KeyError): - tool_choice = AGENT_DEFAULT_TOOL_CHOICE - - tool_execution_mode: Optional[ToolExecutionMode] = None - orchestration_mode: Optional[OrchestrationMode] = None - approval: Optional[AgentApprovalConfig] = None - app_health_check_enabled: Optional[bool] = None - app_ready_check_enabled: Optional[bool] = None - - return AgentExecutionConfig( - max_iterations=max_iterations, - tool_choice=tool_choice, - tool_execution_mode=tool_execution_mode, - orchestration_mode=orchestration_mode, - approval=approval, - app_health_check_enabled=app_health_check_enabled, - app_ready_check_enabled=app_ready_check_enabled, - ) - except Exception as e: - logger.debug(f"Could not load execution config from statestore: {e}") - return AgentExecutionConfig() - - def _merge_execution_configs( - self, base: AgentExecutionConfig, override: AgentExecutionConfig - ) -> AgentExecutionConfig: - """ - Merge two execution configurations, with the override taking precedence. - Only override if the override value is not None. - - Args: - base: Base execution configuration. - override: Override execution configuration. - Returns: - Merged AgentExecutionConfig instance. - """ - - app_health_check_enabled = ( - override.app_health_check_enabled - if override.app_health_check_enabled is not None - else base.app_health_check_enabled - ) - app_ready_check_enabled = ( - override.app_ready_check_enabled - if override.app_ready_check_enabled is not None - else base.app_ready_check_enabled - ) - - merged_config = AgentExecutionConfig( - max_iterations=override.max_iterations or base.max_iterations, - tool_choice=override.tool_choice or base.tool_choice, - tool_execution_mode=override.tool_execution_mode - or base.tool_execution_mode, - orchestration_mode=override.orchestration_mode or base.orchestration_mode, - approval=override.approval or base.approval, - app_health_check_enabled=app_health_check_enabled, - app_ready_check_enabled=app_ready_check_enabled, - ) - return merged_config - - def _resolve_observability_config(self, observability: Optional[AgentObservabilityConfig]) -> AgentObservabilityConfig: - """ - Resolve the observability configuration for the agent in the following order: - 1. Passed through instantiation (highest priority) - 2. Environment variables - 3. Default statestore runtime config (lowest priority) - - Args: - observability: Optional observability config provided during initialization. - Returns: - Resolved AgentObservabilityConfig instance. - """ - - config = AgentObservabilityConfig.from_statestore(self._runtime_conf) - logger.debug(f"Statestore observability config: {config}") - - env_config = AgentObservabilityConfig.from_env() - logger.debug(f"Env observability config: {env_config}") - - config = merge_configs(config, env_config) - logger.debug(f"Merged observability config: {config}") - - if observability: - config = merge_configs(config, observability) - logger.debug(f"Final observability config with override: {config}") - - return config - - def _load_observability_from_statestore(self) -> AgentObservabilityConfig: - """ - Load observability configuration from the state store. - - Returns: - AgentObservabilityConfig instance loaded from state store. - """ - - try: - # Use standard OTEL env var names in statestore config - sdk_disabled = self._runtime_conf.get("OTEL_SDK_DISABLED", "true").lower() - enabled = sdk_disabled != "true" - auth_token = ( - self._runtime_secrets.get("OTEL_EXPORTER_OTLP_HEADERS") - or self._runtime_conf.get("OTEL_EXPORTER_OTLP_HEADERS") - or None - ) - endpoint = self._runtime_conf.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None - service_name = self._runtime_conf.get("OTEL_SERVICE_NAME") or None - logging_enabled = ( - self._runtime_conf.get("OTEL_LOGGING_ENABLED", "false").lower() - == "true" - ) - tracing_enabled = ( - self._runtime_conf.get("OTEL_TRACING_ENABLED", "false").lower() - == "true" - ) - - logging_exporter: Optional[AgentLoggingExporter] = None - logging_exporter_str = self._runtime_conf.get( - "OTEL_LOGS_EXPORTER", "console" - ) - if logging_exporter_str: - try: - logging_exporter = AgentLoggingExporter(logging_exporter_str) - except (ValueError, KeyError): - logging_exporter = AgentLoggingExporter.CONSOLE - - tracing_exporter: Optional[AgentTracingExporter] = None - tracing_exporter_str = self._runtime_conf.get( - "OTEL_TRACES_EXPORTER", "console" - ) - if tracing_exporter_str: - try: - tracing_exporter = AgentTracingExporter(tracing_exporter_str) - except (ValueError, KeyError): - tracing_exporter = AgentTracingExporter.CONSOLE - - return AgentObservabilityConfig( - enabled=enabled, - auth_token=auth_token, - endpoint=endpoint, - service_name=service_name, - logging_enabled=logging_enabled, - logging_exporter=logging_exporter, - tracing_enabled=tracing_enabled, - tracing_exporter=tracing_exporter, - ) - except Exception as e: - logger.debug(f"Could not load observability config from statestore: {e}") - return AgentObservabilityConfig() - - def _merge_observability_configs( - self, base: AgentObservabilityConfig, override: AgentObservabilityConfig - ) -> AgentObservabilityConfig: - """ - Merge two observability configurations, with the override taking precedence. - Only override if the override value is not None. - - Args: - base: Base observability configuration. - override: Override observability configuration. - Returns: - Merged AgentObservabilityConfig instance. - """ - merged_headers = {**base.headers, **override.headers} - - enabled = override.enabled if override.enabled is not None else base.enabled - logging_enabled = ( - override.logging_enabled - if override.logging_enabled is not None - else base.logging_enabled - ) - tracing_enabled = ( - override.tracing_enabled - if override.tracing_enabled is not None - else base.tracing_enabled - ) - - merged_config = AgentObservabilityConfig( - enabled=enabled, - headers=merged_headers, - auth_token=override.auth_token or base.auth_token, - endpoint=override.endpoint or base.endpoint, - service_name=override.service_name or base.service_name, - logging_enabled=logging_enabled, - logging_exporter=override.logging_exporter or base.logging_exporter, - tracing_enabled=tracing_enabled, - tracing_exporter=override.tracing_exporter or base.tracing_exporter, - ) - return merged_config - def _setup_agent_observability(self) -> None: """Setup agent runtime configuration.""" config = self._agent_observability From 7fbe7f59a8f1206d40b6a937b08d03236df08e78 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 20 May 2026 22:35:23 -0700 Subject: [PATCH 42/44] style: lint fixes Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/configs.py | 40 +++---- dapr_agents/agents/utils/models.py | 2 +- .../durableagent/test_observability_config.py | 111 +----------------- 3 files changed, 22 insertions(+), 131 deletions(-) diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 656611932..8fc29c188 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -78,6 +78,7 @@ def _empty_headers() -> Dict[str, str]: logger = logging.getLogger(__name__) + @dataclass class StateModelBundle: """ @@ -463,7 +464,7 @@ def merge_configs(base: T, override: T) -> T: if not is_supported_config_model(type(override)): raise TypeError(f"Unsupported model type: {override!r}") - if type(base) != type(override): + if base.__class__ != override.__class__: raise TypeError( f"Cannot merge models of different types: {base!r} and {override!r}" ) @@ -478,15 +479,15 @@ def merge_configs(base: T, override: T) -> T: merged_values: Dict[str, Any] = {} - for field in model_fields: - base_val = getattr(base, field) - override_val = getattr(override, field) + for model_field in model_fields: + base_val = getattr(base, model_field) + override_val = getattr(override, model_field) if isinstance(base_val, dict) and isinstance(override_val, dict): # Shallow merge dicts - merged_values[field] = {**base_val, **override_val} + merged_values[model_field] = {**base_val, **override_val} else: - merged_values[field] = ( + merged_values[model_field] = ( override_val if override_val is not None else base_val ) @@ -735,7 +736,7 @@ def from_statestore(cls, config: Dict[str, Any]) -> "AgentExecutionConfig": app_health_check_enabled=app_health_check_enabled, app_ready_check_enabled=app_ready_check_enabled, ) - except Exception as e: + except Exception: return AgentExecutionConfig() def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentExecutionConfig": @@ -744,7 +745,7 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentExecutionConfi 1. Statestore runtime config (highest priority) 2. Passed through instantiation 3. Environment variables (lowest priority) - + Args: runtime_config: Runtime configuration. @@ -958,18 +959,14 @@ def from_statestore(cls, config: Dict[str, Any]) -> "AgentObservabilityConfig": endpoint = config.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None service_name = config.get("OTEL_SERVICE_NAME") or None logging_enabled = ( - config.get("OTEL_LOGGING_ENABLED", "false").lower() - == "true" + config.get("OTEL_LOGGING_ENABLED", "false").lower() == "true" ) tracing_enabled = ( - config.get("OTEL_TRACING_ENABLED", "false").lower() - == "true" + config.get("OTEL_TRACING_ENABLED", "false").lower() == "true" ) logging_exporter: Optional[AgentLoggingExporter] = None - logging_exporter_str = config.get( - "OTEL_LOGS_EXPORTER", "console" - ) + logging_exporter_str = config.get("OTEL_LOGS_EXPORTER", "console") if logging_exporter_str: try: logging_exporter = AgentLoggingExporter(logging_exporter_str) @@ -977,9 +974,7 @@ def from_statestore(cls, config: Dict[str, Any]) -> "AgentObservabilityConfig": logging_exporter = AgentLoggingExporter.CONSOLE tracing_exporter: Optional[AgentTracingExporter] = None - tracing_exporter_str = config.get( - "OTEL_TRACES_EXPORTER", "console" - ) + tracing_exporter_str = config.get("OTEL_TRACES_EXPORTER", "console") if tracing_exporter_str: try: tracing_exporter = AgentTracingExporter(tracing_exporter_str) @@ -996,10 +991,12 @@ def from_statestore(cls, config: Dict[str, Any]) -> "AgentObservabilityConfig": tracing_enabled=tracing_enabled, tracing_exporter=tracing_exporter, ) - except Exception as e: + except Exception: return AgentObservabilityConfig() - - def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentObservabilityConfig": + + def resolve_config( + self, runtime_config: Dict[str, Any] + ) -> "AgentObservabilityConfig": """ Resolve the observability configuration for the agent in the following order: 1. Passed through instantiation (highest priority) @@ -1028,7 +1025,6 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentObservabilityC setattr(self, k, v) return self - class AgentMetadata(BaseModel): diff --git a/dapr_agents/agents/utils/models.py b/dapr_agents/agents/utils/models.py index dc93d9348..0822730bf 100644 --- a/dapr_agents/agents/utils/models.py +++ b/dapr_agents/agents/utils/models.py @@ -12,7 +12,7 @@ # from dataclasses import is_dataclass -from typing import Any, Callable, Callable +from typing import Any, Callable from pydantic import BaseModel diff --git a/tests/agents/durableagent/test_observability_config.py b/tests/agents/durableagent/test_observability_config.py index 695cc1b28..1f98bdd93 100644 --- a/tests/agents/durableagent/test_observability_config.py +++ b/tests/agents/durableagent/test_observability_config.py @@ -821,85 +821,13 @@ def test_no_config_sources_returns_defaults(self, mock_llm, monkeypatch): class TestObservabilityConfigMergeLogic: """Test cases for the merge logic specifically.""" - @pytest.fixture(autouse=True) - def setup_env(self, monkeypatch): - """Set up environment variables and mocks for testing.""" - # Clear any OTEL environment variables - for key in list(os.environ.keys()): - if key.startswith("OTEL_"): - monkeypatch.delenv(key, raising=False) - - os.environ["OPENAI_API_KEY"] = "test-api-key" - - # Mock DaprClient - mock_client = MockDaprClient() - self._patch_dapr_client(monkeypatch, mock_client) - - # Mock the observability setup to avoid actual OTel initialization - monkeypatch.setattr( - "dapr_agents.agents.base.AgentBase._setup_agent_observability", Mock() - ) - - yield - if "OPENAI_API_KEY" in os.environ: - del os.environ["OPENAI_API_KEY"] - - def _patch_dapr_client(self, monkeypatch, mock_client): - """Helper to patch DaprClient in both locations.""" - # Create a mock class that returns the mock_client when instantiated - # We need to capture mock_client in a closure - captured_client = mock_client - - class MockDaprClientClass: - def __init__(self, **kwargs): - pass - - def __enter__(self): - return captured_client - - def __exit__(self, *args): - pass - - monkeypatch.setattr("dapr_agents.agents.base.DaprClient", MockDaprClientClass) - monkeypatch.setattr( - "dapr_agents.storage.daprstores.statestore.DaprClient", MockDaprClientClass - ) - - @pytest.fixture - def mock_llm(self): - """Create a mock LLM client.""" - mock = Mock(spec=OpenAIChatClient) - mock.prompt_template = None - mock.__class__.__name__ = "MockLLMClient" - mock.provider = "MockOpenAIProvider" - mock.api = "MockOpenAIAPI" - mock.model = "gpt-4o-mock" - return mock - - def test_merge_none_values_dont_override(self, mock_llm): + def test_merge_none_values_dont_override(self): """Test that None values in override don't override base values.""" - agent = DurableAgent( - name="TestAgent", - role="Test Assistant", - llm=mock_llm, - pubsub=AgentPubSubConfig( - pubsub_name="testpubsub", - agent_topic="TestAgent", - ), - state=AgentStateConfig( - store=StateStoreService(store_name="teststatestore") - ), - registry=AgentRegistryConfig( - store=StateStoreService(store_name="testregistry") - ), - ) - base = AgentObservabilityConfig( enabled=True, service_name="base-service", endpoint="http://base-endpoint:4317", ) - override = AgentObservabilityConfig( enabled=None, # Should not override service_name="override-service", @@ -912,30 +840,13 @@ def test_merge_none_values_dont_override(self, mock_llm): assert merged.service_name == "override-service" # From override assert merged.endpoint == "http://base-endpoint:4317" # From base - def test_merge_boolean_fields_correctly(self, mock_llm): + def test_merge_boolean_fields_correctly(self): """Test that boolean fields merge correctly with None handling.""" - agent = DurableAgent( - name="TestAgent", - role="Test Assistant", - llm=mock_llm, - pubsub=AgentPubSubConfig( - pubsub_name="testpubsub", - agent_topic="TestAgent", - ), - state=AgentStateConfig( - store=StateStoreService(store_name="teststatestore") - ), - registry=AgentRegistryConfig( - store=StateStoreService(store_name="testregistry") - ), - ) - base = AgentObservabilityConfig( enabled=True, logging_enabled=True, tracing_enabled=False, ) - override = AgentObservabilityConfig( enabled=False, logging_enabled=None, @@ -948,24 +859,8 @@ def test_merge_boolean_fields_correctly(self, mock_llm): assert merged.logging_enabled is True # Base wins (override is None) assert merged.tracing_enabled is True # Override wins - def test_merge_empty_configs(self, mock_llm): + def test_merge_empty_configs(self): """Test merging two empty configs.""" - agent = DurableAgent( - name="TestAgent", - role="Test Assistant", - llm=mock_llm, - pubsub=AgentPubSubConfig( - pubsub_name="testpubsub", - agent_topic="TestAgent", - ), - state=AgentStateConfig( - store=StateStoreService(store_name="teststatestore") - ), - registry=AgentRegistryConfig( - store=StateStoreService(store_name="testregistry") - ), - ) - base = AgentObservabilityConfig() override = AgentObservabilityConfig() From 9e193b1d9df2a5e67db8ebb259132fd95b92277b Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 20 May 2026 23:05:11 -0700 Subject: [PATCH 43/44] refactor: change error handling for config resolution Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/configs.py | 76 ++++++++++++++++++------------ dapr_agents/agents/utils/models.py | 32 ++++--------- 2 files changed, 55 insertions(+), 53 deletions(-) diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 8fc29c188..18fcdd321 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -469,32 +469,25 @@ def merge_configs(base: T, override: T) -> T: f"Cannot merge models of different types: {base!r} and {override!r}" ) - try: - # Infer model type from the base - model_fields = get_model_fields(base) - model_factory = get_model_factory(base) - - if not model_fields or not model_factory: - raise TypeError(f"Unsupported model type: {base!r}") - - merged_values: Dict[str, Any] = {} - - for model_field in model_fields: - base_val = getattr(base, model_field) - override_val = getattr(override, model_field) - - if isinstance(base_val, dict) and isinstance(override_val, dict): - # Shallow merge dicts - merged_values[model_field] = {**base_val, **override_val} - else: - merged_values[model_field] = ( - override_val if override_val is not None else base_val - ) - - return model_factory(merged_values) # type: ignore + # Infer model type from the base model + model_fields = get_model_fields(base) + model_factory = get_model_factory(base) + + config: Dict[str, Any] = {} + + for model_field in model_fields: + base_field = getattr(base, model_field) + override_field = getattr(override, model_field) + + if isinstance(base_field, dict) and isinstance(override_field, dict): + # Shallow merge dicts + config[model_field] = {**base_field, **override_field} + else: + config[model_field] = ( + override_field if override_field is not None else base_field + ) - except Exception as e: - raise ValueError(f"Configuration merge failed: {e}") from e + return model_factory(config) @dataclass @@ -756,14 +749,24 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentExecutionConfi config = AgentExecutionConfig.from_env() logger.debug(f"Env execution config: {config}") - config = merge_configs(config, self) + try: + config = merge_configs(config, self) + except Exception as e: + logger.warning(f"Failed to merge execution config with env execution config: {e}") + config = self + logger.debug(f"Merged execution config: {config}") statestore_config = AgentExecutionConfig.from_statestore(runtime_config) logger.debug(f"Statestore execution config: {statestore_config}") - config = merge_configs(config, statestore_config) - logger.debug(f"Final execution config with statestore override: {config}") + try: + config = merge_configs(config, statestore_config) + except Exception as e: + logger.warning(f"Failed to merge execution config with statestore execution config: {e}") + config = self + + logger.debug(f"Final execution config: {config}") for k, v in config.__dict__.items(): setattr(self, k, v) @@ -1005,6 +1008,7 @@ def resolve_config( Args: runtime_config: Runtime configuration. + Returns: Resolved AgentObservabilityConfig instance for fluent chaining. """ @@ -1015,11 +1019,21 @@ def resolve_config( env_config = AgentObservabilityConfig.from_env() logger.debug(f"Env observability config: {env_config}") - config = merge_configs(config, env_config) + try: + config = merge_configs(config, env_config) + except Exception as e: + logger.warning(f"Failed to merge observability config with env observability config: {e}") + config = self + logger.debug(f"Merged observability config: {config}") - config = merge_configs(config, self) - logger.debug(f"Final observability config with override: {config}") + try: + config = merge_configs(config, self) + except Exception as e: + logger.warning(f"Failed to merge observability config with statestore observability config: {e}") + config = self + + logger.debug(f"Final observability config: {config}") for k, v in config.__dict__.items(): setattr(self, k, v) diff --git a/dapr_agents/agents/utils/models.py b/dapr_agents/agents/utils/models.py index 0822730bf..68ec94c38 100644 --- a/dapr_agents/agents/utils/models.py +++ b/dapr_agents/agents/utils/models.py @@ -11,14 +11,14 @@ # limitations under the License. # -from dataclasses import is_dataclass +from dataclasses import fields, is_dataclass from typing import Any, Callable from pydantic import BaseModel def is_pydantic_model(obj: Any) -> bool: - """Check if the given object is a subclass of Pydantic's BaseModel.""" + """Checks if the given object is a subclass of Pydantic's BaseModel.""" return isinstance(obj, type) and issubclass(obj, BaseModel) @@ -27,20 +27,13 @@ def is_supported_config_model(obj: Any) -> bool: return obj is dict or is_dataclass(obj) or is_pydantic_model(obj) -def get_model_fields(model: Any) -> Any | None: - """ - Extract field names from a config model. - - Returns: - Iterable of field names, or None if unsupported type. - """ +def get_model_fields(model: Any) -> Any: + """Returns field names for a model.""" if type(model) is dict: return model.keys() if is_dataclass(model): - from dataclasses import fields as dataclass_fields - - return [f.name for f in dataclass_fields(model)] + return [f.name for f in fields(model)] if hasattr(model, "model_validate"): # Pydantic v2 @@ -50,18 +43,13 @@ def get_model_fields(model: Any) -> Any | None: # Pydantic v1 return model.__fields__.keys() - return None - + raise TypeError(f"Unsupported model type: {model!r}") -def get_model_factory(model: Any) -> Callable[..., Any] | None: - """ - Get the factory function for creating instances of a config model. - Returns: - Callable that takes a dict and returns an instance. - """ +def get_model_factory(model: Any) -> Callable[..., Any]: + """Returns a factory function that takes a dictionary of values and creates a model instance.""" if type(model) is dict: - return dict # type: ignore + return lambda vals: dict(**vals) if is_dataclass(model): return lambda vals: type(model)(**vals) @@ -74,4 +62,4 @@ def get_model_factory(model: Any) -> Callable[..., Any] | None: # Pydantic v1 return lambda vals: type(model).parse_obj(vals) - return None + raise TypeError(f"Unsupported model type: {model!r}") From 1bd3973744dc3495ab13d032e6fa515f58b889f6 Mon Sep 17 00:00:00 2001 From: Jeffrey Zhang Date: Wed, 20 May 2026 23:05:53 -0700 Subject: [PATCH 44/44] style: formatting fix Signed-off-by: Jeffrey Zhang --- dapr_agents/agents/configs.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/dapr_agents/agents/configs.py b/dapr_agents/agents/configs.py index 18fcdd321..4aaed931e 100644 --- a/dapr_agents/agents/configs.py +++ b/dapr_agents/agents/configs.py @@ -752,7 +752,9 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentExecutionConfi try: config = merge_configs(config, self) except Exception as e: - logger.warning(f"Failed to merge execution config with env execution config: {e}") + logger.warning( + f"Failed to merge execution config with env execution config: {e}" + ) config = self logger.debug(f"Merged execution config: {config}") @@ -763,7 +765,9 @@ def resolve_config(self, runtime_config: Dict[str, Any]) -> "AgentExecutionConfi try: config = merge_configs(config, statestore_config) except Exception as e: - logger.warning(f"Failed to merge execution config with statestore execution config: {e}") + logger.warning( + f"Failed to merge execution config with statestore execution config: {e}" + ) config = self logger.debug(f"Final execution config: {config}") @@ -1022,7 +1026,9 @@ def resolve_config( try: config = merge_configs(config, env_config) except Exception as e: - logger.warning(f"Failed to merge observability config with env observability config: {e}") + logger.warning( + f"Failed to merge observability config with env observability config: {e}" + ) config = self logger.debug(f"Merged observability config: {config}") @@ -1030,7 +1036,9 @@ def resolve_config( try: config = merge_configs(config, self) except Exception as e: - logger.warning(f"Failed to merge observability config with statestore observability config: {e}") + logger.warning( + f"Failed to merge observability config with statestore observability config: {e}" + ) config = self logger.debug(f"Final observability config: {config}")