Skip to content

Read AI agent conversation files over the OAP's HTTP route - #235

Merged
wu-sheng merged 1 commit into
masterfrom
ai-agent-files-over-http
Sep 15, 2026
Merged

wu-sheng merged 1 commit into
masterfrom
ai-agent-files-over-http

Conversation

@wu-sheng

Copy link
Copy Markdown
Member

What

The OAP drops the getConversationRawFiles GraphQL query (apache/skywalking-query-protocol#175, merged). A conversation's stored Session Data files are now read from an HTTP route beside the view route:

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

swctl ai-agent follows it.

files

  • Requires --instance-name, --session and --seqs. A file is chosen by its session and its landed seq, both listed in the conversation's asz.view document. There is no read of every file.
  • Sends the seqs 32 per request, the route's limit, and reuses one HTTP client across requests.
  • Reads the application/vnd.skywalking.asz.files+ndjson body one file at a time: a naming line, then exactly the bytes it names, then the newline that follows a non-empty file not ending with one.
  • Checks each file against its sha256. A size in a naming line that no response actually carries is never allocated.
  • --export writes through os.Root, so a file name can never reach outside the export directory, whether through .. or a symbolic link.

view

  • Requires --instance-name, as the OAP route now does.

The GraphQL query asset for raw files is removed. list stays on GraphQL.

Tests

  • New tests for the files reader:
    • byte-for-byte reads, including a 3 MiB record line, a file without a final newline, and an empty file;
    • batching;
    • framing errors, including a size no response carries;
    • required arguments;
    • problem documents.
  • A test that export stays inside its directory.
  • go test ./pkg/aiagent/... ./internal/commands/aiagent/... passes.
  • golangci-lint reports only the goconst findings that already exist on master.
  • The AI agent e2e in apache/skywalking passes with this build:
    • 19 of 19 cases on BanyanDB, Postgres and MySQL;
    • it reads files by session and seq taken from the document, and exports them.

The OAP drops the getConversationRawFiles GraphQL query. A conversation's
stored Session Data files are read from its HTTP route beside the view,
GET /ai-agent/conversations/{conversation}/v1/files, and the view and the
files route now both require the service and the sender's instance.

- ai-agent files requires --instance-name, --session and --seqs. A file is
  chosen by its session and its landed seq, which the conversation's
  asz.view document lists; there is no read of every file. The seqs are
  asked for 32 at a time, the route's limit, over one HTTP client.
- The body, application/vnd.skywalking.asz.files+ndjson, is read file by
  file: a naming line, then exactly the bytes it names, then the newline
  that follows a non-empty file not ending with one. Each file is checked
  against its sha256, and a size no response carries is never allocated.
- --export writes through os.Root, so a file's name cannot reach outside
  the export directory, through ".." or through a symbolic link.
- ai-agent view requires --instance-name.
@wu-sheng
wu-sheng merged commit 30aaacd into master Sep 15, 2026
7 checks passed
@wu-sheng
wu-sheng deleted the ai-agent-files-over-http branch September 15, 2026 15:03
wu-sheng added a commit to apache/skywalking that referenced this pull request Sep 15, 2026
The AI agent e2e reads conversation files through swctl ai-agent files
with --session and --seqs, which apache/skywalking-cli#235 added.
wu-sheng added a commit to apache/skywalking that referenced this pull request Sep 15, 2026
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 added a commit to apache/skywalking that referenced this pull request Sep 16, 2026
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 added a commit to apache/skywalking that referenced this pull request Sep 16, 2026
…TTP (#14084)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants