Skip to content

Commit 4599a52

Browse files
haranrkcopybara-github
authored andcommitted
feat(antigravity): capture client-side tool outcomes for a function_response
A client-side tool's outcome never reaches the trajectory, so an ADK caller saw the function_call and never the function_response. This adds the buffer that captures the outcome from the SDK's post-tool-call and on-tool-error hooks, and the converter that drains it into a matching function_response event. Nothing calls it yet. Co-authored-by: Haran Rajkumar <haranrk@google.com> PiperOrigin-RevId: 967595598
1 parent e4ba704 commit 4599a52

6 files changed

Lines changed: 1028 additions & 143 deletions

File tree

src/google/adk/labs/antigravity/_antigravity_agent.py

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -66,32 +66,6 @@ def _derive_conversation_id(session_id: str, agent_name: str) -> str:
6666
return hashlib.sha256(f'{session_id}/{agent_name}'.encode()).hexdigest()
6767

6868

69-
def _final_model_text(event: Event, author: str) -> str | None:
70-
"""Returns an event's user-visible model text, or None if it carries none.
71-
72-
Partials, other authors, and thought/function parts do not count.
73-
74-
Args:
75-
event: The event to inspect.
76-
author: The agent name whose events count as model output.
77-
78-
Returns:
79-
The concatenated user-visible text, or None if the event carries none.
80-
"""
81-
if event.partial or event.author != author or not event.content:
82-
return None
83-
parts = event.content.parts or []
84-
chunks = [
85-
part.text
86-
for part in parts
87-
if part.text
88-
and not part.thought
89-
and not part.function_call
90-
and not part.function_response
91-
]
92-
return ''.join(chunks) if chunks else None
93-
94-
9569
class AntigravityAgent(BaseAgent):
9670
"""Runs a Google Antigravity SDK agent as an ADK agent.
9771
@@ -275,7 +249,8 @@ async def _run_impl(
275249
ctx.event_author = event.author
276250
if not event.node_info.path and event.author == self.name:
277251
event.node_info.path = ctx.node_path
278-
if (text := _final_model_text(event, self.name)) is not None:
252+
text = _event_converter.final_model_text(event, self.name)
253+
if text is not None:
279254
last_text = text
280255
yield event
281256

src/google/adk/labs/antigravity/_event_converter.py

Lines changed: 160 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,30 @@
1818
independently testable.
1919
2020
Scope: model text (final and, in SSE streaming mode, partial thinking/text
21-
deltas), function calls, and function responses.
21+
deltas), function calls, function responses, and ``final_model_text`` for
22+
reading an event's text back out.
23+
24+
A client-side tool's result never reaches the trajectory; it arrives through
25+
the post-tool-call hook instead (see ``_tool_result_capture``), which is why
26+
``drain_tool_results`` is also called once at the end of a turn.
2227
2328
TODO: Surface SYSTEM_MESSAGE steps (emitted on turn cancellation) as ADK
2429
events; they are currently dropped.
2530
"""
2631

2732
from __future__ import annotations
2833

34+
import json
2935
from typing import TYPE_CHECKING
3036

3137
from google.antigravity import types as sdk_types
3238
from google.genai import types as genai_types
39+
from pydantic import JsonValue
3340

3441
from ...events.event import Event
3542

3643
if TYPE_CHECKING:
44+
from . import _tool_result_capture
3745
from ...agents.invocation_context import InvocationContext
3846

3947

@@ -162,11 +170,62 @@ def _convert_function_calls(
162170
return events
163171

164172

173+
def _function_response_event(
174+
*,
175+
ctx: InvocationContext,
176+
name: str,
177+
call_id: str,
178+
response: dict[str, JsonValue],
179+
) -> Event:
180+
"""Builds the ADK event recording one tool's answer to one call."""
181+
return Event(
182+
invocation_id=ctx.invocation_id,
183+
# Author is the tool name so session history attributes the response to
184+
# the tool, mirroring ADK's own function-response events.
185+
author=name,
186+
branch=ctx.branch,
187+
content=genai_types.Content(
188+
role='user',
189+
parts=[
190+
genai_types.Part(
191+
function_response=genai_types.FunctionResponse(
192+
name=name,
193+
id=call_id,
194+
response=response,
195+
)
196+
)
197+
],
198+
),
199+
)
200+
201+
202+
def _buffered_result_payload(
203+
result: _tool_result_capture.ToolResult,
204+
) -> dict[str, JsonValue]:
205+
"""Returns the ``FunctionResponse.response`` dict for one captured result."""
206+
if result.error:
207+
return {'error': result.error}
208+
209+
# The harness hands back a client tool's value as the JSON string
210+
# ``json.dumps(tool_result_to_dict(...))``, so it usually needs unwrapping.
211+
value = result.result
212+
if isinstance(value, str):
213+
try:
214+
value = json.loads(value)
215+
except ValueError:
216+
pass # Not JSON; the raw text is wrapped below rather than raising.
217+
if isinstance(value, dict):
218+
return value
219+
return {'result': 'success' if value is None else value}
220+
221+
165222
def _convert_function_responses(
166223
step: sdk_types.Step,
167224
*,
168225
ctx: InvocationContext,
226+
seen_tool_calls: set[str],
169227
seen_tool_results: set[str],
228+
tool_results: _tool_result_capture.ToolResultBuffer | None = None,
170229
) -> list[Event]:
171230
"""Converts completed tool-execution steps into function-response events."""
172231
is_tool_response = (
@@ -177,16 +236,38 @@ def _convert_function_responses(
177236
sdk_types.StepStatus.ERROR,
178237
)
179238
)
180-
if not is_tool_response or not step.tool_calls:
239+
if not is_tool_response:
181240
return []
182241

242+
# A client-side tool: the SDK blanks its ``tool_calls`` and ``Step`` has no
243+
# field for a result, so the step names nothing and holds nothing.
244+
if not step.tool_calls:
245+
return drain_tool_results(
246+
ctx=ctx,
247+
seen_tool_calls=seen_tool_calls,
248+
seen_tool_results=seen_tool_results,
249+
tool_results=tool_results,
250+
)
251+
252+
# The hook fires for these tools too, but its copy is keyed by an id this
253+
# side never sees: the SDK gives a built-in's ``ToolCall.id`` the step id
254+
# ``f'{trajectory_id}:{step_index}'``, while the hook is handed the model's
255+
# own call id, or a SHA-256 of that step id when there is none
256+
# (``localharness/tool_metadata.go``, ``ResolveStepCallID``). That copy
257+
# therefore cannot be dropped by id here -- and need not be: the same
258+
# mismatch keeps it out of ``drain_tool_results``, which only takes ids in
259+
# ``seen_tool_calls``. The turn clears the buffer at its end.
260+
# ``ToolResult.step_id`` would be the key that does match -- at head the SDK
261+
# already sets it to that same ``f'{trajectory_id}:{step_index}'`` -- but the
262+
# SDK vendored here predates the field.
183263
events = []
184264
for call in step.tool_calls:
185265
call_id = _build_tool_call_id(step, call)
186266
if call_id in seen_tool_results:
187267
continue
188268
seen_tool_results.add(call_id)
189269

270+
response: dict[str, JsonValue]
190271
if step.status == sdk_types.StepStatus.ERROR:
191272
response = {
192273
'error': (
@@ -198,24 +279,49 @@ def _convert_function_responses(
198279
response = {'result': step.content or 'success'}
199280

200281
events.append(
201-
Event(
202-
invocation_id=ctx.invocation_id,
203-
# Author is the tool name so session history attributes the
204-
# response to the tool, mirroring ADK's own function-response events.
205-
author=call.name,
206-
branch=ctx.branch,
207-
content=genai_types.Content(
208-
role='user',
209-
parts=[
210-
genai_types.Part(
211-
function_response=genai_types.FunctionResponse(
212-
name=call.name,
213-
id=call_id,
214-
response=response,
215-
)
216-
)
217-
],
218-
),
282+
_function_response_event(
283+
ctx=ctx, name=call.name, call_id=call_id, response=response
284+
)
285+
)
286+
return events
287+
288+
289+
def drain_tool_results(
290+
*,
291+
ctx: InvocationContext,
292+
seen_tool_calls: set[str],
293+
seen_tool_results: set[str],
294+
tool_results: _tool_result_capture.ToolResultBuffer | None = None,
295+
) -> list[Event]:
296+
"""Answers emitted calls that have a captured outcome and no response yet.
297+
298+
Args:
299+
ctx: The active invocation context, used for event correlation fields.
300+
seen_tool_calls: Ids of tool calls already emitted. Read to decide what may
301+
be answered; not mutated.
302+
seen_tool_results: Ids of tool results already emitted, mutated in place to
303+
record the ones answered here.
304+
tool_results: This conversation's client-tool outcomes, or None when no
305+
capture hook was registered, in which case nothing is answered. Drained of
306+
every id this call answers.
307+
308+
Returns:
309+
One function-response event per answered call, in the order the tools
310+
finished in. Empty when nothing is owed a response.
311+
"""
312+
if tool_results is None:
313+
return []
314+
315+
events = []
316+
# A response may not precede the call it answers, hence ``seen_tool_calls``.
317+
for call_id, result in tool_results.take(seen_tool_calls - seen_tool_results):
318+
seen_tool_results.add(call_id)
319+
events.append(
320+
_function_response_event(
321+
ctx=ctx,
322+
name=result.name,
323+
call_id=call_id,
324+
response=_buffered_result_payload(result),
219325
)
220326
)
221327
return events
@@ -228,6 +334,7 @@ def convert_step_to_events(
228334
author: str,
229335
seen_tool_calls: set[str],
230336
seen_tool_results: set[str],
337+
tool_results: _tool_result_capture.ToolResultBuffer | None = None,
231338
streaming: bool = False,
232339
) -> list[Event]:
233340
"""Translates one Antigravity ``Step`` into the ADK events it maps to.
@@ -240,6 +347,8 @@ def convert_step_to_events(
240347
deduplicate calls repeated across step transitions.
241348
seen_tool_results: Ids of tool results already emitted, mutated in place to
242349
deduplicate results repeated across step transitions.
350+
tool_results: This conversation's client-tool results, or None when no
351+
capture hook was registered.
243352
streaming: When True (SSE mode), incremental thinking/text deltas are also
244353
emitted as ``partial=True`` events. When False, only final events are
245354
emitted.
@@ -259,6 +368,36 @@ def convert_step_to_events(
259368
step, ctx=ctx, author=author, seen_tool_calls=seen_tool_calls
260369
),
261370
*_convert_function_responses(
262-
step, ctx=ctx, seen_tool_results=seen_tool_results
371+
step,
372+
ctx=ctx,
373+
seen_tool_calls=seen_tool_calls,
374+
seen_tool_results=seen_tool_results,
375+
tool_results=tool_results,
263376
),
264377
]
378+
379+
380+
def final_model_text(event: Event, author: str) -> str | None:
381+
"""Returns an event's user-visible model text, or None if it carries none.
382+
383+
Partials, other authors, and thought/function parts do not count.
384+
385+
Args:
386+
event: The event to inspect.
387+
author: The agent name whose events count as model output.
388+
389+
Returns:
390+
The concatenated user-visible text, or None if the event carries none.
391+
"""
392+
if event.partial or event.author != author or not event.content:
393+
return None
394+
parts = event.content.parts or []
395+
chunks = [
396+
part.text
397+
for part in parts
398+
if part.text
399+
and not part.thought
400+
and not part.function_call
401+
and not part.function_response
402+
]
403+
return ''.join(chunks) if chunks else None

0 commit comments

Comments
 (0)