1818independently testable.
1919
2020Scope: 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
2328TODO: Surface SYSTEM_MESSAGE steps (emitted on turn cancellation) as ADK
2429events; they are currently dropped.
2530"""
2631
2732from __future__ import annotations
2833
34+ import json
2935from typing import TYPE_CHECKING
3036
3137from google .antigravity import types as sdk_types
3238from google .genai import types as genai_types
39+ from pydantic import JsonValue
3340
3441from ...events .event import Event
3542
3643if 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+
165222def _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