#
# 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 datetime import datetime, timedelta, timezone
import functools
import json
import logging
import re
import uuid
from typing import Any, Dict, Iterable, List, Optional
from os import getenv
from dapr_agents.tool.utils.function_calling import sanitize_openai_tool_name
import dapr.ext.workflow as wf

from dapr_agents.agents.orchestration import (
    OrchestrationStrategy,
    AgentOrchestrationStrategy,
    RoundRobinOrchestrationStrategy,
    RandomOrchestrationStrategy,
)
from dapr_agents.agents.orchestrators.llm.prompts import (
    NEXT_STEP_PROMPT,
    PROGRESS_CHECK_PROMPT,
    SUMMARY_GENERATION_PROMPT,
    TASK_PLANNING_PROMPT,
)
from dapr_agents.agents.orchestrators.llm.schemas import (
    IterablePlanStep,
    NextStep,
    ProgressCheckOutput,
    schemas,
)
from dapr_agents.agents.orchestrators.llm.state import PlanStep
from dapr_agents.agents.orchestrators.llm.utils import (
    find_step_in_plan,
    restructure_plan,
    update_step_statuses,
)

from dapr_agents.agents.base import AgentBase
from dapr_agents.agents.configs import (
    OrchestrationMode,
    ToolExecutionMode,
    AgentExecutionConfig,
    AgentMemoryConfig,
    AgentPubSubConfig,
    AgentRegistryConfig,
    AgentStateConfig,
    RuntimeSubscriptionConfig,
    WorkflowGrpcOptions,
    WorkflowRetryPolicy,
    AgentObservabilityConfig,
)
from dapr_agents.agents.prompting import AgentProfileConfig
from dapr_agents.agents.schemas import (
    AgentWorkflowMessage,
    BroadcastMessage,
    TriggerAction,
)
from dapr_agents.llm.chat import ChatClientBase
from dapr_agents.prompt.base import PromptTemplateBase
from dapr_agents.types import (
    AgentError,
    DaprWorkflowStatus,
    UserMessage,
    ToolMessage,
    AssistantMessage,
)
from dapr_agents.types.tools import ToolExecutionRecord, ToolExecutionStatus
from dapr_agents.tool.utils.serialization import serialize_tool_result
from dapr_agents.workflow.decorators import message_router, workflow_entry
from dapr_agents.workflow.utils.grpc import apply_grpc_options
from dapr_agents.workflow.utils.pubsub import broadcast_message
from dapr_agents.tool.workflow.agent_tool import (
    AgentWorkflowTool,
    agent_to_tool,
    agent_workflow_id,
)
from dapr_agents.tool.workflow.tool_context import WorkflowContextInjectedTool
from dapr_agents.workflow.utils.names import sanitize_agent_name

logger = logging.getLogger(__name__)


def _get_framework_from_registry(
    agent_name: str, infra: Optional[Any] = None
) -> Optional[str]:
    """Fetch framework from agent metadata in registry if available."""
    if infra is None or not getattr(infra, "registry_state", None):
        return None
    if not getattr(infra, "registry_state", None):
        return None
    try:
        # Try to get agent metadata from registry
        agents_metadata = infra.get_agents_metadata(exclude_self=False)
        agent_meta = agents_metadata.get(agent_name)
        if agent_meta and isinstance(agent_meta, dict):
            agent_info = agent_meta.get("agent")
            if agent_info and isinstance(agent_info, dict):
                framework = agent_info.get("framework")
                if framework and isinstance(framework, str):
                    return framework
    except Exception:  # noqa: BLE001
        logger.exception(
            f"Failed to fetch framework from registry for agent '{agent_name}'",
        )
    return None


def broadcast_workflow_id(
    agent_name: str,
    framework: Optional[str] = None,
    infra: Optional[Any] = None,
) -> str:
    """Return the Dapr-registered broadcast workflow name for an agent.

    Args:
        agent_name: Name of the agent.
        framework: Optional framework name. If not provided, attempts to fetch from registry.
        infra: Optional infrastructure instance to fetch framework from registry.

    Returns:
        Workflow name in format: dapr.{framework}.{agent_name}.broadcast
        Defaults to "agents" if framework cannot be determined.
    """
    # Sanitize agent name to comply with OpenAI requirements
    sanitized_agent_name = sanitize_openai_tool_name(agent_name)

    # Determine framework: use provided, fetch from registry, or default to "agents"
    if framework is None:
        framework = _get_framework_from_registry(agent_name, infra)
    if framework is None or framework == "Dapr Agents":
        framework = "agents"

    # Sanitize framework name for use in workflow IDs
    sanitized_framework = sanitize_agent_name(framework.lower())

    return f"dapr.{sanitized_framework}.{sanitized_agent_name}.broadcast"


def orchestration_workflow_id(
    agent_name: str,
    framework: Optional[str] = None,
    infra: Optional[Any] = None,
) -> str:
    """Return the Dapr-registered orchestration workflow name for an agent.

    Args:
        agent_name: Name of the agent.
        framework: Optional framework name. If not provided, attempts to fetch from registry.
        infra: Optional infrastructure instance to fetch framework from registry.

    Returns:
        Workflow name in format: dapr.{framework}.{agent_name}.orchestration
        Defaults to "agents" if framework cannot be determined.
    """
    # Sanitize agent name to comply with OpenAI requirements
    sanitized_agent_name = sanitize_openai_tool_name(agent_name)

    # Determine framework: use provided, fetch from registry, or default to "agents"
    if framework is None:
        framework = _get_framework_from_registry(agent_name, infra)
    if framework is None or framework == "Dapr Agents":
        framework = "agents"

    # Sanitize framework name for use in workflow IDs
    sanitized_framework = sanitize_agent_name(framework.lower())

    return f"dapr.{sanitized_framework}.{sanitized_agent_name}.orchestration"


class DurableAgent(AgentBase):
    """
    Workflow-native durable agent runtime on top of AgentBase.

    Overview:
        Wires your AgentBase behavior into Dapr Workflows for durable, pub/sub-driven runs.
        Persists state using the built-in AgentWorkflowState schema while still honoring
        safe hook overrides (entry_factory, message_coercer, etc.).

    """

    def __init__(
        self,
        *,
        # Profile / prompt
        profile: Optional[AgentProfileConfig] = None,
        name: Optional[str] = None,
        role: Optional[str] = None,
        goal: Optional[str] = None,
        instructions: Optional[Iterable[str]] = None,
        style_guidelines: Optional[Iterable[str]] = None,
        system_prompt: Optional[str] = None,
        prompt_template: Optional[PromptTemplateBase] = None,
        # Infrastructure
        pubsub: Optional[AgentPubSubConfig] = None,
        state: Optional[AgentStateConfig] = None,
        registry: Optional[AgentRegistryConfig] = None,
        # Memory
        memory: Optional[AgentMemoryConfig] = None,
        llm: Optional[ChatClientBase] = None,
        tools: Optional[Iterable[Any]] = None,
        # Behavior / execution
        execution: Optional[AgentExecutionConfig] = None,
        # Misc
        agent_metadata: Optional[Dict[str, Any]] = None,
        workflow_grpc: Optional[WorkflowGrpcOptions] = None,
        runtime: Optional[wf.WorkflowRuntime] = None,
        retry_policy: WorkflowRetryPolicy = WorkflowRetryPolicy(),
        agent_observability: Optional[AgentObservabilityConfig] = None,
        configuration: Optional[RuntimeSubscriptionConfig] = None,
    ) -> None:
        """
        Initialize behavior, infrastructure, and workflow runtime.

        Args:
            profile: High-level profile (can be overridden by explicit fields).
            name: Agent name (required if not in `profile`).
            role: Agent role/persona label.
            goal: High-level objective for prompting context.
            instructions: Extra instruction lines for the system prompt.
            style_guidelines: Style directives for the system prompt.
            system_prompt: System prompt override.
            prompt_template: Optional explicit prompt template instance.

            pubsub: Optional Dapr Pub/Sub configuration for triggers/broadcasts.
                If omitted, the agent won't subscribe to any topics and can only be
                triggered directly via AgentRunner.
            state: Durable state configuration (store/key + optional hooks).
            registry: Team registry configuration.
            execution: Execution dials for the agent run.

            memory: Enable long-term conversation memory storage; defaults to false.
            llm: Chat client; defaults to `get_default_llm()`.
            tools: Optional tool callables or ``AgentTool`` instances.
                All agents sharing the same registry are auto-discovered as
                tools at workflow start via ``load_tools``.

            agent_metadata: Extra metadata to publish to the registry.
            workflow_grpc: Optional gRPC overrides for the workflow runtime channel.
            runtime: Optional pre-existing workflow runtime to attach to.
            retry_policy: Durable retry policy configuration.
            agent_observability: Observability configuration for tracing/logging.
            configuration: Optional configuration store settings for hot-reloading.
        """
        # Mark orchestrators to filtered out when other orchestrators query for available agents
        if execution and execution.orchestration_mode:
            agent_metadata = dict(agent_metadata or {})
            agent_metadata["orchestrator"] = True

        # DurableAgent instances in the tools list are converted to AgentWorkflowTool
        # after super().__init__() so they don't reach AgentToolExecutor as raw objects.
        self._agents_as_tools: List[DurableAgent] = [
            item for item in list(tools or []) if isinstance(item, DurableAgent)
        ]
        tools = [
            item for item in list(tools or []) if not isinstance(item, DurableAgent)
        ]

        super().__init__(
            pubsub=pubsub,
            profile=profile,
            name=name,
            role=role,
            goal=goal,
            instructions=instructions,
            style_guidelines=style_guidelines,
            system_prompt=system_prompt,
            state=state,
            memory=memory,
            registry=registry,
            execution=execution,
            agent_metadata=agent_metadata,
            workflow_grpc=workflow_grpc,
            llm=llm,
            tools=tools,
            prompt_template=prompt_template,
            agent_observability=agent_observability,
            configuration=configuration,
        )

        # Convert any DurableAgent objects to AgentWorkflowTool and register immediately.
        # Orchestrators dispatch to sub-agents via their orchestration strategy,
        # not via tool_executor, so agent-as-tool registration is skipped for them.
        if not self.execution.orchestration_mode:
            for item in self._agents_as_tools:
                self.tool_executor.register_tool(
                    agent_to_tool(item.name, description=item.profile.role or "")
                )
            # 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"

        grpc_options = getattr(self, "workflow_grpc_options", None)
        apply_grpc_options(grpc_options)

        self._runtime: wf.WorkflowRuntime = runtime or wf.WorkflowRuntime()
        self._runtime_owned = runtime is None
        self._registered = False
        self._started = False

        try:
            retries = int(getenv("DAPR_API_MAX_RETRIES", ""))
        except ValueError:
            retries = retry_policy.max_attempts

        if retries < 1:
            raise (
                ValueError("max_attempts or DAPR_API_MAX_RETRIES must be at least 1.")
            )

        self._retry_policy: wf.RetryPolicy = wf.RetryPolicy(
            max_number_of_attempts=retries,
            first_retry_interval=timedelta(
                seconds=retry_policy.initial_backoff_seconds
            ),
            max_retry_interval=timedelta(seconds=retry_policy.max_backoff_seconds),
            backoff_coefficient=retry_policy.backoff_multiplier,
            retry_timeout=(
                timedelta(seconds=retry_policy.retry_timeout)
                if retry_policy.retry_timeout
                else None
            ),
        )

        self._orchestration_strategy: Optional[OrchestrationStrategy] = None
        # Initialize orchestration strategy if this agent is an orchestrator
        if self.execution.orchestration_mode:
            # Default to "agent" mode if no mode specified

            match self.execution.orchestration_mode:
                case OrchestrationMode.AGENT:
                    self._orchestration_strategy = AgentOrchestrationStrategy()
                case OrchestrationMode.ROUNDROBIN:
                    self._orchestration_strategy = RoundRobinOrchestrationStrategy()
                case OrchestrationMode.RANDOM:
                    self._orchestration_strategy = RandomOrchestrationStrategy()
                case _:
                    raise ValueError(
                        f"Invalid orchestration_mode: {self.execution.orchestration_mode}. "
                        f"Must be one of: 'agent', 'roundrobin', 'random'"
                    )

            # Store orchestrator name for strategy finalization
            self._orchestration_strategy.orchestrator_name = self.name
            self.effective_team = registry.team_name if registry else "default"

            logger.debug(
                f"Initialized orchestrator '{self.name}' with mode '{self.execution.orchestration_mode}'"
            )

    # ------------------------------------------------------------------
    # Runtime accessors
    # ------------------------------------------------------------------
    @property
    def orchestrator(self) -> bool:
        """True if this agent is configured as an orchestrator (has orchestration strategy)."""
        return self._orchestration_strategy is not None

    @property
    def runtime(self) -> wf.WorkflowRuntime:
        """Return the underlying workflow runtime."""
        return self._runtime

    @property
    def is_started(self) -> bool:
        """Return True when the workflow runtime has been started."""
        return self._started

    @property
    def agent_workflow_name(self) -> str:
        """Dapr-registered name of this agent's primary workflow."""
        return agent_workflow_id(self.name)

    @property
    def broadcast_workflow_name(self) -> str:
        """Dapr-registered name of this agent's broadcast workflow."""
        return broadcast_workflow_id(self.name, infra=self._infra)

    # ------------------------------------------------------------------
    # Workflows / Activities
    # ------------------------------------------------------------------
    @workflow_entry
    @message_router(message_model=TriggerAction)
    def agent_workflow(self, ctx: wf.DaprWorkflowContext, message: dict):
        """
        Primary workflow loop reacting to `TriggerAction` pub/sub messages.

        Args:
            ctx: Dapr workflow context injected by the runtime.
            message: Trigger payload; may include task string and metadata.

        Returns:
            Final assistant message as a dict.

        Raises:
            AgentError: If the loop finishes without producing a final response.
        """
        task = message.get("task")
        metadata = message.get("_message_metadata", {}) or {}

        # Propagate OTel/parent workflow relations if present.
        otel_span_context = message.get("_otel_span_context")
        if "workflow_instance_id" in message:
            metadata["triggering_workflow_instance_id"] = message[
                "workflow_instance_id"
            ]

        trigger_instance_id = metadata.get("triggering_workflow_instance_id")
        source = metadata.get("source") or "direct"

        if not ctx.is_replaying:
            logger.info(f"Initial message from {source} -> {self.name}")

        # Record initial entry via activity to keep deterministic/replay-friendly I/O.
        yield ctx.call_activity(
            self.record_initial_entry,
            input={
                "instance_id": ctx.instance_id,
                "source": source,
                "triggering_workflow_instance_id": trigger_instance_id,
                "trace_context": otel_span_context,
            },
            retry_policy=self._retry_policy,
        )

        # Discover is_tool=True agents from registry and resolve any string-named tools.
        if self.registry:
            yield ctx.call_activity(
                self.load_tools,
                retry_policy=self._retry_policy,
            )

        final_message: Dict[str, Any] = {}
        turn = 0
        _workflow_exc: Optional[Exception] = None

        try:
            # Delegate to orchestration workflow if this agent is an orchestrator
            if self._orchestration_strategy:
                if not ctx.is_replaying:
                    logger.info(
                        "Agent %s delegating to orchestration_workflow (instance=%s)",
                        self.name,
                        ctx.instance_id,
                    )

                final_message = yield ctx.call_child_workflow(
                    workflow=orchestration_workflow_id(self.name, infra=self._infra),
                    input={
                        "task": task,
                        "instance_id": ctx.instance_id,
                        "triggering_workflow_instance_id": trigger_instance_id,
                        "start_time": ctx.current_utc_datetime.isoformat(),
                    },
                    retry_policy=self._retry_policy,
                )

                if not ctx.is_replaying:
                    logger.info(
                        "Orchestration workflow completed (instance=%s)",
                        ctx.instance_id,
                    )

            # Standard agent execution loop
            else:
                for turn in range(1, self.execution.max_iterations + 1):
                    if not ctx.is_replaying:
                        logger.debug(
                            "Agent %s turn %d/%d (instance=%s)",
                            self.name,
                            turn,
                            self.execution.max_iterations,
                            ctx.instance_id,
                        )

                    assistant_response: Dict[str, Any] = yield ctx.call_activity(
                        self.call_llm,
                        input={
                            "task": task,
                            "instance_id": ctx.instance_id,
                            "time": ctx.current_utc_datetime.isoformat(),
                            "source": source,
                        },
                        retry_policy=self._retry_policy,
                    )
                    tool_calls = assistant_response.get("tool_calls") or []
                    if tool_calls:
                        if not ctx.is_replaying:
                            logger.debug(
                                "Agent %s executing %d tool call(s) on turn %d",
                                self.name,
                                len(tool_calls),
                                turn,
                            )

                        workflow_tasks: List[Any] = []
                        workflow_meta: List[Dict[str, Any]] = []
                        activity_tasks: List[Any] = []
                        activity_meta: List[Dict[str, Any]] = []

                        for idx, tc in enumerate(tool_calls):
                            fn_name = tc["function"]["name"]
                            tool_obj = self.tool_executor.get_tool(fn_name)

                            # WorkflowContextInjectedTool instances get executed inline as workflow tasks so they can receive the workflow context.
                            # They must be executed in the workflow to have access to the ctx to call ctx.call_child_workflow,
                            # and cannot be ran within an activity bc activities do not have the workflow context.
                            if tool_obj and isinstance(
                                tool_obj, WorkflowContextInjectedTool
                            ):
                                raw_args = tc["function"].get("arguments", "")
                                try:
                                    args = json.loads(raw_args) if raw_args else {}
                                except json.JSONDecodeError as exc:
                                    raise AgentError(
                                        f"Failed to decode tool arguments for '{fn_name}': {exc}"
                                    ) from exc
                                # Only pass _child_instance_id for AgentWorkflowTool instances
                                # (agent-as-tool calls), not for other WorkflowContextInjectedTool types
                                call_kwargs = {
                                    "ctx": ctx,
                                    "_source_agent": self.name,
                                    **args,
                                }
                                if isinstance(tool_obj, AgentWorkflowTool):
                                    child_instance_id = str(uuid.uuid4())
                                    call_kwargs["_child_instance_id"] = (
                                        child_instance_id
                                    )
                                    workflow_meta.append(
                                        {
                                            "order": idx,
                                            "tool_call": tc,
                                            "child_instance_id": child_instance_id,
                                            "dispatch_time": ctx.current_utc_datetime.isoformat(),
                                        }
                                    )
                                else:
                                    workflow_meta.append(
                                        {
                                            "order": idx,
                                            "tool_call": tc,
                                            "dispatch_time": ctx.current_utc_datetime.isoformat(),
                                        }
                                    )
                                workflow_tasks.append(tool_obj(**call_kwargs))
                            # Invoke and execute regular tools.
                            else:
                                activity_tasks.append(
                                    ctx.call_activity(
                                        self.run_tool,
                                        input={
                                            "tool_call": tc,
                                            "instance_id": ctx.instance_id,
                                            "time": ctx.current_utc_datetime.isoformat(),
                                            "order": idx,
                                        },
                                        retry_policy=self._retry_policy,
                                    )
                                )
                                activity_meta.append(
                                    {
                                        "order": idx,
                                        "tool_call": tc,
                                        "dispatch_time": ctx.current_utc_datetime.isoformat(),
                                    }
                                )

                        all_tasks = workflow_tasks + activity_tasks
                        if (
                            self.execution.tool_execution_mode
                            == ToolExecutionMode.SEQUENTIAL
                        ):
                            results: List[Any] = []
                            for task in all_tasks:
                                results.append((yield task))
                        else:
                            results: List[Any] = yield wf.when_all(all_tasks)

                        ordered: List[Optional[Dict[str, Any]]] = [None] * len(
                            tool_calls
                        )

                        for meta, res in zip(
                            workflow_meta, results[: len(workflow_tasks)]
                        ):
                            tc = meta["tool_call"]
                            fn_name = tc["function"]["name"]
                            serialized = serialize_tool_result(res)
                            tool_msg = ToolMessage(
                                content=serialized,
                                role="tool",
                                name=fn_name,
                                tool_call_id=tc["id"],
                            )
                            ordered[meta["order"]] = tool_msg.model_dump()

                        for meta, res in zip(
                            activity_meta, results[len(workflow_tasks) :]
                        ):
                            ordered[meta["order"]] = res

                        tool_results = [tr for tr in ordered if tr is not None]
                        tool_calls_by_id = {
                            meta["tool_call"]["id"]: {
                                "tool_call": meta["tool_call"],
                                "is_agent_call": True,
                                "child_instance_id": meta.get("child_instance_id"),
                                "dispatch_time": meta.get("dispatch_time"),
                            }
                            for meta in workflow_meta
                        }
                        tool_calls_by_id.update(
                            {
                                meta["tool_call"]["id"]: {
                                    "tool_call": meta["tool_call"],
                                    "is_agent_call": False,
                                    "dispatch_time": meta.get("dispatch_time"),
                                }
                                for meta in activity_meta
                            }
                        )
                        yield ctx.call_activity(
                            self.save_tool_results,
                            input={
                                "tool_results": tool_results,
                                "instance_id": ctx.instance_id,
                                "tool_calls_by_id": tool_calls_by_id,
                            },
                            retry_policy=self._retry_policy,
                        )
                        task = None  # prepare for next turn
                        continue

                    final_message = assistant_response
                    if not ctx.is_replaying:
                        logger.debug(
                            f"Agent {self.name} produced final response on turn {turn} (instance={ctx.instance_id})",
                        )
                    break
                else:
                    # Loop exhausted without a terminating reply → surface a friendly notice.
                    base = final_message.get("content") or ""
                    if base:
                        base = base.rstrip() + "\n\n"
                    base += (
                        "I reached the maximum number of reasoning steps before I could finish. "
                        "Please rephrase or provide more detail so I can try again."
                    )
                    final_message = {"role": "assistant", "content": base}
                    if not ctx.is_replaying:
                        logger.warning(
                            f"Agent {self.name} hit max iterations ({self.execution.max_iterations}) without a final response (instance={ctx.instance_id})",
                        )

        except Exception as exc:  # noqa: BLE001
            logger.exception(f"Agent {self.name} workflow failed: {exc}")
            _workflow_exc = exc
            final_message = {"role": "assistant", "content": f"Error: {str(exc)}"}

        # Orchestrators broadcast the final message to the team for context sharing.
        # Non-orchestrators with broadcast_topic_name are subscribers only.
        if self.broadcast_topic_name and self.orchestrator:
            yield ctx.call_activity(
                self.broadcast_to_team,
                input={"message": final_message},
                retry_policy=self._retry_policy,
            )

        if self.memory is not None:
            yield ctx.call_activity(
                self.summarize,
                input={},
                retry_policy=self._retry_policy,
            )

        # Finalize the workflow entry in durable state.
        yield ctx.call_activity(
            self.finalize_workflow,
            input={
                "instance_id": ctx.instance_id,
                "final_output": final_message.get("content", ""),
                "end_time": ctx.current_utc_datetime.isoformat(),
                "triggering_workflow_instance_id": trigger_instance_id,
            },
            retry_policy=self._retry_policy,
        )

        if not ctx.is_replaying:
            if _workflow_exc is not None:
                verdict = DaprWorkflowStatus.FAILED
            elif turn == self.execution.max_iterations:
                ctx.set_custom_status("max_iterations_reached")
                logger.info(
                    "Workflow reached max iterations without final response (instance=%s)",
                    ctx.instance_id,
                )
                verdict = DaprWorkflowStatus.COMPLETED
            else:
                verdict = DaprWorkflowStatus.COMPLETED
            logger.info(
                "Workflow %s finalized for agent %s with verdict=%s",
                ctx.instance_id,
                self.name,
                verdict,
            )

        if _workflow_exc is not None:
            raise AgentError(
                f"Agent {self.name} workflow failed: {_workflow_exc}"
            ) from _workflow_exc

        return final_message

    def orchestration_workflow(self, ctx: wf.DaprWorkflowContext, message: dict):
        """Dedicated orchestration workflow using strategy pattern.

        This is an internal workflow called via call_child_workflow from agent_workflow.

        Args:
            ctx: Dapr workflow context
            message: Input dict with task, instance_id, triggering_workflow_instance_id, start_time

        Returns:
            Final message dict to be returned to caller
        """
        task = message.get("task")
        instance_id = message.get("instance_id")

        if not ctx.is_replaying:
            logger.info(
                f"Orchestration workflow started for instance {instance_id} with task: {task}"
            )

        agents_result = yield ctx.call_activity(
            self.get_team_members,
            retry_policy=self._retry_policy,
        )

        if isinstance(self._orchestration_strategy, AgentOrchestrationStrategy):
            agents_formatted = agents_result["formatted"]
            plan_prompt = TASK_PLANNING_PROMPT.format(
                task=task, agents=agents_formatted, plan_schema=schemas.plan
            )
            init_response = yield ctx.call_activity(
                self.call_llm,
                input={
                    "instance_id": instance_id,
                    "task": plan_prompt,
                    "time": ctx.current_utc_datetime.isoformat(),
                    "response_format": "IterablePlanStep",
                },
                retry_policy=self._retry_policy,
            )

            content = init_response.get("content", "{}")
            try:
                parsed_content = json.loads(content)
                plan = parsed_content.get("objects", [])
            except json.JSONDecodeError as e:
                logger.error(f"Failed to parse LLM response content: {e}")
                plan = []

            if not ctx.is_replaying:
                logger.info(f"Received plan from initialization with {len(plan)} steps")

            plan_content = json.dumps({"objects": plan}, indent=2)
            plan_message = {
                "role": "assistant",
                "name": self.name,
                "content": plan_content,
            }

            yield ctx.call_activity(
                self.save_plan,
                input={
                    "instance_id": instance_id,
                    "plan_message": plan_message,
                    "time": ctx.current_utc_datetime.isoformat(),
                },
                retry_policy=self._retry_policy,
            )

            orch_state = {
                "task": task,
                "agents": agents_formatted,
                "agents_metadata": agents_result["metadata"],
                "plan": plan,
                "task_history": [],
                "verdict": None,
            }
        else:
            agents_metadata = agents_result["metadata"]
            orch_state = yield ctx.call_activity(
                self.initialize_orchestration,
                input={
                    "task": task,
                    "agents": agents_metadata,
                    "instance_id": instance_id,
                },
                retry_policy=self._retry_policy,
            )

            orch_state["agents_metadata"] = agents_metadata

        for turn in range(1, self.execution.max_iterations + 1):
            if not ctx.is_replaying:
                logger.debug(
                    f"Orchestration turn {turn}/{self.execution.max_iterations} (instance={instance_id})"
                )

            if isinstance(self._orchestration_strategy, AgentOrchestrationStrategy):
                plan = orch_state.get("plan", [])
                agents_formatted = orch_state.get("agents", "")

                if turn > 1:
                    if not ctx.is_replaying:
                        logger.info(f"Plan has {len(plan)} steps (turn {turn})")

                    if len(plan) == 0:
                        raise AgentError(
                            "No plan available; cannot continue orchestration."
                        )

                next_step_prompt = NEXT_STEP_PROMPT.format(
                    task=task,
                    agents=agents_formatted,
                    plan=plan,
                    next_step_schema=schemas.next_step,
                )
                next_step_response = yield ctx.call_activity(
                    self.call_llm,
                    input={
                        "instance_id": instance_id,
                        "task": next_step_prompt,
                        "time": ctx.current_utc_datetime.isoformat(),
                        "response_format": "NextStep",
                    },
                    retry_policy=self._retry_policy,
                )

                try:
                    parsed_content = json.loads(next_step_response.get("content", "{}"))
                    next_agent = parsed_content.get("next_agent")
                    instruction = parsed_content.get("instruction")
                    step_id = parsed_content.get("step")
                    substep_id = parsed_content.get("substep")
                except json.JSONDecodeError as e:
                    logger.error(f"Failed to parse LLM response content: {e}")
                    raise AgentError(f"Failed to parse next step from LLM: {e}")

                if not ctx.is_replaying:
                    logger.info(
                        f"Next step decided: agent={next_agent}, step={step_id}, "
                        f"substep={substep_id}, instruction={instruction}"
                    )

                is_valid = yield ctx.call_activity(
                    self.validate_step,
                    input={
                        "instance_id": instance_id,
                        "plan": self._convert_plan_objects_to_dicts(plan),
                        "step": step_id,
                        "substep": substep_id,
                    },
                    retry_policy=self._retry_policy,
                )

                if not is_valid:
                    if not ctx.is_replaying:
                        logger.warning(
                            f"Step {step_id}, substep {substep_id} not found in plan; skipping turn"
                        )
                    orch_state["verdict"] = "continue"
                    continue

                action = {
                    "agent": next_agent,
                    "instruction": instruction,
                    "metadata": {"step_id": step_id, "substep_id": substep_id},
                }

                orch_state["current_step_id"] = step_id
                orch_state["current_substep_id"] = substep_id
                orch_state["plan"] = plan

            else:
                action = yield ctx.call_activity(
                    self.select_next_task,
                    input={"state": orch_state, "turn": turn, "task": task},
                    retry_policy=self._retry_policy,
                )

            next_agent = action["agent"]
            instruction = action["instruction"]

            if not ctx.is_replaying:
                logger.info(
                    f"Turn {turn}: Selected agent '{next_agent}' with instruction: {instruction[:100]}..."
                )

            agents_metadata = orch_state.get("agents_metadata") or {}
            agent_entry = agents_metadata.get(next_agent)

            if agent_entry is None:
                next_lower = next_agent.lower()
                for key, meta in agents_metadata.items():
                    if key.lower() == next_lower:
                        agent_entry = meta
                        break
                    if next_lower in key.lower() or key.lower() in next_lower:
                        agent_entry = meta
                        break

            if agent_entry is None:
                raise AgentError(
                    f"Agent '{next_agent}' not found in registry. "
                    f"Available agents: {list(agents_metadata.keys())}"
                )

            agent_meta = agent_entry.get("agent")
            if agent_meta is None or not isinstance(agent_meta, dict):
                raise AgentError(
                    f"Agent '{next_agent}' has invalid agent metadata. "
                    f"Available agents: {list(agents_metadata.keys())}"
                )
            agent_appid = agent_entry["agent"]["appid"]
            agent_type = agent_entry["agent"].get("type", "agents").lower()

            agent_appid = agent_meta.get("appid")
            if not agent_appid:
                raise AgentError(
                    f"Agent '{next_agent}' missing appid in metadata. "
                    f"Available agents: {list(agents_metadata.keys())}"
                )

            framework = agent_meta.get("framework")

            child_instance_id = str(uuid.uuid4())
            dispatch_time = ctx.current_utc_datetime.isoformat()
            _agent_tool = agent_to_tool(
                next_agent,
                description="",
                agent_type=agent_type,
                target_app_id=agent_appid,
                framework=framework,
            )
            result = yield _agent_tool(
                ctx=ctx,
                task=instruction,
                _source_agent=self.name,
                _child_instance_id=child_instance_id,
            )
            # Use the child workflow instance ID as the tool_call_id — there is
            # no LLM-assigned ID here since the orchestrator dispatches agents
            # directly (not via LLM tool calls).  save_tool_results will derive
            # agent_workflow_instance_id from tool_call_id because is_agent_call=True
            # and no separate child_instance_id is provided.
            yield ctx.call_activity(
                self.save_tool_results,
                input={
                    "instance_id": instance_id,
                    "tool_results": [
                        {
                            "content": result.get("content", "")
                            if isinstance(result, dict)
                            else str(result),
                            "role": "tool",
                            "name": next_agent,
                            "tool_call_id": child_instance_id,
                        }
                    ],
                    "tool_calls_by_id": {
                        child_instance_id: {
                            "tool_call": {
                                "id": child_instance_id,
                                "type": "function",
                                "function": {
                                    "name": next_agent,
                                    "arguments": json.dumps({"task": instruction}),
                                },
                            },
                            "is_agent_call": True,
                            "dispatch_time": dispatch_time,
                        }
                    },
                    # Do NOT append to entry.messages — the orchestrator dispatches
                    # agents directly (no assistant+tool_calls message exists to
                    # pair with), so adding role:tool messages would violate the
                    # OpenAI constraint and cause a 400 on the next call_llm.
                    "skip_messages": True,
                },
                retry_policy=self._retry_policy,
            )

            result_content = result.get("content", "")
            if result_content.startswith("Error:"):
                raise AgentError(f"Agent '{next_agent}' failed: {result_content}")

            if not ctx.is_replaying:
                logger.info(
                    f"Turn {turn}: Agent '{next_agent}' responded: {result_content[:100]}..."
                )

            if isinstance(self._orchestration_strategy, AgentOrchestrationStrategy):
                plan = orch_state.get("plan", [])
                step_id = orch_state.get("current_step_id")
                substep_id = orch_state.get("current_substep_id")

                target = find_step_in_plan(plan, step_id, substep_id)
                if target:
                    target["status"] = "completed"
                plan = update_step_statuses(plan)

                progress_prompt = PROGRESS_CHECK_PROMPT.format(
                    task=task,
                    plan=plan,
                    step=step_id,
                    substep=substep_id,
                    results=result.get("content", ""),
                    progress_check_schema=schemas.progress_check,
                )
                progress_response = yield ctx.call_activity(
                    self.call_llm,
                    input={
                        "instance_id": instance_id,
                        "task": progress_prompt,
                        "time": ctx.current_utc_datetime.isoformat(),
                        "response_format": "ProgressCheckOutput",
                    },
                    retry_policy=self._retry_policy,
                )

                progress = yield ctx.call_activity(
                    self.evaluate_progress,
                    input={
                        "content": progress_response.get("content", ""),
                        "instance_id": instance_id,
                        "plan_objects": plan,
                    },
                    retry_policy=self._retry_policy,
                )

                plan = progress["plan"]
                verdict = progress["verdict"]

                plan_content = json.dumps({"objects": plan}, indent=2)
                plan_message = {
                    "role": "assistant",
                    "name": self.name,
                    "content": plan_content,
                }
                yield ctx.call_activity(
                    self.save_plan,
                    input={
                        "instance_id": instance_id,
                        "plan_message": plan_message,
                        "time": ctx.current_utc_datetime.isoformat(),
                    },
                    retry_policy=self._retry_policy,
                )

                orch_state["plan"] = plan
                orch_state["verdict"] = verdict
                orch_state["last_agent"] = next_agent
                orch_state["last_result"] = result.get("content", "")

            else:
                process_result = yield ctx.call_activity(
                    self.handle_response,
                    input={
                        "state": orch_state,
                        "response": result,
                        "action": action,
                        "task": task,
                    },
                    retry_policy=self._retry_policy,
                )

                orch_state = process_result["updated_state"]
                verdict = process_result.get("verdict", "continue")

            if isinstance(self._orchestration_strategy, AgentOrchestrationStrategy):
                verdict = orch_state.get("verdict", "continue")
                should_continue = (verdict == "continue") and (
                    turn < self.execution.max_iterations
                )
            else:
                should_continue = yield ctx.call_activity(
                    self.check_completion,
                    input={"state": orch_state, "turn": turn},
                    retry_policy=self._retry_policy,
                )

            if not should_continue:
                if not ctx.is_replaying:
                    logger.info(f"Orchestration stopping after turn {turn}")
                break

        if isinstance(self._orchestration_strategy, AgentOrchestrationStrategy):
            plan = orch_state.get("plan", [])
            verdict = orch_state.get("verdict", "continue")
            step_id = orch_state.get("current_step_id")
            substep_id = orch_state.get("current_substep_id")
            last_agent = orch_state.get("last_agent", "")
            last_result = orch_state.get("last_result", "")

            if verdict == "continue":
                ctx.set_custom_status("max_iterations_reached")
                verdict = "max_iterations_reached"
                logger.info(
                    "Workflow reached max iterations without final response (instance=%s)",
                    ctx.instance_id,
                )

            summary_prompt = SUMMARY_GENERATION_PROMPT.format(
                task=task,
                verdict=verdict,
                plan=plan,
                step=step_id,
                substep=substep_id,
                agent=last_agent,
                result=last_result,
            )
            summary_response = yield ctx.call_activity(
                self.call_llm,
                input={
                    "instance_id": instance_id,
                    "task": summary_prompt,
                    "time": ctx.current_utc_datetime.isoformat(),
                },
                retry_policy=self._retry_policy,
            )

            final_message = {
                "role": "assistant",
                "content": summary_response.get("content", "Orchestration completed."),
                "name": self.name,
            }

        else:
            final_message = yield ctx.call_activity(
                self.finalize_orchestration,
                input={"state": orch_state, "task": task, "instance_id": instance_id},
                retry_policy=self._retry_policy,
            )

        # Broadcast the final plan state to the team so workers have the completed picture.
        # Done here (after all turns) rather than at plan-creation time so workers receive
        # accurate step statuses, not an all-not_started snapshot.
        if self.broadcast_topic_name:
            if isinstance(self._orchestration_strategy, AgentOrchestrationStrategy):
                plan = orch_state.get("plan", [])
                broadcast_msg = {
                    "role": "assistant",
                    "name": self.name,
                    "content": json.dumps({"objects": plan}, indent=2),
                }
            else:
                broadcast_msg = final_message
            yield ctx.call_activity(
                self.broadcast_to_team,
                input={"message": broadcast_msg},
                retry_policy=self._retry_policy,
            )

        if not ctx.is_replaying:
            logger.info(f"Orchestration workflow completed for instance {instance_id}")

        return final_message

    # ------------------------------------------------------------------
    # Strategy Delegation Activities
    # ------------------------------------------------------------------

    def initialize_orchestration(self, ctx: Any, payload: dict) -> dict:
        """Initialize orchestration state via strategy.

        Args:
            ctx: Activity context
            payload: Dict with task, agents, instance_id

        Returns:
            Initial orchestration state from strategy
        """
        if not self._orchestration_strategy:
            raise AgentError("No orchestration strategy configured")

        return self._orchestration_strategy.initialize(
            ctx, payload["task"], payload["agents"]
        )

    def select_next_task(self, ctx: Any, payload: dict) -> dict:
        """Select next agent and instruction via strategy.

        Args:
            ctx: Activity context
            payload: Dict with state, turn, task

        Returns:
            Action dict with agent, instruction, and metadata
        """
        if not self._orchestration_strategy:
            raise AgentError("No orchestration strategy configured")

        return self._orchestration_strategy.select_next_agent(
            ctx, payload["state"], payload["turn"]
        )

    def handle_response(self, ctx: Any, payload: dict) -> dict:
        """Process agent response via strategy.

        Args:
            ctx: Activity context
            payload: Dict with state, response, action, task

        Returns:
            Dict with updated_state and verdict
        """
        if not self._orchestration_strategy:
            raise AgentError("No orchestration strategy configured")

        return self._orchestration_strategy.process_response(
            ctx, payload["state"], payload["response"]
        )

    def check_completion(self, ctx: Any, payload: dict) -> bool:
        """Check if orchestration should continue via strategy.

        Args:
            ctx: Activity context
            payload: Dict with state, turn

        Returns:
            True if should continue, False otherwise
        """
        if not self._orchestration_strategy:
            raise AgentError("No orchestration strategy configured")

        return self._orchestration_strategy.should_continue(
            payload["state"], payload["turn"], self.execution.max_iterations
        )

    def finalize_orchestration(self, ctx: Any, payload: dict) -> dict:
        """Finalize orchestration and generate summary via strategy.

        Args:
            ctx: Activity context
            payload: Dict with state, task, instance_id

        Returns:
            Final message dict for caller
        """
        if not self._orchestration_strategy:
            raise AgentError("No orchestration strategy configured")

        return self._orchestration_strategy.finalize(ctx, payload["state"])

    @message_router(message_model=BroadcastMessage, broadcast=True)
    def on_broadcast(self, ctx: wf.DaprWorkflowContext, message: dict):
        """
        Handle incoming broadcast messages from team agents.

        Stores received context in agent memory so future LLM calls are
        informed by peer activity. Self-originated broadcasts are silently
        ignored to prevent feedback loops.

        Args:
            ctx: Dapr workflow context.
            message: Broadcast payload containing content and metadata.
        """
        metadata = message.get("_message_metadata", {}) or {}
        source = metadata.get("source") or "unknown"
        if source == self.name:
            logger.debug("Agent %s ignoring self-originated broadcast.", self.name)
            return

        logger.info(f"Agent {self.name} received broadcast from {source}")
        yield ctx.call_activity(
            self.record_broadcast,
            input=message,
            retry_policy=self._retry_policy,
        )

    @staticmethod
    def _convert_plan_objects_to_dicts(plan_objects: List[Any]) -> List[Dict[str, Any]]:
        """
        Converts plan objects (Pydantic models or dictionaries) into dictionaries.

        Args:
            plan_objects (List[Any]): A list of plan objects to convert.

        Returns:
            List[Dict[str, Any]]: The converted plan objects as dictionaries.
        """
        if not plan_objects:
            return []
        return [
            obj.model_dump() if hasattr(obj, "model_dump") else dict(obj)
            for obj in plan_objects
        ]

    def get_plan_from_messages(
        self, messages: List[AgentWorkflowMessage]
    ) -> Optional[List[Dict[str, Any]]]:
        # Find all assistant messages with JSON content starting with {
        plan_messages = [
            m
            for m in messages
            if m.role == "assistant" and m.content.strip().startswith("{")
        ]

        # Get the LAST (most recent) plan message
        if not plan_messages:
            return None

        plan_msg = plan_messages[-1]  # Get the last one

        try:
            data = json.loads(plan_msg.content)
            return self._convert_plan_objects_to_dicts(
                [PlanStep(**obj) for obj in data.get("objects", [])]
            )
        except (json.JSONDecodeError, KeyError, TypeError) as e:
            logger.debug("Failed to parse plan from message: %s", e)
            return None

    async def update_plan_internal(
        self,
        *,
        instance_id: str,
        plan: List[Dict[str, Any]],
        status_updates: Optional[List[Dict[str, Any]]] = None,
        plan_updates: Optional[List[Dict[str, Any]]] = None,
    ) -> List[Dict[str, Any]]:
        """
        Apply status/structure updates to the plan and persist them.

        Args:
            instance_id: Workflow instance id.
            plan: Current plan snapshot.
            status_updates: Optional status updates, each with `step`, optional `substep`,
                and `status` fields.
            plan_updates: Optional structural updates (see `restructure_plan` utility).

        Returns:
            The updated plan after applying changes.

        Raises:
            ValueError: If a referenced step/substep is not found in the plan.
        """

        logger.debug("Updating plan for instance %s", instance_id)

        # Validate and apply status updates.
        if status_updates:
            logger.info("Applying %d status update(s) to plan", len(status_updates))
            for u in status_updates:
                step_id = u["step"]
                sub_id = u.get("substep")
                new_status = u["status"]

                logger.debug(
                    f"Updating step {step_id}/{sub_id} to status '{new_status}'"
                )
                target = find_step_in_plan(plan, step_id, sub_id)
                if not target:
                    logger.warning(
                        "Step %s/%s not present in plan — skipping status update.",
                        step_id,
                        sub_id,
                    )
                    continue

                # Apply status update
                target["status"] = new_status
                logger.debug(
                    "Successfully updated status of step %s/%s to '%s'",
                    step_id,
                    sub_id,
                    new_status,
                )

        # Apply structural updates while preserving substeps unless explicitly overridden.
        if plan_updates:
            logger.debug("Applying %d plan restructuring update(s)", len(plan_updates))
            plan = restructure_plan(plan, plan_updates)

        # Apply global consistency checks for statuses
        plan = update_step_statuses(plan)

        logger.debug("Plan successfully updated for instance %s", instance_id)
        return plan

    async def finish_workflow_internal(
        self,
        *,
        instance_id: str,
        plan: List[Dict[str, Any]],
        step: int,
        substep: Optional[float],
        verdict: DaprWorkflowStatus,
        summary: str,
        wf_time: Optional[str],
    ) -> None:
        """
        Finalize workflow by updating statuses (if completed) and storing the summary.

        Args:
            instance_id: Workflow instance id.
            plan: Current plan snapshot.
            step: Completed step id.
            substep: Completed substep id (if any).
            verdict: Outcome category as DaprWorkflowStatus enum
                (e.g., DaprWorkflowStatus.COMPLETED, DaprWorkflowStatus.FAILED).
            summary: Final summary content to persist.
            wf_time: Workflow timestamp (ISO 8601 string) to set as end time if provided.

        Returns:
            None

        Raises:
            ValueError: If a completed step/substep reference is invalid.
        """

        logger.debug(
            "Finalizing workflow for instance %s with verdict '%s'",
            instance_id,
            verdict,
        )

        status_updates: List[Dict[str, Any]] = []

        if verdict == DaprWorkflowStatus.COMPLETED:
            # Find and validate the step or substep
            step_entry = find_step_in_plan(plan, step, substep)
            if not step_entry:
                msg = f"Step {step}/{substep} not found in plan; cannot mark as completed."
                logger.error(msg)
                raise ValueError(msg)

            # Mark the step or substep as completed
            step_entry["status"] = "completed"
            status_updates.append(
                {"step": step, "substep": substep, "status": "completed"}
            )
            logger.debug(f"Marked step {step}/{substep} as completed")

            # If it's a substep, check if all sibling substeps are completed
            if substep is not None:
                parent_step = find_step_in_plan(
                    plan, step
                )  # Get parent without substep
                if parent_step:
                    # Ensure "substeps" is a valid list before iteration
                    substeps = parent_step.get("substeps", [])
                    if not isinstance(substeps, list):
                        substeps = []

                    all_substeps_completed = all(
                        ss.get("status") == "completed" for ss in substeps
                    )
                    if all_substeps_completed:
                        parent_step["status"] = "completed"
                        status_updates.append({"step": step, "status": "completed"})
                        logger.debug(
                            "All substeps of step %s completed; marked parent as completed",
                            step,
                        )

        # Apply updates in one call if any status changes were made
        if status_updates:
            await self.update_plan_internal(
                instance_id=instance_id,
                plan=plan,
                status_updates=status_updates,
            )

        logger.info(
            "Workflow %s finalized with verdict '%s'",
            instance_id,
            verdict,
        )

    # ------------------------------------------------------------------
    # Activities
    # ------------------------------------------------------------------
    def record_initial_entry(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> None:
        """
        Record the initial entry for a workflow instance.
        Input/output/status/timestamps come from Dapr get_workflow.
        We only source, triggering_workflow_instance_id, trace_context.
        """
        instance_id = ctx.workflow_id
        trace_context = payload.get("trace_context")
        source = payload.get("source", "direct")
        triggering_instance = payload.get("triggering_workflow_instance_id")

        try:
            entry = self._infra.get_state(instance_id)
        except Exception:
            logger.exception(
                "Failed to get workflow state for instance_id: %s", instance_id
            )
            raise
        if entry is None:
            return

        entry.source = source
        entry.triggering_workflow_instance_id = triggering_instance
        entry.trace_context = trace_context
        self.save_state(instance_id, entry=entry)

    def record_broadcast(self, _ctx: wf.WorkflowActivityContext, message: dict) -> None:
        """
        Persist a received team broadcast in agent memory.

        Stored as a user message so it is available as context for future
        LLM calls. Called by on_broadcast when a non-self-originated broadcast
        arrives on the shared team topic.

        Args:
            message: Broadcast payload containing content and metadata.
        """
        # Use a fixed session key so all broadcasts accumulate in one memory slot.
        # Key in store: {agent_name}:_memory_broadcast
        session_id = "broadcast"
        metadata = message.get("_message_metadata", {}) or {}
        source = metadata.get("source") or "unknown"
        content = message.get("content", "")
        if self.memory is not None:
            self.memory.add_message(
                UserMessage(content=f"[Broadcast from {source}]: {content}"),
                session_id,
            )
        logger.info(
            "Agent %s recorded broadcast from %s in memory (session=%s)",
            self.name,
            source,
            session_id,
        )

    def call_llm(
        self,
        ctx: wf.WorkflowActivityContext,
        payload: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Ask the LLM to generate the next assistant message.

        Args:
            payload: Must contain 'instance_id'; may include 'task', 'time', and 'response_format'.

        Returns:
            Assistant message as a dict.

        Raises:
            AgentError: If the LLM call fails or yields no message.
        """
        # TODO(@sicoyle): i think i can use the instance_id in teh ctx instead here!!
        instance_id = payload.get("instance_id")
        task = payload.get("task")
        source = payload.get("source")
        response_format_name = payload.get("response_format")

        response_model = None
        if response_format_name:
            model_map = {
                "IterablePlanStep": IterablePlanStep,
                "NextStep": NextStep,
                "ProgressCheckOutput": ProgressCheckOutput,
            }
            response_model = model_map.get(response_format_name)
            if response_model is None:
                logger.warning(
                    "Unknown response_format '%s', ignoring", response_format_name
                )

        # Load state once for all sub-operations in this activity
        entry = self._infra.get_state(instance_id)

        chat_history = self._reconstruct_conversation_history(instance_id, entry=entry)
        messages = self.prompting_helper.build_initial_messages(
            user_input=task,
            chat_history=chat_history,
        )

        # Sync current system messages into per-instance state
        self._sync_system_messages_with_state(instance_id, messages, entry=entry)

        # Persist the user's turn (if any) into the instance timeline + memory
        # Only process and print user message if task is provided (initial turn)
        if task:
            user_message = self._get_last_user_message(messages)
            user_copy = dict(user_message) if user_message else None
            self._process_user_message(
                instance_id, task, user_copy, entry=entry, skip_save=True
            )

            # Skip printing for orchestrators' internal LLM calls
            if user_copy is not None and not self.orchestrator:
                print_msg = {str(k): v for k, v in user_copy.items()}
                if source and source != "direct":
                    print_msg["name"] = f"on-behalf-of {source}"
                self.text_formatter.print_message(print_msg)

        tools = self.get_llm_tools()
        generate_kwargs = {
            "messages": messages,
            "tools": tools,
        }
        if response_model is not None:
            generate_kwargs["response_format"] = response_model
        if tools and self.execution.tool_choice is not None:
            generate_kwargs["tool_choice"] = self.execution.tool_choice

        try:
            response = self.llm.generate(**generate_kwargs)
        except Exception as exc:  # noqa: BLE001
            raise AgentError(str(exc)) from exc

        # Handle structured output response (Pydantic model) vs regular chat response
        if response_model is not None:
            # Structured output: response is the Pydantic model itself
            if hasattr(response, "model_dump"):
                # Response is the structured Pydantic object
                content = json.dumps(response.model_dump())
            else:
                # Fallback: try to serialize as-is
                content = json.dumps(response)

            assistant_message = {
                "role": "assistant",
                "content": content,
            }
        else:
            # Regular chat response
            assistant_message = response.get_message()
            if assistant_message is None:
                raise AgentError("LLM returned no assistant message.")
            assistant_message = assistant_message.model_dump()

        self._save_assistant_message(
            instance_id, assistant_message, entry=entry, skip_save=True
        )
        # Skip printing for orchestrators' internal LLM calls
        if not self.orchestrator:
            self.text_formatter.print_message(assistant_message)
        # Single save for the entire activity
        self.save_state(instance_id, entry=entry)
        return assistant_message

    def run_tool(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> Dict[str, Any]:
        """
        Execute a single tool call.

        Always returns a ToolMessage, even if tool execution fails.
        This ensures every tool_call_id has a corresponding tool message,
        maintaining proper conversation history.

        Args:
            payload: Keys 'tool_call', 'instance_id', 'time', 'order'.

        Returns:
            ToolMessage as a dict. Contains error message if execution failed.

        Raises:
            AgentError: If tool arguments contain invalid JSON.
        """
        tool_call = payload.get("tool_call", {})
        fn_name = tool_call["function"]["name"]
        raw_args = tool_call["function"].get("arguments", "")
        try:
            args = json.loads(raw_args) if raw_args else {}
        except json.JSONDecodeError as exc:
            raise AgentError(f"Invalid JSON in tool args: {exc}") from exc

        # Unwrap MCP-style kwargs wrapper so executor receives flat arguments.
        # This is necessary bc some tool schemas expose a single "kwargs" object and so the llm may return that shape.
        if (
            isinstance(args, dict)
            and len(args) == 1
            and "kwargs" in args
            and isinstance(args.get("kwargs"), dict)
        ):
            args = args["kwargs"]

        async def _execute_tool() -> Any:
            return await self.tool_executor.run_tool(fn_name, **args)

        try:
            result = self._run_asyncio_task(_execute_tool())
            logger.debug(f"Tool {fn_name} returned: {result} (type: {type(result)})")
        except Exception as exc:
            # Ensure we always return a tool result, even on failure
            # This prevents orphaned tool_call_ids that would break conversation history and tool call validations by the LLM provider.
            logger.exception(
                f"Error executing tool '{fn_name}' with tool_call_id '{tool_call.get('id') if isinstance(tool_call, dict) else tool_call}': {exc}"
            )
            err_type = type(exc).__name__
            error_msg = f"Error executing tool '{fn_name}': {err_type}: {str(exc)}"
            result = error_msg

        # Serialize the tool result using centralized utility
        serialized_result = serialize_tool_result(result)

        tool_result = ToolMessage(
            content=serialized_result,
            role="tool",
            name=fn_name,
            tool_call_id=tool_call["id"],
        )

        # Print the tool result for visibility
        self.text_formatter.print_message(tool_result)
        return tool_result.model_dump()

    def save_tool_results(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> None:
        """
        Save tool results to instance state (``entry.messages`` + ``entry.tool_history``).

        This single activity handles two distinct call patterns:

        **Regular agent workflow** (``skip_messages=False``, the default):
            The LLM produced an ``assistant`` message containing ``tool_calls``,
            the tools ran (in parallel), and their results now need to be
            appended to ``entry.messages`` so the *next* ``call_llm`` sees a
            valid conversation sequence::

                [user, assistant+tool_calls, tool(A), tool(B)] → LLM

            Results are also recorded in ``entry.tool_history`` for
            observability and debugging.

        **Orchestration workflow** (``skip_messages=True``):
            Orchestrators dispatch agents *directly* via structured-output
            planning — there is **no** preceding ``assistant+tool_calls`` message
            in the conversation history.  Appending ``role:tool`` messages in
            this case would break the OpenAI API constraint that every tool
            message must immediately follow an assistant message that contains a
            matching ``tool_calls`` entry (HTTP 400).  With ``skip_messages=True``
            results are written to ``entry.tool_history`` only, preserving full
            observability without corrupting the message chain.

        Args:
            payload:
                - ``instance_id`` (str)
                - ``tool_results`` (list[dict]): ToolMessage-shaped dicts
                - ``tool_calls_by_id`` (dict): tool_call_id → dispatch metadata
                  (``tool_call``, ``is_agent_call``, ``child_instance_id``,
                  ``dispatch_time``)
                - ``skip_messages`` (bool, default ``False``): when ``True``,
                  skip appending to ``entry.messages`` (orchestration workflow
                  path — see above)
        """
        instance_id: str = payload.get("instance_id", "")
        tool_results_raw: List[Dict[str, Any]] = payload.get("tool_results", [])
        tool_results: List[ToolMessage] = [ToolMessage(**tr) for tr in tool_results_raw]
        tool_calls_by_id: Dict[str, Any] = payload.get("tool_calls_by_id", {})
        # When True, results go to tool_history only — not entry.messages.
        # See docstring for the full rationale.
        skip_messages: bool = payload.get("skip_messages", False)

        try:
            entry = self._infra.get_state(instance_id)
        except Exception:
            logger.exception(
                f"Failed to get workflow state for instance_id: {instance_id}"
            )
            raise

        # Build the set of tool_call_ids already present in messages and tool_history
        # so we can skip duplicates on workflow replay (Dapr may re-deliver results).
        existing_tool_ids: set[str] = set()
        last_message_is_assistant_with_tool_calls = False
        messages_list: list = []

        if entry is not None and hasattr(entry, "messages"):
            messages_list = getattr(entry, "messages")
            for msg in messages_list:
                try:
                    tid = getattr(msg, "tool_call_id", None)
                    if tid:
                        existing_tool_ids.add(tid)
                except Exception:
                    pass
        # Also check tool_history for deduplication when skip_messages=True
        # (orchestration path) or when messages are skipped
        if entry is not None and hasattr(entry, "tool_history"):
            for record in getattr(entry, "tool_history", []):
                try:
                    tid = getattr(record, "tool_call_id", None)
                    if tid:
                        existing_tool_ids.add(tid)
                except Exception:
                    pass

        # Check if the last non-tool message is an assistant with tool_calls.
        # Scan messages_list from the end, skipping tool messages already saved.
        # Only set True when we actually confirm the right message is present;
        # an empty messages_list stays False so we never append tool results
        # to a message list that has no preceding assistant+tool_calls message.
        if messages_list:
            try:
                for msg in reversed(messages_list):
                    if isinstance(msg, dict):
                        role = msg.get("role")
                        tool_calls_field = msg.get("tool_calls")
                    else:
                        role = getattr(msg, "role", None)
                        tool_calls_field = getattr(msg, "tool_calls", None)
                    if role == "tool":
                        continue  # skip existing tool responses, keep scanning back
                    if role == "assistant" and tool_calls_field:
                        last_message_is_assistant_with_tool_calls = True
                    else:
                        # Last non-tool message is not an assistant+tool_calls — not safe to append
                        last_message_is_assistant_with_tool_calls = False
                    break
            except Exception:
                logger.warning(
                    "Could not scan messages to verify tool result ordering; "
                    "proceeding with save (optimistic)."
                )
                last_message_is_assistant_with_tool_calls = True

        # Process each tool result
        for tool_result in tool_results:
            tool_call_id = tool_result.tool_call_id

            if tool_call_id in existing_tool_ids:
                logger.debug(f"Tool result {tool_call_id} already in entry, skipping")
                continue

            # Append role:tool message so the LLM sees the response on the next
            # turn.  Skipped for orchestrator dispatches — see docstring.
            # Also validate message ordering when skip_messages=False
            if not skip_messages:
                # Only proceed if the last message is an assistant message with tool_calls
                # All tool messages in this batch are responses to that assistant message
                if not last_message_is_assistant_with_tool_calls:
                    logger.warning(
                        f"Skipping tool result {tool_call_id}: tool messages must follow "
                        "an assistant message with tool_calls. Last message is not an assistant "
                        "with tool_calls."
                    )
                elif entry is not None and hasattr(entry, "messages"):
                    tool_message_model = (
                        self._message_coercer(tool_result.model_dump())
                        if getattr(self, "_message_coercer", None)
                        else self._message_dict_to_message_model(
                            tool_result.model_dump()
                        )
                    )
                    entry.messages.append(tool_message_model)
                    if hasattr(entry, "last_message"):
                        entry.last_message = tool_message_model

            # Always record in tool_history regardless of skip_messages so that
            # every agent dispatch (including orchestrator ones) is observable.
            if entry is not None and hasattr(entry, "tool_history"):
                tc_info = tool_calls_by_id.get(tool_call_id, {})
                tc = tc_info.get("tool_call", {})
                fn = tc.get("function", {})
                raw_args = fn.get("arguments", "")
                try:
                    args = json.loads(raw_args) if raw_args else {}
                except json.JSONDecodeError:
                    args = {}
                raw_dispatch = tc_info.get("dispatch_time")
                if raw_dispatch:
                    started_at = self._coerce_datetime(
                        datetime.fromisoformat(raw_dispatch)
                    )
                else:
                    started_at = self._coerce_datetime(datetime.now(timezone.utc))
                completed_at = self._coerce_datetime(datetime.now(timezone.utc))
                is_agent_call = tc_info.get("is_agent_call", False)
                # For regular agent-as-tool calls, child_instance_id is the
                # UUID we pre-generated and passed to ctx.call_child_workflow.
                # For orchestrator dispatches the tool_call_id IS the child
                # instance ID (no separate field needed), so fall back to it.
                agent_wf_instance_id = tc_info.get("child_instance_id") or (
                    tool_call_id if is_agent_call else None
                )
                entry.tool_history.append(
                    ToolExecutionRecord(
                        tool_call_id=tool_call_id,
                        tool_name=tool_result.name or fn.get("name", ""),
                        tool_args=args,
                        status=ToolExecutionStatus.COMPLETED,
                        is_agent_call=is_agent_call,
                        execution_result=tool_result.content,
                        executing_agent=self.name,
                        agent_workflow_instance_id=agent_wf_instance_id,
                        started_at=started_at,
                        completed_at=completed_at,
                    )
                )

            logger.debug(f"Added tool result {tool_call_id} to memory")

        self.save_state(instance_id, entry=entry)

    def broadcast_to_team(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> None:
        """
        Broadcast a message to all agents via pub/sub (if a broadcast topic is set).

        Args:
            payload: Dict containing 'message' (assistant/user-like dict).
        """
        message = payload.get("message", {})
        if not isinstance(message, dict) or not self.broadcast_topic_name:
            logger.debug(
                "Skipping broadcast because payload is invalid or topic is unset."
            )
            return

        try:
            agents_metadata = self.get_agents_metadata(
                exclude_self=False, exclude_orchestrator=False
            )
        except Exception:  # noqa: BLE001
            logger.exception("Unable to load agents metadata; broadcast aborted.")
            return

        # Sanitize agent name to comply with OpenAI's message name requirements
        message["role"] = "user"
        message["name"] = sanitize_openai_tool_name(self.name)
        response_message = BroadcastMessage(**message)

        async def _broadcast() -> None:
            await broadcast_message(
                message=response_message,
                broadcast_topic=self.broadcast_topic_name,
                message_bus=self.message_bus_name,
                source=self.name,
                agents_metadata=agents_metadata,
            )

        try:
            self._run_asyncio_task(_broadcast())
        except Exception:  # noqa: BLE001
            logger.exception("Failed to publish broadcast message.")

    def summarize(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> Dict[str, Any]:
        """
        Summarize the conversation history and tool calls, then save the summary
        to the configured memory store (keyed by workflow instance id).

        Returns:
            Dict with "content" key holding the summary text, or empty dict if
            no memory/store or no conversation to summarize.
        """
        instance_id = ctx.workflow_id
        try:
            entry = self._infra.get_state(instance_id)
        except Exception:
            logger.exception(
                "Failed to get workflow state for instance_id: %s", instance_id
            )
            raise AgentError(
                f"Failed to get workflow state for instance_id: {instance_id}"
            )

        tool_history = getattr(entry, "tool_history", None) or []
        return self._summarize_conversation(instance_id, entry.messages, tool_history)

    # TODO(@sicoyle): I think we can rm this, but need to double check in follow up PR if dapr captures under the hood triggering workflow instance id.
    def finalize_workflow(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> None:
        """
        Finalize workflow state: persist triggering_workflow_instance_id if provided.
        Status/output/end_time come from Dapr get_workflow; we do not store them here.
        """
        instance_id = payload.get("instance_id")
        triggering_workflow_instance_id = payload.get("triggering_workflow_instance_id")

        try:
            entry = self._infra.get_state(instance_id)
        except Exception:
            logger.exception(
                "Failed to get workflow state for instance_id: %s", instance_id
            )
            raise

        if not entry:
            logger.warning("Workflow state not found for instance_id: %s", instance_id)
            return

        entry.triggering_workflow_instance_id = triggering_workflow_instance_id
        self.save_state(instance_id, entry=entry)

    # ------------------------------------------------------------------
    # Agent-as-tool: registry discovery activity
    # ------------------------------------------------------------------

    def load_tools(self, ctx: wf.WorkflowActivityContext) -> List[str]:
        """
        Discover all agents in the shared registry and register each as an
        ``AgentWorkflowTool`` in this agent's tool executor.

        Any agent registered in the same registry is automatically treated as a
        callable tool — no opt-in flag required.  Orchestrators are excluded
        since they coordinate rather than execute tasks.

        The activity is idempotent: agents already present in the tool executor
        are skipped.  It is called at the start of every ``agent_workflow``
        invocation so that newly-registered agents are visible without restarting.

        Returns:
            List of agent names that were newly registered during this call.
        """
        # orchestrators dispatch via strategy, not tool_executor
        if self.orchestrator:
            return []
        if not self.registry:
            return []

        agents_metadata = self._infra.get_agents_metadata(
            exclude_self=True, exclude_orchestrator=True
        )
        registered: List[str] = []

        for name, meta in agents_metadata.items():
            # Normalize name for lookup since AgentTool sanitizes names to TitleCase
            sanitized_name = sanitize_openai_tool_name(name)
            if not self.tool_executor.get_tool(sanitized_name):
                # Defensive check: ensure meta is a dict
                # This should not happen if get_agents_metadata validation works correctly,
                # but we keep this as a safety check.
                if meta is None or not isinstance(meta, dict):
                    logger.error(
                        "Skipping agent %s: metadata is None or not a dict. "
                        "This indicates a bug in get_agents_metadata or corrupted registry data.",
                        name,
                    )
                    continue

                agent_meta = meta.get("agent")
                # Defensive check: ensure agent_meta is a dict
                # This should not happen if get_agents_metadata validation works correctly,
                # but we keep this as a safety check.
                if agent_meta is None or not isinstance(agent_meta, dict):
                    logger.error(
                        "Skipping agent %s: agent metadata is None or not a dict. "
                        "This indicates a bug in get_agents_metadata or corrupted registry data.",
                        name,
                    )
                    continue

                framework = agent_meta.get("framework")

                # For dapr-agents, check if we can get the actual workflow name
                workflow_name = None
                if framework == "Dapr Agents":
                    # Try to get the actual workflow name if available
                    # (This would require the agent to store it in metadata, which
                    # isn't currently done, but we leave this hook for future use)
                    metadata_dict = agent_meta.get("metadata")
                    if isinstance(metadata_dict, dict):
                        workflow_name = metadata_dict.get("workflow_name")

                tool = agent_to_tool(
                    name,
                    description=(
                        f"{agent_meta.get('role', '')}. "
                        f"Goal: {agent_meta.get('goal', '')}"
                    ),
                    target_app_id=agent_meta.get("appid"),
                    framework=framework,
                    workflow_name=workflow_name,
                )
                self.tool_executor.register_tool(tool)
                registered.append(name)
                logger.debug(
                    "Auto-registered registry agent as tool: %s (framework=%s)",
                    name,
                    framework,
                )

        if registered:
            logger.info(
                "Agent %s: loaded %d agent tool(s): %s",
                self.name,
                len(registered),
                registered,
            )
            if self.execution.tool_choice is None:
                self.execution.tool_choice = "auto"

        return registered

    # ------------------------------------------------------------------
    # Orchestrator Activities
    # ------------------------------------------------------------------

    def get_team_members(self, ctx: wf.WorkflowActivityContext) -> Dict[str, Any]:
        """
        Return available agents metadata and formatted string.

        Args:
            ctx: The Dapr Workflow context.

        Returns:
            Dict with 'metadata' (dict) and 'formatted' (str) keys.
        """
        agents_metadata = self.get_agents_metadata(
            exclude_self=True, exclude_orchestrator=True, team=self.effective_team
        )
        if not agents_metadata:
            return {
                "metadata": {},
                "formatted": "No available agents to assign tasks.",
            }
        lines = []
        for name, meta in agents_metadata.items():
            # Defensive check: ensure meta is a dict
            # This should not happen if get_agents_metadata validation works correctly,
            # but we keep this as a safety check.
            if meta is None or not isinstance(meta, dict):
                logger.error(
                    "Skipping agent %s in team members: metadata is None or not a dict. "
                    "This indicates a bug in get_agents_metadata or corrupted registry data.",
                    name,
                )
                continue

            agent_meta = meta.get("agent")
            # Defensive check: ensure agent_meta is a dict
            # This should not happen if get_agents_metadata validation works correctly,
            # but we keep this as a safety check.
            if agent_meta is None or not isinstance(agent_meta, dict):
                logger.error(
                    "Skipping agent %s in team members: agent metadata is None or not a dict. "
                    "This indicates a bug in get_agents_metadata or corrupted registry data.",
                    name,
                )
                continue

            role = agent_meta.get("role", "Unknown role")
            goal = agent_meta.get("goal", "Unknown")
            lines.append(f"- {name}: {role} (Goal: {goal})")
        return {
            "metadata": agents_metadata,
            "formatted": "\n".join(lines),
        }

    def validate_step(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> bool:
        """Return True if (step, substep) exists in the plan."""
        step = payload["step"]
        substep = payload.get("substep")
        plan = payload["plan"]
        ok = bool(find_step_in_plan(plan, step, substep))
        if not ok:
            logger.error(
                "Step %s/%s not in plan for instance %s",
                step,
                substep,
                payload.get("instance_id"),
            )
        return ok

    def evaluate_progress(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> Optional[Dict[str, Any]]:
        """
        Parse progress information from assistant content.

        Args:
            payload: Dict with 'content', 'instance_id', and 'plan_objects'.
        Returns:
            Parsed progress dict or None if not found.
        """

        content = payload["content"]
        plan_objects = list(payload["plan_objects"])
        instance_id = payload["instance_id"]

        if hasattr(content, "choices") and content.choices:
            data = content.choices[0].message.content
            progress = ProgressCheckOutput(**json.loads(data))
        elif isinstance(content, ProgressCheckOutput):
            progress = content
        else:
            try:
                data = json.loads(content) if isinstance(content, str) else content
                progress = ProgressCheckOutput(**data)
            except (json.JSONDecodeError, TypeError, ValueError) as e:
                logger.error(
                    f"Failed to parse progress check output: {e}. Content: {content}"
                )
                raise AgentError(f"Invalid progress check format: {e}")

        status_updates = [
            (u.model_dump() if hasattr(u, "model_dump") else u)
            for u in (progress.plan_status_update or [])
        ]
        plan_updates = [
            (u.model_dump() if hasattr(u, "model_dump") else u)
            for u in (progress.plan_restructure or [])
        ]

        async def _update_plan() -> List[Dict[str, Any]]:
            if status_updates or plan_updates:
                updated_plan = await self.update_plan_internal(
                    instance_id=instance_id,
                    plan=plan_objects,
                    status_updates=status_updates,
                    plan_updates=plan_updates,
                )
            else:
                updated_plan = plan_objects
            return updated_plan

        return {
            "plan": self._run_asyncio_task(_update_plan()),
            "verdict": progress.verdict,
            "status_updates": status_updates,
            "plan_updates": plan_updates,
            "status": "success",
        }

    def save_plan(
        self, ctx: wf.WorkflowActivityContext, payload: Dict[str, Any]
    ) -> None:
        """
        Save the plan as an assistant message to the workflow state.

        Args:
            payload: Dict with 'instance_id', 'plan_message', and 'time'.
        """

        instance_id = payload.get("instance_id")
        if self.state_store:
            self._infra.load_state(instance_id)

        plan_message = payload.get("plan_message")

        try:
            entry = self._infra.get_state(instance_id)
        except Exception:
            logger.exception(
                f"Failed to get workflow state for instance_id: {instance_id}"
            )
            raise

        if entry is not None and hasattr(entry, "messages"):
            plan_message_model = (
                self._message_coercer(plan_message)
                if getattr(self, "_message_coercer", None)
                else self._message_dict_to_message_model(plan_message)
            )
            entry.messages.append(plan_message_model)
            if hasattr(entry, "last_message"):
                entry.last_message = plan_message_model

        # Also add to memory
        self.memory.add_message(
            AssistantMessage(
                content=plan_message.get("content", ""), name=plan_message.get("name")
            ),
            instance_id,
        )

        self.save_state(instance_id, entry=entry)
        logger.info(f"Saved plan to memory for instance {instance_id}")

    # ------------------------------------------------------------------
    # Runtime control
    # ------------------------------------------------------------------
    def start(
        self,
        runtime: Optional[wf.WorkflowRuntime] = None,
        *,
        auto_register: bool = True,
    ) -> None:
        """
        Start the workflow runtime and register this agent's components.

        Behavior:
        • If a runtime is provided, attach to it (we still consider it not owned).
        • Register workflows once (if not already).
        • Always attempt to start the runtime; treat start() as idempotent:
            - If it's already running, swallow/log the exception and continue.
        • We only call shutdown() later if we own the runtime.
        """
        if self._started:
            raise RuntimeError("Agent has already been started.")

        # Set up lifecycle-managed resources (e.g., configuration subscription)
        super().start()

        if runtime is not None:
            self._runtime = runtime
            self._runtime_owned = False
            self._registered = False
            logger.info(
                "Attached injected WorkflowRuntime (owned=%s).", self._runtime_owned
            )

        if auto_register and not self._registered:
            self.register_workflows(self._runtime)
            self._registered = True
            logger.info(
                "Registered workflows/activities on WorkflowRuntime for agent '%s'.",
                self.name,
            )

        # Always try to start; treat as idempotent.
        try:
            self._runtime.start()
            logger.info(
                "WorkflowRuntime started for agent '%s' (owned=%s).",
                self.name,
                self._runtime_owned,
            )
        except Exception as exc:  # noqa: BLE001
            # Most common benign case: runtime already running
            logger.warning(
                "WorkflowRuntime.start() raised for agent '%s' (likely already running): %s",
                self.name,
                exc,
                exc_info=True,
            )

        self._started = True

    def stop(self) -> None:
        """Stop the workflow runtime if it is owned by this instance."""
        if not self._started:
            return

        super().stop()

        if self._runtime_owned:
            try:
                self._runtime.shutdown()
            except Exception:  # noqa: BLE001
                logger.debug(
                    "Error while shutting down workflow runtime", exc_info=True
                )

        self._started = False

    def register(self, runtime: wf.WorkflowRuntime) -> None:
        """
        Register workflows and activities on a provided runtime.

        Args:
            runtime: An externally-managed workflow runtime to register with.
        """
        self._runtime = runtime
        self._runtime_owned = False
        self.register_workflows(runtime)
        self._registered = True

    @staticmethod
    def _named(func: Any, name: str) -> Any:
        """
        Wrap a callable so the Dapr runtime registers it under ``name``
        instead of the method's ``__name__``.  Used for workflows to give
        each agent a unique registration name (EX: ``dapr.agents.frodo.workflow``).
        """

        @functools.wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            return func(*args, **kwargs)

        wrapper.__name__ = name
        return wrapper

    def register_workflows(self, runtime: wf.WorkflowRuntime) -> None:
        """
        Register workflows/activities for this agent.

        Each workflow is registered under a unique, agent-scoped name using
        the ``dapr.agents.<name>.<type>`` format:
        ``*.workflow``, ``*.broadcast``, and ``*.orchestration``.  This
        prevents name collisions when multiple agents share the same Dapr app.

        ``AgentRunner`` discovers the registered names via the
        ``agent_workflow_name`` / ``broadcast_workflow_name`` properties and
        passes them as strings to ``schedule_new_workflow`` — no wrapper
        bookkeeping is needed here.  The broadcast workflow (``*.broadcast``)
        is registered only when a broadcast/pubsub topic is configured
        (i.e., when ``broadcast_topic_name`` is set), so callers should use
        ``broadcast_workflow_name`` only in that case.

        Args:
            runtime: The Dapr workflow runtime to register with.
        """
        # Primary entry point — unique per agent
        runtime.register_workflow(
            self._named(self.agent_workflow, self.agent_workflow_name)
        )
        if self.broadcast_topic_name:
            runtime.register_workflow(
                self._named(self.on_broadcast, self.broadcast_workflow_name)
            )

        # Standard agent activities
        runtime.register_activity(self.record_initial_entry)
        runtime.register_activity(self.record_broadcast)
        runtime.register_activity(self.call_llm)
        runtime.register_activity(self.run_tool)
        runtime.register_activity(self.save_tool_results)
        runtime.register_activity(self.broadcast_to_team)
        runtime.register_activity(self.summarize)
        runtime.register_activity(self.finalize_workflow)
        runtime.register_activity(self.get_team_members)
        runtime.register_activity(self.load_tools)

        # Internal orchestration workflow and activities
        if self._orchestration_strategy:
            runtime.register_workflow(
                self._named(
                    self.orchestration_workflow,
                    orchestration_workflow_id(self.name, infra=self._infra),
                )
            )

            if isinstance(self._orchestration_strategy, AgentOrchestrationStrategy):
                # Agent-based orchestration activities (plan-based with LLM)
                runtime.register_activity(self.validate_step)
                runtime.register_activity(self.evaluate_progress)
                runtime.register_activity(self.save_plan)
            else:
                # RoundRobin and Random orchestration activities
                runtime.register_activity(self.initialize_orchestration)
                runtime.register_activity(self.select_next_task)
                runtime.register_activity(self.handle_response)
                runtime.register_activity(self.check_completion)
                runtime.register_activity(self.finalize_orchestration)
