Skip to content

Expose the /run_sse event serialization as a public function and allow an optional SSE keep-alive #7578

Description

@Javviviii2

🔴 Required Information

Is your feature request related to a specific problem?

Applications that need different run semantics than the built-in /run_sse
(for example running the Runner outside the HTTP request, or fronting it with
their own transport) still want to emit the same wire format so existing ADK
clients keep working. Today that format is not reusable:

  • The serialization lives inline in the nested event_generator() in
    cli/api_server.py. It combines three steps: the artifactDelta split into a
    content event and an action-only event, public_event(...) from
    events/_internal_metadata.py, and _redact_credential_secrets(...) from
    auth/auth_credential.py, then pydantic_core.to_json(...).
  • Two of those helpers are private (underscore module / name), so a downstream
    implementation has to copy the logic and keep it in sync by hand.

The sync cost is real. The framing changed in 2.11 (public_event was
introduced into the stream) and again in 2.12 (credential redaction), and each
time a downstream copy had to be updated and its golden-framing test re-pinned.
A copy that silently diverges either leaks fields the server hides or drops
fields clients expect.

Separately, the stream is silent while the agent waits on a slow tool: nothing
is written between events, and the response writes no periodic frame. Proxies and
clients that treat prolonged silence as a dead stream close it. (Combined with
abort-on-disconnect, see #4029, that ends the run.)

Describe the Solution You'd Like

  1. A public, documented function that turns an Event into the SSE frame
    exactly as run_agent_sse does (including the redaction step), used by
    run_agent_sse itself so the two cannot diverge. For example
    google.adk.cli.sse.format_event_frame(event, *, split_artifact_delta=True).
  2. An optional keep-alive for /run_sse: when no event has been written for N
    seconds, emit an SSE comment (: keep-alive\n\n), which clients ignore per the
    SSE spec. Off by default, so existing behavior is unchanged.

Impact on your work

We maintain a downstream implementation of the stream and re-verify it against
every ADK minor. A public serializer turns that from "diff a private handler" into
"call a function". The keep-alive removes the need for us to inject comment frames
ourselves.

Willingness to contribute

Yes, for both. Item 1 is a refactor with a unit test that asserts the function and
the handler produce identical bytes. Item 2 is a small timer in the generator plus
a test. I would send them as two separate PRs.


🟡 Recommended Information

Describe Alternatives You've Considered

  • Copying the serialization (current approach): works, but must be re-checked on
    every release, as the 2.11 and 2.12 changes showed.
  • Wrapping /run_sse in a proxy that re-frames the response: loses event-level
    control and adds a hop.
  • Comment frames written by a wrapper around the stream: fine as a workaround,
    but every deployment has to reinvent it.

Proposed API / Implementation

# google/adk/cli/sse.py (illustrative)
def format_event_frame(event: Event, *, split_artifact_delta: bool = True) -> list[str]:
  """Returns the `data: ...\n\n` frame(s) `run_agent_sse` writes for `event`."""

# get_fast_api_app(..., sse_keep_alive_seconds: float | None = None)

Additional Context

The contract is already de facto public: clients consume it. This asks for it to
be explicit and shared rather than copied.

Activity

  1. ALDRIN121 commented on Oct 17, 2026

    @ALDRIN121
    Contributor

    Claiming item 1 and PR is up: #7594. Following the assign-first rule in CONTRIBUTING, could a maintainer assign this issue to me?

    Scope is item 1 only — extracting the /run_sse framing (public_event → model_dump(exclude_none=True, by_alias=True, mode="json") → _redact_credential_secrets → to_json, plus the artifactDelta split) into a public google.adk.cli.sse module and making run_agent_sse() use it, so the route and any external copy cannot drift. Item 2 (optional SSE keep-alive) is deliberately left out: it adds a configurable timer to the route and is its own behaviour change, as this issue proposes splitting them.

    One thing I should flag rather than paper over: @Javviviii2 wrote above that they would be willing to send both PRs. I opened #7594 before weighing that. If they would still like to take item 1, say so and I will close mine — no hard feelings, theirs should win on the reporter's-own-work principle. If not, #7594 is ready: check-changes, header-check and cla/google pass, tests/unittests/cli is 1497 passed / 5 skipped / 1 xfailed, and the only red check is agent-triage-pull-request, which is a repo-side Gemini 429.

  2. added a commit that references this issue on Oct 18, 2026
    24fb1a7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions