|
32 | 32 | import agentplatform |
33 | 33 | from google.genai import types as genai_types |
34 | 34 | from google.genai._api_client import BaseApiClient |
| 35 | +from google.genai._gaos.types.interactions import interaction as interaction_types |
| 36 | +from google.genai._gaos.types.interactions import functioncallstep |
| 37 | +from google.genai._gaos.types.interactions import functionresultstep |
| 38 | +from google.genai._gaos.types.interactions import modeloutputstep |
| 39 | +from google.genai._gaos.types.interactions import userinputstep |
35 | 40 | from google.genai.models import Models |
36 | 41 | import pandas as pd |
37 | 42 | from tqdm import tqdm |
@@ -575,6 +580,316 @@ def _is_gemini_agent_resource(agent: str) -> bool: |
575 | 580 | ) |
576 | 581 |
|
577 | 582 |
|
| 583 | +def _step_to_agent_event(step: Any) -> Optional[types.evals.AgentEvent]: |
| 584 | + """Converts a typed GenAI SDK Interaction step to an AgentEvent. |
| 585 | +
|
| 586 | + Uses ``isinstance`` checks against the GenAI SDK step classes so that |
| 587 | + attribute access stays in sync with SDK/proto changes. |
| 588 | +
|
| 589 | + Args: |
| 590 | + step: A step from ``Interaction.steps`` (a GenAI SDK step type). |
| 591 | +
|
| 592 | + Returns: |
| 593 | + An AgentEvent, or ``None`` if the step type is not handled. |
| 594 | + """ |
| 595 | + if isinstance(step, userinputstep.UserInputStep): |
| 596 | + return _text_step_to_event(step, author="user", role="user") |
| 597 | + elif isinstance(step, modeloutputstep.ModelOutputStep): |
| 598 | + return _text_step_to_event(step, author="agent", role="model") |
| 599 | + elif isinstance(step, functioncallstep.FunctionCallStep): |
| 600 | + return _function_call_step_to_event(step) |
| 601 | + elif isinstance(step, functionresultstep.FunctionResultStep): |
| 602 | + return _function_response_step_to_event(step) |
| 603 | + else: |
| 604 | + logger.info( |
| 605 | + "Skipping unhandled interaction step type: %s", type(step).__name__ |
| 606 | + ) |
| 607 | + return None |
| 608 | + |
| 609 | + |
| 610 | +def _function_response_step_to_event( |
| 611 | + step: functionresultstep.FunctionResultStep, |
| 612 | +) -> types.evals.AgentEvent: |
| 613 | + """Converts a FunctionResultStep to an AgentEvent.""" |
| 614 | + result = step.result |
| 615 | + if isinstance(result, dict): |
| 616 | + result_str = json.dumps(result) |
| 617 | + elif isinstance(result, str): |
| 618 | + result_str = result |
| 619 | + else: |
| 620 | + result_str = str(result) if result is not None else "" |
| 621 | + return types.evals.AgentEvent( # pytype: disable=missing-parameter |
| 622 | + author="user", |
| 623 | + content=genai_types.Content( |
| 624 | + role="user", |
| 625 | + parts=[ |
| 626 | + genai_types.Part( |
| 627 | + function_response=genai_types.FunctionResponse( |
| 628 | + name=step.name or "", |
| 629 | + response={"result": result_str}, |
| 630 | + id=step.call_id or "", |
| 631 | + ) |
| 632 | + ) |
| 633 | + ], |
| 634 | + ), |
| 635 | + ) |
| 636 | + |
| 637 | + |
| 638 | +def _function_call_step_to_event( |
| 639 | + step: functioncallstep.FunctionCallStep, |
| 640 | +) -> types.evals.AgentEvent: |
| 641 | + """Converts a FunctionCallStep to an AgentEvent.""" |
| 642 | + return types.evals.AgentEvent( # pytype: disable=missing-parameter |
| 643 | + author="agent", |
| 644 | + content=genai_types.Content( |
| 645 | + role="model", |
| 646 | + parts=[ |
| 647 | + genai_types.Part( |
| 648 | + function_call=genai_types.FunctionCall( |
| 649 | + name=step.name or "", |
| 650 | + args=step.arguments or {}, |
| 651 | + id=step.id or "", |
| 652 | + ) |
| 653 | + ) |
| 654 | + ], |
| 655 | + ), |
| 656 | + ) |
| 657 | + |
| 658 | + |
| 659 | +def _text_step_to_event( |
| 660 | + step: Any, *, author: str, role: str |
| 661 | +) -> Optional[types.evals.AgentEvent]: |
| 662 | + """Converts a text-bearing step (UserInputStep / ModelOutputStep) to an AgentEvent. |
| 663 | +
|
| 664 | + Args: |
| 665 | + step: A GenAI SDK step with a ``content`` attribute. |
| 666 | + author: The event author (``"user"`` or ``"agent"``). |
| 667 | + role: The content role (``"user"`` or ``"model"``). |
| 668 | +
|
| 669 | + Returns: |
| 670 | + An AgentEvent, or ``None`` if no text parts were found. |
| 671 | + """ |
| 672 | + parts = [] |
| 673 | + for content_item in step.content or []: |
| 674 | + if getattr(content_item, "text", None): |
| 675 | + parts.append(genai_types.Part(text=content_item.text)) |
| 676 | + if not parts: |
| 677 | + return None |
| 678 | + return types.evals.AgentEvent( # pytype: disable=missing-parameter |
| 679 | + author=author, |
| 680 | + content=genai_types.Content(role=role, parts=parts), |
| 681 | + ) |
| 682 | + |
| 683 | + |
| 684 | +def _interaction_steps_to_events( |
| 685 | + steps: list[Any], |
| 686 | +) -> list[tuple[types.evals.AgentEvent, type]]: |
| 687 | + """Converts a list of typed Interaction steps to AgentEvents. |
| 688 | +
|
| 689 | + Each step is mapped via ``_step_to_agent_event``. Steps whose type is |
| 690 | + not handled are skipped with a log message. The originating SDK step |
| 691 | + class is returned alongside each event so callers can determine turn |
| 692 | + boundaries without inspecting event content. |
| 693 | +
|
| 694 | + Args: |
| 695 | + steps: The ``steps`` list from a GenAI SDK ``Interaction`` object. |
| 696 | +
|
| 697 | + Returns: |
| 698 | + A list of ``(AgentEvent, step_class)`` tuples. |
| 699 | + """ |
| 700 | + events: list[tuple[types.evals.AgentEvent, type]] = [] |
| 701 | + for step in steps: |
| 702 | + event = _step_to_agent_event(step) |
| 703 | + if event is not None: |
| 704 | + events.append((event, type(step))) |
| 705 | + return events |
| 706 | + |
| 707 | + |
| 708 | +def _interaction_dict_to_agent_data( |
| 709 | + interaction: dict[str, Any], |
| 710 | +) -> types.evals.AgentData: |
| 711 | + """Converts an Interaction API JSON response to an AgentData object. |
| 712 | +
|
| 713 | + Parses the raw dict into a typed ``Interaction`` object (from the GenAI |
| 714 | + SDK) so that step conversion uses ``isinstance`` checks and typed |
| 715 | + attribute access. Steps are grouped into ConversationTurns -- each |
| 716 | + ``UserInputStep`` starts a new turn, so multi-turn conversations |
| 717 | + produce multiple turns. |
| 718 | +
|
| 719 | + Args: |
| 720 | + interaction: A dict from the Interactions API GET response. |
| 721 | +
|
| 722 | + Returns: |
| 723 | + An AgentData object with one or more ConversationTurns. |
| 724 | + """ |
| 725 | + typed_interaction = interaction_types.Interaction.model_validate( |
| 726 | + interaction |
| 727 | + ) |
| 728 | + all_events = _interaction_steps_to_events(typed_interaction.steps or []) |
| 729 | + |
| 730 | + # Group events into turns. Each UserInputStep starts a new turn. |
| 731 | + grouped: list[list[types.evals.AgentEvent]] = [] |
| 732 | + for event, step_type in all_events: |
| 733 | + if not grouped or step_type is userinputstep.UserInputStep: |
| 734 | + grouped.append([]) |
| 735 | + grouped[-1].append(event) |
| 736 | + |
| 737 | + if not grouped: |
| 738 | + return types.evals.AgentData( # pytype: disable=missing-parameter |
| 739 | + turns=[types.evals.ConversationTurn( # pytype: disable=missing-parameter |
| 740 | + turn_index=0, events=[] |
| 741 | + )] |
| 742 | + ) |
| 743 | + return types.evals.AgentData( # pytype: disable=missing-parameter |
| 744 | + turns=[ |
| 745 | + types.evals.ConversationTurn( # pytype: disable=missing-parameter |
| 746 | + turn_index=i, events=events |
| 747 | + ) |
| 748 | + for i, events in enumerate(grouped) |
| 749 | + ] |
| 750 | + ) |
| 751 | + |
| 752 | + |
| 753 | +def _merge_text_parts_in_agent_data( |
| 754 | + agent_data: types.evals.AgentData, |
| 755 | +) -> None: |
| 756 | + """Merges consecutive text events and parts for cleaner trace display. |
| 757 | +
|
| 758 | + The Interaction API may return multiple consecutive ``model_output`` |
| 759 | + steps (one per paragraph) and/or multiple text content items within a |
| 760 | + single step. ``_interaction_dict_to_agent_data`` maps each step to a |
| 761 | + separate event, and each content item to a separate ``part``, causing |
| 762 | + the trace renderer to display them as separate visual blocks. |
| 763 | +
|
| 764 | + This function performs two merges: |
| 765 | +
|
| 766 | + 1. **Event merge** -- consecutive events from the same author that |
| 767 | + contain only text parts are collapsed into a single event. |
| 768 | + 2. **Part merge** -- within each (possibly merged) event, consecutive |
| 769 | + text-only parts are collapsed into a single part. |
| 770 | +
|
| 771 | + Mutates ``agent_data`` in place. |
| 772 | +
|
| 773 | + Args: |
| 774 | + agent_data: An AgentData object to merge in place. |
| 775 | + """ |
| 776 | + for turn in agent_data.turns or []: |
| 777 | + events = turn.events |
| 778 | + if not events: |
| 779 | + continue |
| 780 | + |
| 781 | + # --- Pass 1: merge consecutive text-only events from the same author --- |
| 782 | + merged_events: list[types.evals.AgentEvent] = [] |
| 783 | + for event in events: |
| 784 | + parts = (event.content.parts if event.content else None) or [] |
| 785 | + is_text_only = parts and all( |
| 786 | + p.text is not None |
| 787 | + and p.function_call is None |
| 788 | + and p.function_response is None |
| 789 | + for p in parts |
| 790 | + ) |
| 791 | + if ( |
| 792 | + merged_events |
| 793 | + and is_text_only |
| 794 | + and event.author == merged_events[-1].author |
| 795 | + ): |
| 796 | + prev_content = merged_events[-1].content |
| 797 | + prev_parts = (prev_content.parts if prev_content else None) or [] |
| 798 | + prev_parts.extend(parts) |
| 799 | + continue |
| 800 | + merged_events.append(event) |
| 801 | + turn.events = merged_events |
| 802 | + |
| 803 | + # --- Pass 2: merge consecutive text parts within each event --- |
| 804 | + for event in turn.events: |
| 805 | + content = event.content |
| 806 | + if not content: |
| 807 | + continue |
| 808 | + parts = content.parts |
| 809 | + if not parts or len(parts) <= 1: |
| 810 | + continue |
| 811 | + merged_parts: list[genai_types.Part] = [] |
| 812 | + text_buffer: list[str] = [] |
| 813 | + for part in parts: |
| 814 | + if ( |
| 815 | + part.text is not None |
| 816 | + and part.function_call is None |
| 817 | + and part.function_response is None |
| 818 | + ): |
| 819 | + text_buffer.append(part.text) |
| 820 | + else: |
| 821 | + if text_buffer: |
| 822 | + merged_parts.append( |
| 823 | + genai_types.Part(text="\n".join(text_buffer)) |
| 824 | + ) |
| 825 | + text_buffer = [] |
| 826 | + merged_parts.append(part) |
| 827 | + if text_buffer: |
| 828 | + merged_parts.append(genai_types.Part(text="\n".join(text_buffer))) |
| 829 | + content.parts = merged_parts |
| 830 | + |
| 831 | + |
| 832 | +def _fetch_agent_config_dict( |
| 833 | + api_client: BaseApiClient, |
| 834 | + agent_resource_name: str, |
| 835 | +) -> types.evals.AgentConfig: |
| 836 | + """Fetches an agent's config from the Agent API and returns an AgentConfig. |
| 837 | +
|
| 838 | + Fetches the Agent resource via ``GET agents/{id}``, extracts the |
| 839 | + system instruction, description, base agent type, and tools, and |
| 840 | + strips the ``type`` field from each tool (which ``genai_types.Tool`` |
| 841 | + rejects with ``extra="forbid"``). Built-in tools that become empty |
| 842 | + after stripping ``type`` (e.g. ``code_execution``, ``google_search``) |
| 843 | + are filtered out. |
| 844 | +
|
| 845 | + Args: |
| 846 | + api_client: The API client used to fetch the agent. |
| 847 | + agent_resource_name: Full resource name of the agent, e.g. |
| 848 | + ``projects/p/locations/l/agents/my-agent``. |
| 849 | +
|
| 850 | + Returns: |
| 851 | + An AgentConfig with ``agent_id`` and, when available, |
| 852 | + ``instruction``, ``description``, ``agent_type``, and ``tools``. |
| 853 | + """ |
| 854 | + agent_short_id = agent_resource_name.split("/")[-1] or "agent" |
| 855 | + |
| 856 | + instruction: Optional[str] = None |
| 857 | + description: Optional[str] = None |
| 858 | + agent_type: Optional[str] = None |
| 859 | + tools: Optional[list[genai_types.Tool]] = None |
| 860 | + |
| 861 | + try: |
| 862 | + agent_resp = api_client.request("get", f"agents/{agent_short_id}", {}, None) |
| 863 | + if agent_resp.body: |
| 864 | + agent_dict = json.loads(agent_resp.body) |
| 865 | + instruction = agent_dict.get("system_instruction") or None |
| 866 | + description = agent_dict.get("description") or None |
| 867 | + agent_type = agent_dict.get("base_agent") or None |
| 868 | + if agent_dict.get("tools"): |
| 869 | + cleaned_tools = [] |
| 870 | + for tool in agent_dict["tools"]: |
| 871 | + if isinstance(tool, dict): |
| 872 | + tool = {k: v for k, v in tool.items() if k != "type"} |
| 873 | + if tool: |
| 874 | + cleaned_tools.append(genai_types.Tool.model_validate(tool)) |
| 875 | + if cleaned_tools: |
| 876 | + tools = cleaned_tools |
| 877 | + except Exception as e: # pylint: disable=broad-exception-caught |
| 878 | + logger.warning( |
| 879 | + "Failed to fetch agent config for '%s' (continuing without it): %s", |
| 880 | + agent_resource_name, |
| 881 | + e, |
| 882 | + ) |
| 883 | + |
| 884 | + return types.evals.AgentConfig( # pytype: disable=missing-parameter |
| 885 | + agent_id=agent_short_id, |
| 886 | + instruction=instruction, |
| 887 | + description=description, |
| 888 | + agent_type=agent_type, |
| 889 | + tools=tools, |
| 890 | + ) |
| 891 | + |
| 892 | + |
578 | 893 | def _add_evaluation_run_labels( |
579 | 894 | labels: Optional[dict[str, str]] = None, |
580 | 895 | agent: Optional[str] = None, |
|
0 commit comments