Skip to content

Commit f93c455

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
fix: Merge sandbox-only turns in SDK-side interaction-to-AgentData conversion.
PiperOrigin-RevId: 953630987
1 parent e46d239 commit f93c455

3 files changed

Lines changed: 295 additions & 0 deletions

File tree

agentplatform/_genai/_evals_builtin_tools.py

Lines changed: 57 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,59 @@
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+
else:
140+
# Any other part type (text, inline_data, executable_code,
141+
# code_execution_result, etc.) means this is a real
142+
# conversational event, not sandbox infrastructure.
143+
return False
144+
return True
145+
146+
90147
def agent_tools_to_config_tools(
91148
agent_tools: Optional[list[Any]],
92149
has_environment: bool = False,

agentplatform/_genai/_evals_common.py

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

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

tests/unit/agentplatform/genai/test_evals.py

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

1152011520

11521+
class TestIsSandboxOnlyTurn:
11522+
"""Tests for _is_sandbox_only_turn."""
11523+
11524+
def _make_event(self, *, author, part):
11525+
return agentplatform_genai_types.evals.AgentEvent(
11526+
author=author,
11527+
content=genai_types.Content(role="model", parts=[part]),
11528+
)
11529+
11530+
def test_provision_sandbox_is_sandbox_only(self):
11531+
events = [
11532+
self._make_event(
11533+
author="agent",
11534+
part=genai_types.Part(
11535+
function_call=genai_types.FunctionCall(
11536+
name="provision_sandbox", args={}
11537+
)
11538+
),
11539+
),
11540+
self._make_event(
11541+
author="user",
11542+
part=genai_types.Part(
11543+
function_response=genai_types.FunctionResponse(
11544+
name="provision_sandbox", response={"status": "ready"}
11545+
)
11546+
),
11547+
),
11548+
]
11549+
assert _evals_builtin_tools.is_sandbox_only_turn(events) is True
11550+
11551+
def test_load_sandbox_is_sandbox_only(self):
11552+
events = [
11553+
self._make_event(
11554+
author="agent",
11555+
part=genai_types.Part(
11556+
function_call=genai_types.FunctionCall(
11557+
name="load_sandbox", args={}
11558+
)
11559+
),
11560+
),
11561+
]
11562+
assert _evals_builtin_tools.is_sandbox_only_turn(events) is True
11563+
11564+
def test_non_sandbox_tool_is_not_sandbox_only(self):
11565+
events = [
11566+
self._make_event(
11567+
author="agent",
11568+
part=genai_types.Part(
11569+
function_call=genai_types.FunctionCall(
11570+
name="run_command", args={}
11571+
)
11572+
),
11573+
),
11574+
]
11575+
assert _evals_builtin_tools.is_sandbox_only_turn(events) is False
11576+
11577+
def test_text_event_is_not_sandbox_only(self):
11578+
events = [
11579+
self._make_event(
11580+
author="user",
11581+
part=genai_types.Part(text="Hello"),
11582+
),
11583+
]
11584+
assert _evals_builtin_tools.is_sandbox_only_turn(events) is False
11585+
11586+
def test_empty_events_is_sandbox_only(self):
11587+
assert _evals_builtin_tools.is_sandbox_only_turn([]) is True
11588+
11589+
11590+
class TestSandboxTurnMergingInAgentData:
11591+
"""Tests that sandbox-only turns are merged in _interaction_dict_to_agent_data."""
11592+
11593+
def test_sandbox_prefix_merged_into_single_turn(self):
11594+
"""Sandbox + 1 real interaction produces 1 turn, not 2."""
11595+
interaction_dict = {
11596+
"status": "completed",
11597+
"steps": [
11598+
# Sandbox provisioning (no user_input, creates Turn 0)
11599+
{
11600+
"type": "function_call",
11601+
"name": "provision_sandbox",
11602+
"arguments": {"display_name": "sb"},
11603+
"id": "call_sb",
11604+
},
11605+
{
11606+
"type": "function_result",
11607+
"name": "provision_sandbox",
11608+
"call_id": "call_sb",
11609+
"result": {"status": "ready"},
11610+
},
11611+
# Real user interaction (UserInputStep creates Turn 1)
11612+
{
11613+
"type": "user_input",
11614+
"content": [{"type": "text", "text": "What is 2+2?"}],
11615+
},
11616+
{
11617+
"type": "model_output",
11618+
"content": [{"type": "text", "text": "4"}],
11619+
},
11620+
],
11621+
}
11622+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11623+
# Should produce 1 turn, not 2.
11624+
assert len(result.turns) == 1
11625+
assert result.turns[0].turn_index == 0
11626+
# The turn should contain all events: sandbox + real.
11627+
events = result.turns[0].events
11628+
assert len(events) == 4
11629+
# First two events are sandbox tool call/response.
11630+
assert events[0].content.parts[0].function_call.name == "provision_sandbox"
11631+
assert events[1].content.parts[0].function_response.name == "provision_sandbox"
11632+
# Last two are the real conversation.
11633+
assert events[2].content.parts[0].text == "What is 2+2?"
11634+
assert events[3].content.parts[0].text == "4"
11635+
11636+
def test_sandbox_prefix_with_multi_turn(self):
11637+
"""Sandbox + 2 real turns produces 2 turns, not 3."""
11638+
interaction_dict = {
11639+
"status": "completed",
11640+
"steps": [
11641+
{
11642+
"type": "function_call",
11643+
"name": "provision_sandbox",
11644+
"arguments": {},
11645+
"id": "call_sb",
11646+
},
11647+
{
11648+
"type": "function_result",
11649+
"name": "provision_sandbox",
11650+
"call_id": "call_sb",
11651+
"result": {"status": "ready"},
11652+
},
11653+
{
11654+
"type": "user_input",
11655+
"content": [{"type": "text", "text": "Turn 1"}],
11656+
},
11657+
{
11658+
"type": "model_output",
11659+
"content": [{"type": "text", "text": "Reply 1"}],
11660+
},
11661+
{
11662+
"type": "user_input",
11663+
"content": [{"type": "text", "text": "Turn 2"}],
11664+
},
11665+
{
11666+
"type": "model_output",
11667+
"content": [{"type": "text", "text": "Reply 2"}],
11668+
},
11669+
],
11670+
}
11671+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11672+
assert len(result.turns) == 2
11673+
assert result.turns[0].events[0].content.parts[0].function_call.name == "provision_sandbox"
11674+
assert result.turns[0].events[-1].content.parts[0].text == "Reply 1"
11675+
assert result.turns[1].events[0].content.parts[0].text == "Turn 2"
11676+
11677+
def test_no_sandbox_prefix_unchanged(self):
11678+
"""Interaction without sandbox steps is unaffected."""
11679+
interaction_dict = {
11680+
"status": "completed",
11681+
"steps": [
11682+
{
11683+
"type": "user_input",
11684+
"content": [{"type": "text", "text": "Hello"}],
11685+
},
11686+
{
11687+
"type": "model_output",
11688+
"content": [{"type": "text", "text": "Hi"}],
11689+
},
11690+
],
11691+
}
11692+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11693+
assert len(result.turns) == 1
11694+
assert len(result.turns[0].events) == 2
11695+
11696+
def test_all_sandbox_only_not_discarded(self):
11697+
"""If every step is sandbox-only, it remains as 1 turn (not discarded)."""
11698+
interaction_dict = {
11699+
"status": "completed",
11700+
"steps": [
11701+
{
11702+
"type": "function_call",
11703+
"name": "provision_sandbox",
11704+
"arguments": {},
11705+
"id": "call_sb",
11706+
},
11707+
{
11708+
"type": "function_result",
11709+
"name": "provision_sandbox",
11710+
"call_id": "call_sb",
11711+
"result": {"status": "ready"},
11712+
},
11713+
],
11714+
}
11715+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11716+
# Only 1 group, and it's sandbox-only — but there's no second group
11717+
# to merge into, so it stays as-is.
11718+
assert len(result.turns) == 1
11719+
11720+
def test_non_sandbox_tool_before_user_input_not_merged(self):
11721+
"""A non-sandbox tool call before user_input is NOT merged."""
11722+
interaction_dict = {
11723+
"status": "completed",
11724+
"steps": [
11725+
{
11726+
"type": "function_call",
11727+
"name": "run_command",
11728+
"arguments": {"cmd": "ls"},
11729+
"id": "call_1",
11730+
},
11731+
{
11732+
"type": "function_result",
11733+
"name": "run_command",
11734+
"call_id": "call_1",
11735+
"result": {"output": "file.txt"},
11736+
},
11737+
{
11738+
"type": "user_input",
11739+
"content": [{"type": "text", "text": "Hello"}],
11740+
},
11741+
{
11742+
"type": "model_output",
11743+
"content": [{"type": "text", "text": "Hi"}],
11744+
},
11745+
],
11746+
}
11747+
result = _evals_common._interaction_dict_to_agent_data(interaction_dict)
11748+
# run_command is NOT a sandbox tool, so Turn 0 stays separate.
11749+
assert len(result.turns) == 2
11750+
11751+
1152111752
class TestMergeTextPartsInAgentData:
1152211753
"""Tests for _merge_text_parts_in_agent_data."""
1152311754

0 commit comments

Comments
 (0)