Hey Isaac — there’s no on_agent_turn_completed hook on Agent today. The overridable lifecycle hooks are on_enter, on_exit, on_user_turn_completed, and on_user_turn_exceeded — “agent turn” only exists internally as a telemetry span. But for what you’re describing, I don’t think you need one. Two options that work with current releases (1.6.x):
Option 1 — attach the structured JSON to the function call itself. Inside a tool you have ctx.function_call, and FunctionCall has a free-form extra: dict[str, Any] field. The framework snapshots that call object into FunctionToolsExecutedEvent.function_calls after your tool finishes, so anything you stash in extra during the tool run is right there in your existing handler, paired with its output:
@function_tool
async def my_tool(ctx: RunContext):
prose, structured = await build_result()
await save_to_db(structured)
ctx.function_call.extra["myapp.structured"] = structured
return prose
@session.on("function_tools_executed")
def on_tools_executed(ev: FunctionToolsExecutedEvent):
for call, output in ev.zipped():
structured = call.extra.get("myapp.structured")
if structured is not None:
... # you have both the prose (output.output) and your JSON
Two caveats: namespace your key (provider plugins use extra for their own data, e.g. Google thought signatures), and it persists into the chat history / session report, so keep the payload reasonably sized.
Option 2 — if you specifically want “agent turn completed” semantics, use the speech handle. ctx.speech_handle is the assistant turn your tool is running inside, and SpeechHandle.add_done_callback() fires once that whole turn — including the reply the LLM generates from your tool’s return value — has finished playing out. You can close over the structured object directly, no side channel at all:
@function_tool
async def my_tool(ctx: RunContext):
prose, structured = await build_result()
def on_turn_done(handle): # handle: SpeechHandle
asyncio.create_task(save_to_db(structured))
ctx.speech_handle.add_done_callback(on_turn_done)
return prose
And if you’d rather have it session-wide instead of per-tool: session.on("speech_created") hands you every speech handle as it’s created, so adding a done callback there is effectively a global agent-turn-completed hook.
FWIW, your underlying complaint is legitimate: internally the tool executor does keep the raw, pre-serialization return value (raw_output on the execution result) — it just isn’t surfaced on FunctionToolsExecutedEvent, which only carries the stringified FunctionCallOutput.output. Exposing raw_output on that event would be a reasonable feature request on the livekit/agents repo. Until then, extra is the closest supported channel.