Redis Event Stream Resume

Example demonstrating cross-process streaming resume with RedisEventStream.

redis_event_stream_resume.py
"""
Example demonstrating cross-process streaming resume with RedisEventStream.

With the default in-memory event stream, a background streaming run can only be
resumed from the process that is executing it. Backed by Redis Streams, the
events become location-independent: ANY process (e.g. another AgentOS replica
behind a load balancer) can replay missed events and tail live ones.

This example simulates two containers in one script:
1. The "producer" process starts a background streaming run with a Redis-backed
   event stream, consumes a few events, then disconnects.
2. An "observer" - a separate RedisEventStream instance with its own Redis
   client, sharing nothing with the producer except Redis - resumes the run:
   replays what was missed and tails live events to completion.

Requirements:
- Redis running (./cookbook/scripts/run_redis.sh)
- OPENAI_API_KEY set
- pip install redis

Usage:
    .venvs/demo/bin/python cookbook/02_agents/14_advanced/redis_event_stream_resume.py
"""

import asyncio
import json

from agno.agent import Agent
from agno.db.in_memory import InMemoryDb
from agno.models.openai import OpenAIResponses
from agno.os.event_streams import RedisEventStream, set_event_stream
from redis.asyncio import Redis as AsyncRedis

REDIS_URL = "redis://localhost:6379"

agent = Agent(
    name="CrossProcessStreamAgent",
    model=OpenAIResponses(id="gpt-5.5"),
    description="An agent whose background stream is observable from any process",
    db=InMemoryDb(),
)

def extract_run_id(sse_chunk: str) -> str:
    data_line = next(
        line for line in sse_chunk.splitlines() if line.startswith("data: ")
    )
    return json.loads(data_line[len("data: ") :]).get("run_id")

async def main():
    # 1. The "producer" process: configure the Redis-backed event stream, then
    # start a background streaming run. The producer XADDs every event to a
    # per-run Redis stream instead of an in-process buffer.
    producer_client = AsyncRedis.from_url(REDIS_URL)
    set_event_stream(RedisEventStream(producer_client))

    stream = agent.arun(
        "Write a six-line poem about distributed systems, one line at a time.",
        background=True,
        stream=True,
        session_id="redis-resume-demo",
    )

    run_id = None
    consumed = 0
    async for sse_chunk in stream:
        if run_id is None and '"run_id"' in sse_chunk:
            run_id = extract_run_id(sse_chunk)
        consumed += 1
        if consumed >= 3:
            print(
                f"Producer consumed {consumed} events, client disconnects (run continues)"
            )
            break

    assert run_id is not None, "run_id not seen in the first events"

    # 2. The "observer" process: a separate RedisEventStream with its own
    # client. In production this is a different container - it never talks to
    # the producer, only to Redis. This is exactly what the AgentOS
    # /runs/{run_id}/resume endpoint does on whichever replica receives the
    # reconnect.
    observer = RedisEventStream(AsyncRedis.from_url(REDIS_URL))
    last_seen_index = consumed - 1

    print(f"\nObserver resuming run {run_id} from event index {last_seen_index}...")
    replayed = await observer.replay(run_id, last_event_index=last_seen_index)
    print(f"Observer replayed {len(replayed)} missed events")

    final_index = last_seen_index
    async for event_index, _sse_data in observer.tail(
        run_id, last_event_index=last_seen_index
    ):
        final_index = event_index
    print(f"Observer tailed live events up to index {final_index}; run finished")

    status = await observer.get_run_status(run_id)
    print(f"\nFinal run status seen by the observer: {status}")

    result = await agent.aget_run_output(run_id=run_id, session_id="redis-resume-demo")
    print(f"Final content:\n{result.content}")

if __name__ == "__main__":
    asyncio.run(main())

Resume from the last indexed event

Before running the saved example, add the import below and replace its entire main() with this version, retaining the original agent configuration and final asyncio.run(main()) entry point. Counted chunks are not a resume cursor: keepalives have no index, and indexed events may have gaps. The final cursor is initialized so an already completed run with no missed events is valid.

import json


async def main():
    producer_client = AsyncRedis.from_url(REDIS_URL)
    observer_client = AsyncRedis.from_url(REDIS_URL)
    set_event_stream(RedisEventStream(producer_client))
    observer = RedisEventStream(observer_client)
    try:
        stream = agent.arun(
            "Write a six-line poem about reliable systems.",
            background=True,
            stream=True,
            session_id="redis-resume-demo",
        )
        run_id = None
        last_seen_index = -1
        consumed = 0
        try:
            async for sse_chunk in stream:
                data_lines = [line[5:].lstrip() for line in sse_chunk.splitlines()
                              if line.startswith("data:")]
                if not data_lines:
                    continue  # Ignore keepalive comments.
                payload = json.loads("\n".join(data_lines))
                index = payload.get("event_index")
                if not isinstance(index, int) or index < 0:
                    continue
                run_id = payload.get("run_id") or run_id
                last_seen_index = max(last_seen_index, index)
                consumed += 1
                if consumed >= 3:
                    break
        finally:
            await stream.aclose()
        if run_id is None:
            raise RuntimeError("No indexed run event was received")
        # tail subscribes, replays missed events, then follows live events.
        final_index = last_seen_index
        async for event_index, sse_data in observer.tail(
            run_id, last_event_index=last_seen_index
        ):
            if event_index >= 0:
                final_index = event_index
                print(sse_data, end="")
        print(f"\nLast observed event index: {final_index}")
        status = await observer.get_run_status(run_id)
        print(f"Final stream status: {status}")
        result = await agent.aget_run_output(
            run_id=run_id, session_id="redis-resume-demo"
        )
        if result is None:
            print("No saved run output is available")
        else:
            print(f"Saved run status: {result.status}")
            print(f"Final content: {result.content}")
    finally:
        await observer_client.aclose()
        await producer_client.aclose()

tail() includes replay before following new events. If you separately process replay() results, advance the cursor before tailing to avoid processing them twice. Disconnect survival requires the producer's event loop to remain alive; an event buffer alone does not make execution survive a process restart.

This script uses two Redis clients in one process and an in-memory agent database. It demonstrates shared event observation. An authenticated AgentOS deployment also needs a shared session database and consistent authorization/ownership configuration across replicas; use the multi-replica setup and a durable queue for restart recovery.

Run the Example

Set up your virtual environment

uv venv --python 3.12
source .venv/bin/activate

Install dependencies

uv pip install -U "agno[os]" openai redis

Export your OpenAI API key

export OPENAI_API_KEY="your_openai_api_key_here"

Run Redis

docker run -d --name my-redis -p 6379:6379 redis

Run the example

Save the code above with the replacement main() as redis_event_stream_resume.py, then run:

python redis_event_stream_resume.py

Full source: cookbook/02_agents/14_advanced/redis_event_stream_resume.py