Skip to content

Commit 1f9defe

Browse files
authored
Merge pull request #142 from 10xHub/fix/realtime
Add image handling and reconnect configuration to realtime components
2 parents a90fc22 + e84c9c3 commit 1f9defe

11 files changed

Lines changed: 467 additions & 18 deletions

File tree

agentflow/core/realtime/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
RealtimeClient,
1818
RealtimeConfig,
1919
RealtimeEvent,
20+
ReconnectConfig,
2021
SessionUpdateEvent,
2122
ToolCallEvent,
2223
ToolResultEvent,
@@ -42,6 +43,7 @@
4243
"RealtimeClient",
4344
"RealtimeConfig",
4445
"RealtimeEvent",
46+
"ReconnectConfig",
4547
"SessionUpdateEvent",
4648
"ToolCallEvent",
4749
"ToolResultEvent",

agentflow/core/realtime/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,20 @@ class VADConfig(BaseModel):
143143
silence_duration_ms: int | None = None
144144

145145

146+
class ReconnectConfig(BaseModel):
147+
"""Reconnect/backoff policy for a dropped realtime socket.
148+
149+
Provider-initiated ``go_away`` rotations always reconnect immediately (no backoff). Only
150+
error-driven drops back off: attempt ``n`` waits ``min(base_delay * 2**(n-1), max_delay)``
151+
seconds, up to ``max_attempts`` tries before the session ends with a fatal error. Set
152+
``max_attempts=0`` to disable error-driven reconnect entirely.
153+
"""
154+
155+
base_delay: float = Field(default=0.5, ge=0.0)
156+
max_delay: float = Field(default=10.0, ge=0.0)
157+
max_attempts: int = Field(default=5, ge=0)
158+
159+
146160
class RealtimeConfig(BaseModel):
147161
"""Per-session configuration handed to a :class:`RealtimeClient`.
148162
@@ -161,6 +175,7 @@ class RealtimeConfig(BaseModel):
161175
input_audio_transcription: bool = True
162176
output_audio_transcription: bool = True
163177
vad: VADConfig = Field(default_factory=VADConfig)
178+
reconnect: ReconnectConfig = Field(default_factory=ReconnectConfig)
164179
context_window_compression: bool = False
165180
session_resumption: bool = True
166181
tools: list[Any] | None = None
@@ -200,6 +215,10 @@ async def send_text(self, text: str) -> None:
200215
"""Send a text turn into the live session."""
201216
...
202217

218+
async def send_image(self, data: bytes, mime_type: str) -> None:
219+
"""Send a single image frame (still image or video frame) into the live session."""
220+
...
221+
203222
async def send_activity_start(self) -> None:
204223
"""Manual-VAD / push-to-talk: mark the start of user activity."""
205224
...

agentflow/core/realtime/live_agent.py

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@
4343
from agentflow.runtime.publisher.events import ContentType, Event, EventModel, EventType
4444
from agentflow.runtime.publisher.publish import publish_event
4545
from 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

4857
if 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
# ------------------------------------------------------------------ #

agentflow/core/realtime/providers/gemini_live.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,11 @@ async def send_text(self, text: str) -> None:
301301
session = self._require_session()
302302
await session.send_realtime_input(text=text)
303303

304+
async def send_image(self, data: bytes, mime_type: str = "image/jpeg") -> None:
305+
session = self._require_session()
306+
_, types = self._genai()
307+
await session.send_realtime_input(media=types.Blob(data=data, mime_type=mime_type))
308+
304309
async def send_activity_start(self) -> None:
305310
session = self._require_session()
306311
_, types = self._genai()

agentflow/core/realtime/queue.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,23 @@
2323

2424
logger = logging.getLogger(__name__)
2525

26-
LiveInputKind = Literal["audio", "text", "activity_start", "activity_end", "close"]
26+
LiveInputKind = Literal["audio", "text", "image", "activity_start", "activity_end", "close"]
2727

2828

2929
@dataclass(slots=True)
3030
class LiveInput:
3131
"""A single upstream transport frame. Construct via ``LiveInputQueue.send_*``.
3232
3333
``kind`` discriminates the frame; only the fields relevant to that kind are set
34-
(``data``/``sample_rate`` for audio, ``text`` for text, neither for control frames).
34+
(``data``/``sample_rate`` for audio, ``data``/``mime_type`` for image, ``text`` for
35+
text, none for control frames).
3536
"""
3637

3738
kind: LiveInputKind
3839
data: bytes | None = None
3940
text: str | None = None
4041
sample_rate: int = INPUT_SAMPLE_RATE
42+
mime_type: str | None = None
4143

4244

4345
class LiveInputQueue:
@@ -71,6 +73,15 @@ def send_audio(self, data: bytes, sample_rate: int = INPUT_SAMPLE_RATE) -> None:
7173
def send_text(self, text: str) -> None:
7274
self._put(LiveInput(kind="text", text=text))
7375

76+
def send_image(self, data: bytes, mime_type: str = "image/jpeg") -> None:
77+
"""Send a single image frame (e.g. a JPEG camera frame) into the live session.
78+
79+
Gemini Live accepts still images and video as individual frames; send video as a
80+
stream of frames (~1 fps is the model's effective ceiling). ``mime_type`` must be an
81+
image type the provider supports (default ``image/jpeg``).
82+
"""
83+
self._put(LiveInput(kind="image", data=data, mime_type=mime_type))
84+
7485
def send_activity_start(self) -> None:
7586
self._put(LiveInput(kind="activity_start"))
7687

agentflow/prebuilt/agent/audio.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
from agentflow.core.graph.tool_node import ToolNode
1616
from agentflow.core.realtime.base import RealtimeClient, RealtimeConfig
1717
from agentflow.core.realtime.live_agent import LiveAgent
18+
from agentflow.core.skills.models import SkillConfig
1819
from agentflow.core.state.agent_state import AgentState
1920
from agentflow.core.state.base_context import BaseContextManager
2021
from agentflow.runtime.publisher.base_publisher import BasePublisher
2122
from agentflow.storage.checkpointer.base_checkpointer import BaseCheckpointer
22-
from agentflow.storage.media.storage.base import BaseMediaStore
2323
from agentflow.storage.store.base_store import BaseStore
24+
from agentflow.storage.store.memory_config import MemoryConfig
2425
from agentflow.utils.callbacks import CallbackManager
2526
from agentflow.utils.constants import END
2627
from agentflow.utils.id_generator import BaseIDGenerator, DefaultIDGenerator
@@ -43,8 +44,8 @@ def __init__( # noqa: PLR0913
4344
tools: Iterable[Callable] | None = None,
4445
client: Any = None,
4546
pass_user_info_to_mcp: bool = False,
46-
skills: Any | None = None,
47-
memory: Any | None = None,
47+
skills: SkillConfig | None = None,
48+
memory: MemoryConfig | None = None,
4849
realtime_client_factory: Callable[[], RealtimeClient] | None = None,
4950
live_node_name: str = "LIVE",
5051
**agent_kwargs: Any,
@@ -76,7 +77,10 @@ def __init__( # noqa: PLR0913
7677

7778
@staticmethod
7879
def _build_tool_node(
79-
*, tools: list[Callable], client: Any, pass_user_info_to_mcp: bool
80+
*,
81+
tools: list[Callable],
82+
client: Any,
83+
pass_user_info_to_mcp: bool,
8084
) -> ToolNode | None:
8185
if not tools and client is None:
8286
return None
@@ -103,23 +107,22 @@ def compile(
103107
self,
104108
checkpointer: BaseCheckpointer[StateT] | None = None,
105109
store: BaseStore | None = None,
106-
interrupt_before: list[str] | None = None,
107-
interrupt_after: list[str] | None = None,
108110
callback_manager: CallbackManager | None = None,
109-
media_store: BaseMediaStore | None = None,
110111
shutdown_timeout: float = 30.0,
111112
) -> CompiledGraph:
113+
# No media_store: realtime media (images/video) is sent frame-by-frame straight to
114+
# the live model via the input queue (see LiveInputQueue.send_image); it is never
115+
# offloaded to or resolved from a media store, so the parameter would be dead here.
112116
self._configure_graph()
117+
113118
if self._graph is None: # pragma: no cover - _configure_graph always assigns
114119
raise RuntimeError("graph configuration failed")
120+
115121
return self._graph.compile(
116122
checkpointer=checkpointer,
117123
store=store,
118-
interrupt_before=interrupt_before,
119-
interrupt_after=interrupt_after,
120124
callback_manager=callback_manager
121125
if callback_manager is not None
122126
else CallbackManager(),
123-
media_store=media_store,
124127
shutdown_timeout=shutdown_timeout,
125128
)

0 commit comments

Comments
 (0)