from __future__ import annotations

import ast
import json
import logging
import uuid
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
from threading import RLock
from typing import Any, Protocol

from vv_agent.config import EndpointConfig, EndpointOption, ResolvedModelConfig, build_openai_llm_from_local_settings
from vv_agent.constants import BATCH_SUB_TASKS_TOOL_NAME, CREATE_SUB_TASK_TOOL_NAME, TASK_FINISH_TOOL_NAME
from vv_agent.llm.base import LLMClient
from vv_agent.memory import MemoryManager
from vv_agent.prompt import build_system_prompt
from vv_agent.runtime.backends.base import ExecutionBackend
from vv_agent.runtime.backends.inline import InlineBackend
from vv_agent.runtime.context import ExecutionContext
from vv_agent.runtime.cycle_runner import CycleRunner
from vv_agent.runtime.hooks import RuntimeHook, RuntimeHookManager
from vv_agent.runtime.token_usage import summarize_task_token_usage
from vv_agent.runtime.tool_call_runner import ToolCallRunner
from vv_agent.tools import ToolContext, ToolRegistry
from vv_agent.types import (
    AgentResult,
    AgentStatus,
    AgentTask,
    CycleRecord,
    Message,
    SubAgentConfig,
    SubTaskOutcome,
    SubTaskRequest,
    ToolCall,
    ToolDirective,
    ToolExecutionResult,
)
from vv_agent.workspace import LocalWorkspaceBackend, WorkspaceBackend

RuntimeLogHandler = Callable[[str, dict[str, Any]], None]
BeforeCycleMessageProvider = Callable[[int, list[Message], dict[str, Any]], list[Message]]
InterruptionMessageProvider = Callable[[], list[Message]]

_ACTIVE_SUB_AGENT_SESSIONS_LOCK = RLock()
_ACTIVE_SUB_AGENT_SESSIONS: dict[str, Any] = {}


def steer_sub_agent_session(*, session_id: str, prompt: str) -> bool:
    """Steer a running sub-agent session by its sub-session id."""
    normalized_session_id = str(session_id or "").strip()
    normalized_prompt = str(prompt or "").strip()
    if not normalized_session_id or not normalized_prompt:
        return False
    with _ACTIVE_SUB_AGENT_SESSIONS_LOCK:
        session = _ACTIVE_SUB_AGENT_SESSIONS.get(normalized_session_id)
    if session is None:
        return False
    try:
        session.steer(normalized_prompt)
    except Exception as exc:
        logging.getLogger(__name__).warning(
            "Failed to steer sub-agent session %s: %s",
            normalized_session_id,
            exc,
        )
        return False
    return True


def _register_sub_agent_session(session_id: str, session: Any) -> None:
    normalized_session_id = str(session_id or "").strip()
    if not normalized_session_id:
        return
    with _ACTIVE_SUB_AGENT_SESSIONS_LOCK:
        _ACTIVE_SUB_AGENT_SESSIONS[normalized_session_id] = session


def _unregister_sub_agent_session(session_id: str, session: Any | None = None) -> None:
    normalized_session_id = str(session_id or "").strip()
    if not normalized_session_id:
        return
    with _ACTIVE_SUB_AGENT_SESSIONS_LOCK:
        registered = _ACTIVE_SUB_AGENT_SESSIONS.get(normalized_session_id)
        if registered is None:
            return
        if session is not None and registered is not session:
            return
        _ACTIVE_SUB_AGENT_SESSIONS.pop(normalized_session_id, None)


class LLMBuilder(Protocol):
    def __call__(
        self,
        settings_path: str | Path,
        *,
        backend: str,
        model: str,
        timeout_seconds: float = 90.0,
    ) -> tuple[LLMClient, ResolvedModelConfig]:
        ...


class AgentRuntime:
    def __init__(
        self,
        *,
        llm_client: LLMClient,
        tool_registry: ToolRegistry,
        default_workspace: str | Path | None = None,
        log_handler: RuntimeLogHandler | None = None,
        log_preview_chars: int | None = None,
        settings_file: str | Path | None = None,
        default_backend: str | None = None,
        llm_builder: LLMBuilder | None = None,
        tool_registry_factory: Callable[[], ToolRegistry] | None = None,
        sub_agent_timeout_seconds: float = 90.0,
        hooks: list[RuntimeHook] | None = None,
        execution_backend: ExecutionBackend | None = None,
        workspace_backend: WorkspaceBackend | None = None,
    ) -> None:
        self.llm_client = llm_client
        self.tool_registry = tool_registry
        self.default_workspace = Path(default_workspace).resolve() if default_workspace else None
        self.log_handler = log_handler
        if log_preview_chars is None:
            self.log_preview_chars: int | None = None
        else:
            self.log_preview_chars = max(int(log_preview_chars), 40)
        self.settings_file = Path(settings_file).resolve() if settings_file else None
        self.default_backend = default_backend
        self.llm_builder = llm_builder or build_openai_llm_from_local_settings
        self.tool_registry_factory = tool_registry_factory
        self.sub_agent_timeout_seconds = max(sub_agent_timeout_seconds, 1.0)
        self.hook_manager = RuntimeHookManager(hooks=list(hooks or []))
        self.execution_backend: ExecutionBackend = execution_backend or InlineBackend()
        self._workspace_backend = workspace_backend
        self._memory_summary_clients: dict[tuple[str, str], LLMClient] = {}
        self._memory_summary_defaults: tuple[str | None, str | None] | None = None
        self.cycle_runner = CycleRunner(
            llm_client=llm_client,
            tool_registry=tool_registry,
            hook_manager=self.hook_manager,
        )
        self.tool_call_runner = ToolCallRunner(
            tool_registry=tool_registry,
            hook_manager=self.hook_manager,
        )

    def run(
        self,
        task: AgentTask,
        *,
        workspace: str | Path | None = None,
        shared_state: dict[str, Any] | None = None,
        initial_messages: list[Message] | None = None,
        user_message: str | None = None,
        before_cycle_messages: BeforeCycleMessageProvider | None = None,
        interruption_messages: InterruptionMessageProvider | None = None,
        ctx: ExecutionContext | None = None,
    ) -> AgentResult:
        workspace_path = self._prepare_workspace(workspace)
        shared = dict(shared_state or {})
        shared.setdefault("todo_list", [])
        if isinstance(task.metadata, dict):
            if "available_skills" not in shared and task.metadata.get("available_skills") is not None:
                shared["available_skills"] = task.metadata.get("available_skills")
            if "available_skills" not in shared and task.metadata.get("skill_directories") is not None:
                shared["available_skills"] = task.metadata.get("skill_directories")
            if "bound_skills" not in shared and task.metadata.get("bound_skills") is not None:
                shared["bound_skills"] = task.metadata.get("bound_skills")
            if "active_skills" not in shared and task.metadata.get("active_skills") is not None:
                shared["active_skills"] = list(task.metadata.get("active_skills") or [])

        messages = self._build_initial_messages(
            task=task,
            initial_messages=initial_messages,
            user_message=user_message,
        )
        self._emit_log(
            "run_started",
            task_id=task.task_id,
            model=task.model,
            workspace=str(workspace_path),
            max_cycles=task.max_cycles,
        )

        memory_manager = self._build_memory_manager(task=task, workspace_path=workspace_path)

        cycle_executor = self._build_cycle_executor(
            task=task,
            workspace_path=workspace_path,
            workspace_backend=self._workspace_backend or LocalWorkspaceBackend(workspace_path),
            memory_manager=memory_manager,
            before_cycle_messages=before_cycle_messages,
            interruption_messages=interruption_messages,
        )

        runtime_ctx = ctx
        if runtime_ctx is None:
            runtime_ctx = ExecutionContext()
        runtime_ctx.metadata.setdefault("execution_backend", self.execution_backend)

        return self.execution_backend.execute(
            task=task,
            initial_messages=messages,
            shared_state=shared,
            cycle_executor=cycle_executor,
            ctx=runtime_ctx,
            max_cycles=task.max_cycles,
        )

    def _build_cycle_executor(
        self,
        *,
        task: AgentTask,
        workspace_path: Path,
        workspace_backend: WorkspaceBackend,
        memory_manager: MemoryManager,
        before_cycle_messages: BeforeCycleMessageProvider | None,
        interruption_messages: InterruptionMessageProvider | None,
    ) -> Callable[[int, list[Message], list[CycleRecord], dict[str, Any], ExecutionContext | None], AgentResult | None]:
        def executor(
            cycle_index: int,
            messages: list[Message],
            cycles: list[CycleRecord],
            shared: dict[str, Any],
            ctx: ExecutionContext | None,
        ) -> AgentResult | None:
            if before_cycle_messages is not None:
                injected = before_cycle_messages(cycle_index, messages, shared)
                if injected:
                    messages.extend(injected)
                    self._emit_log(
                        "cycle_injected_messages",
                        cycle=cycle_index,
                        count=len(injected),
                    )
            self._emit_log(
                "cycle_started",
                cycle=cycle_index,
                max_cycles=task.max_cycles,
                message_count=len(messages),
            )
            previous_total_tokens: int | None = None
            recent_tool_call_ids: set[str] | None = None
            if cycles:
                last_cycle = cycles[-1]
                usage = last_cycle.token_usage
                total_tokens = int(getattr(usage, "total_tokens", 0) or 0)
                if total_tokens <= 0:
                    total_tokens = int(getattr(usage, "prompt_tokens", 0) or 0) + int(
                        getattr(usage, "completion_tokens", 0) or 0
                    )
                if total_tokens > 0:
                    previous_total_tokens = total_tokens

                candidate_ids = {call.id for call in last_cycle.tool_calls if getattr(call, "id", "")}
                if candidate_ids:
                    recent_tool_call_ids = candidate_ids
            try:
                updated_messages, cycle_record = self.cycle_runner.run_cycle(
                    task=task,
                    messages=messages,
                    cycle_index=cycle_index,
                    memory_manager=memory_manager,
                    previous_total_tokens=previous_total_tokens,
                    recent_tool_call_ids=recent_tool_call_ids,
                    shared_state=shared,
                    ctx=ctx,
                )
            except Exception as exc:
                self._emit_log("cycle_failed", cycle=cycle_index, error=str(exc))
                return AgentResult(
                    status=AgentStatus.FAILED,
                    messages=messages,
                    cycles=cycles,
                    error=f"LLM call failed in cycle {cycle_index}: {exc}",
                    shared_state=shared,
                    token_usage=summarize_task_token_usage(cycles),
                )
            # Replace messages list contents in-place so caller sees updates
            messages.clear()
            messages.extend(updated_messages)

            self._emit_log(
                "cycle_llm_response",
                cycle=cycle_index,
                assistant_message=cycle_record.assistant_message,
                assistant_preview=self._preview_text(cycle_record.assistant_message),
                tool_calls=[call.to_dict() for call in cycle_record.tool_calls],
                tool_call_names=[call.name for call in cycle_record.tool_calls],
                tool_call_count=len(cycle_record.tool_calls),
                token_usage=cycle_record.token_usage.to_dict(),
            )

            if cycle_record.tool_calls:
                context = ToolContext(
                    workspace=workspace_path,
                    shared_state=shared,
                    cycle_index=cycle_index,
                    workspace_backend=workspace_backend,
                    sub_task_runner=self._build_sub_task_runner(
                        parent_task=task,
                        workspace_path=workspace_path,
                        parent_shared_state=shared,
                        ctx=ctx,
                    ),
                    ctx=ctx,
                    task_metadata=dict(task.metadata),
                )

                def _on_tool_result(call: ToolCall, result: ToolExecutionResult, *, _cycle: int = cycle_index) -> None:
                    self._emit_log(
                        "tool_result",
                        cycle=_cycle,
                        tool_name=call.name,
                        tool_call_id=result.tool_call_id,
                        status=result.status_code.value if result.status_code else result.status,
                        directive=result.directive.value,
                        error_code=result.error_code,
                        content=result.content,
                        result=result.content,
                        content_preview=self._preview_text(result.content),
                    )

                tool_outcome = self.tool_call_runner.run(
                    task=task,
                    tool_calls=cycle_record.tool_calls,
                    context=context,
                    messages=messages,
                    cycle_record=cycle_record,
                    interruption_provider=interruption_messages,
                    on_tool_result=_on_tool_result,
                    ctx=ctx,
                )
                tool_result = tool_outcome.directive_result
                cycles.append(cycle_record)
                if tool_outcome.interruption_messages:
                    messages.extend(tool_outcome.interruption_messages)
                    self._emit_log(
                        "run_steered",
                        cycle=cycle_index,
                        steering_count=len(tool_outcome.interruption_messages),
                    )

                if tool_result and tool_result.directive == ToolDirective.WAIT_USER:
                    wait_reason = tool_result.metadata.get("question") if isinstance(tool_result.metadata, dict) else None
                    if not wait_reason:
                        wait_reason = tool_result.content
                    self._emit_log(
                        "run_wait_user",
                        cycle=cycle_index,
                        wait_reason=self._preview_text(str(wait_reason)),
                    )
                    return AgentResult(
                        status=AgentStatus.WAIT_USER,
                        messages=messages,
                        cycles=cycles,
                        wait_reason=str(wait_reason),
                        shared_state=shared,
                        token_usage=summarize_task_token_usage(cycles),
                    )

                if tool_result and tool_result.directive == ToolDirective.FINISH:
                    final_answer = self._extract_final_message(tool_result)
                    self._emit_log(
                        "run_completed",
                        cycle=cycle_index,
                        final_answer=self._preview_text(final_answer),
                    )
                    return AgentResult(
                        status=AgentStatus.COMPLETED,
                        messages=messages,
                        cycles=cycles,
                        final_answer=final_answer,
                        shared_state=shared,
                        token_usage=summarize_task_token_usage(cycles),
                    )

                return None  # continue to next cycle

            cycles.append(cycle_record)
            if task.no_tool_policy == "finish":
                self._emit_log(
                    "run_completed",
                    cycle=cycle_index,
                    final_answer=self._preview_text(cycle_record.assistant_message),
                )
                return AgentResult(
                    status=AgentStatus.COMPLETED,
                    messages=messages,
                    cycles=cycles,
                    final_answer=cycle_record.assistant_message,
                    shared_state=shared,
                    token_usage=summarize_task_token_usage(cycles),
                )

            if task.no_tool_policy == "wait_user":
                self._emit_log(
                    "run_wait_user",
                    cycle=cycle_index,
                    wait_reason=self._preview_text(cycle_record.assistant_message or "No tool call"),
                )
                return AgentResult(
                    status=AgentStatus.WAIT_USER,
                    messages=messages,
                    cycles=cycles,
                    wait_reason=cycle_record.assistant_message or "No tool call and runtime is waiting for user.",
                    shared_state=shared,
                    token_usage=summarize_task_token_usage(cycles),
                )

            if cycle_index < task.max_cycles:
                messages.append(Message(role="user", content=self._build_continue_hint()))

            return None  # continue to next cycle

        return executor

    def _emit_cycle_tool_results(self, *, cycle_record: CycleRecord) -> None:
        for idx, result in enumerate(cycle_record.tool_results):
            tool_name = None
            if idx < len(cycle_record.tool_calls):
                tool_name = cycle_record.tool_calls[idx].name
            self._emit_log(
                "tool_result",
                cycle=cycle_record.index,
                tool_name=tool_name or "unknown",
                tool_call_id=result.tool_call_id,
                status=result.status_code.value if result.status_code else result.status,
                directive=result.directive.value,
                error_code=result.error_code,
                content=result.content,
                result=result.content,
                content_preview=self._preview_text(result.content),
            )

    def _emit_log(self, event: str, **payload: Any) -> None:
        if self.log_handler is None:
            return
        self.log_handler(event, payload)

    @staticmethod
    def _copy_message(message: Message) -> Message:
        return Message(
            role=message.role,
            content=message.content,
            name=message.name,
            tool_call_id=message.tool_call_id,
            tool_calls=list(message.tool_calls) if message.tool_calls else None,
            reasoning_content=message.reasoning_content,
            image_url=message.image_url,
        )

    def _build_initial_messages(
        self,
        *,
        task: AgentTask,
        initial_messages: list[Message] | None,
        user_message: str | None,
    ) -> list[Message]:
        if initial_messages:
            prepared = [self._copy_message(message) for message in initial_messages]
            if not prepared or prepared[0].role != "system":
                prepared.insert(0, Message(role="system", content=task.system_prompt))
            message_to_append = task.user_prompt if user_message is None else user_message
            if message_to_append:
                prepared.append(Message(role="user", content=message_to_append))
            return prepared

        first_user_message = task.user_prompt if user_message is None else user_message
        return [
            Message(role="system", content=task.system_prompt),
            Message(role="user", content=first_user_message),
        ]

    def _build_memory_manager(self, *, task: AgentTask, workspace_path: Path) -> MemoryManager:
        metadata = task.metadata if isinstance(task.metadata, dict) else {}

        def read_int(key: str, default: int, *, minimum: int = 0) -> int:
            raw = metadata.get(key, default)
            try:
                value = int(raw)
            except (TypeError, ValueError):
                value = default
            return max(value, minimum)

        warning_threshold = max(1, min(task.memory_threshold_percentage, 100))
        local_summary_backend, local_summary_model = self._load_local_memory_summary_defaults()
        metadata_summary_backend = self._read_optional_str(
            metadata,
            "memory_summary_backend",
            "compress_memory_summary_backend",
            "memory_compress_backend",
        )
        metadata_summary_model = self._read_optional_str(
            metadata,
            "memory_summary_model",
            "compress_memory_summary_model",
            "memory_compress_model",
        )
        summary_backend = metadata_summary_backend or local_summary_backend or self.default_backend
        summary_model = metadata_summary_model or local_summary_model or task.model
        return MemoryManager(
            compact_threshold=max(task.memory_compact_threshold, 0),
            keep_recent_messages=read_int("memory_keep_recent_messages", 10, minimum=1),
            language=str(metadata.get("language", "zh-CN")),
            warning_threshold_percentage=warning_threshold,
            include_memory_warning=bool(metadata.get("include_memory_warning", False)),
            tool_result_compact_threshold=read_int("tool_result_compact_threshold", 2000),
            tool_result_keep_last=read_int("tool_result_keep_last", 3),
            tool_result_excerpt_head=read_int("tool_result_excerpt_head", 200),
            tool_result_excerpt_tail=read_int("tool_result_excerpt_tail", 200),
            tool_calls_keep_last=read_int("tool_calls_keep_last", 3),
            assistant_no_tool_keep_last=read_int("assistant_no_tool_keep_last", 1),
            tool_result_artifact_dir=str(metadata.get("tool_result_artifact_dir", ".memory/tool_results")),
            workspace=workspace_path if task.use_workspace else None,
            summary_event_limit=read_int("summary_event_limit", 40, minimum=1),
            summary_backend=summary_backend,
            summary_model=summary_model,
            summary_callback=self._summarize_memory_prompt,
        )

    @staticmethod
    def _read_optional_str(metadata: dict[str, Any], *keys: str) -> str | None:
        for key in keys:
            raw = metadata.get(key)
            if isinstance(raw, str):
                value = raw.strip()
                if value:
                    return value
        return None

    def _load_local_memory_summary_defaults(self) -> tuple[str | None, str | None]:
        if self._memory_summary_defaults is not None:
            return self._memory_summary_defaults

        backend: str | None = None
        model: str | None = None
        settings_file = self.settings_file
        if settings_file is None or not settings_file.exists():
            self._memory_summary_defaults = (None, None)
            return self._memory_summary_defaults

        try:
            module = ast.parse(settings_file.read_text(encoding="utf-8"), filename=str(settings_file))
            backend = self._read_literal_setting(
                module,
                "DEFAULT_USER_MEMORY_SUMMARIZE_BACKEND",
                "DEFAULT_MEMORY_SUMMARIZE_BACKEND",
                "VV_AGENT_MEMORY_SUMMARY_BACKEND",
            )
            model = self._read_literal_setting(
                module,
                "DEFAULT_USER_MEMORY_SUMMARIZE_MODEL",
                "DEFAULT_MEMORY_SUMMARIZE_MODEL",
                "VV_AGENT_MEMORY_SUMMARY_MODEL",
            )
        except Exception:
            logging.getLogger(__name__).debug(
                "Failed to load memory summary defaults from settings file",
                exc_info=True,
            )

        self._memory_summary_defaults = (backend, model)
        return self._memory_summary_defaults

    @staticmethod
    def _read_literal_setting(module: ast.Module, *names: str) -> str | None:
        if not names:
            return None
        name_set = set(names)

        for node in module.body:
            target_name: str | None = None
            value_node: ast.expr | None = None

            if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
                target_name = node.target.id
                value_node = node.value
            elif isinstance(node, ast.Assign):
                for target in node.targets:
                    if isinstance(target, ast.Name):
                        target_name = target.id
                        value_node = node.value
                        break

            if target_name not in name_set or value_node is None:
                continue

            try:
                literal = ast.literal_eval(value_node)
            except Exception:
                continue
            if isinstance(literal, str):
                value = literal.strip()
                if value:
                    return value
        return None

    def _summarize_memory_prompt(self, prompt: str, backend: str | None, model: str | None) -> str | None:
        backend_name = (backend or self.default_backend or "").strip()
        model_name = (model or "").strip()
        if not backend_name or not model_name:
            return None
        if self.settings_file is None:
            return None

        cache_key = (backend_name, model_name)
        client = self._memory_summary_clients.get(cache_key)
        if client is None:
            client, _ = self.llm_builder(
                self.settings_file,
                backend=backend_name,
                model=model_name,
                timeout_seconds=self.sub_agent_timeout_seconds,
            )
            self._memory_summary_clients[cache_key] = client

        response = client.complete(
            model=model_name,
            messages=[Message(role="user", content=prompt)],
            tools=[],
        )
        content = (response.content or "").strip()
        return content or None

    def _preview_text(self, text: str) -> str:
        cleaned = text.replace("\n", " ").strip()
        if self.log_preview_chars is None or self.log_preview_chars <= 0:
            return cleaned
        if len(cleaned) <= self.log_preview_chars:
            return cleaned
        return f"{cleaned[: self.log_preview_chars - 3]}..."

    def _prepare_workspace(self, workspace: str | Path | None) -> Path:
        target = Path(workspace) if workspace else self.default_workspace
        if target is None:
            target = Path.cwd() / ".vv-agent-workspace"
        target = target.resolve()
        target.mkdir(parents=True, exist_ok=True)
        return target

    @staticmethod
    def _build_continue_hint() -> str:
        return (
            "No tool call was produced. "
            f"Continue the task and call `{TASK_FINISH_TOOL_NAME}` "
            "when all todo items are done."
        )

    @staticmethod
    def _extract_final_message(result: ToolExecutionResult) -> str:
        if isinstance(result.metadata, dict):
            final = result.metadata.get("final_message")
            if isinstance(final, str) and final:
                return final

        try:
            payload = json.loads(result.content)
        except json.JSONDecodeError:
            payload = None

        if isinstance(payload, dict):
            message = payload.get("message")
            if isinstance(message, str) and message:
                return message

        return result.content

    def _build_sub_task_runner(
        self,
        *,
        parent_task: AgentTask,
        workspace_path: Path,
        parent_shared_state: dict[str, Any],
        ctx: ExecutionContext | None = None,
    ) -> Callable[[SubTaskRequest], SubTaskOutcome] | None:
        if not parent_task.sub_agents:
            return None

        def runner(request: SubTaskRequest) -> SubTaskOutcome:
            return self._run_sub_task(
                parent_task=parent_task,
                workspace_path=workspace_path,
                parent_shared_state=parent_shared_state,
                request=request,
                ctx=ctx,
            )

        return runner

    def _run_sub_task(
        self,
        *,
        parent_task: AgentTask,
        workspace_path: Path,
        parent_shared_state: dict[str, Any],
        request: SubTaskRequest,
        ctx: ExecutionContext | None = None,
    ) -> SubTaskOutcome:
        from vv_agent.sdk.session import create_agent_session
        from vv_agent.sdk.types import AgentDefinition, AgentRun

        sub_task_id = f"{parent_task.task_id}_sub_{request.agent_name}_{uuid.uuid4().hex[:8]}"
        sub_session_id = sub_task_id
        sub_agent = parent_task.sub_agents.get(request.agent_name)
        if sub_agent is None:
            available = ", ".join(sorted(parent_task.sub_agents))
            return SubTaskOutcome(
                task_id=sub_task_id,
                session_id=sub_session_id,
                agent_name=request.agent_name,
                status=AgentStatus.FAILED,
                error=f"Unknown sub-agent {request.agent_name!r}. Available: {available}",
            )

        def _emit_sub_session_event(event: str, payload: dict[str, Any]) -> None:
            if self.log_handler is None:
                return
            enriched = dict(payload)
            enriched.setdefault("task_id", sub_task_id)
            enriched.setdefault("session_id", sub_session_id)
            enriched.setdefault("sub_agent_name", request.agent_name)
            self.log_handler(f"sub_agent_{event}", enriched)

        try:
            llm_client, model_id, resolved_payload, resolved_config = self._resolve_sub_agent_client(
                parent_task=parent_task,
                sub_agent=sub_agent,
            )
            sub_task = self._build_sub_agent_task(
                parent_task=parent_task,
                sub_task_id=sub_task_id,
                sub_session_id=sub_session_id,
                sub_agent_name=request.agent_name,
                sub_agent=sub_agent,
                resolved_model_id=model_id,
                request=request,
                parent_shared_state=parent_shared_state,
                workspace_path=workspace_path,
            )
            sub_runtime = AgentRuntime(
                llm_client=llm_client,
                tool_registry=self._build_sub_agent_registry(),
                default_workspace=workspace_path,
                log_preview_chars=self.log_preview_chars,
                settings_file=self.settings_file,
                default_backend=self.default_backend,
                llm_builder=self.llm_builder,
                tool_registry_factory=self.tool_registry_factory,
                sub_agent_timeout_seconds=self.sub_agent_timeout_seconds,
            )

            sub_agent_definition = AgentDefinition(
                description=sub_agent.description,
                model=sub_task.model,
                backend=sub_agent.backend or self.default_backend,
                language=str(parent_task.metadata.get("language", "zh-CN")),
                max_cycles=sub_task.max_cycles,
                no_tool_policy=sub_task.no_tool_policy,
                allow_interruption=sub_task.allow_interruption,
                use_workspace=sub_task.use_workspace,
                enable_todo_management=True,
                agent_type=sub_task.agent_type,
                native_multimodal=sub_task.native_multimodal,
                enable_sub_agents=False,
                sub_agents={},
                extra_tool_names=list(sub_task.extra_tool_names),
                exclude_tools=list(sub_task.exclude_tools),
                metadata=dict(sub_task.metadata),
                system_prompt=sub_task.system_prompt,
            )

            def _execute_sub_run(
                *,
                prompt: str,
                task_name: str,
                workspace: str | Path,
                shared_state: dict[str, Any] | None = None,
                initial_messages: list[Message] | None = None,
                before_cycle_messages: BeforeCycleMessageProvider | None = None,
                interruption_messages: InterruptionMessageProvider | None = None,
                log_handler: RuntimeLogHandler | None = None,
                **_: Any,
            ) -> AgentRun:
                run_task = replace(
                    sub_task,
                    task_id=sub_task_id,
                    user_prompt=prompt,
                )
                run_shared_state = dict(shared_state or {})
                run_shared_state.setdefault("todo_list", [])

                child_ctx = self._build_child_ctx(ctx)
                if log_handler is not None:
                    parent_stream_callback = child_ctx.stream_callback if child_ctx is not None else None

                    def _sub_stream_callback(text: str) -> None:
                        log_handler(
                            "stream_delta",
                            {
                                "delta": text,
                                "task_id": sub_task_id,
                                "session_id": sub_session_id,
                                "sub_agent_name": request.agent_name,
                            },
                        )
                        if parent_stream_callback is not None:
                            parent_stream_callback(text)

                    if child_ctx is None:
                        child_ctx = ExecutionContext(stream_callback=_sub_stream_callback)
                    else:
                        child_ctx.stream_callback = _sub_stream_callback

                previous_log_handler = sub_runtime.log_handler
                sub_runtime.log_handler = log_handler
                try:
                    sub_result = sub_runtime.run(
                        run_task,
                        workspace=workspace,
                        shared_state=run_shared_state,
                        initial_messages=initial_messages,
                        user_message=prompt,
                        before_cycle_messages=before_cycle_messages,
                        interruption_messages=interruption_messages,
                        ctx=child_ctx,
                    )
                finally:
                    sub_runtime.log_handler = previous_log_handler
                return AgentRun(
                    agent_name=task_name,
                    result=sub_result,
                    resolved=resolved_config,
                )

            sub_session = create_agent_session(
                execute_run=_execute_sub_run,
                session_id=sub_session_id,
                agent_name=request.agent_name,
                definition=sub_agent_definition,
                workspace=workspace_path,
                shared_state={"todo_list": []},
            )

            try:
                _register_sub_agent_session(sub_session_id, sub_session)
                unsub = sub_session.subscribe(_emit_sub_session_event)
                _emit_sub_session_event(
                    "session_created",
                    {
                        "agent_name": request.agent_name,
                        "model": sub_task.model,
                        "workspace": str(workspace_path),
                        "max_cycles": sub_task.max_cycles,
                    },
                )
                try:
                    sub_run = sub_session.prompt(sub_task.user_prompt, auto_follow_up=False)
                finally:
                    unsub()
            finally:
                _unregister_sub_agent_session(sub_session_id, sub_session)
        except Exception as exc:
            return SubTaskOutcome(
                task_id=sub_task_id,
                session_id=sub_session_id,
                agent_name=request.agent_name,
                status=AgentStatus.FAILED,
                error=str(exc),
            )

        return SubTaskOutcome(
            task_id=sub_task_id,
            session_id=sub_session_id,
            agent_name=request.agent_name,
            status=sub_run.result.status,
            final_answer=sub_run.result.final_answer,
            wait_reason=sub_run.result.wait_reason,
            error=sub_run.result.error,
            cycles=len(sub_run.result.cycles),
            todo_list=sub_run.result.todo_list,
            resolved=resolved_payload,
        )

    def _resolve_sub_agent_client(
        self,
        *,
        parent_task: AgentTask,
        sub_agent: SubAgentConfig,
    ) -> tuple[LLMClient, str, dict[str, str], ResolvedModelConfig]:
        backend = sub_agent.backend or self.default_backend or "inline"
        if self.settings_file is None:
            if sub_agent.model != parent_task.model:
                raise ValueError(
                    "Sub-agent model resolution requires runtime settings_file when sub-agent model differs from parent model."
                )
            fallback_endpoint = EndpointConfig(
                endpoint_id="inline-session",
                api_key="",
                api_base="",
            )
            fallback_resolved = ResolvedModelConfig(
                backend=backend,
                requested_model=parent_task.model,
                selected_model=parent_task.model,
                model_id=parent_task.model,
                endpoint_options=[EndpointOption(endpoint=fallback_endpoint, model_id=parent_task.model)],
            )
            return self.llm_client, parent_task.model, {}, fallback_resolved

        if not backend:
            raise ValueError("Sub-agent backend is required when settings_file is configured.")

        llm_client, resolved = self.llm_builder(
            self.settings_file,
            backend=backend,
            model=sub_agent.model,
            timeout_seconds=self.sub_agent_timeout_seconds,
        )
        return (
            llm_client,
            resolved.model_id,
            {
                "backend": resolved.backend,
                "selected_model": resolved.selected_model,
                "model_id": resolved.model_id,
                "endpoint": resolved.endpoint.endpoint_id,
            },
            resolved,
        )

    def _build_sub_agent_task(
        self,
        *,
        parent_task: AgentTask,
        sub_task_id: str,
        sub_session_id: str,
        sub_agent_name: str,
        sub_agent: SubAgentConfig,
        resolved_model_id: str,
        request: SubTaskRequest,
        parent_shared_state: dict[str, Any],
        workspace_path: Path,
    ) -> AgentTask:
        language = str(parent_task.metadata.get("language", "zh-CN"))
        available_skills = parent_task.metadata.get("available_skills")
        if not isinstance(available_skills, list):
            available_skills = parent_task.metadata.get("bound_skills")
        if not isinstance(available_skills, list):
            available_skills = None

        system_prompt = sub_agent.system_prompt or build_system_prompt(
            sub_agent.description,
            language=language,
            allow_interruption=False,
            use_workspace=parent_task.use_workspace,
            enable_todo_management=True,
            agent_type=parent_task.agent_type,
            available_skills=available_skills,
            workspace=workspace_path,
        )

        user_prompt = request.task_description
        if request.output_requirements:
            user_prompt = (
                f"{user_prompt}\n\n<Output Requirements>\n{request.output_requirements}\n</Output Requirements>"
            )
        if request.include_main_summary:
            parent_summary = self._build_parent_summary(parent_task=parent_task, parent_shared_state=parent_shared_state)
            if parent_summary:
                user_prompt = (
                    f"{user_prompt}\n\n<Main Task Summary>\n{parent_summary}\n</Main Task Summary>"
                )

        excluded_tools = set(parent_task.exclude_tools)
        excluded_tools.update(sub_agent.exclude_tools)
        excluded_tools.update({CREATE_SUB_TASK_TOOL_NAME, BATCH_SUB_TASKS_TOOL_NAME})
        metadata = {
            "is_sub_task": True,
            "parent_task_id": parent_task.task_id,
            "sub_agent_name": sub_agent_name,
        }
        for key in ("bash_shell", "windows_shell_priority", "bash_env"):
            value = parent_task.metadata.get(key)
            if value is not None:
                metadata[key] = value
        if request.metadata:
            metadata.update(request.metadata)
        metadata["task_id"] = sub_task_id
        metadata["session_id"] = sub_session_id
        metadata["browser_scope_key"] = sub_session_id

        return AgentTask(
            task_id=sub_task_id,
            model=resolved_model_id,
            system_prompt=system_prompt,
            user_prompt=user_prompt,
            max_cycles=max(sub_agent.max_cycles, 1),
            memory_compact_threshold=parent_task.memory_compact_threshold,
            memory_threshold_percentage=parent_task.memory_threshold_percentage,
            no_tool_policy="continue",
            allow_interruption=False,
            use_workspace=parent_task.use_workspace,
            has_sub_agents=False,
            sub_agents={},
            agent_type=parent_task.agent_type,
            native_multimodal=parent_task.native_multimodal,
            extra_tool_names=list(parent_task.extra_tool_names),
            exclude_tools=sorted(excluded_tools),
            metadata=metadata,
        )

    def _build_sub_agent_registry(self) -> ToolRegistry:
        if self.tool_registry_factory is not None:
            return self.tool_registry_factory()
        return self.tool_registry

    @staticmethod
    def _build_child_ctx(
        ctx: ExecutionContext | None,
        *,
        stream_callback: Callable[[str], None] | None = None,
    ) -> ExecutionContext | None:
        if ctx is None:
            if stream_callback is None:
                return None
            return ExecutionContext(stream_callback=stream_callback)
        child_stream_callback = stream_callback if stream_callback is not None else ctx.stream_callback
        child_token = ctx.cancellation_token.child() if ctx.cancellation_token is not None else None
        return ExecutionContext(
            cancellation_token=child_token,
            stream_callback=child_stream_callback,
            state_store=ctx.state_store,
            metadata=dict(ctx.metadata),
        )

    @staticmethod
    def _build_parent_summary(*, parent_task: AgentTask, parent_shared_state: dict[str, Any]) -> str:
        lines = [f"Parent task goal: {parent_task.user_prompt}"]
        todo_list = parent_shared_state.get("todo_list")
        if isinstance(todo_list, list) and todo_list:
            lines.append("Parent TODO status:")
            for item in todo_list:
                if not isinstance(item, dict):
                    continue
                title = str(item.get("title", "Untitled"))
                status = str(item.get("status", "pending"))
                lines.append(f"- [{status}] {title}")
        return "\n".join(lines)
