Skip to content

AI agent conversations: provider bodies in the view, and files over HTTP - #14084

Merged
wu-sheng merged 1 commit into
masterfrom
ai-agent-provider-bodies
Sep 16, 2026
Merged

wu-sheng merged 1 commit into
masterfrom
ai-agent-provider-bodies

Conversation

@wu-sheng

@wu-sheng wu-sheng commented Sep 15, 2026

Copy link
Copy Markdown
Member

Two things for the AI agent conversations the SkyWalking AI Sessionizer lands:

  1. the provider bodies joined to their calls in the asz.view document OAP serves — an llm.call step names where its request and its response landed, and the document never carries the bodies;
  2. a new route, GET /ai-agent/conversations/{conversation}/v1/files, which serves the raw Session Data files of one session by (session, seq), at most 32 a request, as application/vnd.skywalking.asz.files+ndjson: a line naming each file, then exactly the bytes it names.

Together they let a page show the exact prompt a model call sent and the answer it got, loading only the files that call points at. The protocol side is apache/skywalking-query-protocol#175; the reader is apache/skywalking-horizon-ui#165.

The join

Ported from the Sessionizer's own reader, rule for rule. A response joins by the message id it carries, which is the call's own. A request names no call, only the request before it and its prompt, so it joins to the call of its stream whose previous call's response carries that request id and whose prompt is the one it names, when exactly one request and exactly one call carry the pair. A synthetic call takes part in no join, a body landed twice is one body, and no request joins in a stream whose landed lines have a gap. Nothing is joined by position or by time.

The route

  • Requires service, instance, session and at least one seq; at most 32, each a positive whole number. coldStage selects cold storage.
  • Framing is by declared byte count: a non-empty file that does not end with a newline is followed by one, which is not part of it. The naming line carries file, seq, lines, bytes, digest, and copies where the read saw that seq more than once.
  • gzip when the caller takes it, written a chunk at a time so nothing compressed accumulates.
  • Errors are RFC 9457 problem documents — including a request that runs out of time, which previously took the server's own plain-text 503.

Bounds and cancellation

  • A conversation's rounds and files are read readWindow at a time (16), and every read asks whether the caller is still there, so an abandoned request stops instead of running to completion.
  • fileRange looks for the newest intact round one round at a time, as before, but stops after 16 rounds instead of descending to round 1. A head round that does not parse used to cost one storage read per round — measured at 100,001 reads for one request on a long chain, all of which ran on after the client had gone.
  • Record decoding follows the Sessionizer's: a record whose own fields do not decode stops that file where its reader stops, and a null in flags decodes to an empty string rather than throwing. What is inside parts, dropped and usage is deliberately not inspected — Go ignores fields it does not declare, and refusing a record it accepts would hide that record and every one behind it.
  • The chain check coalesces absent sequences into runs as it finds them, instead of one entry per sequence, and its counter can no longer wrap.

Settings

Five, in the order an operator meets them: conversationListMaxLimit (renamed from maxListLimit, and its comment now says plainly that it counts rounds, not conversations, so a busy conversation spends the budget of the quiet ones), viewRequestTimeout, readWindow (the former fileReadWindow and roundReadWindow, which were the same idea with the same value), maxResponseBytes (BanyanDB alone accepts it; Elasticsearch and JDBC bound a read by hits and by rows) and maxFileBytes.

Testing

64 module tests pass on a clean build, including the e2e case under test/e2e-v2/cases/ai-agent/, which pushes real Sessionizer output, checks seven concrete request/response joins, and calls the files route with the pinned CLI, verifying the digest each naming line advertises.

Reviewed by GPT-6 across four rounds; every finding is fixed in this commit. Two are worth naming: the first version of the bounded fileRange read a window of round bodies, which fetched sixteen to find one in the healthy case and could lose a readable duplicate when a storage capped the window — it is now the original per-round read with a bound; and a JDBC change that claimed to bound memory did not, because the driver buffers the result regardless.

Provider bodies in the conversation view.
The AI Sessionizer can land the request and response bodies an agent
runtime exchanged with its model provider, apache/skywalking-ai-sessionizer#10.
They arrive as Session Data files of kind provider_body, one directory
per session: <session>/provider_body/provider_body-<stamp>-<seq>.sd. The
OAP already stored them like any other file, but gave them a wrong name,
<session>/runs//..., which the raw-file query could not parse back.

- FileNames names the provider_body directory and parses it back.
- The asz.view document follows the Sessionizer's. An llm.call step lists
  its request and then its response under provider_bodies, each as a role
  and the ref of the landed record, never the body. summary.provider_bodies
  and summary.captured_prompts count them. The join uses the bodies' own
  ids: a response by its message id, a request by the previous call's
  response request id and its prompt, and only when exactly one request
  and one call carry them. A synthetic call takes part in no join, and no
  request joins in a stream whose landed transcript lines have a gap.
  Calls on one record are ordered by id, as the Sessionizer now orders
  them.
- What the join reads is decoded as the Sessionizer decodes it. A manifest
  whose known keys, or its segments' keys, have the wrong JSON type is no
  body. A record whose call or run is not a string is gone to the join. An
  ord is read from the raw line: the digits after a leading {"ord":, as an
  unsigned 64-bit number, or else the decoded value, where null is 0. A
  transcript whose records end at a line that does not decode has a gap.
  SessionDataFile keeps each record's leading ord digits and whether its
  records stopped early.
- The test data is refreshed from the Sessionizer: the two existing sets
  gain the two summary counts, and the provider-bodies and
  provider-bodies-errors sets are added. Both documents equal the
  Sessionizer's key for key. A session without its provider_body file
  folds to the same nodes, a stream with a gap joins responses only, and a
  manifest or an ord the Sessionizer reads differently counts as it does;
  each of those cases gave the same counts from asz conversation.
- The e2e builds the Sessionizer's provider-bodies scenario into the same
  root and checks the file, every call's bodies and the export by name.
  The views, list, sender and token expectations count the new session.
  The Sessionizer is pinned to the commit that lands provider bodies,
  since its view now carries the two summary counts.

Conversation files over HTTP, not GraphQL.
The query protocol drops getConversationRawFiles, apache/skywalking-query-protocol#175.
A conversation's stored Session Data files are read from a second HTTP
route beside the view, so a page loads what a step points at, such as an
llm.call's provider bodies, only when a reader opens it:

  GET /ai-agent/conversations/{conversation}/v1/files
      ?service=&instance=&session=&seq=[&seq=...][&coldStage=true]

- A file is chosen by its session and its landed seq, the two columns the
  storage reads it by; the Sessionizer assigns a seq once per file in a
  session. One to 32 seqs a request: a file is cut at 2 MiB, so a response
  holds about 64 MiB. There is no read of every file.
- The body is application/vnd.skywalking.asz.files+ndjson: for each file a
  naming line {file, seq, lines, bytes, digest}, exactly that many bytes,
  and a newline after a non-empty file that does not end with one. Files
  come in seq order, the order provider bodies are read in.
- The read takes its time range from the newest intact round, reading the
  rounds down from the head only until one is intact, then reads one
  storage window of files and hands it to the response before reading the
  next. Windows are produced one at a time and cannot overflow.
- The route compresses with gzip itself, a chunk at a time. Armeria's
  encoder keeps every compressed chunk of a response in one growing buffer
  until the response ends. The view route still uses it.
- Both routes require the service and the sender's instance, as a list row
  names them, so every read is a full series lookup; serviceId goes. A bad
  coldStage gets a problem document, not Armeria's plain-text 400.
- The e2e reads files through swctl by session and seq taken from the
  document, and passes the instance on every view and route call. The
  raw-files case counts the three Session Data files of the first
  conversation, since rounds are no longer served.

The e2e pins swctl to the merge commit of apache/skywalking-cli#235, which
adds swctl ai-agent files with --session and --seqs.
@wu-sheng
wu-sheng force-pushed the ai-agent-provider-bodies branch from 4117ed1 to 02b1da9 Compare September 16, 2026 06:19
@wu-sheng
wu-sheng merged commit 289b4b3 into master Sep 16, 2026
468 of 471 checks passed
@wu-sheng
wu-sheng deleted the ai-agent-provider-bodies branch September 16, 2026 07:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend OAP backend related. feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants