Skip to content

Commit 8a9ab80

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
fix: Merge sandbox-only turns in SDK-side interaction-to-AgentData conversion.
FUTURE_COPYBARA_INTEGRATE_REVIEW=#7003 from googleapis:release-please--branches--main 5905f2d PiperOrigin-RevId: 952235858
1 parent 922fa6a commit 8a9ab80

3 files changed

Lines changed: 294 additions & 0 deletions

File tree

agentplatform/_genai/_evals_builtin_tools.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
publishing internal tool contract details.
2626
2727
**If the server catalog changes, this SDK-side copy must be updated to match.**
28+
29+
This module also provides sandbox-detection helpers
30+
(``SANDBOX_TOOL_NAMES``, ``is_sandbox_only_turn``) used by the display
31+
path (``_evals_common._interaction_dict_to_agent_data``).
2832
"""
2933

3034
from typing import Any, Optional
@@ -87,6 +91,58 @@
8791
]
8892

8993

94+
# Names of sandbox orchestration tools, derived from ``SANDBOX_DECLARATIONS``
95+
# so there is a single source of truth.
96+
SANDBOX_TOOL_NAMES: frozenset[str] = frozenset(
97+
decl.name for decl in SANDBOX_DECLARATIONS if decl.name
98+
)
99+
100+
101+
def is_sandbox_only_turn(
102+
events: list[Any],
103+
) -> bool:
104+
"""Returns True if a turn contains only sandbox initialization events.
105+
106+
Sandbox provisioning events (``provision_sandbox``, ``load_sandbox``)
107+
are infrastructure setup steps that happen before the user's first
108+
real prompt.
109+
110+
A turn is sandbox-only when every event is either a
111+
``function_call`` or ``function_response`` referencing a sandbox
112+
tool name. Events with plain text content (model output, user
113+
input) disqualify the turn.
114+
115+
Args:
116+
events: The list of AgentEvents in the turn.
117+
118+
Returns:
119+
True if the turn is sandbox-only and should be merged into the
120+
next real turn for display.
121+
"""
122+
if not events:
123+
return True
124+
125+
for event in events:
126+
content = getattr(event, "content", None)
127+
if not content:
128+
continue
129+
parts = getattr(content, "parts", None)
130+
if not parts:
131+
continue
132+
for part in parts:
133+
if getattr(part, "function_call", None):
134+
if part.function_call.name not in SANDBOX_TOOL_NAMES:
135+
return False
136+
elif getattr(part, "function_response", None):
137+
if part.function_response.name not in SANDBOX_TOOL_NAMES:
138+
return False
139+
elif getattr(part, "text", None):
140+
# Text content means this is a real conversational event,
141+
# not sandbox infrastructure.
142+
return False
143+
return True
144+
145+
90146
def agent_tools_to_config_tools(
91147
agent_tools: Optional[list[Any]],
92148
has_environment: bool = False,

agentplatform/_genai/_evals_common.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,13 @@ def _interaction_dict_to_agent_data(
760760
grouped.append([])
761761
grouped[-1].append(event)
762762

763+
# Merge leading sandbox-only turns into the first real turn.
764+
# Sandbox provisioning events (provision_sandbox, load_sandbox) are
765+
# infrastructure setup that precedes the user's first real prompt.
766+
while len(grouped) > 1 and _evals_builtin_tools.is_sandbox_only_turn(grouped[0]):
767+
grouped[1] = grouped[0] + grouped[1]
768+
grouped.pop(0)
769+
763770
if not grouped:
764771
return types.evals.AgentData( # pytype: disable=missing-parameter
765772
turns=[

tests/unit/agentplatform/genai/test_evals.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11384,6 +11384,237 @@ def test_unknown_steps_skipped(self):
1138411384
assert len(events) == 2
1138511385

1138611386

11387+
class TestIsSandboxOnlyTurn:
11388+
"""Tests for _is_sandbox_only_turn."""
11389+
11390+
def _make_event(self, *, author, part):
11391+
return agentplatform_genai_types.evals.AgentEvent(
11392+
author=author,
11393+
content=genai_types.Content(role="model", parts=[part]),
11394+
)
11395+
11396+
def test_provision_sandbox_is_sandbox_only(self):
11397+
events = [
11398+
self._make_event(
11399+
author="agent",
11400+
part=genai_types.Part(
11401+
function_call=genai_types.FunctionCall(
11402+
name="provision_sandbox", args={}
11403+
)
11404+
),
11405+
),
11406+
self._make_event(
11407+
author="user",
11408+
part=genai_types.Part(
11409+
function_response=genai_types.FunctionResponse(
11410+
name="provision_sandbox", response={"status": "ready"}
11411+
)
11412+
),
11413+
),
11414+
]
11415+
assert _evals_builtin_tools.is_sandbox_only_turn(events) is True
11416+
11417+
def test_load_sandbox_is_sandbox_only(self):
11418+
events = [
11419+
self._make_event(
11420+
author="agent",
11421+
part=genai_types.Part(
11422+
function_call=genai_types.FunctionCall(
11423+
name="load_sandbox", args={}
11424+
)
11425+
),
11426+
),
11427+
]
11428+
assert _evals_builtin_tools.is_sandbox_only_turn(events) is True
11429+
11430+
def test_non_sandbox_tool_is_not_sandbox_only(self):
11431+
events = [
11432+
self._make_event(
11433+
author="agent",
11434+
part=genai_types.Part(
11435+
function_call=genai_types.FunctionCall(
11436+
name="run_command", args={}
11437+
)
11438+
),
11439+
),
11440+
]
11441+
assert _evals_builtin_tools.is_sandbox_only_turn(events) is False
11442+
11443+
def test_text_event_is_not_sandbox_only(self):
11444+
events = [
11445+
self._make_event(
11446+
author="user",
11447+
part=genai_types.Part(text="Hello"),
11448+
),
11449+
]
11450+
assert _evals_builtin_tools.is_sandbox_only_turn(events) is False
11451+
11452+
def test_empty_events_is_sandbox_only(self):
11453+
assert _evals_builtin_tools.is_sandbox_only_turn([]) is True
11454+
11455+
11456+
class TestSandboxTurnMergingInAgentData:
11457+
"""Tests that sandbox-only turns are merged in _interaction_dict_to_agent_data."""
11458+
11459+
def test_sandbox_prefix_merged_into_single_turn(self):
11460+
"""Sandbox + 1 real interaction produces 1 turn, not 2."""
11461+
interaction_dict = {
11462+
"status": "completed",
11463+
"steps": [
11464+
# Sandbox provisioning (no user_input, creates Turn 0)
11465+
{
11466+
"type": "function_call",
11467+
"name": "provision_sandbox",
11468+
"arguments": {"display_name": "sb"},
11469+
"id": "call_sb",
11470+
},
11471+
{
11472+
"type": "function_result",
11473+
"name": "provision_sandbox",
11474+
"call_id": "call_sb",
11475+
"result": {"status": "ready"},
11476+
},
11477+
# Real user interaction (UserInputStep creates Turn 1)
11478+
{
11479+
"type": "user_input",
11480+
"content": [{"type": "text", "text": "What is 2+2?"}],
11481+
},
11482+
{
11483+
"type": "model_output",
11484+
"content": [{"type": "text", "text": "4"}],
11485+
},
11486+
],
11487+
}
11488+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11489+
# Should produce 1 turn, not 2.
11490+
assert len(result.turns) == 1
11491+
assert result.turns[0].turn_index == 0
11492+
# The turn should contain all events: sandbox + real.
11493+
events = result.turns[0].events
11494+
assert len(events) == 4
11495+
# First two events are sandbox tool call/response.
11496+
assert events[0].content.parts[0].function_call.name == "provision_sandbox"
11497+
assert events[1].content.parts[0].function_response.name == "provision_sandbox"
11498+
# Last two are the real conversation.
11499+
assert events[2].content.parts[0].text == "What is 2+2?"
11500+
assert events[3].content.parts[0].text == "4"
11501+
11502+
def test_sandbox_prefix_with_multi_turn(self):
11503+
"""Sandbox + 2 real turns produces 2 turns, not 3."""
11504+
interaction_dict = {
11505+
"status": "completed",
11506+
"steps": [
11507+
{
11508+
"type": "function_call",
11509+
"name": "provision_sandbox",
11510+
"arguments": {},
11511+
"id": "call_sb",
11512+
},
11513+
{
11514+
"type": "function_result",
11515+
"name": "provision_sandbox",
11516+
"call_id": "call_sb",
11517+
"result": {"status": "ready"},
11518+
},
11519+
{
11520+
"type": "user_input",
11521+
"content": [{"type": "text", "text": "Turn 1"}],
11522+
},
11523+
{
11524+
"type": "model_output",
11525+
"content": [{"type": "text", "text": "Reply 1"}],
11526+
},
11527+
{
11528+
"type": "user_input",
11529+
"content": [{"type": "text", "text": "Turn 2"}],
11530+
},
11531+
{
11532+
"type": "model_output",
11533+
"content": [{"type": "text", "text": "Reply 2"}],
11534+
},
11535+
],
11536+
}
11537+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11538+
assert len(result.turns) == 2
11539+
assert result.turns[0].events[0].content.parts[0].function_call.name == "provision_sandbox"
11540+
assert result.turns[0].events[-1].content.parts[0].text == "Reply 1"
11541+
assert result.turns[1].events[0].content.parts[0].text == "Turn 2"
11542+
11543+
def test_no_sandbox_prefix_unchanged(self):
11544+
"""Interaction without sandbox steps is unaffected."""
11545+
interaction_dict = {
11546+
"status": "completed",
11547+
"steps": [
11548+
{
11549+
"type": "user_input",
11550+
"content": [{"type": "text", "text": "Hello"}],
11551+
},
11552+
{
11553+
"type": "model_output",
11554+
"content": [{"type": "text", "text": "Hi"}],
11555+
},
11556+
],
11557+
}
11558+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11559+
assert len(result.turns) == 1
11560+
assert len(result.turns[0].events) == 2
11561+
11562+
def test_all_sandbox_only_not_discarded(self):
11563+
"""If every step is sandbox-only, it remains as 1 turn (not discarded)."""
11564+
interaction_dict = {
11565+
"status": "completed",
11566+
"steps": [
11567+
{
11568+
"type": "function_call",
11569+
"name": "provision_sandbox",
11570+
"arguments": {},
11571+
"id": "call_sb",
11572+
},
11573+
{
11574+
"type": "function_result",
11575+
"name": "provision_sandbox",
11576+
"call_id": "call_sb",
11577+
"result": {"status": "ready"},
11578+
},
11579+
],
11580+
}
11581+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11582+
# Only 1 group, and it's sandbox-only — but there's no second group
11583+
# to merge into, so it stays as-is.
11584+
assert len(result.turns) == 1
11585+
11586+
def test_non_sandbox_tool_before_user_input_not_merged(self):
11587+
"""A non-sandbox tool call before user_input is NOT merged."""
11588+
interaction_dict = {
11589+
"status": "completed",
11590+
"steps": [
11591+
{
11592+
"type": "function_call",
11593+
"name": "run_command",
11594+
"arguments": {"cmd": "ls"},
11595+
"id": "call_1",
11596+
},
11597+
{
11598+
"type": "function_result",
11599+
"name": "run_command",
11600+
"call_id": "call_1",
11601+
"result": {"output": "file.txt"},
11602+
},
11603+
{
11604+
"type": "user_input",
11605+
"content": [{"type": "text", "text": "Hello"}],
11606+
},
11607+
{
11608+
"type": "model_output",
11609+
"content": [{"type": "text", "text": "Hi"}],
11610+
},
11611+
],
11612+
}
11613+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11614+
# run_command is NOT a sandbox tool, so Turn 0 stays separate.
11615+
assert len(result.turns) == 2
11616+
11617+
1138711618
class TestMergeTextPartsInAgentData:
1138811619
"""Tests for _merge_text_parts_in_agent_data."""
1138911620

0 commit comments

Comments
 (0)