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