# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import copy
import logging
from typing import AsyncGenerator
from typing import Optional

from google.genai import types
from typing_extensions import override

from ...agents.invocation_context import InvocationContext
from ...events._branch_path import _BranchPath
from ...events.event import Event
from ...models.llm_request import LlmRequest
from ._base_llm_processor import BaseLlmRequestProcessor
from .functions import AF_FUNCTION_CALL_ID_PREFIX
from .functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
from .functions import REQUEST_EUC_FUNCTION_CALL_NAME

logger = logging.getLogger('google_adk.' + __name__)


class _ContentLlmRequestProcessor(BaseLlmRequestProcessor):
  """Builds the contents for the LLM request."""

  @override
  async def run_async(
      self, invocation_context: InvocationContext, llm_request: LlmRequest
  ) -> AsyncGenerator[Event, None]:
    from ...models.google_llm import Gemini

    agent = invocation_context.agent
    preserve_function_call_ids = False
    if hasattr(agent, 'canonical_model'):
      canonical_model = agent.canonical_model
      if (
          isinstance(canonical_model, Gemini)
          and canonical_model.use_interactions_api
      ):
        preserve_function_call_ids = True
      else:
        # Anthropic and LiteLLM-backed providers (e.g. OpenAI) pair tool
        # calls with their results by id, so `adk-*` fallback ids must
        # survive replay.
        id_pairing_model_types: list[type] = []
        try:
          from ...models.anthropic_llm import AnthropicLlm

          id_pairing_model_types.append(AnthropicLlm)
        except (ImportError, OSError):
          pass
        try:
          from ...models.lite_llm import LiteLlm

          id_pairing_model_types.append(LiteLlm)
        except (ImportError, OSError):
          pass
        try:
          from ...labs.openai import OpenAIResponsesLlm

          id_pairing_model_types.append(OpenAIResponsesLlm)
        except (ImportError, OSError):
          pass
        if isinstance(canonical_model, tuple(id_pairing_model_types)):
          preserve_function_call_ids = True

    # Preserve all contents that were added by instruction processor
    # (since llm_request.contents will be completely reassigned below)
    instruction_related_contents = llm_request.contents
    run_config = invocation_context.run_config
    include_thoughts_from_other_agents = (
        run_config.include_thoughts_from_other_agents
        if run_config is not None
        else False
    )

    is_single_turn = getattr(agent, 'mode', None) == 'single_turn'
    if agent.include_contents == 'default':
      # Include full conversation history
      llm_request.contents = _get_contents(
          invocation_context.branch,
          invocation_context.session.events,
          agent.name,
          preserve_function_call_ids=preserve_function_call_ids,
          isolation_scope=invocation_context.isolation_scope,
          is_single_turn=is_single_turn,
          user_content=invocation_context.user_content,
          include_thoughts_from_other_agents=include_thoughts_from_other_agents,
      )
    else:
      # Include current turn context only (no conversation history)
      llm_request.contents = _get_current_turn_contents(
          invocation_context.branch,
          invocation_context.session.events,
          agent.name,
          preserve_function_call_ids=preserve_function_call_ids,
          isolation_scope=invocation_context.isolation_scope,
          is_single_turn=is_single_turn,
          user_content=invocation_context.user_content,
          include_thoughts_from_other_agents=False,
      )

    if (
        invocation_context.run_config
        and invocation_context.run_config.model_input_context
    ):
      _add_model_input_context_to_user_content(
          invocation_context,
          llm_request,
          copy.deepcopy(invocation_context.run_config.model_input_context),
      )

    # Add instruction-related contents to proper position in conversation
    await _add_instructions_to_user_content(
        invocation_context, llm_request, instruction_related_contents
    )

    # Maintain async generator behavior
    if False:  # Ensures it behaves as a generator
      yield  # This is a no-op but maintains generator structure


request_processor = _ContentLlmRequestProcessor()


def _rearrange_events_for_async_function_responses_in_history(
    events: list[Event],
) -> list[Event]:
  """Rearrange the async function_response events in the history."""
  function_call_id_to_response_events_index: dict[str, int] = {}
  for i, event in enumerate(events):
    function_responses = event.get_function_responses()
    if function_responses:
      for function_response in function_responses:
        function_call_id = function_response.id
        function_call_id_to_response_events_index[function_call_id] = i

  if not function_call_id_to_response_events_index:
    return events

  result_events: list[Event] = []
  for event in events:
    if event.get_function_responses():
      # function_response should be handled together with function_call below.
      continue
    elif event.get_function_calls():

      function_response_events_indices = set()
      for function_call in event.get_function_calls():
        function_call_id = function_call.id
        if function_call_id in function_call_id_to_response_events_index:
          function_response_events_indices.add(
              function_call_id_to_response_events_index[function_call_id]
          )
      result_events.append(event)
      if not function_response_events_indices:
        continue
      if len(function_response_events_indices) == 1:
        result_events.append(
            events[next(iter(function_response_events_indices))]
        )
      else:  # Merge all async function_response as one response event
        result_events.append(
            _merge_function_response_events(
                [events[i] for i in sorted(function_response_events_indices)]
            )
        )
      continue
    else:
      result_events.append(event)

  return result_events


def _rearrange_events_for_latest_function_response(
    events: list[Event],
) -> list[Event]:
  """Rearrange the events for the latest function_response.

  If the latest function_response is for an async function_call, all events
  between the initial function_call and the latest function_response will be
  removed.

  Args:
    events: A list of events.

  Returns:
    A list of events with the latest function_response rearranged.
  """
  if len(events) < 2:
    # No need to process, since there is no function_call.
    return events

  function_responses = events[-1].get_function_responses()
  if not function_responses:
    # No need to process, since the latest event is not function_response.
    return events

  function_responses_ids = set()
  for function_response in function_responses:
    function_responses_ids.add(function_response.id)

  function_calls = events[-2].get_function_calls()

  if function_calls:
    for function_call in function_calls:
      # The latest function_response is already matched
      if function_call.id in function_responses_ids:
        return events

  function_call_event_idx = -1
  # look for corresponding function call event reversely
  for idx in range(len(events) - 2, -1, -1):
    event = events[idx]
    function_calls = event.get_function_calls()
    if function_calls:
      for function_call in function_calls:
        if function_call.id in function_responses_ids:
          function_call_event_idx = idx
          function_call_ids = {
              function_call.id for function_call in function_calls
          }
          # last response event should only contain the responses for the
          # function calls in the same function call event
          if not function_responses_ids.issubset(function_call_ids):
            raise ValueError(
                'Last response event should only contain the responses for the'
                ' function calls in the same function call event. Function'
                f' call ids found : {function_call_ids}, function response'
                f' ids provided: {function_responses_ids}'
            )
          # collect all function responses from the function call event to
          # the last response event
          function_responses_ids = function_call_ids
          break

  if function_call_event_idx == -1:
    logger.debug(
        'No function call event found for function responses ids: %s in'
        ' event list: %s',
        function_responses_ids,
        events,
    )
    raise ValueError(
        'No function call event found for function responses ids:'
        f' {function_responses_ids}'
    )

  # collect all function response between last function response event
  # and function call event

  function_response_events: list[Event] = []
  for idx in range(function_call_event_idx + 1, len(events) - 1):
    event = events[idx]
    function_responses = event.get_function_responses()
    if function_responses and any([
        function_response.id in function_responses_ids
        for function_response in function_responses
    ]):
      function_response_events.append(event)
  function_response_events.append(events[-1])

  result_events = events[: function_call_event_idx + 1]
  result_events.append(
      _merge_function_response_events(function_response_events)
  )

  return result_events


def _is_part_invisible(
    p: types.Part, *, include_thoughts: bool = False
) -> bool:
  """Returns whether a part is invisible for LLM context.

  A part is invisible if:
  - It has no meaningful content (text, inline_data, file_data, function_call,
    function_response, executable_code, or code_execution_result), OR
  - It is marked as a thought AND does not contain function_call or
    function_response

  Function calls and responses are never invisible, even if marked as thought,
  because they represent actions that need to be executed or results that need
  to be processed.

  Args:
    p: The part to check.
  """
  # Function calls and responses are never invisible, even if marked as thought
  if p.function_call or p.function_response:
    return False

  return (p.thought and not include_thoughts) or not (
      p.text
      or p.inline_data
      or p.file_data
      or p.executable_code
      or p.code_execution_result
  )


def _contains_empty_content(
    event: Event, *, include_thoughts: bool = False
) -> bool:
  """Check if an event should be skipped due to missing or empty content.

  This can happen to the events that only changed session state.
  When both content and transcriptions are empty, the event will be considered
  as empty. The content is considered empty if none of its parts contain text,
  inline data, file data, function call, function response, executable code, or
  code execution result. Parts with only thoughts are also considered empty.

  Args:
    event: The event to check.

  Returns:
    True if the event should be skipped, False otherwise.
  """
  if event.actions and event.actions.compaction:
    return False

  return (
      not event.content
      or not event.content.role
      or not event.content.parts
      or all(
          _is_part_invisible(p, include_thoughts=include_thoughts)
          for p in event.content.parts
      )
  ) and (not event.output_transcription and not event.input_transcription)


_SINGLE_TURN_NUDGE = (
    'Important: You will not receive any user replies or clarifications.'
    ' Complete the task using only the information provided above.'
)


def _build_task_input_user_content(
    all_events: list[Event],
    isolation_scope: str,
    is_single_turn: bool = False,
    user_content: Optional[types.Content] = None,
) -> Optional[types.Content]:
  """Find the originating task-delegation FC and convert its args to user content.

  A task agent runs under ``isolation_scope=<fc_id>``, where ``fc_id``
  matches the function_call.id that delegated to it.  The FC itself
  lives on a parent event (typically the chat coordinator's), so it
  is filtered out of the task agent's content by the isolation_scope
  filter.  This helper rebuilds it as a user-role text content so the
  task agent's LLM sees its task as the first turn.

  When no matching FC is found (workflow-node task case — task agent
  dispatched directly by a Workflow, not via FC delegation), falls
  back to ``user_content`` (set on the InvocationContext by the
  wrapper to ``to_user_content(node_input)``).

  When ``is_single_turn`` is True, appends a second text part nudging
  the LLM that no further user replies will arrive — single-turn
  agents must complete the task from the input alone.

  Returns None if neither source yields content.
  """
  for event in all_events:
    if not event.content or not event.content.parts:
      continue
    for part in event.content.parts:
      fc = part.function_call
      if fc and fc.id == isolation_scope and fc.args:
        # Render args as JSON string — same shape an LLM would emit.
        try:
          import json as _json

          text = _json.dumps(dict(fc.args))
        except (TypeError, ValueError):
          text = str(fc.args)
        parts = [types.Part(text=text)]
        if is_single_turn:
          parts.append(types.Part(text=_SINGLE_TURN_NUDGE))
        return types.Content(role='user', parts=parts)

  # Fallback: workflow-node task with no originating FC.  Use the
  # node_input that the wrapper stamped onto ``ic.user_content``.
  if user_content and user_content.parts:
    parts = list(user_content.parts)
    if is_single_turn:
      parts.append(types.Part(text=_SINGLE_TURN_NUDGE))
    return types.Content(role='user', parts=parts)
  return None


def _should_include_event_in_context(
    current_branch: Optional[str],
    event: Event,
    isolation_scope: Optional[str] = None,
    *,
    include_thoughts: bool = False,
) -> bool:
  """Determines if an event should be included in the LLM context.

  This filters out events that are considered empty (e.g., no text, function
  calls, or transcriptions), do not belong to the current agent's branch, or
  are internal events like authentication or confirmation requests.

  Events are scoped via ``isolation_scope``: an event is visible to an
  agent only when their ``isolation_scope`` values match exactly. A chat
  coordinator (unscoped, ``isolation_scope=None``) sees only unscoped
  events; a task or single_turn agent (scoped under the originating
  function-call id) sees only its own scoped events.

  Args:
    current_branch: The current branch of the agent.
    event: The event to filter.
    isolation_scope: The agent's isolation_scope. None means unscoped.

  Returns:
    True if the event should be included in the context, False otherwise.
  """
  ev_iso = getattr(event, 'isolation_scope', None)
  if ev_iso != isolation_scope:
    return False
  return not (
      _contains_empty_content(event, include_thoughts=include_thoughts)
      or not _is_event_belongs_to_branch(current_branch, event)
      or _is_adk_framework_event(event)
      or _is_auth_event(event)
      or _is_request_confirmation_event(event)
  )


def _process_compaction_events(events: list[Event]) -> list[Event]:
  """Processes events by applying compaction.

  Identifies compacted ranges and filters out events that are covered by
  compaction summaries.

  Args:
    events: A list of events to process.

  Returns:
    A list of events with compaction applied.
  """
  # Example:
  # [event_1(ts=1), event_2(ts=2), compaction_1(1-2), event_3(ts=4),
  #  compaction_2(2-4), event_4(ts=6)].
  #
  # Overlaps are resolved by keeping only non-subsumed compaction summaries.
  # A summary event is materialized at its compaction end timestamp, and raw
  # events inside any kept compaction range are filtered out.
  compaction_infos: list[tuple[int, float, float]] = []
  for i, event in enumerate(events):
    if not (event.actions and event.actions.compaction):
      continue
    compaction = event.actions.compaction
    if (
        compaction.start_timestamp is None
        or compaction.end_timestamp is None
        or compaction.compacted_content is None
    ):
      continue
    compaction_infos.append(
        (i, compaction.start_timestamp, compaction.end_timestamp)
    )

  subsumed_compaction_event_indexes: set[int] = set()
  for event_index, start_ts, end_ts in compaction_infos:
    for other_index, other_start, other_end in compaction_infos:
      if other_index == event_index:
        continue
      if other_start <= start_ts and other_end >= end_ts:
        if (
            other_start < start_ts
            or other_end > end_ts
            or other_index > event_index
        ):
          subsumed_compaction_event_indexes.add(event_index)
          break

  compaction_ranges: list[tuple[float, float]] = []
  processed_items: list[tuple[float, int, Event]] = []

  for i, event in enumerate(events):
    if event.actions and event.actions.compaction:
      if i in subsumed_compaction_event_indexes:
        continue
      compaction = event.actions.compaction
      if (
          compaction.start_timestamp is None
          or compaction.end_timestamp is None
          or compaction.compacted_content is None
      ):
        continue
      compaction_ranges.append(
          (compaction.start_timestamp, compaction.end_timestamp)
      )
      processed_items.append((
          compaction.end_timestamp,
          i,
          Event(
              timestamp=compaction.end_timestamp,
              author='model',
              content=compaction.compacted_content,
              branch=event.branch,
              invocation_id=event.invocation_id,
              actions=event.actions,
          ),
      ))

  def _is_timestamp_compacted(ts: float) -> bool:
    for start_ts, end_ts in compaction_ranges:
      if start_ts <= ts <= end_ts:
        return True
    return False

  for i, event in enumerate(events):
    if event.actions and event.actions.compaction:
      continue
    if _is_timestamp_compacted(event.timestamp):
      continue
    processed_items.append((event.timestamp, i, event))

  # Keep chronological order and a stable tie-breaker for equal timestamps.
  processed_items.sort(key=lambda item: (item[0], item[1]))
  return [event for _, _, event in processed_items]


def _recover_compacted_function_calls(
    events: list[Event],
    source_events: list[Event],
) -> list[Event]:
  """Re-injects function-call events that compaction removed.

  Compaction can summarize away a function_call while a matching
  function_response survives outside the compacted range. The clearest case
  is a long-running tool call: the call is compacted along with its
  intermediate placeholder response, then the real result arrives on resume
  (a later event not covered by the summary). That surviving response would
  be orphaned, which breaks call/response pairing during prompt assembly (it
  raises in `_rearrange_events_for_latest_function_response`).

  For each response whose call is no longer present, this restores the
  original call event from `source_events` (the pre-compaction list),
  inserting it immediately before the first surviving response that
  references it. The whole call event is re-injected verbatim (rather than
  trimmed to the resumed call) so parallel-call thought signatures, which only
  the first part carries, are preserved. Any sibling responses that compaction
  removed are re-injected too, so a sibling is not surfaced as a phantom
  pending call.

  Args:
    events: The post-compaction events being assembled into request contents.
    source_events: The pre-compaction events to recover missing calls from.

  Returns:
    `events` with any recoverable missing function-call events (and their
    compacted sibling responses) re-injected; the original list is returned
    unchanged when nothing needs recovery.
  """
  call_ids_present: set[str] = set()
  response_ids_present: set[str] = set()
  for event in events:
    for function_call in event.get_function_calls():
      if function_call.id:
        call_ids_present.add(function_call.id)
    for function_response in event.get_function_responses():
      if function_response.id:
        response_ids_present.add(function_response.id)

  orphaned_ids = {
      response_id
      for response_id in response_ids_present
      if response_id not in call_ids_present
  }
  if not orphaned_ids:
    return events

  call_event_by_id: dict[str, Event] = {}
  for event in source_events:
    for function_call in event.get_function_calls():
      if function_call.id in orphaned_ids:
        call_event_by_id.setdefault(function_call.id, event)

  if not call_event_by_id:
    return events

  response_event_by_id: dict[str, Event] = {}
  for event in source_events:
    for function_response in event.get_function_responses():
      if function_response.id:
        response_event_by_id.setdefault(function_response.id, event)

  result: list[Event] = []
  reinjected_ids: set[str] = set()
  for event in events:
    for function_response in event.get_function_responses():
      call_event = call_event_by_id.get(function_response.id)
      if call_event is None or function_response.id in reinjected_ids:
        continue
      result.append(call_event)
      sibling_ids = [
          function_call.id
          for function_call in call_event.get_function_calls()
          if function_call.id
      ]
      reinjected_ids.update(sibling_ids)
      # Recover sibling responses that compaction removed so a parallel sibling
      # is not left looking like a pending call.
      for sibling_id in sibling_ids:
        if sibling_id not in response_ids_present:
          sibling_response = response_event_by_id.get(sibling_id)
          if sibling_response is not None:
            result.append(sibling_response)
    result.append(event)
  return result


def _copy_content_for_request(
    content: types.Content,
    *,
    strip_client_function_call_ids: bool,
) -> types.Content:
  """Returns a session-isolated copy of ``content`` for an LLM request.

  ``Content`` and every ``Part`` are shallow-copied so downstream request
  processors (nl_planning, code_execution) can mutate them without corrupting
  session events; payloads are shared by reference to avoid the deep recursion
  that the previous ``deepcopy`` paid on every request.

  Because the copy is shallow, nested fields (e.g. ``function_call.args``,
  ``inline_data.data``) are shared with the session events. Downstream
  processors must therefore only replace ``Part`` objects or set top-level
  ``Part`` fields; mutating a nested field in place would corrupt session
  history.

  Args:
    content: The (session-owned) content to copy. Not mutated.
    strip_client_function_call_ids: Whether to remove ``adk-`` prefixed function
      call/response ids (mirrors ``remove_client_function_call_id``).

  Returns:
    An isolated ``Content`` safe to attach to an ``LlmRequest``.
  """
  new_content = content.model_copy()
  parts = content.parts
  if not parts:
    return new_content

  new_parts = []
  for part in parts:
    new_part = part.model_copy()
    if strip_client_function_call_ids:
      fc = new_part.function_call
      if fc and fc.id and fc.id.startswith(AF_FUNCTION_CALL_ID_PREFIX):
        new_part.function_call = fc.model_copy(update={'id': None})
      fr = new_part.function_response
      if fr and fr.id and fr.id.startswith(AF_FUNCTION_CALL_ID_PREFIX):
        new_part.function_response = fr.model_copy(update={'id': None})
    new_parts.append(new_part)
  new_content.parts = new_parts
  return new_content


def _get_contents(
    current_branch: Optional[str],
    events: list[Event],
    agent_name: str = '',
    *,
    preserve_function_call_ids: bool = False,
    isolation_scope: Optional[str] = None,
    is_single_turn: bool = False,
    user_content: Optional[types.Content] = None,
    include_thoughts_from_other_agents: bool = False,
) -> list[types.Content]:
  """Get the contents for the LLM request.

  Applies filtering, rearrangement, and content processing to events.

  Args:
    current_branch: The current branch of the agent.
    events: Events to process.
    agent_name: The name of the agent.
    preserve_function_call_ids: Whether to preserve function call ids.
    isolation_scope: scope tag — when set, restricts events
      to those with matching ``event.isolation_scope`` (or unscoped).
    user_content: Fallback first user turn for task agents whose
      originating delegation FC is not in session (workflow-node
      task case).
    include_thoughts_from_other_agents: Whether to include thought parts from
      other agents when presenting their messages as user context.

  Returns:
    A list of processed contents.
  """
  accumulated_input_transcription = ''
  accumulated_output_transcription = ''

  # Filter out events that are annulled by a rewind.
  # By iterating backward, when a rewind event is found, we skip all events
  # from that point back to the `rewind_before_invocation_id`, thus removing
  # them from the history used for the LLM request.
  rewind_filtered_events = []
  i = len(events) - 1
  while i >= 0:
    event = events[i]
    if event.actions and event.actions.rewind_before_invocation_id:
      rewind_invocation_id = event.actions.rewind_before_invocation_id
      for j in range(0, i, 1):
        if events[j].invocation_id == rewind_invocation_id:
          i = j
          break
    else:
      rewind_filtered_events.append(event)
    i -= 1
  rewind_filtered_events.reverse()

  # Parse the events, leaving the contents and the function calls and
  # responses from the current agent.
  raw_filtered_events = [
      e
      for e in rewind_filtered_events
      if _should_include_event_in_context(
          current_branch,
          e,
          isolation_scope=isolation_scope,
          include_thoughts=(
              include_thoughts_from_other_agents
              and _is_other_agent_reply(agent_name, e)
          ),
      )
  ]

  has_compaction_events = any(
      e.actions and e.actions.compaction for e in raw_filtered_events
  )

  if has_compaction_events:
    events_to_process = _process_compaction_events(raw_filtered_events)
    # Compaction may have removed a function_call whose response survives
    # (e.g. a long-running call resumed after it was compacted); restore it so
    # the call/response pairing is intact.
    events_to_process = _recover_compacted_function_calls(
        events_to_process, raw_filtered_events
    )
  else:
    events_to_process = raw_filtered_events

  # Build mapping of function call IDs to their authors
  fc_author_by_id = {}
  for e in events_to_process:
    if e.content and e.content.parts:
      for part in e.content.parts:
        if part.function_call:
          fc_author_by_id[part.function_call.id] = e.author

  filtered_events = []
  # aggregate transcription events
  for i in range(len(events_to_process)):
    event = events_to_process[i]
    if not event.content:
      # Convert transcription into normal event
      if event.input_transcription and event.input_transcription.text:
        accumulated_input_transcription += event.input_transcription.text
        if (
            i != len(events_to_process) - 1
            and events_to_process[i + 1].input_transcription
            and events_to_process[i + 1].input_transcription.text
        ):
          continue
        event = event.model_copy(deep=True)
        event.input_transcription = None
        event.content = types.Content(
            role='user',
            parts=[types.Part(text=accumulated_input_transcription)],
        )
        accumulated_input_transcription = ''
      elif event.output_transcription and event.output_transcription.text:
        accumulated_output_transcription += event.output_transcription.text
        if (
            i != len(events_to_process) - 1
            and events_to_process[i + 1].output_transcription
            and events_to_process[i + 1].output_transcription.text
        ):
          continue
        event = event.model_copy(deep=True)
        event.output_transcription = None
        event.content = types.Content(
            role='model',
            parts=[types.Part(text=accumulated_output_transcription)],
        )
        accumulated_output_transcription = ''

    is_other_reply = _is_other_agent_reply(agent_name, event)

    # Check if it's a FunctionResponse for another agent
    if not is_other_reply and event.content:
      for part in event.content.parts or []:
        if part.function_response:
          resp_id = part.function_response.id
          call_author = fc_author_by_id.get(resp_id)
          if (
              call_author
              and call_author != agent_name
              and call_author != 'user'
          ):
            is_other_reply = True
            break

    if is_other_reply:
      if converted_event := _present_other_agent_message(
          event, include_thoughts=include_thoughts_from_other_agents
      ):
        filtered_events.append(converted_event)
    else:
      filtered_events.append(event)

  # Rearrange events for proper function call/response pairing
  result_events = _rearrange_events_for_latest_function_response(
      filtered_events
  )
  result_events = _rearrange_events_for_async_function_responses_in_history(
      result_events
  )

  # Convert events to contents
  contents = []
  for event in result_events:
    if event.content:
      contents.append(
          _copy_content_for_request(
              event.content,
              strip_client_function_call_ids=not preserve_function_call_ids,
          )
      )

  # for scoped agents (task / single_turn), prepend a
  # synthetic user-role content built from the originating FC's args.
  # The FC lives in an UNSCOPED parent event (e.g., the coordinator's
  # task-delegation FC), which the strict isolation filter just
  # excluded — so we re-derive it directly from the full session
  # events here.  This becomes the agent's first turn: "your task is
  # X" instead of starting cold from system instruction only.
  if isolation_scope is not None:
    leading = _build_task_input_user_content(
        events,
        isolation_scope,
        is_single_turn=is_single_turn,
        user_content=user_content,
    )
    if leading is not None:
      contents.insert(0, leading)

  return contents


def _get_current_turn_contents(
    current_branch: Optional[str],
    events: list[Event],
    agent_name: str = '',
    *,
    preserve_function_call_ids: bool = False,
    is_single_turn: bool = False,
    isolation_scope: Optional[str] = None,
    user_content: Optional[types.Content] = None,
    include_thoughts_from_other_agents: bool = False,
) -> list[types.Content]:
  """Get contents for the current turn only (no conversation history).

  When include_contents='none', we want to include:
  - The current user input
  - Tool calls and responses from the current turn
  But exclude conversation history from previous turns.

  In multi-agent scenarios, the "current turn" for an agent starts from an
  actual user or from another agent.

  Args:
    current_branch: The current branch of the agent.
    events: A list of all session events.
    agent_name: The name of the agent.
    preserve_function_call_ids: Whether to preserve function call ids.
    include_thoughts_from_other_agents: Whether to include thought parts from
      other agents when presenting their messages as user context.

  Returns:
    A list of contents for the current turn only, preserving context needed
    for proper tool execution while excluding conversation history.
  """
  # Find the latest event that starts the current turn and process from there
  for i in range(len(events) - 1, -1, -1):
    event = events[i]
    if (
        _should_include_event_in_context(
            current_branch,
            event,
            isolation_scope=isolation_scope,
            include_thoughts=(
                include_thoughts_from_other_agents
                and _is_other_agent_reply(agent_name, event)
            ),
        )
        and (event.author == 'user' or _is_other_agent_reply(agent_name, event))
        and not _is_direct_transfer(event)
    ):
      return _get_contents(
          current_branch,
          events[i:],
          agent_name,
          preserve_function_call_ids=preserve_function_call_ids,
          isolation_scope=isolation_scope,
          is_single_turn=is_single_turn,
          user_content=user_content,
          include_thoughts_from_other_agents=include_thoughts_from_other_agents,
      )

  return []


def _is_direct_transfer(event: Event) -> bool:
  """Whether the event is a direct ``transfer_to_agent`` event.

  When ``include_contents='none'`` and control is handed to a sub-agent via
  ``transfer_to_agent``, the trailing transfer events (the function call and
  its response) must not be treated as the start of the current turn.
  Otherwise the sub-agent's turn would anchor on the parent's transfer event
  and drop the latest user input. Skipping these events lets the turn anchor
  on the real user input (or a non-transfer model request) instead, while the
  transfer events are still included as context.
  """
  return bool(
      event.actions.transfer_to_agent
      or (
          event.content
          and event.content.parts
          and any(
              p.function_call and p.function_call.name == 'transfer_to_agent'
              for p in event.content.parts
          )
      )
  )


def _is_other_agent_reply(current_agent_name: str, event: Event) -> bool:
  """Whether the event is a reply from another agent."""
  # In live/bidi mode, all events from any agents, including the current
  # agent, will be marked as other agent's reply. When agent transfers,
  # the conversation history will be sent to the Live API. If the current
  # agent previously used `transfer_to_agent` to transfer to another agent,
  # when the conversation is sent back to the current agent, the history will
  # contain a `transfer_to_agent` function call event from the current agent.
  # The Live API marks anything after the function response as model response.
  # This will confuse the model and cause the model to not respond.
  #
  # E.g. when the conversation is transferred from agent A to agent B, then
  # back to agent A, the history in the last transfer will be:
  #   User: "Some message that triggers transfer to agent B"
  #   Model: transfer_to_agent(B)
  #   User: transfer_to_agent(B) response
  #   User: "Some message that triggers transfer to agent A"
  #   User: "For context: [agent B] called transfer_to_agent(A)"
  #   User: "For context: [agent B] tool transfer_to_agent(A) returned result:"
  #
  # In this case, the last three events are marked as model response by the
  # Live API, instead of user input.
  if event.live_session_id:
    return event.author != 'user'
  return bool(
      current_agent_name
      and event.author != current_agent_name
      and event.author != 'user'
  )


def _present_other_agent_message(
    event: Event, *, include_thoughts: bool = False
) -> Optional[Event]:
  """Presents another agent's message as user context for the current agent.

  Reformats the event with role='user' and adds '[agent_name] said:' prefix
  to provide context without confusion about authorship.

  Args:
    event: The event from another agent to present as context.
    include_thoughts: Whether to include thought parts as explicit text
      context.

  Returns:
    Event reformatted as user-role context with agent attribution, or None
    if no meaningful content remains after filtering.
  """
  if not event.content or not event.content.parts:
    return event

  content = types.Content()
  content.role = 'user'
  content.parts = [types.Part(text='For context:')]
  for part in event.content.parts:
    if part.thought:
      if include_thoughts and part.text is not None and part.text.strip():
        content.parts.append(
            types.Part(text=f'[{event.author}] thought: {part.text}')
        )
      continue
    elif part.text is not None and part.text.strip():
      content.parts.append(
          types.Part(text=f'[{event.author}] said: {part.text}')
      )
    elif part.function_call:
      content.parts.append(
          types.Part(
              text=(
                  f'[{event.author}] called tool `{part.function_call.name}`'
                  f' with parameters: {part.function_call.args}'
              )
          )
      )
    elif part.function_response:
      # Otherwise, create a new text part.
      content.parts.append(
          types.Part(
              text=(
                  f'[{event.author}] `{part.function_response.name}` tool'
                  f' returned result: {part.function_response.response}'
              )
          )
      )
    elif (
        part.inline_data
        or part.file_data
        or part.executable_code
        or part.code_execution_result
    ):
      content.parts.append(part)
    else:
      continue

  # Return None when only "For context:" remains.
  if len(content.parts) == 1:
    return None

  return Event(
      timestamp=event.timestamp,
      author='user',
      content=content,
      branch=event.branch,
  )


def _merge_function_response_events(
    function_response_events: list[Event],
) -> Event:
  """Merges a list of function_response events into one event.

  The key goal is to ensure:
  1. function_call and function_response are always of the same number.
  2. The function_call and function_response are consecutively in the content.

  Args:
    function_response_events: A list of function_response events.
      NOTE: function_response_events must fulfill these requirements: 1. The
        list is in increasing order of timestamp; 2. the first event is the
        initial function_response event; 3. all later events should contain at
        least one function_response part that related to the function_call
        event.
      Caveat: This implementation doesn't support when a parallel function_call
        event contains async function_call of the same name.

  Returns:
    A merged event, that is
      1. All later function_response will replace function_response part in
          the initial function_response event.
      2. All non-function_response parts will be appended to the part list of
          the initial function_response event.
  """
  if not function_response_events:
    raise ValueError('At least one function_response event is required.')

  merged_event = function_response_events[0].model_copy(deep=True)
  parts_in_merged_event: list[types.Part] = merged_event.content.parts  # type: ignore

  if not parts_in_merged_event:
    raise ValueError('There should be at least one function_response part.')

  part_indices_in_merged_event: dict[str, int] = {}
  for idx, part in enumerate(parts_in_merged_event):
    if part.function_response:
      function_call_id: str = part.function_response.id  # type: ignore
      part_indices_in_merged_event[function_call_id] = idx

  for event in function_response_events[1:]:
    if not event.content.parts:
      raise ValueError('There should be at least one function_response part.')

    for part in event.content.parts:
      if part.function_response:
        function_call_id: str = part.function_response.id  # type: ignore
        if function_call_id in part_indices_in_merged_event:
          parts_in_merged_event[
              part_indices_in_merged_event[function_call_id]
          ] = part
        else:
          parts_in_merged_event.append(part)
          part_indices_in_merged_event[function_call_id] = (
              len(parts_in_merged_event) - 1
          )

      else:
        parts_in_merged_event.append(part)

  return merged_event


def _is_event_belongs_to_branch(
    invocation_branch: Optional[str], event: Event
) -> bool:
  """Check if an event belongs to the current branch.

  This is for event context segregation between agents. E.g. agent A shouldn't
  see output of agent B.
  """
  if not invocation_branch or not event.branch:
    return True

  inv_path = _BranchPath.from_string(invocation_branch)
  evt_path = _BranchPath.from_string(event.branch)
  return inv_path == evt_path or inv_path.is_descendant_of(evt_path)


def _is_function_call_event(event: Event, function_name: str) -> bool:
  """Checks if an event is a function call/response for a given function name."""
  if not event.content or not event.content.parts:
    return False
  for part in event.content.parts:
    if part.function_call and part.function_call.name == function_name:
      return True
    if part.function_response and part.function_response.name == function_name:
      return True
  return False


def _is_auth_event(event: Event) -> bool:
  """Checks if the event is an authentication event."""
  return _is_function_call_event(event, REQUEST_EUC_FUNCTION_CALL_NAME)


def _is_request_confirmation_event(event: Event) -> bool:
  """Checks if the event is a request confirmation event."""
  return _is_function_call_event(event, REQUEST_CONFIRMATION_FUNCTION_CALL_NAME)


def _is_adk_framework_event(event: Event) -> bool:
  """Checks if the event is an ADK framework event."""
  return _is_function_call_event(event, 'adk_framework')


def _is_live_model_media_event_with_inline_data(event: Event) -> bool:
  """Check if the event is a live/bidi media event (audio, video, image) with inline data.

  There are two possible cases and we only care about the second case:
  content=Content(
    parts=[
      Part(
        file_data=FileData(
          file_uri='artifact://live_bidi_streaming_multi_agent/user/cccf0b8b-4a30-449a-890e-e8b8deb661a1/_adk_live/adk_live_audio_storage_input_audio_1756092402277.pcm#1',
          mime_type='audio/pcm'
        )
      ),
    ],
    role='user'
  )
  content=Content(
    parts=[
      Part(
        inline_data=Blob(
          data=b'\x01\x00\x00...',
          mime_type='audio/pcm;rate=24000'
        )
      ),
    ],
    role='model'
  ) grounding_metadata=None partial=None turn_complete=None finish_reason=None
  error_code=None error_message=None...
  """
  if not event.content or not event.content.parts:
    return False
  for part in event.content.parts:
    if part.inline_data and part.inline_data.mime_type:
      mime = part.inline_data.mime_type.lower()
      if (
          mime.startswith('audio/')
          or mime.startswith('video/')
          or mime.startswith('image/')
      ):
        return True
  return False


def _content_contains_function_response(content: types.Content) -> bool:
  """Checks whether the content includes any function response parts."""
  if not content.parts:
    return False
  for part in content.parts:
    if part.function_response:
      return True
  return False


def _add_model_input_context_to_user_content(
    invocation_context: InvocationContext,
    llm_request: LlmRequest,
    model_input_context: list[types.Content],
) -> None:
  """Insert transient model input context before the invocation user content."""
  if not model_input_context:
    return

  insert_index = 0
  user_content = invocation_context.user_content
  if user_content:
    for i in range(len(llm_request.contents) - 1, -1, -1):
      if llm_request.contents[i] == user_content:
        insert_index = i
        break

  llm_request.contents[insert_index:insert_index] = model_input_context


async def _add_instructions_to_user_content(
    invocation_context: InvocationContext,
    llm_request: LlmRequest,
    instruction_contents: list,
) -> None:
  """Insert instruction-related contents at proper position in conversation.

  This function inserts instruction-related contents (passed as parameter) at
  the
  proper position in the conversation flow, specifically before the last
  continuous
  batch of user content to maintain conversation context.

  Args:
    invocation_context: The invocation context
    llm_request: The LLM request to modify
    instruction_contents: List of instruction-related contents to insert
  """
  if not instruction_contents:
    return

  # Find the insertion point: before the last continuous batch of user content
  # Walk backwards to find the first non-user content, then insert after it
  insert_index = len(llm_request.contents)

  if llm_request.contents:
    for i in range(len(llm_request.contents) - 1, -1, -1):
      content = llm_request.contents[i]
      if content.role != 'user':
        insert_index = i + 1
        break
      if _content_contains_function_response(content):
        insert_index = i + 1
        break
      insert_index = i
  else:
    # No contents remaining, just append at the end
    insert_index = 0

  # Insert all instruction contents at the proper position using efficient slicing
  llm_request.contents[insert_index:insert_index] = instruction_contents
