4343from agentflow .runtime .publisher .events import ContentType , Event , EventModel , EventType
4444from agentflow .runtime .publisher .publish import publish_event
4545from agentflow .utils import CallbackManager
46+ from agentflow .utils .callbacks import GraphLifecycleContext
47+
48+
49+ # Event kinds that constitute model/user turn content. A turn starts on the first of these
50+ # after a turn boundary and ends on turn_complete/interrupted; control frames (session_update,
51+ # go_away, error) never open a turn.
52+ _TURN_CONTENT_TYPES = frozenset (
53+ {"audio_delta" , "input_transcript" , "output_transcript" , "tool_call" , "tool_result" }
54+ )
4655
4756
4857if TYPE_CHECKING :
@@ -118,10 +127,12 @@ def __init__(
118127 self ._output_transcript_buf = ""
119128
120129 # Error-driven reconnect backoff (go_away reconnects are immediate; only transient
121- # drops back off). Instance attributes so tests can shrink them.
122- self ._reconnect_base_delay = 0.5
123- self ._reconnect_max_delay = 10.0
124- self ._reconnect_max_attempts = 5
130+ # drops back off). Seeded from RealtimeConfig.reconnect; kept as instance attributes
131+ # so tests can shrink them without rebuilding the config.
132+ rc = self .realtime_config .reconnect
133+ self ._reconnect_base_delay = rc .base_delay
134+ self ._reconnect_max_delay = rc .max_delay
135+ self ._reconnect_max_attempts = rc .max_attempts
125136
126137 # Builder mixins (no-op when their config is None).
127138 self ._setup_memory (memory )
@@ -150,7 +161,7 @@ def _resolve_tool_node(self) -> ToolNode | None:
150161 # ------------------------------------------------------------------ #
151162 # The duplex realtime loop.
152163 # ------------------------------------------------------------------ #
153- async def arun (
164+ async def arun ( # noqa: PLR0912, PLR0915
154165 self ,
155166 input_queue : LiveInputQueue ,
156167 config : dict [str , Any ],
@@ -168,6 +179,7 @@ async def arun(
168179 self ._output_transcript_buf = ""
169180 rt = self ._session_realtime_config (config )
170181 rt = await self ._resolve_session_tools (rt )
182+ rt = await self ._resolve_session_system_instruction (rt , state , config )
171183
172184 handle = await self ._load_resume_handle (config , checkpointer )
173185 client = self ._client_factory ()
@@ -177,11 +189,17 @@ async def arun(
177189 # model would receive the whole conversation twice (handle restore + reseed).
178190 await self ._maybe_reseed (config , checkpointer , context_manager , resumed = handle is not None )
179191
192+ # Session start mirrors a graph run: the LIVE node *is* the graph, so on_graph_start
193+ # fires once here (before any turn) and on_graph_end once when the session ends.
194+ state = await self ._fire_graph_start (callback_manager , config , state )
195+
180196 # Closing the input queue ends the session: the pump sets this when it drains the
181197 # close sentinel, and the receive loop stops once the provider goes idle.
182198 stop_event = asyncio .Event ()
183199 pump_task = asyncio .create_task (self ._pump (input_queue , stop_event ))
184200 attempts = 0 # consecutive error-driven reconnect attempts (reset on healthy receive)
201+ turn_index = 0 # 1-based count of turns started; doubles as on_graph_end total_steps
202+ turn_active = False # a turn is open (content seen, no turn_complete/interrupt yet)
185203 try :
186204 while True :
187205 reconnect = False
@@ -190,16 +208,29 @@ async def arun(
190208 try :
191209 async for event in self ._receive_until_stop (self ._active_client , stop_event ):
192210 received_any = True
211+ if not turn_active and event .type in _TURN_CONTENT_TYPES :
212+ turn_index += 1
213+ turn_active = True
214+ state = await self ._fire_turn_start (
215+ callback_manager , config , state , turn_index
216+ )
193217 for out in await self ._handle_event (
194218 event , config , state , checkpointer , callback_manager
195219 ):
196220 yield out
221+ if turn_active and event .type in ("turn_complete" , "interrupted" ):
222+ state = await self ._fire_turn_end (
223+ callback_manager , config , state , turn_index
224+ )
225+ turn_active = False
197226 if event .type == "go_away" :
198227 reconnect = True
199228 forced = True
200229 break
201230 if event .type == "error" and getattr (event , "fatal" , False ):
202- return
231+ # break (not return) so on_graph_end still fires for the session.
232+ reconnect = False
233+ break
203234 except Exception :
204235 # Transient drop: only resume if input is still open (avoid an
205236 # infinite reconnect storm once the session is shutting down).
@@ -218,6 +249,11 @@ async def arun(
218249 if fatal is not None :
219250 yield fatal
220251 break
252+
253+ # Balance a turn cut off by session end (no turn_complete arrived), then close out.
254+ if turn_active :
255+ state = await self ._fire_turn_end (callback_manager , config , state , turn_index )
256+ await self ._fire_graph_end (callback_manager , config , state , turn_index )
221257 finally :
222258 pump_task .cancel ()
223259 with contextlib .suppress (asyncio .CancelledError ):
@@ -258,6 +294,40 @@ async def _resolve_session_tools(self, rt: RealtimeConfig) -> RealtimeConfig:
258294 return rt
259295 return rt .model_copy (update = {"tools" : schemas })
260296
297+ async def _resolve_session_system_instruction (
298+ self , rt : RealtimeConfig , state : AgentState , config : dict [str , Any ]
299+ ) -> RealtimeConfig :
300+ """Flatten the agent's system prompt (+ skills + memory) into ``system_instruction``.
301+
302+ Gemini Live takes a single ``system_instruction`` string fixed at connect time, so
303+ the per-turn prompt list other agents send must be collapsed once, here. This is what
304+ makes ``system_prompt``, the skills trigger table / session-mode content, and the
305+ memory system prompt actually reach the model in realtime (the matching tools are
306+ advertised separately by :meth:`_resolve_session_tools`).
307+
308+ State-dependent pieces (session-mode skill from a state field, memory preload from the
309+ latest user query) are therefore a connect-time snapshot, not re-evaluated per turn;
310+ dynamic behaviour mid-session goes through ``set_skill`` / memory tools instead.
311+
312+ ``{field}`` placeholders in the prompt content are interpolated from ``state`` exactly
313+ like the turn-based path (via :func:`convert_messages`), so a system prompt that reads
314+ from state behaves identically here.
315+ """
316+ from agentflow .utils .converter import _interpolate_system_prompts
317+
318+ base = list (self .system_prompt )
319+ if not base and rt .system_instruction :
320+ base = [{"role" : "system" , "content" : rt .system_instruction }]
321+
322+ prompts = self ._build_skill_prompts (state , base )
323+ prompts = prompts + await self ._build_memory_prompts (state , config )
324+ prompts = _interpolate_system_prompts (prompts , state )
325+
326+ instruction = "\n \n " .join (str (p ["content" ]) for p in prompts if p .get ("content" )).strip ()
327+ if not instruction :
328+ return rt
329+ return rt .model_copy (update = {"system_instruction" : instruction })
330+
261331 async def _receive_until_stop (
262332 self , client : RealtimeClient , stop_event : asyncio .Event
263333 ) -> AsyncIterator [RealtimeEvent ]:
@@ -307,6 +377,8 @@ async def _pump(
307377 await client .send_audio (item .data , item .sample_rate )
308378 elif item .kind == "text" and item .text is not None :
309379 await client .send_text (item .text )
380+ elif item .kind == "image" and item .data is not None :
381+ await client .send_image (item .data , item .mime_type or "image/jpeg" )
310382 elif item .kind == "activity_start" :
311383 await client .send_activity_start ()
312384 elif item .kind == "activity_end" :
@@ -565,6 +637,38 @@ async def _reconnect(self, rt: RealtimeConfig) -> None:
565637 await client .connect (rt , resume_handle = self ._resume_handle )
566638 self ._active_client = client
567639
640+ # ------------------------------------------------------------------ #
641+ # Lifecycle hooks (session == graph run; turn == one model generation).
642+ # ------------------------------------------------------------------ #
643+ async def _fire_graph_start (
644+ self , cb : CallbackManager , config : dict [str , Any ], state : AgentState
645+ ) -> AgentState :
646+ if not cb ._lifecycle_hooks :
647+ return state
648+ return await cb .fire_on_graph_start (GraphLifecycleContext (config = config ), state )
649+
650+ async def _fire_graph_end (
651+ self , cb : CallbackManager , config : dict [str , Any ], state : AgentState , turns : int
652+ ) -> None :
653+ if not cb ._lifecycle_hooks :
654+ return
655+ messages = list (getattr (state , "context" , []) or [])
656+ await cb .fire_on_graph_end (GraphLifecycleContext (config = config ), state , messages , turns )
657+
658+ async def _fire_turn_start (
659+ self , cb : CallbackManager , config : dict [str , Any ], state : AgentState , turn_index : int
660+ ) -> AgentState :
661+ if not cb ._lifecycle_hooks :
662+ return state
663+ return await cb .fire_on_turn_start (GraphLifecycleContext (config = config ), state , turn_index )
664+
665+ async def _fire_turn_end (
666+ self , cb : CallbackManager , config : dict [str , Any ], state : AgentState , turn_index : int
667+ ) -> AgentState :
668+ if not cb ._lifecycle_hooks :
669+ return state
670+ return await cb .fire_on_turn_end (GraphLifecycleContext (config = config ), state , turn_index )
671+
568672 # ------------------------------------------------------------------ #
569673 # Observability for events ToolNode doesn't already publish.
570674 # ------------------------------------------------------------------ #
0 commit comments