Skip to content

Commit 81f9f2e

Browse files
haranrkcopybara-github
authored andcommitted
feat(models): surface and recover environment_id from interactions
Add environment_id to LlmResponse, populated from the interactions stream loop, and return it alongside interaction_id from the previous-interaction lookup so stateful agents can reuse a sandbox environment across turns. Co-authored-by: Haran Rajkumar <haranrk@google.com> PiperOrigin-RevId: 940034114
1 parent 8c4173e commit 81f9f2e

6 files changed

Lines changed: 197 additions & 14 deletions

File tree

src/google/adk/flows/llm_flows/interactions_processor.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,17 @@ def _is_event_in_branch(current_branch: Optional[str], event: Event) -> bool:
3737
return event.branch == current_branch or not event.branch
3838

3939

40-
def _find_previous_interaction_id(
40+
def _find_previous_interaction_state(
4141
events: list[Event],
4242
*,
4343
agent_name: str,
4444
current_branch: Optional[str],
45-
) -> Optional[str]:
46-
"""Find the most recent interaction_id authored by ``agent_name``.
45+
) -> tuple[Optional[str], Optional[str]]:
46+
"""Find the most recent (interaction_id, environment_id) for ``agent_name``.
4747
4848
Scans ``events`` in reverse, skipping events outside ``current_branch``, and
49-
returns the first ``interaction_id`` from an event authored by this agent.
49+
returns the ids from the first event authored by this agent that carries an
50+
interaction_id.
5051
"""
5152
logger.debug(
5253
'Finding previous_interaction_id: agent=%s, branch=%s, num_events=%d',
@@ -75,8 +76,8 @@ def _find_previous_interaction_id(
7576
agent_name,
7677
event.interaction_id,
7778
)
78-
return event.interaction_id
79-
return None
79+
return event.interaction_id, event.environment_id
80+
return None, None
8081

8182

8283
class InteractionsRequestProcessor(BaseLlmRequestProcessor):
@@ -126,11 +127,12 @@ def _find_previous_interaction_id(
126127
self, invocation_context: 'InvocationContext'
127128
) -> Optional[str]:
128129
"""Find the previous interaction ID from session events."""
129-
return _find_previous_interaction_id(
130+
interaction_id, _ = _find_previous_interaction_state(
130131
invocation_context.session.events,
131132
agent_name=invocation_context.agent.name,
132133
current_branch=invocation_context.branch,
133134
)
135+
return interaction_id
134136

135137

136138
# Module-level processor instance for use in flow configuration

src/google/adk/models/interactions_utils.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,29 @@ def _extract_stream_interaction_id(
112112
return None
113113

114114

115+
def _extract_stream_environment_id(
116+
event: InteractionSSEEvent,
117+
) -> str | None:
118+
"""Extract the environment id from an Interactions SSE event, if present.
119+
120+
The non-streaming ``Interaction`` declares an ``environment_id`` field. On
121+
streaming SSE events the id is read opportunistically from the carried
122+
interaction (created/completed events allow extra fields), so it is returned
123+
only when the API actually includes it and is ``None`` otherwise.
124+
"""
125+
interaction = None
126+
if isinstance(event, (InteractionCreatedEvent, InteractionCompletedEvent)):
127+
interaction = event.interaction
128+
elif isinstance(event, Interaction):
129+
interaction = event
130+
131+
if interaction is None:
132+
return None
133+
134+
env_id = getattr(interaction, 'environment_id', None)
135+
return env_id if isinstance(env_id, str) else None
136+
137+
115138
def _encode_base64_string(data: bytes) -> str:
116139
"""Encode bytes to a base64 string."""
117140
return base64.b64encode(data).decode('utf-8')
@@ -1445,6 +1468,7 @@ async def _create_interactions(
14451468
LlmResponse objects converted from interaction responses.
14461469
"""
14471470
current_interaction_id: str | None = None
1471+
current_environment_id: str | None = None
14481472

14491473
if stream:
14501474
responses = await api_client.aio.interactions.create(
@@ -1456,18 +1480,24 @@ async def _create_interactions(
14561480
interaction_id = _extract_stream_interaction_id(event)
14571481
if interaction_id:
14581482
current_interaction_id = interaction_id
1483+
environment_id = _extract_stream_environment_id(event)
1484+
if environment_id:
1485+
current_environment_id = environment_id
14591486
llm_response = convert_interaction_event_to_llm_response(
14601487
event, state, current_interaction_id
14611488
)
14621489
if llm_response:
1490+
llm_response.environment_id = current_environment_id
14631491
yield llm_response
14641492
else:
14651493
interaction = await api_client.aio.interactions.create(
14661494
**create_kwargs, stream=False
14671495
)
14681496
logger.info('Interaction response received.')
14691497
logger.debug(build_interactions_response_log(interaction))
1470-
yield convert_interaction_to_llm_response(interaction)
1498+
llm_response = convert_interaction_to_llm_response(interaction)
1499+
llm_response.environment_id = interaction.environment_id
1500+
yield llm_response
14711501

14721502

14731503
async def generate_content_via_interactions(

src/google/adk/models/llm_response.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,14 @@ class LlmResponse(BaseModel):
155155
It can be used to identify and chain interactions for stateful conversations.
156156
"""
157157

158+
environment_id: Optional[str] = None
159+
"""The execution environment ID from the interactions API.
160+
161+
This field is populated when an interactions-API agent (e.g. ManagedAgent)
162+
provisions or reuses a sandbox environment. It is persisted on the resulting
163+
Event so subsequent turns can reuse the same environment for stateful work.
164+
"""
165+
158166
def get_function_calls(self) -> list[types.FunctionCall]:
159167
"""Returns the function calls in the response."""
160168
func_calls = []

tests/unittests/flows/llm_flows/test_interactions_processor.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,11 @@ def test_find_previous_interaction_id_returns_latest_for_agent():
236236
_evt("other_agent", "int_3", None),
237237
]
238238

239-
result = interactions_processor._find_previous_interaction_id(
239+
result = interactions_processor._find_previous_interaction_state(
240240
events, agent_name="my_agent", current_branch=None
241241
)
242242

243-
assert result == "int_2"
243+
assert result[0] == "int_2"
244244

245245

246246
def test_find_previous_interaction_id_respects_branch():
@@ -249,18 +249,32 @@ def test_find_previous_interaction_id_respects_branch():
249249
_evt("my_agent", "int_other_branch", "branch_b"),
250250
]
251251

252-
result = interactions_processor._find_previous_interaction_id(
252+
result = interactions_processor._find_previous_interaction_state(
253253
events, agent_name="my_agent", current_branch="branch_a"
254254
)
255255

256-
assert result == "int_main"
256+
assert result[0] == "int_main"
257257

258258

259259
def test_find_previous_interaction_id_none_when_absent():
260260
events = [_evt("user", None, None)]
261261

262-
result = interactions_processor._find_previous_interaction_id(
262+
result = interactions_processor._find_previous_interaction_state(
263263
events, agent_name="my_agent", current_branch=None
264264
)
265265

266-
assert result is None
266+
assert result[0] is None
267+
268+
269+
def test_find_previous_interaction_state_returns_both_ids():
270+
events = [
271+
Event(author="my_agent", interaction_id="int_1", environment_id="env_1"),
272+
Event(author="user"),
273+
Event(author="my_agent", interaction_id="int_2", environment_id="env_2"),
274+
]
275+
276+
state = interactions_processor._find_previous_interaction_state(
277+
events, agent_name="my_agent", current_branch=None
278+
)
279+
280+
assert state == ("int_2", "env_2")

tests/unittests/models/test_interactions_utils.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2028,3 +2028,122 @@ async def test_generate_content_via_interactions_non_streaming_yields_single_res
20282028
assert len(responses) == 1
20292029
assert responses[0].interaction_id == 'interaction_ns'
20302030
assert responses[0].content.parts[0].text == 'Sunny in Tokyo.'
2031+
2032+
2033+
def _build_stream_with_environment() -> list[object]:
2034+
"""A streamed interaction whose completed event carries an environment id."""
2035+
now = datetime.now(timezone.utc).isoformat()
2036+
created = InteractionCreatedEvent(
2037+
event_type='interaction.created',
2038+
interaction=InteractionSseEventInteraction(
2039+
id='interaction_env',
2040+
created=now,
2041+
updated=now,
2042+
status='requires_action',
2043+
steps=[],
2044+
),
2045+
)
2046+
step_start = StepStart(
2047+
event_type='step.start',
2048+
index=0,
2049+
step=ModelOutputStep(type='model_output'),
2050+
)
2051+
step_delta = StepDelta(
2052+
event_type='step.delta',
2053+
index=0,
2054+
delta={'type': 'text', 'text': 'hi'},
2055+
)
2056+
step_stop = StepStop(event_type='step.stop', index=0)
2057+
completed = InteractionCompletedEvent(
2058+
event_type='interaction.completed',
2059+
interaction=InteractionSseEventInteraction(
2060+
id='interaction_env',
2061+
created=now,
2062+
updated=now,
2063+
status='completed',
2064+
environment_id='env_xyz',
2065+
steps=[
2066+
ModelOutputStep(
2067+
type='model_output',
2068+
content=[TextContent(type='text', text='hi')],
2069+
)
2070+
],
2071+
),
2072+
)
2073+
return [created, step_start, step_delta, step_stop, completed]
2074+
2075+
2076+
def test_create_interactions_surfaces_environment_id():
2077+
api_client = _FakeApiClient(_build_stream_with_environment())
2078+
2079+
async def _collect():
2080+
out = []
2081+
async for r in interactions_utils._create_interactions(
2082+
api_client,
2083+
create_kwargs={'agent': 'agents/a', 'input': []},
2084+
stream=True,
2085+
):
2086+
out.append(r)
2087+
return out
2088+
2089+
responses = asyncio.run(_collect())
2090+
assert responses, 'expected streamed responses'
2091+
# The env id arrives only on the completed event, so earlier partial
2092+
# responses carry no environment id.
2093+
assert responses[0].environment_id is None
2094+
assert responses[-1].environment_id == 'env_xyz'
2095+
2096+
2097+
class _FakeNonStreamInteractions:
2098+
"""Fake interactions resource returning a full Interaction (non-streaming)."""
2099+
2100+
def __init__(self, interaction: Interaction):
2101+
self._interaction = interaction
2102+
2103+
async def create(self, **_kwargs):
2104+
return self._interaction
2105+
2106+
2107+
class _FakeNonStreamAio:
2108+
"""Namespace matching the expected api_client.aio shape (non-streaming)."""
2109+
2110+
def __init__(self, interaction: Interaction):
2111+
self.interactions = _FakeNonStreamInteractions(interaction)
2112+
2113+
2114+
class _FakeNonStreamApiClient:
2115+
"""Minimal fake API client whose create() returns a full Interaction."""
2116+
2117+
def __init__(self, interaction: Interaction):
2118+
self.aio = _FakeNonStreamAio(interaction)
2119+
2120+
2121+
def test_create_interactions_surfaces_environment_id_non_stream():
2122+
interaction = Interaction(
2123+
id='interaction_ns',
2124+
status='completed',
2125+
created=datetime.now(timezone.utc).isoformat(),
2126+
updated=datetime.now(timezone.utc).isoformat(),
2127+
environment_id='env_ns',
2128+
steps=[
2129+
ModelOutputStep(
2130+
type='model_output',
2131+
content=[TextContent(type='text', text='hi')],
2132+
)
2133+
],
2134+
)
2135+
api_client = _FakeNonStreamApiClient(interaction)
2136+
2137+
async def _collect():
2138+
out = []
2139+
async for r in interactions_utils._create_interactions(
2140+
api_client,
2141+
create_kwargs={'agent': 'agents/a', 'input': []},
2142+
stream=False,
2143+
):
2144+
out.append(r)
2145+
return out
2146+
2147+
responses = asyncio.run(_collect())
2148+
assert len(responses) == 1
2149+
assert responses[-1].environment_id == 'env_ns'

tests/unittests/models/test_llm_response.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,3 +447,13 @@ def test_get_function_responses_empty_when_no_content():
447447
def test_get_function_responses_empty_when_no_parts():
448448
response = LlmResponse(content=types.Content(parts=None))
449449
assert response.get_function_responses() == []
450+
451+
452+
def test_environment_id_defaults_to_none_and_roundtrips():
453+
resp = LlmResponse()
454+
assert resp.environment_id is None
455+
456+
resp.environment_id = 'env_abc'
457+
dumped = resp.model_dump(exclude_none=True)
458+
assert dumped['environment_id'] == 'env_abc'
459+
assert LlmResponse.model_validate(dumped).environment_id == 'env_abc'

0 commit comments

Comments
 (0)