"""Application service for agent entry.

这层的职责是把“接口层如何调用 AgentLoop”统一收口。

接口层以后不应该各自做这些事情：
1. 自己 new `AgentLoop`
2. 自己决定何时 `boot()`
3. 自己处理 direct run 的同步/异步包装

统一放在 `AgentService` 后，CLI / Web / Gateway 才能共享同一条运行主链。
"""

from __future__ import annotations

import asyncio
from pathlib import Path
from typing import Any
from uuid import uuid4

from beaver.coordinator.models import ExecutionNode, TeamRunResult
from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader
from beaver.engine.providers import make_provider_bundle
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.foundation.models import CronJob, CronRunRecord
from beaver.prompts.main_agent import normalize_main_agent_prompt_locale
from beaver.tasks import (
    EvidenceBuilder,
    MainAgentRouter,
    RunEvidence,
    TaskEvidencePacket,
    TaskExecutionPlan,
    TaskRecord,
    render_task_evidence,
)
from beaver.tasks.service import normalize_acceptance_type


NOTIFICATION_SESSION_ID = "notify:default:scheduled"


class AgentService:
    """面向 interfaces 的统一 agent 运行入口。

    这里明确区分两种调用模式：
    1. direct mode
       - 不启动后台运行循环
       - 直接调用 `process_direct()` / `run_direct()`
    2. running mode
       - 先 `await start()`
       - 之后所有外部任务都必须走 `submit_direct()`
       - 不允许再直接调用 `process_direct()`
    """

    def __init__(
        self,
        *,
        workspace: str | Path | None = None,
        config_path: str | Path | None = None,
        profile: AgentProfile | None = None,
        loader: EngineLoader | None = None,
    ) -> None:
        self.profile = profile or AgentProfile()
        self.loader = loader or EngineLoader(workspace=workspace, config_path=config_path)
        self._apply_configured_profile_defaults()
        self._loop: AgentLoop | None = None
        self._run_task: asyncio.Task[None] | None = None
        self._main_agent_router = MainAgentRouter()
        self._runtime_services: dict[str, Any] = {}

    def _apply_configured_profile_defaults(self) -> None:
        defaults = self.loader.config.agents_defaults
        self.profile.max_tokens = None
        self.profile.temperature = 0.2
        self.profile.max_context_messages = 1000
        self.profile.max_tool_iterations = 30
        if defaults.max_tokens is not None:
            self.profile.max_tokens = max(1, defaults.max_tokens)
        if defaults.temperature is not None:
            self.profile.temperature = defaults.temperature
        if defaults.max_context_messages is not None:
            self.profile.max_context_messages = max(1, defaults.max_context_messages)
        if defaults.max_tool_iterations is not None:
            self.profile.max_tool_iterations = max(0, defaults.max_tool_iterations)

    def create_loop(self) -> AgentLoop:
        """创建并缓存当前 service 使用的 AgentLoop。"""

        if self._loop is None:
            self._loop = AgentLoop(profile=self.profile, loader=self.loader)
            self._loop.runtime_services.update(self._runtime_services)
            self._loop.boot()
        return self._loop

    def register_runtime_service(self, name: str, service: Any) -> None:
        """Expose process-level services to tools during agent runs."""

        self._runtime_services[name] = service
        if self._loop is not None:
            self._loop.runtime_services[name] = service

    @property
    def has_loop(self) -> bool:
        """当前 service 是否已经创建过 loop。"""

        return self._loop is not None

    @property
    def is_running(self) -> bool:
        """当前 service 是否处于 running mode。"""

        return self._run_task is not None and not self._run_task.done()

    def close(self) -> None:
        """关闭当前 service 持有的 runtime。"""

        if self._run_task is not None and not self._run_task.done():
            raise RuntimeError("AgentService.close() requires stop() before closing a running loop")
        self._run_task = None
        if self._loop is None:
            return
        try:
            self._loop.close()
        finally:
            self._loop = None

    async def start(self) -> None:
        """启动后台运行循环，进入 running mode。

        进入 running mode 后：
        - 外部任务必须通过 `submit_direct()` 提交
        - `process_direct()` 不再允许直接调用
        """

        if self._run_task is not None and not self._run_task.done():
            return
        loop = self.create_loop()
        self._run_task = asyncio.create_task(loop.run())
        while not loop.is_running:
            if self._run_task.done():
                await self._run_task
                break
            await asyncio.sleep(0)

    async def _stop_impl(
        self,
        *,
        timeout_seconds: float | None = None,
        force: bool = False,
    ) -> None:
        """内部停止实现，支持 graceful timeout 和可选 force cancel。"""

        if self._run_task is None:
            return
        run_task = self._run_task
        loop = self.create_loop()
        try:
            await loop.stop()
            if timeout_seconds is None:
                await run_task
            else:
                try:
                    await asyncio.wait_for(asyncio.shield(run_task), timeout=timeout_seconds)
                except asyncio.TimeoutError as exc:
                    if force:
                        run_task.cancel()
                        try:
                            await run_task
                        except asyncio.CancelledError:
                            pass
                    else:
                        raise TimeoutError(
                            f"AgentService.stop() timed out after {timeout_seconds} seconds while draining queued tasks"
                        ) from exc
        finally:
            if run_task.done():
                self._run_task = None

    async def stop(
        self,
        *,
        timeout_seconds: float | None = None,
        force: bool = False,
    ) -> None:
        """停止后台运行循环并等待退出。

        参数：
        - `timeout_seconds`: graceful drain 的最长等待时间；`None` 表示一直等
        - `force`: 超时后是否 cancel 掉运行循环 task
        """

        await self._stop_impl(timeout_seconds=timeout_seconds, force=force)

    async def shutdown(
        self,
        *,
        timeout_seconds: float | None = None,
        force: bool = False,
    ) -> None:
        """先停运行循环，再释放 runtime。"""

        await self._stop_impl(timeout_seconds=timeout_seconds, force=force)
        self.close()

    async def process_direct(
        self,
        message: str,
        **kwargs: Any,
    ) -> AgentRunResult:
        """异步 direct run 入口。

        仅在 direct mode 下可用。

        如果 service 已经 `start()` 进入 running mode，
        调用方必须改用 `submit_direct()`，不能绕过运行队列直接执行。
        """

        if self._run_task is not None and not self._run_task.done():
            raise RuntimeError(
                "AgentService.process_direct() is unavailable while the service is running; "
                "use 'await AgentService.submit_direct(...)' after start()."
            )
        loop = self.create_loop()
        return await self._process_with_main_agent(message, runner=loop.process_direct, kwargs=kwargs)

    async def submit_direct(
        self,
        message: str,
        **kwargs: Any,
    ) -> AgentRunResult:
        """向 running mode 下的 loop 提交 direct task。

        这是 `start()` 之后唯一合法的外部任务入口。
        """

        loop = self.create_loop()
        return await self._process_with_main_agent(message, runner=loop.submit_direct, kwargs=kwargs)

    async def run_scheduled_task(
        self,
        message: str,
        *,
        session_id: str,
        cron_job_id: str,
        cron_job_name: str,
        scheduled_run_id: str | None = None,
        requires_followup: bool = False,
    ) -> AgentRunResult:
        """Run a cron trigger as a normal internal Task.

        Scheduled jobs are product-level Tasks, not hidden one-off agent turns.
        This entry bypasses the main-agent classifier and forces Task mode so
        every trigger produces a TaskRecord, evidence, acceptance state, and a
        run_id that the scheduled-task history can link to.
        """

        loaded = self.create_loop().boot()
        task_service = self._require_loaded(loaded, "task_service")
        loop = self.create_loop()
        task = task_service.create_task(
            session_id=session_id,
            description=message,
            creator="cron",
            metadata={
                "source": "scheduled_cron",
                "cron_job_id": cron_job_id,
                "cron_job_name": cron_job_name,
                "scheduled_run_id": scheduled_run_id,
                "user_engaged": False,
                "requires_followup": requires_followup,
            },
        )
        execution_context = (
            "This turn was triggered automatically by a scheduled task.\n\n"
            f"Cron Job ID: {cron_job_id}\n"
            f"Cron Job Name: {cron_job_name}\n"
            f"Scheduled Run ID: {scheduled_run_id or 'unknown'}\n"
            "Run it as a normal Beaver Task. Do not ask the user for confirmation; "
            "execute the task and report the concrete outcome."
        )
        runner = loop.submit_direct if self.is_running else loop.process_direct
        result = await self._run_task_mode(
            message,
            runner=runner,
            task=task,
            kwargs={
                "session_id": session_id,
                "source": "cron",
                "user_id": "cron",
                "title": cron_job_name,
                "execution_context": execution_context,
            },
        )
        loaded = self.create_loop().boot()
        session_manager = self._require_loaded(loaded, "session_manager")
        session_manager.update_latest_assistant_event_payload(
            result.session_id,
            result.run_id,
            {
                "message_type": "scheduled_reply",
                "scheduled_job_id": cron_job_id,
                "scheduled_run_id": scheduled_run_id,
                "cron_job_name": cron_job_name,
                "mode": "notification",
            },
        )
        return result

    async def run_scheduled_notification(
        self,
        message: str,
        *,
        session_id: str = NOTIFICATION_SESSION_ID,
        cron_job_id: str,
        cron_job_name: str,
        scheduled_run_id: str,
    ) -> AgentRunResult:
        """Run a cron trigger as a notification result, not as an active Task."""

        loop = self.create_loop()
        loaded = loop.boot()
        session_manager = self._require_loaded(loaded, "session_manager")
        runner = loop.submit_direct if self.is_running else loop.process_direct
        execution_context = (
            "This turn was triggered automatically by a scheduled notification.\n\n"
            f"Cron Job ID: {cron_job_id}\n"
            f"Cron Job Name: {cron_job_name}\n"
            f"Scheduled Run ID: {scheduled_run_id}\n"
            "Generate the notification content directly for the user. Do not ask for confirmation."
        )
        result = await runner(
            message,
            session_id=session_id,
            source="notification",
            user_id="cron",
            title=cron_job_name,
            execution_context=execution_context,
        )
        session_manager.update_latest_assistant_event_payload(
            result.session_id,
            result.run_id,
            {
                "message_type": "scheduled_result",
                "scheduled_job_id": cron_job_id,
                "scheduled_run_id": scheduled_run_id,
                "cron_job_name": cron_job_name,
                "mode": "notification",
            },
        )
        return result

    def engage_scheduled_run(
        self,
        *,
        job: CronJob,
        run: CronRunRecord,
        intent: str = "revise_once",
        thinking_enabled: bool | None = None,
    ) -> TaskRecord:
        """Create or mark the Task that lets the user work on a scheduled result."""

        loaded = self.create_loop().boot()
        task_service = self._require_loaded(loaded, "task_service")
        if run.task_id:
            existing = task_service.get_task(run.task_id)
            if existing is not None:
                existing.metadata["user_engaged"] = True
                existing.metadata["engage_intent"] = intent
                task_service.store.upsert_task(existing)
                return existing

        task = task_service.create_task(
            session_id=run.notification_session_id or NOTIFICATION_SESSION_ID,
            description=f"修改定时通知：{job.name}",
            creator="cron",
            metadata={
                "source": "scheduled_run",
                "cron_job_id": job.id,
                "cron_job_name": job.name,
                "scheduled_run_id": run.scheduled_run_id,
                "scheduled_output": run.output,
                "user_engaged": True,
                "engage_intent": intent,
            },
        )
        return task

    async def submit_scheduled_reply(
        self,
        message: str,
        *,
        job: CronJob,
        run: CronRunRecord,
        intent: str = "revise_once",
    ) -> AgentRunResult:
        task = self.engage_scheduled_run(job=job, run=run, intent=intent)
        loop = self.create_loop()
        runner = loop.submit_direct if self.is_running else loop.process_direct
        execution_context = (
            "The user is replying to a scheduled notification result.\n\n"
            f"Cron Job ID: {job.id}\n"
            f"Cron Job Name: {job.name}\n"
            f"Scheduled Run ID: {run.scheduled_run_id}\n"
            f"Engagement intent: {intent}\n"
            f"Original scheduled instruction: {job.payload.message}\n"
            f"Original notification output:\n{run.output or ''}\n\n"
            "Handle this as a Task continuation. If the intent is update_future, explain the durable change "
            "that should apply to future notifications."
        )
        return await self._run_task_mode(
            message,
            runner=runner,
            task=task,
            kwargs={
                "session_id": task.session_id,
                "source": "notification",
                "user_id": "web",
                "title": job.name,
                "execution_context": execution_context,
                "thinking_enabled": thinking_enabled,
            },
        )

    async def submit_acceptance(
        self,
        *,
        session_id: str,
        run_id: str,
        acceptance_type: str,
        comment: str | None = None,
    ) -> dict[str, Any]:
        """Record user acceptance for the internal task linked to a run."""

        loaded = self.create_loop().boot()
        task_service = self._require_loaded(loaded, "task_service")
        task = task_service.get_task_by_run_id(run_id)
        if task is None or task.session_id != session_id:
            raise ValueError(f"No internal task found for run_id={run_id!r}")

        normalized = normalize_acceptance_type(acceptance_type)
        legacy_feedback_type = "satisfied" if normalized == "accept" else normalized

        already_recorded = any(
            item.get("run_id") == run_id and item.get("acceptance_type") == normalized
            for item in task.feedback
        )
        conflicting_acceptance = next(
            (
                item
                for item in task.feedback
                if item.get("run_id") == run_id and item.get("acceptance_type") != normalized
            ),
            None,
        )
        if conflicting_acceptance is not None:
            raise ValueError(
                f"Acceptance for run_id={run_id!r} was already recorded as "
                f"{conflicting_acceptance.get('acceptance_type')!r}"
            )
        if task.status in {"closed", "abandoned"} and not already_recorded:
            raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
        updated = task if already_recorded else task_service.add_acceptance(
            task.task_id,
            acceptance_type=normalized,
            comment=comment,
            run_id=run_id,
        )
        session_manager = self._require_loaded(loaded, "session_manager")
        session_manager.update_latest_assistant_event_payload(
            session_id,
            run_id,
            {
                "task_id": updated.task_id,
                "task_status": updated.status,
                "acceptance_state": normalized,
                "feedback_state": legacy_feedback_type,
            },
        )
        if not already_recorded:
            session_manager.append_message(
                session_id,
                run_id=run_id,
                role="system",
                event_type="task_acceptance_recorded",
                event_payload={
                    "task_id": task.task_id,
                    "acceptance_type": normalized,
                    "feedback_type": legacy_feedback_type,
                    "comment": comment,
                    "task_status": updated.status,
                },
                content=comment,
                context_visible=False,
            )

        generated_candidates = []
        if not already_recorded:
            run_memory_store = self._require_loaded(loaded, "run_memory_store")
            acceptance_payload = {
                "acceptance_type": normalized,
                "feedback_type": legacy_feedback_type,
                "comment": comment or "",
                "task_status": updated.status,
                "final_accepted_run_id": updated.metadata.get("final_accepted_run_id"),
            }
            run_memory_store.update_run_record(
                run_id,
                success=normalized == "accept",
                feedback=acceptance_payload,
            )
            run_memory_store.update_skill_effects_for_run(
                run_id,
                success=normalized == "accept",
                feedback_score=self._acceptance_score_for_learning(normalized),
                notes=(comment or normalized).strip(),
            )
            skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
            skill_learning_service.rescore_skill_versions()
        if already_recorded:
            generated_candidates = []
        elif normalized == "accept":
            generated_candidates = [
                item.to_dict()
                for item in skill_learning_service.build_learning_candidates_for_task(
                    updated.task_id,
                    final_accepted_run_id=run_id,
                )
            ]
        elif normalized == "abandon":
            session_manager.append_message(
                session_id,
                run_id=run_id,
                role="system",
                event_type="task_failure_evidence_recorded",
                event_payload={
                    "task_id": updated.task_id,
                    "acceptance_type": normalized,
                    "feedback_type": legacy_feedback_type,
                    "comment": comment or "",
                    "task_status": updated.status,
                    "durable_memory_written": False,
                },
                content=(comment or "Task abandoned; retained as run/session failure evidence."),
                context_visible=False,
            )

        return {
            "session_id": session_id,
            "run_id": run_id,
            "task_id": updated.task_id,
            "task_status": updated.status,
            "acceptance_type": normalized,
            "feedback_type": legacy_feedback_type,
            "learning_candidates": generated_candidates,
        }

    async def submit_feedback(
        self,
        *,
        session_id: str,
        run_id: str,
        feedback_type: str,
        comment: str | None = None,
    ) -> dict[str, Any]:
        """Backward-compatible wrapper for older clients."""

        return await self.submit_acceptance(
            session_id=session_id,
            run_id=run_id,
            acceptance_type=feedback_type,
            comment=comment,
        )

    async def _process_with_main_agent(
        self,
        message: str,
        *,
        runner: Any,
        kwargs: dict[str, Any],
    ) -> AgentRunResult:
        loaded = self.create_loop().boot()
        task_service = self._require_loaded(loaded, "task_service")
        session_manager = self._require_loaded(loaded, "session_manager")
        session_id = kwargs.get("session_id") or uuid4().hex
        kwargs = dict(kwargs)
        kwargs["session_id"] = session_id

        provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
        kwargs["provider_bundle"] = provider_bundle
        router_provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
        router_runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
        active_task = task_service.get_latest_open_task(session_id)
        decision = await self._main_agent_router.classify(
            message,
            active_task=active_task,
            provider=router_provider,
            model=getattr(router_runtime, "model", None),
            recent_messages=session_manager.get_messages_as_conversation(session_id),
            intent_skill=self._load_intent_agent_skill(loaded),
            thinking_enabled=kwargs.get("thinking_enabled"),
        )
        kwargs["intent_agent_decision"] = self._intent_decision_payload(
            decision,
            active_task=active_task,
        )
        if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"):
            active_task.metadata["short_title"] = decision.short_title
            task_service.store.upsert_task(active_task)
        if active_task is not None and (decision.action == "simple_chat" or decision.starts_new_task):
            await self._accept_active_task_for_new_topic(active_task)
        if active_task is not None and decision.closes_task:
            task_service.close_task(active_task.task_id, reason=decision.reason)
            return await runner(message, **kwargs)
        if active_task is not None and decision.abandons_task:
            task_service.abandon_task(active_task.task_id, reason=decision.reason)
            return await runner(message, **kwargs)
        if not decision.is_task:
            kwargs["include_skill_assembly"] = False
            kwargs["include_tools"] = False
            return await runner(message, **kwargs)

        task = (
            task_service.create_task(
                session_id=session_id,
                description=message,
                metadata={
                    "prompt_locale": normalize_main_agent_prompt_locale(kwargs.get("prompt_locale")),
                    "router_reason": decision.reason,
                    **({"short_title": decision.short_title} if decision.short_title else {}),
                },
            )
            if active_task is None or decision.starts_new_task
            else active_task
        )
        if active_task is not None and decision.action == "revise_task" and task.task_id == active_task.task_id:
            task = self._record_revision_acceptance_for_task(
                loaded,
                task=task,
                session_id=session_id,
                comment=message,
            )
        return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)

    async def _accept_active_task_for_new_topic(self, task: TaskRecord) -> None:
        """Accept a completed active Task before routing an unrelated new topic."""

        if task.status != "awaiting_acceptance":
            return
        run_id = next((item for item in reversed(task.run_ids) if item), None)
        if not run_id:
            return
        await self.submit_acceptance(
            session_id=task.session_id,
            run_id=run_id,
            acceptance_type="accept",
        )

    def _record_revision_acceptance_for_task(
        self,
        loaded: Any,
        *,
        task: TaskRecord,
        session_id: str,
        comment: str,
    ) -> TaskRecord:
        """Mark the latest acceptance-eligible run as revised before continuing a task."""

        if task.status not in {"awaiting_acceptance", "needs_revision"}:
            return task
        run_id = next((item for item in reversed(task.run_ids) if item), None)
        if not run_id:
            return task

        existing = next((item for item in task.feedback if item.get("run_id") == run_id), None)
        if existing is not None:
            if existing.get("acceptance_type") != "revise":
                return task
            updated = task
            already_recorded = True
        else:
            task_service = self._require_loaded(loaded, "task_service")
            updated = task_service.add_acceptance(
                task.task_id,
                acceptance_type="revise",
                comment=comment,
                run_id=run_id,
            )
            already_recorded = False

        session_manager = self._require_loaded(loaded, "session_manager")
        session_manager.update_latest_assistant_event_payload(
            session_id,
            run_id,
            {
                "task_id": updated.task_id,
                "task_status": updated.status,
                "acceptance_state": "revise",
                "feedback_state": "revise",
            },
        )
        if already_recorded:
            return updated

        session_manager.append_message(
            session_id,
            run_id=run_id,
            role="system",
            event_type="task_acceptance_recorded",
            event_payload={
                "task_id": updated.task_id,
                "acceptance_type": "revise",
                "feedback_type": "revise",
                "comment": comment,
                "task_status": updated.status,
                "auto_recorded": True,
            },
            content=comment,
            context_visible=False,
        )
        run_memory_store = self._require_loaded(loaded, "run_memory_store")
        run_memory_store.update_run_record(
            run_id,
            success=False,
            feedback={
                "acceptance_type": "revise",
                "feedback_type": "revise",
                "comment": comment,
                "task_status": updated.status,
            },
        )
        run_memory_store.update_skill_effects_for_run(
            run_id,
            success=False,
            feedback_score=self._acceptance_score_for_learning("revise"),
            notes=comment.strip() or "revise",
        )
        skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
        skill_learning_service.rescore_skill_versions()
        return updated

    async def _run_task_mode(
        self,
        message: str,
        *,
        runner: Any,
        kwargs: dict[str, Any],
        task: TaskRecord,
    ) -> AgentRunResult:
        loaded = self.create_loop().boot()
        task_service = self._require_loaded(loaded, "task_service")
        task_execution_planner = self._require_loaded(loaded, "task_execution_planner")
        session_manager = self._require_loaded(loaded, "session_manager")

        base_execution_context = kwargs.get("execution_context")
        prompt_locale = kwargs.get("prompt_locale") or task.metadata.get("prompt_locale")
        output_language_instruction = self._output_language_instruction(prompt_locale)
        provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
        kwargs = dict(kwargs)
        team_provider_bundle_factory = kwargs.pop("team_provider_bundle_factory", None)
        kwargs["provider_bundle"] = provider_bundle

        attempt_index = int(task.metadata.get("latest_attempt_index") or 0) + 1
        task_service.start_run(task.task_id, user_message=message, attempt_index=attempt_index)
        plan = await task_execution_planner.plan(
            task=task,
            user_message=message,
            attempt_index=attempt_index,
            provider_bundle=provider_bundle,
        )
        self._append_task_observation(
            session_manager,
            task.session_id,
            event_type="task_execution_planned",
            payload={
                "task_id": task.task_id,
                "attempt_index": attempt_index,
                **plan.to_event_payload(),
            },
        )
        team_summaries: list[str] = []
        team_execution_context = ""
        team_result: TeamRunResult | None = None
        if plan.is_team:
            team_result, team_error = await self._run_team_for_task(
                plan,
                task=task,
                parent_session_id=kwargs["session_id"],
                provider_bundle_factory=team_provider_bundle_factory
                or self._build_team_provider_bundle_factory(loaded, kwargs),
            )
            if team_result is not None:
                team_summaries = [self._team_summary_for_validation(team_result)]
                team_packet = TaskEvidencePacket(
                    task_id=task.task_id,
                    attempt_index=attempt_index,
                    main_run=None,
                    team_runs=self._team_run_evidence(team_result),
                    team_node_results=list(team_result.node_results),
                    final_output="",
                )
                team_execution_context = self._join_context(
                    self._team_execution_context(plan, team_result),
                    "Rendered team evidence:\n" + render_task_evidence(team_packet),
                )
                self._append_task_observation(
                    session_manager,
                    task.session_id,
                    event_type="task_team_run_completed" if team_result.success else "task_team_run_failed",
                    payload={
                        "task_id": task.task_id,
                        "attempt_index": attempt_index,
                        "plan_mode": plan.mode,
                        "strategy": plan.graph.strategy if plan.graph else None,
                        "node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
                        "team_run_ids": team_result.run_ids,
                        "team_success": team_result.success,
                        "node_results": self._team_node_results_for_event(plan, team_result),
                        "reason": plan.reason,
                        "error": None if team_result.success else "one or more team nodes failed",
                    },
                )
            else:
                team_summaries = [f"Team execution failed: {team_error}"]
                team_execution_context = self._failed_team_execution_context(plan, team_error or "unknown error")
                self._append_task_observation(
                    session_manager,
                    task.session_id,
                    event_type="task_team_run_failed",
                    payload={
                        "task_id": task.task_id,
                        "attempt_index": attempt_index,
                        "plan_mode": plan.mode,
                        "strategy": plan.graph.strategy if plan.graph else None,
                        "node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
                        "team_run_ids": [],
                        "team_success": False,
                        "reason": plan.reason,
                        "error": team_error,
                    },
                )

        attempt_kwargs = dict(kwargs)
        attempt_kwargs.update(
            {
                "task_id": task.task_id,
                "task_mode": True,
                "attempt_index": attempt_index,
                "allow_candidate_generation": False,
            }
        )
        attempt_kwargs["execution_context"] = self._join_context(
            base_execution_context,
            output_language_instruction,
            team_execution_context,
        )
        if plan.is_team and team_execution_context:
            attempt_kwargs["include_tools"] = False
            attempt_kwargs["max_tool_iterations"] = 0
        attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
            task=task,
            user_message=message,
            attempt_index=attempt_index,
            plan=plan,
            team_summaries=team_summaries,
        )

        result = await runner(message, **attempt_kwargs)
        self._append_task_observation(
            session_manager,
            task.session_id,
            event_type="task_synthesis_completed",
            payload={
                "task_id": task.task_id,
                "attempt_index": attempt_index,
                "main_run_id": result.run_id,
                "plan_mode": plan.mode,
                "strategy": plan.graph.strategy if plan.graph else None,
            },
        )
        task = task_service.append_run(
            task.task_id,
            result.run_id,
            skill_names=self._skill_names_for_run(loaded, result.run_id),
        )
        evidence_packet = self._build_task_evidence_packet(
            session_manager=session_manager,
            task=task,
            attempt_index=attempt_index,
            result=result,
            team_result=team_result,
        )
        evidence_text = render_task_evidence(evidence_packet)
        evidence_debug = {
            "evidence_run_ids": [
                item.run_id for item in [evidence_packet.main_run, *evidence_packet.team_runs] if item is not None
            ],
            "evidence_session_ids": [
                item.session_id
                for item in [evidence_packet.main_run, *evidence_packet.team_runs]
                if item is not None
            ],
            "tool_result_count": sum(
                len(item.tool_results)
                for item in [evidence_packet.main_run, *evidence_packet.team_runs]
                if item is not None
            ),
            "evidence_length": len(evidence_text),
        }
        session_manager.update_latest_assistant_event_payload(
            result.session_id,
            result.run_id,
            {
                "task_id": task.task_id,
                "task_status": task.status,
                "evidence_status": "recorded",
            },
        )
        session_manager.append_message(
            result.session_id,
            run_id=result.run_id,
            role="system",
            event_type="task_evidence_recorded",
            event_payload={
                "task_id": task.task_id,
                "attempt_index": attempt_index,
                "evidence_debug": evidence_debug,
            },
            content=None,
            context_visible=False,
        )
        result.task_id = task.task_id
        result.task_status = task.status
        result.validation_result = None
        return result

    async def _run_team_for_task(
        self,
        plan: TaskExecutionPlan,
        *,
        task: TaskRecord,
        parent_session_id: str,
        provider_bundle_factory: Any,
    ) -> tuple[TeamRunResult | None, str | None]:
        if plan.graph is None:
            return None, "team plan did not include an execution graph"
        try:
            from beaver.services.team_service import TeamService

            result = await TeamService(self.create_loop()).run_team(
                plan.graph,
                parent_task_id=task.task_id,
                parent_session_id=parent_session_id,
                parent_run_id=None,
                provider_bundle_factory=provider_bundle_factory,
                allow_candidate_generation=False,
            )
            return result, None
        except Exception as exc:
            return None, str(exc)

    @staticmethod
    def _require_loaded(loaded: Any, field_name: str) -> Any:
        value = getattr(loaded, field_name)
        if value is None:
            raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}")
        return value

    @staticmethod
    def _load_intent_agent_skill(loaded: Any) -> str | None:
        skills_loader = getattr(loaded, "skills_loader", None)
        if skills_loader is None:
            return None
        return skills_loader.load_skill("intent-agent-router")

    @staticmethod
    def _intent_decision_payload(decision: Any, *, active_task: TaskRecord | None) -> dict[str, Any]:
        action = decision.action or ("create_task" if decision.is_task and active_task is None else decision.mode)
        return {
            "agent": "intent_agent",
            "choice": action,
            "mode": "task" if decision.is_task else "simple",
            "reason": decision.reason,
            "active_task_id": active_task.task_id if active_task is not None else None,
            "starts_new_task": bool(decision.starts_new_task or (decision.is_task and active_task is None)),
            "closes_task": bool(decision.closes_task),
            "abandons_task": bool(decision.abandons_task),
            "short_title": decision.short_title,
        }

    @staticmethod
    def _output_language_instruction(prompt_locale: str | None) -> str:
        locale = normalize_main_agent_prompt_locale(prompt_locale)
        if locale == "en":
            return (
                "Output language: English. Use English for user-facing task titles, summaries, plans, "
                "and final answers unless the user explicitly requests another language."
            )
        if locale == "zh-Hant":
            return (
                "輸出語言：繁體中文。除非使用者明確要求其他語言，所有面向使用者的任務標題、摘要、"
                "計劃與最終回答都使用繁體中文。"
            )
        return (
            "输出语言：简体中文。除非用户明确要求其他语言，所有面向用户的任务标题、摘要、"
            "计划与最终回答都使用简体中文。"
        )

    @staticmethod
    def _skill_names_for_run(loaded: Any, run_id: str) -> list[str]:
        store = getattr(loaded, "run_memory_store", None)
        if store is None:
            return []
        for record in store.list_runs():
            if record.run_id == run_id:
                return [receipt.skill_name for receipt in record.activated_skills]
        return []

    @staticmethod
    def _acceptance_score_for_learning(acceptance_type: str) -> float:
        if acceptance_type == "accept":
            return 1.0
        if acceptance_type == "revise":
            return 0.5
        return 0.0

    @staticmethod
    def _build_skill_selection_context(
        *,
        task: TaskRecord,
        user_message: str,
        attempt_index: int,
        plan: TaskExecutionPlan | None = None,
        team_summaries: list[str] | None = None,
    ) -> str:
        phase = f"attempt_{attempt_index}"
        if task.feedback and task.feedback[-1].get("acceptance_type") == "revise":
            phase = f"revision_attempt_{attempt_index}"
        elif plan is not None and plan.is_team:
            phase = f"team_synthesis_attempt_{attempt_index}"

        sections = [
            f"Task goal:\n{task.goal or task.description}",
            f"Task description:\n{task.description}",
            f"Current user request:\n{user_message}",
            f"Execution phase:\n{phase}",
            f"Task status:\n{task.status}",
        ]
        if task.constraints:
            sections.append("Known constraints:\n" + "\n".join(f"- {item}" for item in task.constraints))
        if task.skill_names:
            sections.append(
                "Previously activated skills (reuse bias, not pinned):\n"
                + "\n".join(f"- {item}" for item in task.skill_names)
            )
        else:
            sections.append("Previously activated skills:\nNone")
        if task.feedback:
            history_lines = []
            for item in task.feedback[-5:]:
                kind = item.get("acceptance_type") or item.get("feedback_type")
                comment = item.get("comment") or ""
                run_id = item.get("run_id") or ""
                history_lines.append(f"- {kind} run={run_id}: {comment}".strip())
            sections.append("Task acceptance history:\n" + "\n".join(history_lines))
        if plan is not None:
            plan_lines = [
                f"mode: {plan.mode}",
                f"reason: {plan.reason}",
            ]
            if plan.final_synthesis_instruction:
                plan_lines.append(f"final synthesis instruction: {plan.final_synthesis_instruction}")
            if plan.graph is not None:
                plan_lines.append(f"strategy: {plan.graph.strategy}")
                plan_lines.append(
                    "nodes:\n"
                    + "\n".join(
                        f"- {node.node_id}: {node.task}"
                        for node in plan.graph.nodes
                    )
                )
            sections.append("Execution plan:\n" + "\n".join(plan_lines))
        if team_summaries:
            sections.append("Team execution summaries:\n" + "\n\n".join(team_summaries)[:2400])
        sections.append(
            "Skill selection instruction:\n"
            "Prefer reusing previously activated skills when they still match the Task. "
            "Select new skills only if the current request, revision, or execution plan needs a different capability. "
            "If no published skill matches, return [] and let the run continue without skills."
        )
        return "\n\n".join(section for section in sections if section.strip())

    @staticmethod
    def _run_excerpt(session_manager: Any, session_id: str, run_id: str) -> str:
        lines = []
        for event in session_manager.get_run_event_records(session_id, run_id):
            if event.context_visible and event.content:
                lines.append(f"{event.role}: {event.content.strip()}")
        return "\n".join(lines[:12])[:2400]

    @staticmethod
    def _tool_summaries(session_manager: Any, session_id: str, run_id: str) -> list[str]:
        summaries = []
        for event in session_manager.get_run_event_records(session_id, run_id):
            if event.event_type != "tool_result_recorded":
                continue
            text = (event.content or "").strip()
            if text:
                summaries.append(f"{event.tool_name or 'tool'}: {text[:500]}")
        return summaries[:12]

    @staticmethod
    def _append_task_observation(
        session_manager: Any,
        session_id: str,
        *,
        event_type: str,
        payload: dict[str, Any],
    ) -> None:
        session_manager.append_message(
            session_id,
            role="system",
            event_type=event_type,
            event_payload=payload,
            content=payload.get("reason") or payload.get("error"),
            context_visible=False,
        )

    @staticmethod
    def _join_context(*parts: str | None) -> str:
        return "\n\n".join(part.strip() for part in parts if part and part.strip())

    @staticmethod
    def _team_summary_for_validation(result: TeamRunResult) -> str:
        lines = [
            f"success={result.success}",
            f"task_id={result.task_id or ''}",
            "summary:",
            result.summary,
            "nodes:",
        ]
        for node in result.node_results:
            lines.append(
                f"- {node.node_id}: success={node.success} finish_reason={node.finish_reason} "
                f"error={node.error or ''} output={node.output_text[:500]}"
            )
        return "\n".join(lines)

    @staticmethod
    def _team_node_results_for_event(plan: TaskExecutionPlan, result: TeamRunResult) -> list[dict[str, Any]]:
        nodes = {node.node_id: node for node in plan.graph.nodes} if plan.graph else {}
        payloads: list[dict[str, Any]] = []
        for item in result.node_results:
            payload = item.to_dict()
            node = nodes.get(item.node_id)
            if node is not None:
                payload["selected_skill_names"] = list(node.inherited_pinned_skills)
                payload["ephemeral_skill_names"] = [
                    skill.name for skill in node.inherited_pinned_skill_contexts
                ]
                payload["skill_query"] = node.agent.metadata.get("skill_query")
                payload["ephemeral_guidance_id"] = node.agent.metadata.get("ephemeral_guidance_id")
                payload["ephemeral_guidance_name"] = node.agent.metadata.get("ephemeral_guidance_name")
                payload["ephemeral_used"] = bool(node.inherited_pinned_skill_contexts)
            payloads.append(payload)
        return payloads

    @staticmethod
    def _team_run_evidence(result: TeamRunResult | None) -> list[RunEvidence]:
        if result is None:
            return []
        return [node.evidence for node in result.node_results if node.evidence is not None]

    def _build_task_evidence_packet(
        self,
        *,
        session_manager: Any,
        task: TaskRecord,
        attempt_index: int,
        result: AgentRunResult,
        team_result: TeamRunResult | None,
    ) -> TaskEvidencePacket:
        main_run = EvidenceBuilder(session_manager).build_run_evidence(
            result.session_id,
            result.run_id,
            result.output_text,
            result.finish_reason,
        )
        return TaskEvidencePacket(
            task_id=task.task_id,
            attempt_index=attempt_index,
            main_run=main_run,
            team_runs=self._team_run_evidence(team_result),
            team_node_results=list(team_result.node_results) if team_result is not None else [],
            final_output=result.output_text,
        )

    @staticmethod
    def _team_execution_context(plan: TaskExecutionPlan, result: TeamRunResult) -> str:
        node_lines = [
            (
                f"- {node.node_id}: success={node.success}, finish_reason={node.finish_reason}, "
                f"run_id={node.run_id or ''}, error={node.error or ''}\n{node.output_text}"
            )
            for node in result.node_results
        ]
        return "\n\n".join(
            item
            for item in [
                "Task team execution result:",
                f"Planner reason: {plan.reason}",
                f"Strategy: {plan.graph.strategy if plan.graph else ''}",
                f"Team success: {result.success}",
                f"Team summary:\n{result.summary}",
                "Node results:\n" + "\n\n".join(node_lines),
                (
                    "Final synthesis instruction:\n" + plan.final_synthesis_instruction
                    if plan.final_synthesis_instruction
                    else None
                ),
                (
                    "Use successful team outputs as internal evidence. If one or more nodes failed, "
                    "do not blindly repeat failed tool calls. Produce a user-visible fallback answer "
                    "with available evidence and clearly state any missing or uncertain data."
                ),
            ]
            if item
        )

    @staticmethod
    def _failed_team_execution_context(plan: TaskExecutionPlan, error: str) -> str:
        return "\n\n".join(
            [
                "Task team execution failed before final synthesis.",
                f"Planner reason: {plan.reason}",
                f"Strategy: {plan.graph.strategy if plan.graph else ''}",
                f"Error: {error}",
                (
                    "Proceed as the main agent. Do not blindly repeat failed tool calls; "
                    "produce a user-visible fallback answer with available evidence and clearly "
                    "state any missing or uncertain data."
                ),
            ]
        )

    def _build_team_provider_bundle_factory(self, loaded: Any, kwargs: dict[str, Any]) -> Any:
        def factory(node: ExecutionNode) -> Any:
            node_kwargs = dict(kwargs)
            node_kwargs.pop("provider_bundle", None)
            if node.agent.model:
                node_kwargs["model"] = node.agent.model
            if node.agent.provider_name:
                node_kwargs["provider_name"] = node.agent.provider_name
            return self._make_provider_bundle_for_task(loaded, node_kwargs)

        return factory

    def _make_provider_bundle_for_task(self, loaded: Any, kwargs: dict[str, Any]) -> Any:
        config = loaded.config
        configured_provider = config.resolve_provider_target(
            model=kwargs.get("model"),
            provider_name=kwargs.get("provider_name"),
        )
        resolved_model = configured_provider.get("model") or self.profile.default_model
        resolved_provider_name = configured_provider.get("provider_name") or kwargs.get("provider_name")
        return make_provider_bundle(
            model=resolved_model,
            provider_name=resolved_provider_name,
            api_key=kwargs.get("api_key") or configured_provider.get("api_key"),
            api_base=kwargs.get("api_base") or configured_provider.get("api_base"),
            request_timeout_seconds=configured_provider.get("request_timeout_seconds"),
            extra_headers=kwargs.get("extra_headers") or configured_provider.get("extra_headers"),
            routing=kwargs.get("routing"),
            fallback_target=kwargs.get("fallback_target"),
            auxiliary_target=kwargs.get("auxiliary_target"),
            embedding_target=kwargs.get("embedding_target") or config.resolve_embedding_target(),
            embedding_model=kwargs.get("embedding_model") or config.default_embedding_model,
        )

    async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
        """把 bus inbound 映射成标准 runtime 调用，并返回结构化 outbound。"""

        channel_identity = inbound.channel_identity
        try:
            result = await self.submit_direct(
                inbound.content,
                session_id=inbound.session_id,
                source=f"gateway:{inbound.channel}",
                user_id=inbound.user_id or (channel_identity.user_id if channel_identity else None),
                title=inbound.title,
                execution_context=inbound.execution_context,
                model=inbound.model,
                provider_name=inbound.provider_name,
                embedding_model=inbound.embedding_model,
                channel_identity=channel_identity,
            )
        except Exception as exc:
            return self.build_outbound_error(
                inbound,
                detail=str(exc),
                finish_reason=self._classify_inbound_failure(exc),
            )
        return self.build_outbound_message(inbound, result)

    @staticmethod
    def _classify_inbound_failure(exc: Exception) -> str:
        """把 runtime 异常收口为更稳定的 bus finish reason。"""

        if isinstance(exc, RuntimeError):
            detail = str(exc)
            if (
                "requires an active run() loop" in detail
                or "not accepting new tasks after stop()" in detail
            ):
                return "stopped"
        return "error"

    @staticmethod
    def build_outbound_message(inbound: InboundMessage, result: AgentRunResult) -> OutboundMessage:
        """把一次 runtime 正常结果转成 bus outbound。"""

        return OutboundMessage(
            message_id=inbound.message_id,
            channel=inbound.channel,
            session_id=result.session_id,
            run_id=result.run_id,
            content=result.output_text,
            finish_reason=result.finish_reason,
            provider_name=result.provider_name,
            model=result.model,
            content_type=inbound.content_type,
            channel_identity=inbound.channel_identity,
            usage=dict(result.usage),
            metadata={
                "inbound_metadata": dict(inbound.metadata),
                "task_id": getattr(result, "task_id", None),
                "task_status": getattr(result, "task_status", None),
                "evidence_status": "recorded" if getattr(result, "task_id", None) else None,
                "validation_result": None,
            },
        )

    @staticmethod
    def build_outbound_error(
        inbound: InboundMessage,
        *,
        detail: str,
        finish_reason: str = "error",
    ) -> OutboundMessage:
        """把 inbound 处理失败转换成结构化 outbound 错误消息。"""

        return OutboundMessage(
            message_id=inbound.message_id,
            channel=inbound.channel,
            session_id=inbound.session_id,
            content=detail,
            finish_reason=finish_reason,
            content_type=inbound.content_type,
            channel_identity=inbound.channel_identity,
            metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
        )

    def run_direct(
        self,
        message: str,
        **kwargs: Any,
    ) -> AgentRunResult:
        """同步 direct run 包装。

        主要给当前 CLI 或简单脚本使用。真正的长期方向仍然是让 interfaces
        在 direct mode 下直接走 `await process_direct(...)`。
        """

        try:
            asyncio.get_running_loop()
        except RuntimeError:
            pass
        else:
            raise RuntimeError(
                "AgentService.run_direct() cannot be used inside an active event loop; "
                "use 'await AgentService.process_direct(...)' instead."
            )
        return asyncio.run(self.process_direct(message, **kwargs))
