"""LLMNode — direct LLM generation node.

Supports single-shot and iterative tool calling.

When ``max_tool_rounds > 0``, the node iterates:
  1. Call LLM with tools
  2. If tool calls -> execute them, append results to conversation
  3. Call LLM again with updated conversation
  4. Repeat until no tool calls or max rounds reached

This makes the LLMNode the universal compute primitive for all
pathway topologies — single-shot, multi-round, or any custom graph.
"""

from __future__ import annotations

import json
import logging
import time
from typing import TYPE_CHECKING, Literal

from pydantic import Field

from core.common import JsonObject

if TYPE_CHECKING:
    from core.pathways.context import Context
    from core.types.llm import LLMGenerateResult
from core.common.errors import LLMError, ToolError
from core.llm.invoke import generate, generate_stream, resolve_model
from core.pathways.models.io import NodeInputs, NodeOutputs
from core.pathways.models.llm import LLMOutput, LLMUsage
from core.pathways.nodes.base import NodeBase
from core.pathways.nodes.models import ToolExecutionResultItem
from core.pathways.nodes.tool_calling import (
    ToolNameMap,
    execute_tool_calls,
    get_tool_schemas_for_names,
)
from core.types import ToolCall
from core.types.agents import ModelRouting
from core.types.execution import StepFailure, StepOutcome, StepSuccess
from core.types.llm import LLMMessage, LLMToolCall
from core.types.stream import TokenDelta, ToolCallCompleted, ToolCallStarted

logger = logging.getLogger(__name__)


class LLMNode(NodeBase):
    """Direct LLM generation — core compute primitive.

    Sends a prompt to an LLM provider and returns the generated response.
    Supports tool calling, vision (images), structured JSON output,
    and iterative multi-round tool calling.

    Attributes
    ----------
    prompt : str
        Template with ``{{var}}`` placeholders resolved from upstream inputs.
    model : str
        Capability name (``"reasoning"``, ``"code"``) or explicit model.
    temperature : float
        Sampling temperature.
    max_tokens : int | None
        Maximum output tokens.
    system : str | None
        System prompt.
    response_format : str
        ``"text"`` or ``"json"``.
    json_schema : JsonObject | None
        JSON schema when ``response_format="json"``.
    images : str | list[str] | None
        Base64 images for vision models.
    tools : list[str] | str
        Tool patterns (e.g., ``["web.*"]``) or template string.
    max_tool_rounds : int
        Maximum rounds of iterative tool calling. 0 = single-shot (default).
        When > 0, the node loops: LLM call → tool execution → feed results
        back → LLM call again, until no tool calls or max rounds reached.
    model_routing : ModelRouting | None
        Per-phase model routing. When set, the first round uses the
        ``planning`` model, middle rounds use ``execution``, and the
        synthesis call uses ``evaluation``. When ``None``, all rounds
        use the single ``model`` field.
    """

    type: Literal["llm"] = "llm"
    prompt: str
    model: str = "reasoning"
    temperature: float = 0.7
    max_tokens: int | None = 2048
    system: str | None = None
    response_format: Literal["text", "json"] = "text"
    json_schema: JsonObject | None = None
    images: str | list[str] | None = None
    tools: list[str] | str = Field(default_factory=list)
    max_tool_rounds: int = 0
    model_routing: ModelRouting | None = None

    # -----------------------------------------------------------------
    # Effective config resolution — pathway YAML wins, else RunState defaults
    # -----------------------------------------------------------------

    def _effective_model(self, ctx: Context) -> str:
        """Resolve model: explicit YAML value > RunState default > field default."""
        if "model" in self.model_fields_set:
            return self.model
        return ctx.run.default_model

    def _effective_tools(self, ctx: Context) -> list[str] | str:
        """Resolve tools: explicit YAML value > RunState default > field default."""
        if "tools" in self.model_fields_set:
            return self.tools
        return ctx.run.default_tools

    def _effective_temperature(self, ctx: Context) -> float:
        """Resolve temperature: explicit YAML value > RunState default > field default."""
        if "temperature" in self.model_fields_set:
            return self.temperature
        return ctx.run.default_temperature

    def _effective_system(self, ctx: Context) -> str | None:
        """Resolve system prompt: explicit YAML value > RunState system prompt."""
        if "system" in self.model_fields_set:
            return self.system
        return ctx.run.system_prompt

    def _effective_max_tool_rounds(self, ctx: Context) -> int:
        """Resolve max tool rounds: explicit YAML value > RunState max_steps > field default."""
        if "max_tool_rounds" in self.model_fields_set:
            return self.max_tool_rounds
        return ctx.run.max_steps

    # -----------------------------------------------------------------
    # Compute
    # -----------------------------------------------------------------

    async def compute(self, inputs: NodeInputs, ctx: Context) -> NodeOutputs:
        """Execute LLM call with optional tool execution.

        When max_tool_rounds > 0, iterates tool calling in a loop.

        Recognised input keys (passed from RunState):
        - ``conversation_history``: list[LLMMessage] of prior messages for multi-turn
        - ``budget_max_tokens``: int token ceiling for this turn
        - ``budget_max_cost``: float USD ceiling for this turn
        """
        # Resolve effective config from YAML fields or agent context
        effective_system = self._effective_system(ctx)
        effective_temperature = self._effective_temperature(ctx)
        effective_max_tool_rounds = self._effective_max_tool_rounds(ctx)

        # Use to_dict() only for Jinja2 template resolution.
        template_data = inputs.to_dict()
        formatted_prompt = self._format_template(self.prompt, template_data)
        formatted_system = (
            self._format_template(effective_system, template_data) if effective_system else None
        )
        resolved_model_str = self._resolve_model_string(template_data, ctx)
        resolved_tools = self._resolve_tool_list(template_data, ctx)
        resolved_images = self._resolve_images(template_data)

        # Use inputs.get() for typed field access — preserves Pydantic
        # models (e.g. list[LLMMessage]) without serialising to dicts.
        raw_history = inputs.get("conversation_history") or []
        conversation_history: list[LLMMessage] = [
            msg if isinstance(msg, LLMMessage) else LLMMessage(**msg)  # type: ignore[arg-type]
            for msg in raw_history
        ]
        budget_max_tokens: int = int(inputs.get("budget_max_tokens") or 0)
        budget_max_cost: float = float(inputs.get("budget_max_cost") or 0.0)

        tools_for_llm = None
        tool_name_map: ToolNameMap = {}
        if resolved_tools:
            raw_schemas, tool_name_map = get_tool_schemas_for_names(resolved_tools, ctx)
            tools_for_llm = [s for s in raw_schemas if not s.function.name.startswith("llm_")]

        logger.info(
            "[LLMNode %s] tools=%r resolved=%r schemas=%d max_tool_rounds=%d history_msgs=%d",
            self.id,
            self.tools,
            resolved_tools,
            len(tools_for_llm or []),
            effective_max_tool_rounds,
            len(conversation_history),
        )

        if effective_max_tool_rounds > 0 and tools_for_llm:
            return await self._iterative_tool_calling(
                prompt=formatted_prompt,
                system=formatted_system,
                model=resolved_model_str,
                temperature=effective_temperature,
                max_tool_rounds=effective_max_tool_rounds,
                tools=tools_for_llm,
                images=resolved_images,
                ctx=ctx,
                conversation_history=conversation_history,
                budget_max_tokens=budget_max_tokens,
                budget_max_cost=budget_max_cost,
                tool_name_map=tool_name_map,
            )

        # Single-shot mode (original behavior)
        return await self._single_shot(
            prompt=formatted_prompt,
            system=formatted_system,
            model=resolved_model_str,
            temperature=effective_temperature,
            tools=tools_for_llm,
            images=resolved_images,
            ctx=ctx,
            conversation_history=conversation_history,
            tool_name_map=tool_name_map,
        )

    async def _single_shot(
        self,
        *,
        prompt: str,
        system: str | None,
        model: str,
        temperature: float,
        tools: list[object] | None,
        images: list[str] | None,
        ctx: Context,
        conversation_history: list[LLMMessage] | None = None,
        tool_name_map: ToolNameMap | None = None,
    ) -> NodeOutputs:
        """Single LLM call with optional one-round tool execution."""
        resolved_model = resolve_model(model)

        # When conversation history is provided, use messages mode so the
        # LLM sees the full multi-turn context.
        messages_arg: list[LLMMessage] | None = None
        prompt_arg: str | None = prompt
        if conversation_history:
            messages_arg = list(conversation_history)
            messages_arg.append(LLMMessage(role="user", content=prompt))
            prompt_arg = None

        # --- Streaming path: yield token deltas via callback ---
        if ctx.run.is_streaming:
            llm_result = await self._generate_with_streaming(
                prompt=prompt_arg,
                messages=messages_arg,
                model=model,
                system=system,
                temperature=temperature,
                max_tokens=self.max_tokens or 4096,
                images=images,
                response_format=self.response_format,
                tools=tools,
                tool_choice="auto" if tools else None,
                ctx=ctx,
            )
        else:
            llm_result = await generate(
                prompt=prompt_arg,
                messages=messages_arg,
                model=model,
                system=system,
                temperature=temperature,
                max_tokens=self.max_tokens or 4096,
                images=images,
                response_format=self.response_format,
                tools=tools,
                tool_choice="auto" if tools else None,
                context=ctx,
            )

        usage = LLMUsage(
            input_tokens=int(llm_result.input_tokens or 0),
            output_tokens=int(llm_result.output_tokens or 0),
            tokens_used=int(llm_result.tokens_used or 0),
        )

        typed_tool_calls: list[ToolCall] | None = None
        tool_results: list[ToolExecutionResultItem] = []
        if llm_result.tool_calls:
            typed_tool_calls = _convert_llm_tool_calls(llm_result.tool_calls)
            tool_results = await _execute_tool_calls_with_stream_events(
                typed_tool_calls,
                llm_result.tool_calls,
                ctx,
                tool_name_map=tool_name_map,
            )

        metadata: JsonObject = llm_result.metadata or {}
        if tool_results:
            metadata["tool_results"] = [r.model_dump() for r in tool_results]
        if self.response_format == "json" and llm_result.content:
            metadata["parsed"] = json.loads(llm_result.content)

        output = LLMOutput(
            response=llm_result.content or "",
            model=llm_result.model or resolved_model,
            provider=llm_result.provider,
            usage=usage,
            finish_reason=llm_result.finish_reason,
            tool_calls=llm_result.tool_calls,
            cost_usd=llm_result.cost_usd,
            metadata=metadata,
        )
        output_dict = output.model_dump()

        # Promote parsed JSON fields so downstream templates can use
        # {{node.urgency}} instead of {{node.output.metadata.parsed.urgency}}.
        parsed = metadata.get("parsed")
        if isinstance(parsed, dict):
            output_dict.update(parsed)

        return NodeOutputs(output=output_dict)

    async def _generate_with_streaming(
        self,
        *,
        prompt: str | None,
        messages: list[LLMMessage] | None,
        model: str,
        system: str | None,
        temperature: float,
        max_tokens: int,
        images: list[str] | None,
        response_format: str,
        tools: list[object] | None,
        tool_choice: str | None,
        ctx: Context,
    ) -> LLMGenerateResult:
        """Call the LLM with streaming, emitting TokenDelta events.

        Collects the full response and returns an ``LLMGenerateResult``
        so the rest of the node logic stays unchanged.
        """
        from core.types.llm import LLMGenerateResult

        content_parts: list[str] = []
        all_tool_calls: list[LLMToolCall] = []
        finish_reason: str | None = None
        total_input_tokens = 0
        total_output_tokens = 0

        async for chunk in generate_stream(
            prompt=prompt or "",
            messages=messages,
            model=model,
            system=system,
            temperature=temperature,
            max_tokens=max_tokens,
            images=images,
            response_format=response_format,
            tools=tools,
            tool_choice=tool_choice,
            context=ctx,
        ):
            if chunk.delta:
                content_parts.append(chunk.delta)
                ctx.run.emit_stream_event(TokenDelta(content=chunk.delta))
            if chunk.tool_calls:
                all_tool_calls.extend(chunk.tool_calls)
            if chunk.finish_reason:
                finish_reason = chunk.finish_reason
            # Accumulate usage (providers may report in the final chunk)
            total_input_tokens += chunk.input_tokens
            total_output_tokens += chunk.output_tokens

        tokens_used = total_input_tokens + total_output_tokens
        return LLMGenerateResult(
            content="".join(content_parts),
            model=resolve_model(model),
            tokens_used=tokens_used,
            input_tokens=total_input_tokens,
            output_tokens=total_output_tokens,
            cost_usd=0.0,
            provider="streaming",
            finish_reason=finish_reason,
            tool_calls=all_tool_calls or None,
        )

    def _resolve_phase_model(self, base_model: str, phase: str) -> str:
        """Resolve the model for a given execution phase.

        Args:
            base_model: The default model string.
            phase: One of ``"planning"``, ``"execution"``, ``"evaluation"``.

        Returns
        -------
            The model string for the requested phase.
        """
        if self.model_routing is not None:
            return getattr(self.model_routing, phase, base_model)
        return base_model

    async def _iterative_tool_calling(
        self,
        *,
        prompt: str,
        system: str | None,
        model: str,
        temperature: float,
        max_tool_rounds: int,
        tools: list[object],
        images: list[str] | None,
        ctx: Context,
        conversation_history: list[LLMMessage] | None = None,
        budget_max_tokens: int = 0,
        budget_max_cost: float = 0.0,
        tool_name_map: ToolNameMap | None = None,
    ) -> NodeOutputs:
        """Iterative tool calling — multi-round LLM + tool execution.

        Loops: LLM call -> tool execution -> feed results -> LLM call -> ...
        Until no tool calls or max_tool_rounds reached.

        Features:
        - Model routing per phase (planning / execution / evaluation)
        - Structured step outcomes (StepSuccess / StepFailure)
        - Per-tool-call duration_ms tracking
        - Budget enforcement (tokens + cost) per round
        """
        # Prepend conversation history so the LLM has multi-turn context
        messages: list[LLMMessage] = []
        if conversation_history:
            messages.extend(conversation_history)
        messages.append(LLMMessage(role="user", content=prompt))

        total_tokens = 0
        total_cost = 0.0
        budget_exceeded = False
        budget_exceeded_reason = ""
        all_tool_calls: list[ToolCall] = []
        all_raw_tool_calls: list[LLMToolCall] = []
        all_tool_results: list[ToolExecutionResultItem] = []
        step_outcomes: list[StepOutcome] = []
        rounds_used = 0
        final_response = ""

        for round_num in range(max_tool_rounds):
            # Phase-aware model selection: first round = planning, rest = execution
            phase = "planning" if round_num == 0 else "execution"
            round_model = self._resolve_phase_model(model, phase)

            try:
                if ctx.run.is_streaming:
                    llm_result = await self._generate_with_streaming(
                        prompt=None,
                        messages=messages,
                        model=round_model,
                        system=system,
                        temperature=temperature,
                        max_tokens=self.max_tokens or 4096,
                        images=images if round_num == 0 else None,
                        response_format=self.response_format,
                        tools=tools,
                        tool_choice="auto",
                        ctx=ctx,
                    )
                else:
                    llm_result = await generate(
                        messages=messages,
                        model=round_model,
                        system=system,
                        temperature=temperature,
                        max_tokens=self.max_tokens or 4096,
                        images=images if round_num == 0 else None,
                        response_format=self.response_format,
                        tools=tools,
                        tool_choice="auto",
                        context=ctx,
                    )
            except LLMError as e:
                logger.exception("[LLMNode %s] LLM call failed: %s", self.id, e)
                step_outcomes.append(
                    StepFailure(
                        error=f"LLM call failed: {e}",
                        can_retry=False,
                        exception_type=e.error_type,
                    ),
                )
                final_response = f"Error during reasoning: {e}"
                break
            except Exception as e:
                logger.exception("[LLMNode %s] LLM call failed: %s", self.id, e)
                step_outcomes.append(
                    StepFailure(
                        error=f"LLM call failed: {e}",
                        can_retry=False,
                        exception_type=type(e).__name__,
                    ),
                )
                final_response = f"Error during reasoning: {e}"
                break

            total_tokens += llm_result.tokens_used
            total_cost += llm_result.cost_usd
            rounds_used = round_num + 1

            logger.info(
                "[LLMNode %s] round=%d/%d tool_calls=%d tokens=%d cost=%.4f model=%s",
                self.id,
                round_num + 1,
                max_tool_rounds,
                len(llm_result.tool_calls or []),
                llm_result.tokens_used,
                total_cost,
                round_model,
            )

            # Budget enforcement: stop if we've exceeded token or cost limits
            if budget_max_tokens > 0 and total_tokens >= budget_max_tokens:
                budget_exceeded = True
                budget_exceeded_reason = (
                    f"Token budget exceeded: {total_tokens}/{budget_max_tokens}"
                )
                logger.warning("[LLMNode %s] %s", self.id, budget_exceeded_reason)
                final_response = llm_result.content or ""
                break
            if budget_max_cost > 0 and total_cost >= budget_max_cost:
                budget_exceeded = True
                budget_exceeded_reason = (
                    f"Cost budget exceeded: ${total_cost:.4f}/${budget_max_cost:.4f}"
                )
                logger.warning("[LLMNode %s] %s", self.id, budget_exceeded_reason)
                final_response = llm_result.content or ""
                break

            # No tool calls = final answer
            if not llm_result.tool_calls:
                final_response = llm_result.content or ""
                break

            # Execute tool calls with per-call duration tracking
            typed_calls = _convert_llm_tool_calls(llm_result.tool_calls)
            tool_results = await _execute_tool_calls_with_tracking(
                typed_calls,
                llm_result.tool_calls,
                ctx,
                step_outcomes,
                tool_name_map=tool_name_map,
            )

            # Emit stream events for tool calls
            if ctx.run.is_streaming:
                for tc, tr in zip(llm_result.tool_calls, tool_results, strict=False):
                    ctx.run.emit_stream_event(
                        ToolCallCompleted(
                            tool_call_id=tc.id,
                            tool_name=tc.function.name,
                            output=str(tr.output) if tr.success else (tr.error or "Error"),
                            success=tr.success,
                        ),
                    )
            all_tool_calls.extend(typed_calls)
            all_raw_tool_calls.extend(llm_result.tool_calls)
            all_tool_results.extend(tool_results)

            # Append assistant message with tool calls to conversation
            messages.append(
                LLMMessage(
                    role="assistant",
                    content=llm_result.content or None,
                    tool_calls=llm_result.tool_calls,
                ),
            )

            # Append tool result messages to conversation
            for tc, tr in zip(llm_result.tool_calls, tool_results, strict=False):
                content = str(tr.output) if tr.success else f"Error: {tr.error}"
                messages.append(
                    LLMMessage(
                        role="tool",
                        content=content,
                        tool_call_id=tc.id,
                        name=tc.function.name,
                    ),
                )
        else:
            # Max rounds exhausted — synthesize a final answer without tools
            logger.info(
                "[LLMNode %s] max_tool_rounds=%d exhausted, synthesizing final answer",
                self.id,
                max_tool_rounds,
            )
            eval_model = self._resolve_phase_model(model, "evaluation")
            messages.append(
                LLMMessage(
                    role="user",
                    content=(
                        "You have used all available tool steps. "
                        "Based on the information gathered so far, provide your best answer."
                    ),
                ),
            )
            try:
                synth_result = await generate(
                    messages=messages,
                    model=eval_model,
                    system=system,
                    temperature=temperature,
                    max_tokens=self.max_tokens or 4096,
                    tools=None,
                    context=ctx,
                )
                total_tokens += synth_result.tokens_used
                total_cost += synth_result.cost_usd
                final_response = synth_result.content or ""
            except Exception as e:
                logger.warning("[LLMNode %s] Synthesis failed: %s", self.id, e)
                final_response = f"Unable to synthesize results: {e}"

        # Build output with full metrics
        usage = LLMUsage(
            input_tokens=0,
            output_tokens=0,
            tokens_used=total_tokens,
        )

        has_errors = any(isinstance(s, StepFailure) for s in step_outcomes)

        metadata: JsonObject = {
            "tool_results": [r.model_dump() for r in all_tool_results],
            "step_outcomes": [s.model_dump() for s in step_outcomes],
            "rounds_used": rounds_used,
            "max_tool_rounds": max_tool_rounds,
            "total_tokens": total_tokens,
            "total_cost_usd": total_cost,
            "budget_exceeded": budget_exceeded,
            "budget_exceeded_reason": budget_exceeded_reason or None,
        }

        output = LLMOutput(
            response=final_response,
            model=model,
            provider=None,
            usage=usage,
            finish_reason="stop" if not has_errors else "error",
            tool_calls=all_raw_tool_calls or None,
            cost_usd=total_cost,
            metadata=metadata,
        )
        output_dict = output.model_dump()

        # Promote parsed JSON fields (same as single-shot path).
        parsed = metadata.get("parsed")
        if isinstance(parsed, dict):
            output_dict.update(parsed)

        return NodeOutputs(output=output_dict)

    def _resolve_model_string(self, data: JsonObject, ctx: Context) -> str:
        """Resolve model: Jinja2 template > effective model from context."""
        effective = self._effective_model(ctx)
        if "{{" in effective:
            resolved = self._format_template(effective, data).strip()
            return resolved or "reasoning"
        return effective or "reasoning"

    def _resolve_tool_list(self, data: JsonObject, ctx: Context) -> list[str]:
        """Resolve tool patterns: Jinja2 template > effective tools from context."""
        effective = self._effective_tools(ctx)
        if isinstance(effective, str) and "{{" in effective:
            resolved = self._resolve_path(data, effective.strip("{} "))
            return resolved if isinstance(resolved, list) else []
        if isinstance(effective, list):
            return effective
        return []

    def _resolve_images(self, data: JsonObject) -> list[str] | None:
        """Resolve image references from config or template."""
        if self.images is None:
            return None
        if isinstance(self.images, str) and "{{" in self.images:
            raw = self._resolve_path(data, self.images.strip("{}"))
            if isinstance(raw, list):
                return [str(img) for img in raw if img]
            if isinstance(raw, str) and raw:
                return [raw]
            return None
        if isinstance(self.images, list):
            return [str(img) for img in self.images if img]
        if isinstance(self.images, str) and self.images:
            return [self.images]
        return None


async def _execute_tool_calls_with_stream_events(
    typed_calls: list[ToolCall],
    raw_calls: list[LLMToolCall],
    ctx: Context,
    *,
    tool_name_map: ToolNameMap | None = None,
) -> list[ToolExecutionResultItem]:
    """Execute tool calls, emitting stream events for start/complete."""
    results: list[ToolExecutionResultItem] = []
    for typed_call, raw_call in zip(typed_calls, raw_calls, strict=False):
        if ctx.run.is_streaming:
            ctx.run.emit_stream_event(
                ToolCallStarted(tool_name=typed_call.tool, tool_call_id=raw_call.id),
            )
        try:
            single_results = await execute_tool_calls([typed_call], ctx, tool_name_map)
            tr = (
                single_results[0]
                if single_results
                else ToolExecutionResultItem(
                    tool=typed_call.tool,
                    success=False,
                    error="No result returned",
                )
            )
        except Exception as e:
            tr = ToolExecutionResultItem(tool=typed_call.tool, success=False, error=str(e))
        results.append(tr)
        if ctx.run.is_streaming:
            ctx.run.emit_stream_event(
                ToolCallCompleted(
                    tool_call_id=raw_call.id,
                    tool_name=typed_call.tool,
                    output=str(tr.output) if tr.success else (tr.error or "Error"),
                    success=tr.success,
                ),
            )
    return results


def _convert_llm_tool_calls(raw: list[LLMToolCall]) -> list[ToolCall]:
    """Convert LLMToolCall Pydantic models to internal ToolCall models."""
    result: list[ToolCall] = []
    for tc in raw:
        args_str = tc.function.arguments or "{}"
        try:
            args = json.loads(args_str)
        except json.JSONDecodeError:
            args = {}
        result.append(
            ToolCall(
                id=tc.id,
                tool=tc.function.name,
                args=args if isinstance(args, dict) else {},
            ),
        )
    return result


async def _execute_tool_calls_with_tracking(
    typed_calls: list[ToolCall],
    raw_calls: list[LLMToolCall],
    ctx: Context,
    step_outcomes: list[StepOutcome],
    *,
    tool_name_map: ToolNameMap | None = None,
) -> list[ToolExecutionResultItem]:
    """Execute tool calls with per-call duration tracking and step outcome recording.

    Wraps the standard ``execute_tool_calls`` with timing and structured
    outcome tracking ported from ReactLoop.
    """
    results: list[ToolExecutionResultItem] = []
    for typed_call, raw_call in zip(typed_calls, raw_calls, strict=False):
        start = time.time()
        try:
            single_results = await execute_tool_calls([typed_call], ctx, tool_name_map)
            duration_ms = (time.time() - start) * 1000
            tr = (
                single_results[0]
                if single_results
                else ToolExecutionResultItem(
                    tool=typed_call.tool,
                    success=False,
                    error="No result returned",
                )
            )
            results.append(tr)

            if tr.success:
                step_outcomes.append(
                    StepSuccess(
                        output=str(tr.output) if tr.output is not None else "OK",
                        tool_name=typed_call.tool,
                        tool_call_id=raw_call.id,
                        duration_ms=duration_ms,
                    ),
                )
            else:
                step_outcomes.append(
                    StepFailure(
                        error=tr.error or "Tool returned failure",
                        tool_name=typed_call.tool,
                        can_retry=True,
                    ),
                )
        except ToolError as e:
            duration_ms = (time.time() - start) * 1000
            logger.warning("Tool %s failed (ToolError): %s", typed_call.tool, e)
            results.append(
                ToolExecutionResultItem(
                    tool=typed_call.tool,
                    success=False,
                    error=str(e),
                ),
            )
            step_outcomes.append(
                StepFailure(
                    error=str(e),
                    tool_name=typed_call.tool,
                    can_retry=True,
                    exception_type=e.error_type,
                ),
            )
        except Exception as e:
            duration_ms = (time.time() - start) * 1000
            logger.warning("Tool %s failed: %s", typed_call.tool, e)
            results.append(
                ToolExecutionResultItem(
                    tool=typed_call.tool,
                    success=False,
                    error=str(e),
                ),
            )
            step_outcomes.append(
                StepFailure(
                    error=str(e),
                    tool_name=typed_call.tool,
                    can_retry=True,
                    exception_type=type(e).__name__,
                ),
            )
    return results


__all__ = ["LLMNode"]
