✨ Support streaming methods through the observability proxy#38
✨ Support streaming methods through the observability proxy#38MFA-X-AI wants to merge 4 commits into
Conversation
MethodProxy.__call__ unconditionally awaited the wrapped method, so any async generator method (e.g. LLMProtocol.generate_stream) called through the observability proxy raised TypeError. Dispatch on the method kind at call time and return a matching wrapper that emits CALL/EXCEPTION/RESULT around iteration, with RESULT carrying a chunk count.
Generator wrappers in MethodProxy now collect chunks and emit them in the RESULT event: joined string when all chunks are str (chunk boundaries are a provider detail), raw list otherwise. Early close (GeneratorExit) also emits a RESULT with the partial content and closed_early flag instead of leaving only a dangling CALL. Addresses review on #38: count-only RESULT made it impossible to observe what a streamed call returned. Measured log cost is on par with the non-streaming path (~2.7 KB per 500-token response vs 43.5x for per-delta events).
|
Thanks for the check, seems like we missed it because it was focusing on enabling the generator. The latest commit changed both generator wrappers to accumulate chunks and emit them in the {"stream": True, "chunks": 3, "content": "Hello World"}
As raised in yesterday's call on the log volume concern - we mocked the event sequence each option would emit, using xaibo's
The per-delta multiplier is envelope-driven (~370 B of Considered and deliberately left out of this PR:
Tests should be updated to pin the new payload, plus new cases for typed (non-string) chunks, empty stream, and the early-close partial result. Heads-up: where this is going (so the follow-ups have context)This is prerequisite 1 of the AG-UI plan from the standup. Sequence, dependency-ordered:
|
treo
left a comment
There was a problem hiding this comment.
The latest commit changed both generator wrappers to accumulate chunks and emit them in the RESULT event:
I'm not sure we want that in this place. The observability aspect of Xaibo is supposed to give you everything that happens in the communication between two modules.
Yes, the default debug logger produces a lot of data that way. It is meant to capture everything that is going on. That is how it helps with debugging in complex situations.
If that overwhelms the viewer, then that is an issue with the viewer that needs to be resolved. I know that the current one needs work to be more useful for long traces.
If we want to have a more compact storage format, we can also do that. But that would just be a different event processor, not something that mangles the events before they can even be handled.
Streamed chunks are no longer accumulated in the proxy. Each chunk is emitted as its own YIELD event when it crosses the module boundary, and the final RESULT only marks completion with a chunk count. The debug UI skips YIELD events in call grouping so streamed calls still pair their CALL and RESULT.
|
Gotcha. Sorry I took yesterday's discussion about chunk-by-chunk logging being super heavy as, better not ship it without a proper handler, so having the proxy accumulate temporarily until that handler existed made sense. Since you're OK with the volume, the latest commit now emits every chunk as its own event. Since this only affects generator methods (today that's where each One thing I noticed was that the current implementation final The derived data (the joined text) is kept off the capture layer, but that's a judgment call: it means re-buffering in the proxy and duplicating what's already on the log, and the tradeoff is that a log/UI reader has to reassemble the content from
I've added tests that pin the new sequence, typed (non-string) chunks, empty stream, early close, and mid-stream failure for both the async and sync generator wrappers, so every branch in each has coverage. Additional NotesOne small UI change rode along: the debug UI's call-grouping assumed exactly two events per
What gets recorded — every chunk hits the log the moment it's yielded: sequenceDiagram
participant M as llm.generate_stream()
participant P as MethodProxy
participant L as event log
P->>L: CALL
M-->>P: yield "Hel"
P->>L: YIELD "Hel"
M-->>P: yield "lo"
P->>L: YIELD "lo"
M-->>P: stream ends
P->>L: RESULT {stream: true, chunks: 2}
What the viewer renders today — just the grouped bookends: sequenceDiagram
participant C as caller
participant M as llm module
C->>M: generate_stream(messages)
M-->>C: RESULT {stream: true, chunks: 2}
Note over C,M: YIELD rows skipped — stream content not shown (yet)
So stream content stays raw-log-only until either the joined text goes onto On the log-volume numbers, some comparsion from other frameworks for the capture-everything / compact-downstream split:
This will come up again in the ResponseProtocol extension: orchestrators will call the response handler once per delta (text chunk, tool-call step), and those calls go through the same proxy — so the same volume question lands there, with the same answer: the capture layer stays complete, compaction lives in the listener. |
Background
My research project uses Xaibo for the agent configs / orchestration, and a custom frontend. Since observability is the main sell there, I decided implementing a standard protocol would be best, hence landed on AG-UI. Basically a server adapter that streams tool calls, steps, and reasoning to any AG-UI client. Thought it would be great if we could put xaibo on the same integration list as LangGraph, CrewAI, Pydantic AI, and the Claude Agent SDK. To get there, there are a few PRs needed (richer
generate_streamevents → orchestrator event vocabulary →AGUIAdapter), and all of it flows through proxied streaming calls. This fix is prerequisite 1: without it, an AG-UI adapter could stream or be observable, not both.Current situation
Every module resolved through the exchange is unconditionally wrapped in the observability proxy, and
MethodProxy.__call__always doesawait self._method(...)— so async generator methods likeLLMProtocol.generate_streamraiseTypeError: object async_generator can't be used in 'await' expression, meaning no module can consume another module's streaming method through dependency injection at all. The only workaround is reaching past the proxy (getattr(self.llm, "_obj", self.llm)), which silently exits the observability layer for exactly the calls you most want visibility into. The proxy just needs to speak all four Python calling conventions (plain, async, generator, async generator) instead of one.Examples from Other Frameworks
Observability layers that wrap LLM calls all hit this and converged on the same fix — dispatch on function kind at wrap time:
@traceable: checksinspect.isasyncgenfunction/isgeneratorfunction, yields through, closes the run on exhaustionThis PR applies the same pattern to
MethodProxy.Changes
MethodProxy.__call__dispatches on method kind and returns a matching wrapper: coroutine, async generator, or sync generatorCALLon start,EXCEPTIONon mid-stream failure,RESULTwith{"stream": True, "chunks": n}on exhaustion; early close (GeneratorExit) is normal terminationtests/core/test_proxy_generator_methods.py(7 tests, incl.MockLLM.generate_streamthrough aProxy)Technical Details
async def __call__: a coroutine that returns an async generator would force callers intoawait-then-iterate, breaking duck-typing against unproxied modules. Plaindef __call__returning the un-awaited coroutine is behaviorally identical for existing callers (await proxy.method(...)works unchanged).call_idpairing.Verification
Without the fix, 6 of the 7 new tests fail with the
TypeErrorabove; with it, all pass.