You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/06 14:11:50 UTC
(skywalking) branch master updated: Add AI agent conversations landed by the AI Sessionizer (#14057)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking.git
The following commit(s) were added to refs/heads/master by this push:
new 116a0bd1e6 Add AI agent conversations landed by the AI Sessionizer (#14057)
116a0bd1e6 is described below
commit 116a0bd1e683344aee2d4c44d97a757f1bcd40e1
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Sep 6 22:11:43 2026 +0800
Add AI agent conversations landed by the AI Sessionizer (#14057)
The AI Sessionizer (apache/skywalking-ai-sessionizer) pushes an agent
runtime's conversation as Session Data and Session Flow files over OTLP
logs under a new AI_AGENT layer. The OAP verifies each file's digest and
line count in the bundled LAL rule's ConversationFile output builder,
stores it verbatim in two record models, ai_agent_session_data and
ai_agent_session_flow, in a BanyanDB group of their own, recordsAIAgent,
and answers a conversation with one asz.view 1.0 document, equal key for
key to the one the Sessionizer prints for the same files.
The list page and the raw-file export are GraphQL queries,
listConversations and getConversationRawFiles. The document itself is
served by GET /ai-agent/conversations/{conversation}/v1/view on the same
HTTP server, streamed and compressed under its own timeout, because a
long conversation renders to tens of megabytes: measured, a 136 MB
session folds to a 70 MB document in about eleven seconds, past the
server's ten second default. Its Content-Type names the format and the
version, application/vnd.asz.view+json; version=1.0 or the +yaml twin,
and an error is an application/problem+json document.
The e2e builds sessions with the Sessionizer's own scenario tool, lands
one of them in three stages, pushes everything to the OAP, and compares
every document with the one the Sessionizer's viewer serves, over
HTTP/1.1 and HTTP/2, as JSON and YAML, gzipped or not.
---
.github/workflows/skywalking.yaml | 9 +
.licenserc.yaml | 3 +
docs/en/academy/scaling-with-apache-skywalking.md | 4 +-
docs/en/api/query-protocol.md | 20 +
docs/en/changes/changes.md | 1 +
docs/en/setup/backend/ai-agent-conversation.md | 171 +++
docs/en/setup/backend/configuration-vocabulary.md | 7 +
docs/en/setup/backend/storages/banyandb.md | 23 +
docs/en/setup/backend/storages/elasticsearch.md | 2 +
docs/menu.yml | 2 +
.../ai-agent-conversation}/pom.xml | 44 +-
.../conversation/AIAgentConversationConfig.java | 63 +
.../conversation/AIAgentConversationModule.java | 45 +
.../conversation/AIAgentConversationProvider.java | 139 ++
.../agent/conversation/fold/ConversationFold.java | 295 ++++
.../ai/agent/conversation/format/Digests.java | 103 ++
.../ai/agent/conversation/format/FileNames.java | 136 ++
.../ai/agent/conversation/format/RawJson.java | 195 +++
.../server/ai/agent/conversation/format/Ref.java | 66 +
.../agent/conversation/format/SessionDataFile.java | 332 +++++
.../conversation/format/SessionFlowRound.java | 419 ++++++
.../server/ai/agent/conversation/format/Times.java | 87 ++
.../ingest/ConversationFileBuilder.java | 336 +++++
.../query/ConversationQueryService.java | 415 ++++++
.../query/IConversationQueryService.java | 78 ++
.../conversation/query/http/CompressResponse.java | 43 +
.../query/http/ConversationViewHandler.java | 226 ++++
.../query/input/ConversationCondition.java | 30 +
.../query/input/ConversationListCondition.java | 32 +
.../query/type/ConversationFileFormat.java | 29 +
.../conversation/query/type/ConversationList.java | 31 +
.../query/type/ConversationRawFile.java | 37 +
.../query/type/ConversationRawFiles.java | 31 +
.../conversation/query/type/ConversationRow.java | 40 +
.../conversation/view/ConversationViewBuilder.java | 1425 ++++++++++++++++++++
.../ai/agent/conversation/view/ViewJson.java | 45 +
.../ai/agent/conversation/view/ViewYaml.java | 71 +
...walking.oap.server.core.source.LALOutputBuilder | 18 +
...ywalking.oap.server.library.module.ModuleDefine | 18 +
...alking.oap.server.library.module.ModuleProvider | 18 +
.../ai/agent/conversation/AIAgentLalRuleTest.java | 203 +++
.../conversation/ConversationFileBuilderTest.java | 212 +++
.../conversation/ConversationViewBuilderTest.java | 179 +++
.../oap/server/ai/agent/conversation/Fixtures.java | 98 ++
.../ai/agent/conversation/SessionFormatsTest.java | 165 +++
.../query/ConversationQueryServiceTest.java | 170 +++
.../query/http/ConversationViewHandlerTest.java | 252 ++++
.../src/test/resources/fixtures/.gitattributes | 3 +
.../test/resources/fixtures/asz-view-example.json | 1043 ++++++++++++++
.../test/resources/fixtures/asz-view-example.yaml | 773 +++++++++++
.../meta-20260101T000000.000000000Z-000003.sd | 3 +
.../resources/fixtures/r000001-3ad0dcd4cd53.sf | 45 +
...transcript-20260101T000000.000000000Z-000001.sd | 18 +
...transcript-20260101T000000.000000000Z-000002.sd | 4 +
oap-server/analyzer/pom.xml | 1 +
.../skywalking/oap/server/core/analysis/Layer.java | 6 +
.../manual/aiagent/AIAgentSessionDataRecord.java | 143 ++
.../manual/aiagent/AIAgentSessionFlowRecord.java | 170 +++
.../oap/server/core/source/DefaultScopeDefine.java | 2 +
.../oap/server/core/storage/StorageModule.java | 2 +
.../server/core/storage/annotation/BanyanDB.java | 5 +
.../query/IAIAgentConversationQueryDAO.java | 202 +++
.../library/banyandb/v1/client/BanyanDBClient.java | 42 +-
.../query-graphql-plugin/pom.xml | 5 +
.../oap/query/graphql/GraphQLQueryProvider.java | 8 +-
.../graphql/resolver/AIAgentConversationQuery.java | 126 ++
.../src/main/resources/query-protocol | 2 +-
oap-server/server-starter/pom.xml | 6 +
.../src/main/resources/application.yml | 19 +-
.../server-starter/src/main/resources/bydb.yml | 22 +
.../src/main/resources/lal/ai-agent.yaml | 58 +
.../banyandb/BanyanDBConfigDumpExtension.java | 1 +
.../plugin/banyandb/BanyanDBConfigLoader.java | 7 +
.../plugin/banyandb/BanyanDBStorageClient.java | 18 +-
.../plugin/banyandb/BanyanDBStorageConfig.java | 10 +
.../plugin/banyandb/BanyanDBStorageProvider.java | 4 +
.../storage/plugin/banyandb/MetadataRegistry.java | 9 +
.../banyandb/stream/AbstractBanyanDBDAO.java | 25 +-
.../BanyanDBAIAgentConversationQueryDAO.java | 246 ++++
.../src/test/resources/bydb.yml | 19 +
.../StorageModuleElasticsearchProvider.java | 4 +
.../query/AIAgentConversationQueryEsDAO.java | 276 ++++
.../plugin/jdbc/common/JDBCStorageProvider.java | 5 +
.../dao/JDBCAIAgentConversationQueryDAO.java | 333 +++++
.../plugin/jdbc/mysql/MySQLTableInstaller.java | 10 +
.../src/main/resources/application.yml | 13 +
test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml | 57 +
.../cases/ai-agent/banyandb/docker-compose.yml | 232 ++++
test/e2e-v2/cases/ai-agent/banyandb/e2e.yaml | 52 +
test/e2e-v2/cases/ai-agent/es/docker-compose.yml | 241 ++++
test/e2e-v2/cases/ai-agent/es/e2e.yaml | 42 +
.../ai-agent/expected/conversations-filters.yml | 21 +
.../ai-agent/expected/conversations-instance.yml | 17 +
.../ai-agent/expected/conversations-limit.yml | 17 +
.../ai-agent/expected/conversations-window.yml | 17 +
.../cases/ai-agent/expected/conversations.yml | 65 +
test/e2e-v2/cases/ai-agent/expected/gap.yml | 29 +
test/e2e-v2/cases/ai-agent/expected/instance.yml | 22 +
test/e2e-v2/cases/ai-agent/expected/lost-file.yml | 27 +
.../e2e-v2/cases/ai-agent/expected/multi-round.yml | 52 +
test/e2e-v2/cases/ai-agent/expected/raw-files.yml | 18 +
test/e2e-v2/cases/ai-agent/expected/reject.yml | 17 +
test/e2e-v2/cases/ai-agent/expected/service.yml | 24 +
test/e2e-v2/cases/ai-agent/expected/size.yml | 18 +
test/e2e-v2/cases/ai-agent/expected/views.yml | 32 +
test/e2e-v2/cases/ai-agent/fixture.yaml | 54 +
test/e2e-v2/cases/ai-agent/lost-file.yaml | 34 +
.../e2e-v2/cases/ai-agent/mysql/docker-compose.yml | 243 ++++
test/e2e-v2/cases/ai-agent/mysql/e2e.yaml | 42 +
.../cases/ai-agent/postgres/docker-compose.yml | 241 ++++
test/e2e-v2/cases/ai-agent/postgres/e2e.yaml | 42 +
test/e2e-v2/cases/ai-agent/three-rounds.yaml | 40 +
test/e2e-v2/cases/ai-agent/verify.sh | 281 ++++
.../expected/silence-after-graphql-critical.yml | 1 +
.../alarm/expected/silence-after-graphql-warn.yml | 2 +
.../expected/silence-before-graphql-critical.yml | 1 +
.../alarm/expected/silence-before-graphql-warn.yml | 2 +
test/e2e-v2/cases/baseline/expected/critical.yml | 1 +
test/e2e-v2/cases/php/Dockerfile.php | 9 +-
test/e2e-v2/cases/storage/expected/config-dump.yml | 9 +-
test/e2e-v2/script/env | 3 +-
121 files changed, 12016 insertions(+), 45 deletions(-)
diff --git a/.github/workflows/skywalking.yaml b/.github/workflows/skywalking.yaml
index c177cc086e..8d17d3124e 100644
--- a/.github/workflows/skywalking.yaml
+++ b/.github/workflows/skywalking.yaml
@@ -488,6 +488,15 @@ jobs:
env: ES_VERSION=8.18.8
- name: Log BanyanDB
config: test/e2e-v2/cases/log/banyandb/e2e.yaml
+ - name: AI Agent Conversations BanyanDB
+ config: test/e2e-v2/cases/ai-agent/banyandb/e2e.yaml
+ - name: AI Agent Conversations ES 8.18.8
+ config: test/e2e-v2/cases/ai-agent/es/e2e.yaml
+ env: ES_VERSION=8.18.8
+ - name: AI Agent Conversations MySQL
+ config: test/e2e-v2/cases/ai-agent/mysql/e2e.yaml
+ - name: AI Agent Conversations Postgres
+ config: test/e2e-v2/cases/ai-agent/postgres/e2e.yaml
- name: Log FluentBit ES 8.18.8
config: test/e2e-v2/cases/log/fluent-bit/e2e.yaml
diff --git a/.licenserc.yaml b/.licenserc.yaml
index 556b796515..1f50cd5cb4 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -25,6 +25,7 @@ header:
- '.github/ISSUE_TEMPLATE'
- '.github/PULL_REQUEST_TEMPLATE'
- '**/.gitignore'
+ - '**/.gitattributes'
- '.gitmodules'
- '.lift'
- '.mvn'
@@ -48,6 +49,8 @@ header:
- '**/*.pem'
- '**/*.key'
- '**/*.txt'
+ # AI Sessionizer data files and its rendered asz.view document, used as test fixtures as-is
+ - 'oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/**'
- 'LICENSE'
- 'NOTICE'
- '**/src/main/fbs/istio/**'
diff --git a/docs/en/academy/scaling-with-apache-skywalking.md b/docs/en/academy/scaling-with-apache-skywalking.md
index d9a6952ae7..c54b6462ad 100644
--- a/docs/en/academy/scaling-with-apache-skywalking.md
+++ b/docs/en/academy/scaling-with-apache-skywalking.md
@@ -176,7 +176,7 @@ kubectl label namespace default istio-injection=enabled
#### Install SWCK
-SWCK provides convenience for users to deploy and upgrade SkyWalking related components based on Kubernetes. The automatic scale function of Satellite also mainly relies on SWCK. For more information, you could refer to the [official documentation](https://github.com/apache/skywalking-swck/blob/master/docs/operator.md#guides-of-operator-deployment).
+SWCK provides convenience for users to deploy and upgrade SkyWalking related components based on Kubernetes. The automatic scale function of Satellite also mainly relies on SWCK. For more information, you could refer to the [official documentation](https://github.com/apache/skywalking-swck/blob/master/docs/en/setup/operator.md#operator-deployment).
```shell
# Install cert-manager
@@ -184,7 +184,7 @@ kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.3
# Deploy SWCK
mkdir -p skywalking-swck && cd skywalking-swck
-wget https://dlcdn.apache.org/skywalking/swck/0.6.1/skywalking-swck-0.6.1-bin.tgz
+wget https://archive.apache.org/dist/skywalking/swck/0.6.1/skywalking-swck-0.6.1-bin.tgz
tar -zxvf skywalking-swck-0.6.1-bin.tgz
cd config
kubectl apply -f operator-bundle.yaml
diff --git a/docs/en/api/query-protocol.md b/docs/en/api/query-protocol.md
index 90c9fb8188..efca61e97a 100644
--- a/docs/en/api/query-protocol.md
+++ b/docs/en/api/query-protocol.md
@@ -323,6 +323,26 @@ extend type Query {
}
```
+### AI Agent Conversations
+Provide [AI agent conversation](../setup/backend/ai-agent-conversation.md) query APIs since 11.1.0: the list page and the raw-file export
+are GraphQL queries; the conversation itself is an HTTP route on the same server, because its `asz.view` document is as
+large as the conversation and is streamed.
+```graphql
+extend type Query {
+ # The conversations of a service active in the duration, newest first.
+ listConversations(condition: ConversationListCondition!, duration: Duration!, debug: Boolean): ConversationList
+ # Every file of a conversation, as stored. Select `body` to export them.
+ getConversationRawFiles(condition: ConversationCondition!, files: [ID!], debug: Boolean): ConversationRawFiles
+}
+```
+```
+GET /ai-agent/conversations/{conversation}/v1/view?service={serviceName}[&instance={instanceName}]
+```
+The body is one `asz.view` 1.0 document, streamed, and its `Content-Type` names the format and the version:
+`application/vnd.skywalking.asz.view+json; version=1.0`, or the `+yaml` twin when `Accept` asks for YAML; compressed on
+`Accept-Encoding`. `v1` in the path is the document version. 400 without a service, 404 when no round of the conversation
+is stored, 500 on a storage failure, each as `application/problem+json`.
+
## Condition
### Duration
Duration is a widely used parameter type as the APM data is time-related. See the following for more details.
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index f094b4c0b8..ac736cc694 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -11,6 +11,7 @@
* Add BanyanDB trace tail sampling metrics to the BanyanDB self-observability layer, in a new `otel-rules/banyandb/banyandb-trace-sampling.yaml` rule file. It covers the whole `banyandb_trace_pipeline_*` / `banyandb_trace_tst_pipeline_*` catalog a sampler plugin chain emits — pipeline reconciliation, per-plugin `Decide` execution rate and latency, chain batching, the trace-level evaluated / retained / dropped / immature outcomes, every fail-open guard and bounded-retention counter, drop- [...]
* Fix a second `CounterWindow` key collision in the v2 MAL engine, this time ACROSS rules. `rate()` / `increase()` / `irate()` resolve their lower bound from a process-wide window keyed on the counter's own name plus its post-`.sum(...)` label set, with nothing identifying the rule doing the evaluation. Two rules that read one wire family, tell their streams apart with `tagEqual(...)`, and then `.sum(...)` away the label they filtered on therefore collapse onto one window slot and differ [...]
* Fix `meter_rabbitmq_node_outgoing_messages_total` double-counting one of its terms. The rule summed six delivery-rate terms but `rabbitmq_global_messages_delivered_get_auto_ack_total` appeared twice, so auto-ack `basic.get` deliveries were counted once more than the other four delivery paths and the reported outgoing rate ran high whenever polling consumers were in use. The duplicate term is removed, leaving the five distinct families (redelivered, consume auto/manual ack, get auto/man [...]
+* Add AI agent conversations landed by the AI Sessionizer: the `AI_AGENT` layer, the bundled `lal/ai-agent.yaml` rule with the `ConversationFile` output builder that verifies and stores Session Data and Session Flow files, the `ai_agent_session_data` and `ai_agent_session_flow` models in a new BanyanDB group `recordsAIAgent`, the `ai-agent-conversation` module that folds a conversation into one `asz.view` document, and the `listConversations` (with optional `conversation` and `title` con [...]
#### UI
* Add a Virtual GenAI evaluation-record page and evaluation-score chart in Horizon UI, so operators can inspect evaluation result, level, reason, judge model, timestamp, trace linkage, and the `gen_ai_model_evaluation_score_ppm` trend for evaluated records.
diff --git a/docs/en/setup/backend/ai-agent-conversation.md b/docs/en/setup/backend/ai-agent-conversation.md
new file mode 100644
index 0000000000..d04e444be7
--- /dev/null
+++ b/docs/en/setup/backend/ai-agent-conversation.md
@@ -0,0 +1,171 @@
+# AI Agent Conversations
+
+Since 11.1.0, SkyWalking stores and serves the conversations of long-lived AI agents. The feature requires the
+[SkyWalking AI Sessionizer](https://github.com/apache/skywalking-ai-sessionizer) as the sender: it is the
+producer of the files described here, and a record under this layer without their attributes is rejected. The
+Sessionizer collects an agent
+runtime's transcripts into two file formats, Session Data (`.sd`, the records as collected) and Session Flow
+(`.sf`, an append-only chain of rounds that describe the conversation's structure), and pushes every file as one
+OTLP log record. The OAP verifies each file on arrival, stores it verbatim, and answers a conversation query with
+one `asz.view` document that a viewer renders without opening any file.
+
+In the Sessionizer's model a **conversation** is the unit of storage, analysis and export. A **session** is the
+source-runtime context a record came from, carried as provenance: one conversation may contain several sessions,
+and a session belongs to exactly one conversation.
+
+## How a file reaches the OAP
+
+The sender puts these resource attributes on every request:
+
+| Attribute | Value |
+|-----------------------|--------------------------------------------------------------------------------|
+| `service.name` | the name the sender is configured with, or else the runtime that produced the session, such as `Claude Code` |
+| `service.instance.id` | who is pushing, in words the people reading the OAP recognise: a mailbox, a name or a machine, `user@host` of the pushing machine by default |
+| `service.layer` | `AI_AGENT` |
+
+Each log record is one file. The body is the file's text. The record attributes name the file (`asz.format`,
+`asz.file`, `asz.file.digest`, `asz.lines`, `asz.session`, `asz.seq` for a Session Data file; `asz.conversation`,
+`asz.round`, the conversation's time range and its title and counts for a round). The two file formats are
+documented by the Sessionizer under
+[Session Data](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/session-data/) and
+[Session Flow](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/session-flow/), and
+the wire attributes under
+[Export over OpenTelemetry](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/setup/export-otlp/).
+
+The OAP routes these records like every other OTLP log: by layer, to the bundled LAL rule
+`lal/ai-agent.yaml`. The rule's output type, `ConversationFile`, checks the body's sha256 against
+`asz.file.digest` and its line count against `asz.lines`, and stores the file in the table its format names. A
+file that fails either check is dropped and counted in the `ai_agent_conversation_files_rejected` self-observability
+metric with the reason as a label, and so is a file larger than `maxFileBytes`, under the reason `size`: one
+file over the storage's message limit fails the write it travels in, and every record behind it in that write
+with it, so the limit is applied where one file is one record; a stored file is a verified file. A round's title and counts are read only
+when the record carries them: they came with a later round header, and a round from before them lands and lists
+with zero counts. The service and its instance appear on the service list under the `AI_AGENT` layer as for any
+other log sender.
+
+Nothing is folded or decoded at ingest, so an OAP cluster needs no shared state for this feature.
+
+## Storage
+
+Two record models, both super datasets:
+
+| Model | One row per | Keys | Stored only |
+|-------------------------|--------------------|----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
+| `ai_agent_session_data` | Session Data file | `service_id`, `service_instance_id`, `session`, indexed `seq` | `digest`, `body` |
+| `ai_agent_session_flow` | Session Flow round | `service_id`, `service_instance_id`, indexed `conversation`, `round` | `session_from_time`, `title`, `talks`, `steps`, `streams`, `segments`, `unresolved`, `digest`, `body` |
+
+A Session Data row carries nothing but its keys and the file: the file's kind, stream or run, time range and name
+are on its first line and are read from there. A round's stored-only columns exist for the list page, which reads
+them without opening a body; its `round` number is queryable so a long chain is read window by window. The row's
+timestamp is the file's latest record time, or the conversation's last activity for a round, so a conversation's
+files are found by its own time range. A row belongs to its sender: its id is the service, the instance and the
+file's digest, so the same file pushed again by the same sender lands on the same row, and pushed by another
+service or sender makes another.
+
+- **BanyanDB**: both models live in their own group, `recordsAIAgent`, configured like the log group with hot,
+ warm and cold stages under `SW_STORAGE_BANYANDB_AI_AGENT_*`, 30 days hot by default. Both tables expire
+ together, because a round whose files are gone is a broken chain. See the
+ [BanyanDB storage document](storages/banyandb.md).
+- **Elasticsearch**: two super-dataset index families, `sw_ai_agent_session_data-*` and
+ `sw_ai_agent_session_flow-*`, sharded by `superDatasetIndexShardsFactor`; retention is the single
+ `recordDataTTL`. The columns the reads sort and range on, `seq`, `round` and `timestamp`, keep doc values.
+- **JDBC** (MySQL, PostgreSQL, H2): two tables of their own; the body is stored as Base64 text, `LONGTEXT` on
+ MySQL, since a body near `maxFileBytes` outgrows `MEDIUMTEXT` once encoded, `MEDIUMTEXT` on H2, a CLOB there,
+ and `TEXT` on PostgreSQL; retention is `recordDataTTL`.
+
+## Query
+
+The list and the export are GraphQL queries in `ai-agent-conversation.graphqls`; the conversation itself is an
+HTTP route on the same server, because its document is as large as the conversation.
+
+- `listConversations(condition, duration)` lists one row per conversation of a service, optionally of one sender,
+ from the newest round's attributes. The rounds are read newest first, at most `limit` (default 1000), then
+ folded to one row per conversation. An optional `conversation` narrows the read to one conversation by id, and an
+ optional `title` keeps only the rows whose title contains the text, case-insensitively — matched after folding, on
+ the newest round's title, so it never widens the rounds read.
+- `getConversationRawFiles(condition, files)` lists every landed file and round of a conversation with its id,
+ digest and size; selecting `body` returns the files verbatim, which is the export path. The optional `files`
+ argument narrows the read to named files.
+
+### The conversation view route
+
+```
+GET /ai-agent/conversations/{conversation}/v1/view?service={serviceName}[&instance={instanceName}]
+```
+
+It answers with the whole conversation, once, as one `asz.view` version 1.0 document, the document the
+Sessionizer defines under
+[The asz.view document](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/asz-view/)
+and serves from its own viewer; the OAP's document equals it, key for key, for the same files. `v1` in the path
+is the document version. The OAP reads the conversation's rounds over the whole retention window, then the
+files of each session the head round names over the time range the head round carries, checks the chain, folds
+the rounds, resolves every reference into the landed records, and renders the document. Verification is
+content, not an error: a missing round or file, or a failed digest, is written into the document's
+`summary.state` and `summary.problems`, and the rest of the document holds whatever could still be folded. The
+fold shows as much as landed: a round that is missing, that does not read, or that the fold refuses is skipped,
+the chain resumes at the next stored round, and the absent rounds are named once as a range, as are the files a
+round names that did not land. The round the chain resumes at is listed unverified, because nothing links it to
+what is absent; the rounds after it verify against it; `head` names the last round folded. This goes further
+than the Sessionizer's own viewer, whose fold stops before the first gap. The document is built on every call
+and nothing is cached.
+
+| Parameter or header | Meaning |
+|---|---|
+| `service` / `serviceId` | the service by name, or by id; one of them is required |
+| `instance` | optional, the sender's instance name from the list row; with it, every storage read is a full series lookup |
+| `Accept` | `application/vnd.skywalking.asz.view+yaml`, or any type naming `yaml`, for YAML; anything else, JSON, as `asz conversation -json` prints it |
+| `Content-Type` | names the document and its version, the HTTP way: `application/vnd.skywalking.asz.view+json; version=1.0` or `application/vnd.skywalking.asz.view+yaml; version=1.0`. The document's own first two keys, `format` and `version`, say the same |
+| `Accept-Encoding` | the body is compressed when the client allows; a document is repetitive text and shrinks several times over |
+| status | 200 with the document; 400 when no service is named; 404 when the service stores no round of the conversation; 500 on a storage failure. An error is `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)): `{"type": "about:blank", "title": "Not Found", "status": 404, "detail": "..."}` |
+
+The route is on the core HTTP server beside `/graphql`, so it has the same host, port, context path and TLS
+settings, and serves HTTP/1.1 and HTTP/2 alike. The body is streamed: it is written to the response as it is
+rendered, never held whole in memory, and a slow client holds back the render. The route runs under its own
+timeout, `viewRequestTimeout`, in place of the server's default of ten seconds, because the floor for a large
+conversation is seconds of storage reads plus seconds of fold and render.
+
+The conversation page of the UI makes one call, this route, and nothing else.
+
+## Configuration
+
+```yaml
+ai-agent-conversation:
+ selector: ${SW_AI_AGENT_CONVERSATION:default}
+ default:
+ fileReadWindow: ${SW_AI_AGENT_CONVERSATION_FILE_READ_WINDOW:16}
+ roundReadWindow: ${SW_AI_AGENT_CONVERSATION_ROUND_READ_WINDOW:16}
+ maxListLimit: ${SW_AI_AGENT_CONVERSATION_MAX_LIST_LIMIT:10000}
+ viewRequestTimeout: ${SW_AI_AGENT_CONVERSATION_VIEW_REQUEST_TIMEOUT:120}
+ maxFileBytes: ${SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES:15728640}
+ maxResponseBytes: ${SW_AI_AGENT_CONVERSATION_MAX_RESPONSE_BYTES:104857600}
+```
+
+| Key | Meaning |
+|------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
+| `fileReadWindow` | how many Session Data files one storage query fetches, a batch size and not a limit: the view and the raw-file export read every file of the conversation, this many per query. Files are cut at 2 MiB, so a window is a few tens of megabytes; the window times `maxFileBytes` is the most one query can answer with. |
+| `roundReadWindow` | how many Session Flow rounds one storage query fetches, the same way: the head round is fixed first, then the chain is read from round 1 to the head, this many per query. A round is cut at 2 MiB by the Sessionizer, and the same bound applies. |
+| `maxListLimit` | the most rounds one list query reads before folding, and the ceiling of the query's `limit` argument. |
+| `viewRequestTimeout` | how long one conversation view request may take, in seconds. |
+| `maxFileBytes` | the largest file stored, in bytes; a larger one is rejected at ingest and counted under the reason `size`. 15 MiB by default, under BanyanDB's 16 MiB gRPC message limit. The Sessionizer cuts files and rounds at 2 MiB; only a round from before that cut is larger. |
+| `maxResponseBytes` | the most bytes one window read may answer with, applied to that read alone on a storage that caps a response per call. The BanyanDB client holds every other read to 50 MB; this module's two window reads carry it as a call option on the same connection, so nothing else changes. 100 MiB by default, above sixteen files at the 2 MiB cut with room for files landed whole. For a root of larger files, raise it or lower the windows, so that the window times `maxFileBytes` s [...]
+
+The GraphQL query module requires this module, so it cannot be disabled while the GraphQL query module is active.
+
+## Limits on the path
+
+- The OAP's OTLP/HTTP endpoint accepts requests of up to 10 MiB, the HTTP server's default. The Sessionizer's
+ request budget defaults to 8 MiB for that reason; a single file is cut at 2 MiB, so it always fits.
+- The files of a conversation are read in windows of `fileReadWindow` files, and its rounds in windows of
+ `roundReadWindow` rounds, per storage query, inside one view request. On BanyanDB each of those queries may answer
+ with up to `maxResponseBytes`, 100 MiB by default, as a call option on the shared client in place of its 50 MB
+ default, which every other read keeps; the window times `maxFileBytes` must stay under it. Elasticsearch answers
+ at most 10,000 hits to one search.
+- A read that is not bound to a duration, the view and the export, covers every retained stage: on BanyanDB the
+ default stages and, when the group keeps one, the cold stage. A conversation the list found in cold storage
+ is served, and one that spans stages is served whole.
+- When the caller names no sender, the view and the export read across every sender of the service and keep one
+ copy of a file or round two senders both pushed, so a Sessionizer renamed between pushes still yields the
+ whole conversation.
+- The `asz.view` document grows with the conversation. A session of 136 MB of landed files renders to a 70 MB
+ document in about five seconds after about six seconds of storage reads, which is why the view is a streamed
+ route with its own timeout and not a GraphQL query.
diff --git a/docs/en/setup/backend/configuration-vocabulary.md b/docs/en/setup/backend/configuration-vocabulary.md
index f1235a27e1..94838a47bc 100644
--- a/docs/en/setup/backend/configuration-vocabulary.md
+++ b/docs/en/setup/backend/configuration-vocabulary.md
@@ -172,6 +172,13 @@ It divided into several modules, each of which has its own settings. The followi
| log-analyzer | default | Log Analyzer. | SW_LOG_ANALYZER [...]
| - | - | lalFiles | The LAL configuration file names (without file extension) to be activated. Read [LAL](../../concepts-and-designs/lal.md) for more details. [...]
| - | - | malFiles | The MAL configuration file names (without file extension) to be activated. Read [LAL](../../concepts-and-designs/lal.md) for more details. [...]
+| ai-agent-conversation | default | Conversations of long-lived AI agents landed by the AI Sessionizer as Session Data and Session Flow files over OTLP logs under the `AI_AGENT` layer. | SW_AI_AGENT_CONVERSATION | default | - |
+| - | - | fileReadWindow | How many Session Data files one storage read fetches; keeps one BanyanDB response under its inbound cap. | SW_AI_AGENT_CONVERSATION_FILE_READ_WINDOW | 16 |
+| - | - | roundReadWindow | How many Session Flow rounds one storage read fetches; a long conversation is read window by window up to its head. | SW_AI_AGENT_CONVERSATION_ROUND_READ_WINDOW | 16 |
+| - | - | maxListLimit | The most rounds one list query reads before folding to one row per conversation. | SW_AI_AGENT_CONVERSATION_MAX_LIST_LIMIT | 10000 |
+| - | - | viewRequestTimeout | How long one conversation view request may take, in seconds, in place of the HTTP server's default. | SW_AI_AGENT_CONVERSATION_VIEW_REQUEST_TIMEOUT | 120 |
+| - | - | maxFileBytes | The largest file stored, in bytes; a larger one is rejected at ingest and counted under the reason `size`. Under BanyanDB's 16 MiB gRPC message limit. | SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES | 15728640 |
+| - | - | maxResponseBytes | The most bytes one window read may answer with, applied to that read alone where the storage caps a response per call, in place of the BanyanDB client's 50 MB. | SW_AI_AGENT_CONVERSATION_MAX_RESPONSE_BYTES | 104857600 |
| event-analyzer | default | Event Analyzer. | SW_EVENT_ANALYZER [...]
| receiver-register | default | gRPC and HTTPRestful services that provide service, service instance and endpoint register. | - [...]
| receiver-trace | default | gRPC and HTTPRestful services that accept SkyWalking format traces. | - [...]
diff --git a/docs/en/setup/backend/storages/banyandb.md b/docs/en/setup/backend/storages/banyandb.md
index 5ddf418818..662ca29b53 100644
--- a/docs/en/setup/backend/storages/banyandb.md
+++ b/docs/en/setup/backend/storages/banyandb.md
@@ -199,6 +199,29 @@ groups:
ttl: ${SW_STORAGE_BANYANDB_LOG_COLD_TTL_DAYS:30}
replicas: ${SW_STORAGE_BANYANDB_LOG_COLD_REPLICAS:0}
nodeSelector: ${SW_STORAGE_BANYANDB_LOG_COLD_NODE_SELECTOR:"type=cold"}
+ # The "recordsAIAgent" group holds AI agent conversation files landed by the AI Sessionizer: Session Data (.sd)
+ # and Session Flow (.sf) rows of up to 2 MiB each. It mirrors the log group's keys but has its own retention,
+ # because conversations are kept for weeks and a round whose files have expired is a broken chain.
+ # See the "AI Agent Conversations" document.
+ recordsAIAgent:
+ shardNum: ${SW_STORAGE_BANYANDB_AI_AGENT_SHARD_NUM:2}
+ segmentInterval: ${SW_STORAGE_BANYANDB_AI_AGENT_SI_DAYS:1}
+ ttl: ${SW_STORAGE_BANYANDB_AI_AGENT_TTL_DAYS:30}
+ replicas: ${SW_STORAGE_BANYANDB_AI_AGENT_REPLICAS:0}
+ enableWarmStage: ${SW_STORAGE_BANYANDB_AI_AGENT_ENABLE_WARM_STAGE:false}
+ enableColdStage: ${SW_STORAGE_BANYANDB_AI_AGENT_ENABLE_COLD_STAGE:false}
+ warm:
+ shardNum: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_SHARD_NUM:2}
+ segmentInterval: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_SI_DAYS:1}
+ ttl: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_TTL_DAYS:60}
+ replicas: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_REPLICAS:0}
+ nodeSelector: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_NODE_SELECTOR:"type=warm"}
+ cold:
+ shardNum: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_SHARD_NUM:2}
+ segmentInterval: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_SI_DAYS:1}
+ ttl: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_TTL_DAYS:180}
+ replicas: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_REPLICAS:0}
+ nodeSelector: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_NODE_SELECTOR:"type=cold"}
recordsBrowserErrorLog:
shardNum: ${SW_STORAGE_BANYANDB_BROWSER_ERROR_LOG_SHARD_NUM:2}
segmentInterval: ${SW_STORAGE_BANYANDB_BROWSER_ERROR_LOG_SI_DAYS:1}
diff --git a/docs/en/setup/backend/storages/elasticsearch.md b/docs/en/setup/backend/storages/elasticsearch.md
index e83892fe7d..d78abcb3ee 100644
--- a/docs/en/setup/backend/storages/elasticsearch.md
+++ b/docs/en/setup/backend/storages/elasticsearch.md
@@ -188,6 +188,8 @@ And also you can [specify the settings for each index individually.](#specify-se
| sw_segment-`${day-format}` | indexShardsNumber * superDatasetIndexShardsFactor | superDatasetIndexReplicasNumber |
| sw_browser_error_log-`${day-format}` | indexShardsNumber * superDatasetIndexShardsFactor | superDatasetIndexReplicasNumber |
| sw_zipkin_span-`${day-format}` | indexShardsNumber * superDatasetIndexShardsFactor | superDatasetIndexReplicasNumber |
+| sw_ai_agent_session_data-`${day-format}` | indexShardsNumber * superDatasetIndexShardsFactor | superDatasetIndexReplicasNumber |
+| sw_ai_agent_session_flow-`${day-format}` | indexShardsNumber * superDatasetIndexShardsFactor | superDatasetIndexReplicasNumber |
| sw_records-all-`${day-format}` | indexShardsNumber | indexReplicasNumber |
#### Advanced Configurations For Elasticsearch Index
diff --git a/docs/menu.yml b/docs/menu.yml
index d0e4cc3fe5..d9a069c9db 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -166,6 +166,8 @@ catalog:
path: "/en/setup/service-agent/virtual-genai"
- name: "AI Evaluation"
path: "/en/setup/backend/ai-evaluation"
+ - name: "AI Agent Conversations"
+ path: "/en/setup/backend/ai-agent-conversation"
- name: "Envoy AI Gateway"
path: "/en/setup/backend/backend-envoy-ai-gateway-monitoring"
- name: "Self Observability"
diff --git a/oap-server/server-query-plugin/query-graphql-plugin/pom.xml b/oap-server/analyzer/ai-agent-conversation/pom.xml
similarity index 71%
copy from oap-server/server-query-plugin/query-graphql-plugin/pom.xml
copy to oap-server/analyzer/ai-agent-conversation/pom.xml
index 82ed6c7949..07c2b52395 100644
--- a/oap-server/server-query-plugin/query-graphql-plugin/pom.xml
+++ b/oap-server/analyzer/ai-agent-conversation/pom.xml
@@ -18,57 +18,57 @@
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
<parent>
- <artifactId>server-query-plugin</artifactId>
<groupId>org.apache.skywalking</groupId>
+ <artifactId>analyzer</artifactId>
<version>${revision}</version>
</parent>
- <modelVersion>4.0.0</modelVersion>
- <artifactId>query-graphql-plugin</artifactId>
+ <groupId>org.apache.skywalking</groupId>
+ <artifactId>ai-agent-conversation</artifactId>
+
+ <description>
+ AI agent conversations landed by the AI Sessionizer: the LAL output builder that verifies and stores
+ Session Data and Session Flow files, and the read side that folds a conversation's rounds into one
+ asz.view document.
+ </description>
<dependencies>
<dependency>
<groupId>org.apache.skywalking</groupId>
- <artifactId>server-core</artifactId>
+ <artifactId>library-module</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
- <artifactId>server-health-checker</artifactId>
+ <artifactId>telemetry-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
- <artifactId>log-analyzer</artifactId>
+ <artifactId>server-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
- <artifactId>mqe-rt</artifactId>
+ <artifactId>log-analyzer</artifactId>
<version>${project.version}</version>
+ <scope>test</scope>
</dependency>
<dependency>
- <groupId>com.graphql-java</groupId>
- <artifactId>graphql-java</artifactId>
- </dependency>
- <dependency>
- <groupId>com.graphql-java-kickstart</groupId>
- <artifactId>graphql-java-tools</artifactId>
- </dependency>
- <dependency>
- <groupId>com.graphql-java</groupId>
- <artifactId>graphql-java-extended-scalars</artifactId>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>com.linecorp.armeria</groupId>
- <artifactId>armeria-graphql</artifactId>
+ <artifactId>armeria-junit5</artifactId>
+ <scope>test</scope>
</dependency>
-
<dependency>
- <groupId>org.apache.skywalking</groupId>
- <artifactId>library-kubernetes-support</artifactId>
- <version>${project.version}</version>
+ <groupId>org.yaml</groupId>
+ <artifactId>snakeyaml</artifactId>
</dependency>
</dependencies>
</project>
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationConfig.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationConfig.java
new file mode 100644
index 0000000000..0a33a68e58
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationConfig.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.library.module.ModuleConfig;
+
+@Getter
+@Setter
+public class AIAgentConversationConfig extends ModuleConfig {
+ /**
+ * How many Session Data files one storage read fetches. Files are cut at 2 MiB, so a window is a few tens of
+ * megabytes; the window times {@link #maxFileBytes} bounds what one read can answer with, and
+ * {@link #maxResponseBytes} must cover it.
+ */
+ private int fileReadWindow = 16;
+ /**
+ * How many Session Flow rounds one storage read fetches. A round is cut at 2 MiB by the Sessionizer, and the
+ * same bound as for files applies.
+ */
+ private int roundReadWindow = 16;
+ /**
+ * The most bytes one window read may answer with, applied to that read alone on a storage that caps a
+ * response per call: BanyanDB's client holds every other read to 50 MB. 100 MiB by default, above sixteen
+ * files at the 2 MiB cut with room for files landed whole; a root of larger files needs it raised or the
+ * windows lowered, so that the window times {@link #maxFileBytes} stays under it.
+ */
+ private int maxResponseBytes = 100 * 1024 * 1024;
+ /**
+ * How long one conversation view request may take, in seconds, in place of the HTTP server's default of
+ * ten: the floor is the storage read of every landed file plus the fold and the render, which is seconds
+ * for a conversation of a hundred megabytes.
+ */
+ private int viewRequestTimeout = 120;
+ /**
+ * The most rounds one list query reads before folding to one row per conversation, and the ceiling of the
+ * query's own limit argument.
+ */
+ private int maxListLimit = 10000;
+ /**
+ * The largest file stored, in bytes; a larger one is rejected at ingest and counted under the reason
+ * <code>size</code>. Below BanyanDB's 16 MiB gRPC message limit, because one file over the limit fails the
+ * storage write it travels in, and every record behind it in that write is lost with it. The Sessionizer cuts files and rounds at 2 MiB; a round from before that cut can be larger.
+ */
+ private int maxFileBytes = 15 * 1024 * 1024;
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationModule.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationModule.java
new file mode 100644
index 0000000000..13163784d8
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationModule.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.IConversationQueryService;
+import org.apache.skywalking.oap.server.library.module.ModuleDefine;
+
+/**
+ * Conversations of long-lived AI agents, landed by the AI Sessionizer as Session Data (<code>.sd</code>) and
+ * Session Flow (<code>.sf</code>) files over OTLP logs under the <code>AI_AGENT</code> layer.
+ *
+ * <p>Ingest is a LAL output builder, {@code ConversationFile}, that the bundled <code>lal/ai-agent.yaml</code>
+ * rule names; it verifies each file and stores it verbatim. The read side folds a conversation's rounds and
+ * resolves every reference into the landed records, answering with one <code>asz.view</code> document.
+ */
+public class AIAgentConversationModule extends ModuleDefine {
+ public static final String NAME = "ai-agent-conversation";
+
+ public AIAgentConversationModule() {
+ super(NAME);
+ }
+
+ @Override
+ public Class[] services() {
+ return new Class[] {
+ IConversationQueryService.class
+ };
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationProvider.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationProvider.java
new file mode 100644
index 0000000000..c5a737ae7e
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationProvider.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import com.linecorp.armeria.common.HttpMethod;
+import java.time.Duration;
+import java.util.Collections;
+import org.apache.skywalking.oap.server.ai.agent.conversation.ingest.ConversationFileBuilder;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.ConversationQueryService;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.IConversationQueryService;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.http.ConversationViewHandler;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.server.HTTPHandlerRegister;
+import org.apache.skywalking.oap.server.core.storage.StorageModule;
+import org.apache.skywalking.oap.server.library.module.ModuleConfig;
+import org.apache.skywalking.oap.server.library.module.ModuleDefine;
+import org.apache.skywalking.oap.server.library.module.ModuleProvider;
+import org.apache.skywalking.oap.server.library.module.ModuleStartException;
+import org.apache.skywalking.oap.server.library.module.ServiceNotProvidedException;
+import org.apache.skywalking.oap.server.telemetry.TelemetryModule;
+import org.apache.skywalking.oap.server.telemetry.api.MetricsCreator;
+import org.apache.skywalking.oap.server.telemetry.api.MetricsTag;
+
+public class AIAgentConversationProvider extends ModuleProvider {
+ private AIAgentConversationConfig config = new AIAgentConversationConfig();
+ private ConversationQueryService queryService;
+
+ @Override
+ public String name() {
+ return "default";
+ }
+
+ @Override
+ public Class<? extends ModuleDefine> module() {
+ return AIAgentConversationModule.class;
+ }
+
+ @Override
+ public ConfigCreator<? extends ModuleConfig> newConfigCreator() {
+ return new ConfigCreator<AIAgentConversationConfig>() {
+ @Override
+ public Class<AIAgentConversationConfig> type() {
+ return AIAgentConversationConfig.class;
+ }
+
+ @Override
+ public void onInitialized(final AIAgentConversationConfig initialized) {
+ config = initialized;
+ }
+ };
+ }
+
+ @Override
+ public void prepare() throws ServiceNotProvidedException, ModuleStartException {
+ if (config.getFileReadWindow() <= 0) {
+ throw new ModuleStartException("fileReadWindow should be greater than 0");
+ }
+ if (config.getRoundReadWindow() <= 0) {
+ throw new ModuleStartException("roundReadWindow should be greater than 0");
+ }
+ if (config.getMaxListLimit() <= 0) {
+ throw new ModuleStartException("maxListLimit should be greater than 0");
+ }
+ if (config.getViewRequestTimeout() <= 0) {
+ throw new ModuleStartException("viewRequestTimeout should be greater than 0");
+ }
+ if (config.getMaxFileBytes() <= 0) {
+ throw new ModuleStartException("maxFileBytes should be greater than 0");
+ }
+ if (config.getMaxResponseBytes() <= 0) {
+ throw new ModuleStartException("maxResponseBytes should be greater than 0");
+ }
+ queryService = new ConversationQueryService(getManager(), config);
+ registerServiceImplementation(IConversationQueryService.class, queryService);
+ }
+
+ @Override
+ public void start() throws ServiceNotProvidedException, ModuleStartException {
+ getManager().find(CoreModule.NAME)
+ .provider()
+ .getService(HTTPHandlerRegister.class)
+ .addHandler(
+ new ConversationViewHandler(queryService, Duration.ofSeconds(config.getViewRequestTimeout())),
+ Collections.singletonList(HttpMethod.GET));
+ final MetricsCreator metricsCreator = getManager().find(TelemetryModule.NAME)
+ .provider()
+ .getService(MetricsCreator.class);
+ ConversationFileBuilder.setMaxFileBytes(config.getMaxFileBytes());
+ ConversationFileBuilder.setMetrics(
+ metricsCreator.createCounter(
+ "ai_agent_conversation_files_accepted", "AI agent conversation files verified and stored",
+ new MetricsTag.Keys("format"), new MetricsTag.Values("sd")),
+ metricsCreator.createCounter(
+ "ai_agent_conversation_files_accepted", "AI agent conversation files verified and stored",
+ new MetricsTag.Keys("format"), new MetricsTag.Values("sf")),
+ metricsCreator.createCounter(
+ "ai_agent_conversation_files_rejected", "AI agent conversation files rejected at ingest",
+ new MetricsTag.Keys("reason"), new MetricsTag.Values("digest")),
+ metricsCreator.createCounter(
+ "ai_agent_conversation_files_rejected", "AI agent conversation files rejected at ingest",
+ new MetricsTag.Keys("reason"), new MetricsTag.Values("lines")),
+ metricsCreator.createCounter(
+ "ai_agent_conversation_files_rejected", "AI agent conversation files rejected at ingest",
+ new MetricsTag.Keys("reason"), new MetricsTag.Values("attributes")),
+ metricsCreator.createCounter(
+ "ai_agent_conversation_files_rejected", "AI agent conversation files rejected at ingest",
+ new MetricsTag.Keys("reason"), new MetricsTag.Values("size"))
+ );
+ }
+
+ @Override
+ public void notifyAfterCompleted() throws ServiceNotProvidedException, ModuleStartException {
+ }
+
+ @Override
+ public String[] requiredModules() {
+ return new String[] {
+ CoreModule.NAME,
+ StorageModule.NAME,
+ TelemetryModule.NAME
+ };
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/fold/ConversationFold.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/fold/ConversationFold.java
new file mode 100644
index 0000000000..8fcd6e44da
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/fold/ConversationFold.java
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.fold;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import lombok.Getter;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Ref;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
+
+/**
+ * The fold of a conversation's rounds, as the Sessionizer's <code>sessionflow.View</code> folds them: entities
+ * keyed by id, a higher revision replaces a lower one, a tombstone removes, absence means unchanged.
+ *
+ * <p>Rounds must fold in order and each must name the previous round's commit digest. A gap in the stored
+ * rounds is bridged by {@link #applyAfterGap}: what the absent rounds carried stays as it was, and the document
+ * names them, so a viewer sees as much as landed.
+ */
+@Getter
+public final class ConversationFold {
+ private final Map<String, SessionFlowRound.Node> nodes = new LinkedHashMap<>();
+ private final Map<String, SessionFlowRound.Relation> relations = new LinkedHashMap<>();
+ private final Map<String, SessionFlowRound.Unresolved> unresolved = new LinkedHashMap<>();
+ private String conversation;
+ private String session;
+ private String parser;
+ private String policy;
+ private long round;
+ private String digest;
+ private long throughSeq;
+ private String inputDigest;
+ private String sessionFromTime;
+ private String sessionThroughTime;
+ private Map<String, List<SessionFlowRound.Node>> kids;
+ private Map<String, List<SessionFlowRound.Relation>> from;
+ private Map<String, List<SessionFlowRound.Relation>> to;
+
+ /**
+ * Fold one round on top of the current head, or refuse it the way the Sessionizer's <code>Apply</code>
+ * does: a round out of order, naming another head, or from another conversation, session, parser or policy
+ * is not merged, and the reason is returned in the Sessionizer's words for the document to carry.
+ *
+ * @param r the next round of the chain
+ * @return null when the round was folded, else why it was refused
+ */
+ @Nullable
+ public String apply(final SessionFlowRound r) {
+ final SessionFlowRound.Header h = r.getHeader();
+ if (h.getRound() != round + 1) {
+ return "sessionflow: cannot apply round " + h.getRound() + " to a view at round " + round
+ + ": rounds must fold in order";
+ }
+ if (round > 0 && !nullToEmpty(h.getPrevious()).equals(nullToEmpty(digest))) {
+ return "sessionflow: round " + h.getRound() + " names previous \"" + nullToEmpty(h.getPrevious())
+ + "\", but the view's head is \"" + nullToEmpty(digest) + "\"";
+ }
+ if (round == 0) {
+ conversation = h.getConversation();
+ session = h.getSession();
+ parser = h.getParser();
+ policy = h.getPolicy();
+ } else {
+ if (!nullToEmpty(conversation).equals(nullToEmpty(h.getConversation()))) {
+ return "sessionflow: round " + h.getRound() + " belongs to conversation \"" + nullToEmpty(h.getConversation())
+ + "\", the chain to \"" + nullToEmpty(conversation) + "\"";
+ }
+ if (!nullToEmpty(session).equals(nullToEmpty(h.getSession()))) {
+ return "sessionflow: round " + h.getRound() + " carries session \"" + nullToEmpty(h.getSession())
+ + "\", the chain \"" + nullToEmpty(session) + "\"";
+ }
+ if (!nullToEmpty(parser).equals(nullToEmpty(h.getParser()))) {
+ return "sessionflow: round " + h.getRound() + " was produced by parser \"" + nullToEmpty(h.getParser())
+ + "\", the chain by \"" + nullToEmpty(parser) + "\"; a chain is one interpretation";
+ }
+ if (!nullToEmpty(policy).equals(nullToEmpty(h.getPolicy()))) {
+ return "sessionflow: round " + h.getRound() + " was produced under policy \"" + nullToEmpty(h.getPolicy())
+ + "\", the chain under \"" + nullToEmpty(policy) + "\"";
+ }
+ }
+ for (final SessionFlowRound.Node n : r.getNodes()) {
+ if (n.isTombstone()) {
+ nodes.remove(n.getId());
+ } else {
+ nodes.put(n.getId(), n);
+ }
+ }
+ for (final SessionFlowRound.Relation rel : r.getRelations()) {
+ if (rel.isTombstone()) {
+ relations.remove(rel.getId());
+ } else {
+ relations.put(rel.getId(), rel);
+ }
+ }
+ for (final SessionFlowRound.Unresolved u : r.getUnresolved()) {
+ if (u.isTombstone()) {
+ unresolved.remove(u.getId());
+ } else {
+ unresolved.put(u.getId(), u);
+ }
+ }
+ kids = null;
+ from = null;
+ to = null;
+ round = h.getRound();
+ digest = r.getCommitDigest();
+ throughSeq = h.getThroughSeq();
+ inputDigest = h.getInputDigest();
+ // the session's range is what the newest round says it is; a round from before the header carried it,
+ // or one built by hand, leaves what the rounds before it said, so a read bounded by the range stays bounded
+ if (!nullToEmpty(h.getSessionFromTime()).isEmpty()) {
+ sessionFromTime = h.getSessionFromTime();
+ }
+ if (!nullToEmpty(h.getSessionThroughTime()).isEmpty()) {
+ sessionThroughTime = h.getSessionThroughTime();
+ }
+ return null;
+ }
+
+ /**
+ * Fold a stored round that follows a gap: the rounds between the head and it are absent, so its previous
+ * digest is taken on trust and what the absent rounds carried stays as it was. When nothing folded before it,
+ * the conversation's identity is read from its header, as {@link #apply} reads it from round 1. It is refused
+ * for the same reasons as {@link #apply}, and then the head stays where it was.
+ *
+ * @param r the round the chain resumes at
+ * @return null when the round was folded, else why it was refused
+ */
+ @Nullable
+ public String applyAfterGap(final SessionFlowRound r) {
+ final SessionFlowRound.Header h = r.getHeader();
+ final long headRound = round;
+ final String headDigest = digest;
+ final boolean first = round == 0;
+ if (first) {
+ conversation = h.getConversation();
+ session = h.getSession();
+ parser = h.getParser();
+ policy = h.getPolicy();
+ }
+ round = h.getRound() - 1;
+ digest = h.getPrevious();
+ final String refused = apply(r);
+ if (refused != null) {
+ round = headRound;
+ digest = headDigest;
+ if (first) {
+ conversation = null;
+ session = null;
+ parser = null;
+ policy = null;
+ }
+ }
+ return refused;
+ }
+
+ private static String nullToEmpty(@Nullable final String s) {
+ return s == null ? "" : s;
+ }
+
+ /**
+ * @param id a node id
+ * @return the node's children in record order
+ */
+ public List<SessionFlowRound.Node> children(final String id) {
+ index();
+ return kids.getOrDefault(id, Collections.emptyList());
+ }
+
+ public List<SessionFlowRound.Relation> relationsFrom(final String id) {
+ index();
+ return from.getOrDefault(id, Collections.emptyList());
+ }
+
+ public List<SessionFlowRound.Relation> relationsTo(final String id) {
+ index();
+ return to.getOrDefault(id, Collections.emptyList());
+ }
+
+ @Nullable
+ public SessionFlowRound.Node node(final String id) {
+ return nodes.get(id);
+ }
+
+ /**
+ * @param kind a node kind
+ * @return the nodes of that kind, in record order
+ */
+ public List<SessionFlowRound.Node> nodesOfKind(final String kind) {
+ final List<SessionFlowRound.Node> out = new ArrayList<>();
+ for (final SessionFlowRound.Node n : nodes.values()) {
+ if (kind.equals(n.getKind())) {
+ out.add(n);
+ }
+ }
+ return inOrder(out);
+ }
+
+ /**
+ * @return the open unresolved references, by id
+ */
+ public List<SessionFlowRound.Unresolved> openUnresolved() {
+ final List<SessionFlowRound.Unresolved> out = new ArrayList<>();
+ for (final SessionFlowRound.Unresolved u : unresolved.values()) {
+ if ("open".equals(u.getState())) {
+ out.add(u);
+ }
+ }
+ out.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId));
+ return out;
+ }
+
+ /**
+ * Record order: by the record a node stands on, then by id; a positioned node before one without a
+ * reference.
+ *
+ * @param nodes the nodes
+ * @return a sorted copy
+ */
+ public static List<SessionFlowRound.Node> inOrder(final List<SessionFlowRound.Node> nodes) {
+ final List<SessionFlowRound.Node> out = new ArrayList<>(nodes);
+ out.sort(ConversationFold::compare);
+ return out;
+ }
+
+ public static int compare(final SessionFlowRound.Node a, final SessionFlowRound.Node b) {
+ final Ref ap = a.getRef();
+ final Ref bp = b.getRef();
+ if (ap != null && bp != null) {
+ if (ap.getSeq() != bp.getSeq()) {
+ return Long.compare(ap.getSeq(), bp.getSeq());
+ }
+ if (ap.getRow() != bp.getRow()) {
+ return Long.compare(ap.getRow(), bp.getRow());
+ }
+ if (ap.getBlock() != null && bp.getBlock() != null && !ap.getBlock().equals(bp.getBlock())) {
+ return Integer.compare(ap.getBlock(), bp.getBlock());
+ }
+ } else if (ap != null) {
+ return -1;
+ } else if (bp != null) {
+ return 1;
+ }
+ return a.getId().compareTo(b.getId());
+ }
+
+ private void index() {
+ if (kids != null) {
+ return;
+ }
+ final Map<String, List<SessionFlowRound.Node>> k = new HashMap<>();
+ for (final SessionFlowRound.Node n : nodes.values()) {
+ if (n.getParent() != null && !n.getParent().isEmpty()) {
+ k.computeIfAbsent(n.getParent(), x -> new ArrayList<>()).add(n);
+ }
+ }
+ for (final List<SessionFlowRound.Node> list : k.values()) {
+ list.sort(ConversationFold::compare);
+ }
+ final Map<String, List<SessionFlowRound.Relation>> f = new HashMap<>();
+ final Map<String, List<SessionFlowRound.Relation>> t = new HashMap<>();
+ for (final SessionFlowRound.Relation r : relations.values()) {
+ f.computeIfAbsent(r.getFrom(), x -> new ArrayList<>()).add(r);
+ t.computeIfAbsent(r.getTo(), x -> new ArrayList<>()).add(r);
+ }
+ kids = k;
+ from = f;
+ to = t;
+ }
+
+ private static String first12(final String s) {
+ return s == null ? "" : s.substring(0, Math.min(12, s.length()));
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Digests.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Digests.java
new file mode 100644
index 0000000000..29a70bb193
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Digests.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * The digests the Sessionizer writes, reproduced so a stored file verifies and a chain checks.
+ */
+public final class Digests {
+ private static final char[] HEX = "0123456789abcdef".toCharArray();
+
+ private Digests() {
+ }
+
+ /**
+ * @param bytes the bytes
+ * @return lowercase hex sha256 of the bytes, the file digest on the wire and on every closing line
+ */
+ public static String sha256Hex(final byte[] bytes) {
+ return hex(sha256().digest(bytes));
+ }
+
+ /**
+ * A round's <code>input_digest</code>: the previous round's input digest hashed with the digests of the files
+ * the round newly consumed, sorted, each preceded by a zero byte. Empty previous for round 1.
+ *
+ * @param previous the previous round's input digest, or empty
+ * @param added the digests of the files in the round's window
+ * @return the chained digest
+ */
+ public static String chainInputDigest(final String previous, final List<String> added) {
+ final List<String> sorted = new ArrayList<>(added);
+ Collections.sort(sorted);
+ final MessageDigest md = sha256();
+ md.update(previous.getBytes(StandardCharsets.UTF_8));
+ for (final String digest : sorted) {
+ md.update((byte) 0);
+ md.update(digest.getBytes(StandardCharsets.UTF_8));
+ }
+ return hex(md.digest());
+ }
+
+ /**
+ * @return a fresh sha256 digest
+ */
+ public static MessageDigest sha256() {
+ try {
+ return MessageDigest.getInstance("SHA-256");
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is not available", e);
+ }
+ }
+
+ /**
+ * @param digest the digest bytes
+ * @return lowercase hex
+ */
+ public static String hex(final byte[] digest) {
+ final char[] out = new char[digest.length * 2];
+ for (int i = 0; i < digest.length; i++) {
+ final int b = digest[i] & 0xff;
+ out[i * 2] = HEX[b >>> 4];
+ out[i * 2 + 1] = HEX[b & 0x0f];
+ }
+ return new String(out);
+ }
+
+ /**
+ * @param body the file bytes
+ * @return how many lines the body has, counting newline terminators, the way <code>asz.lines</code> counts
+ */
+ public static int countLines(final byte[] body) {
+ int n = 0;
+ for (final byte b : body) {
+ if (b == '\n') {
+ n++;
+ }
+ }
+ return n;
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/FileNames.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/FileNames.java
new file mode 100644
index 0000000000..d2c795a8f1
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/FileNames.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+/**
+ * A landed file's name follows the storage-root layout from its header line, so the name is never stored and is
+ * derived on read, and a name given to a query is parsed back into what it encodes: a session and a seq, or a
+ * round.
+ *
+ * <pre>
+ * <session>/streams/<stream>/transcript-<stamp>-<seq>.sd
+ * <session>/streams/<stream>/meta-<stamp>-<seq>.sd
+ * <session>/runs/<run>/journal-<stamp>-<seq>.sd
+ * <session>/runs/<run>/manifest-<stamp>-<seq>.sd
+ * <session>/runs/<run>/script-<stamp>-<seq>.sd
+ * _conversations/<conversation>/rounds/r<round>-<digest12>.sf
+ * </pre>
+ */
+public final class FileNames {
+ private static final Pattern DATA_FILE =
+ Pattern.compile("^(?<session>[^/]+)/(streams|runs)/[^/]+/[a-z]+-[^/-]+-(?<seq>\\d{6,})\\.sd$");
+ private static final Pattern ROUND_FILE =
+ Pattern.compile("^_conversations/(?<conversation>[^/]+)/rounds/r(?<round>\\d{6,})-[0-9a-f]+\\.sf$");
+
+ private FileNames() {
+ }
+
+ /**
+ * @param header the header line of a Session Data file
+ * @return the file's relative path in the storage root
+ */
+ public static String dataFile(final SessionDataFile.Header header) {
+ final String prefix;
+ final String dir;
+ switch (header.getKind() == null ? "" : header.getKind()) {
+ case "transcript":
+ prefix = "transcript";
+ dir = "streams/" + header.getStream();
+ break;
+ case "agent_meta":
+ prefix = "meta";
+ dir = "streams/" + header.getStream();
+ break;
+ case "journal":
+ prefix = "journal";
+ dir = "runs/" + header.getBatch();
+ break;
+ case "workflow_manifest":
+ prefix = "manifest";
+ dir = "runs/" + header.getBatch();
+ break;
+ case "workflow_script":
+ prefix = "script";
+ dir = "runs/" + header.getBatch();
+ break;
+ default:
+ prefix = header.getKind() == null ? "file" : header.getKind();
+ dir = StringUtil.isNotEmpty(header.getStream())
+ ? "streams/" + header.getStream()
+ : "runs/" + header.getBatch();
+ break;
+ }
+ final String stamp = Times.fileStamp(header.getAt());
+ return header.getSession() + "/" + dir + "/" + prefix + "-" + (stamp == null ? "unknown" : stamp)
+ + "-" + String.format(Locale.ROOT, "%06d", header.getSeq()) + ".sd";
+ }
+
+ /**
+ * @param conversation the conversation
+ * @param round the round number
+ * @param commitDigest the round's commit digest
+ * @return the round file's relative path in the storage root
+ */
+ public static String roundFile(final String conversation, final long round, final String commitDigest) {
+ final String digest12 = commitDigest == null ? "" : commitDigest.substring(0, Math.min(12, commitDigest.length()));
+ return "_conversations/" + conversation + "/rounds/r" + String.format(Locale.ROOT, "%06d", round)
+ + "-" + digest12 + ".sf";
+ }
+
+ /**
+ * @param id a file id as returned by the raw-files query
+ * @return what the id names, or null when it is not a landed file or round name
+ */
+ @Nullable
+ public static Parsed parse(final String id) {
+ if (StringUtil.isEmpty(id)) {
+ return null;
+ }
+ final Matcher data = DATA_FILE.matcher(id);
+ if (data.matches()) {
+ return new Parsed(data.group("session"), Long.parseLong(data.group("seq")), null, -1);
+ }
+ final Matcher round = ROUND_FILE.matcher(id);
+ if (round.matches()) {
+ return new Parsed(null, -1, round.group("conversation"), Long.parseLong(round.group("round")));
+ }
+ return null;
+ }
+
+ @Getter
+ @RequiredArgsConstructor
+ public static final class Parsed {
+ private final String session;
+ private final long seq;
+ private final String conversation;
+ private final long round;
+
+ public boolean isDataFile() {
+ return session != null;
+ }
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJson.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJson.java
new file mode 100644
index 0000000000..2fe9e9d0d6
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJson.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * Finds the raw text of each part's <code>data</code> value in one record line, so a part's data is rendered
+ * as the Sessionizer wrote it. Go keeps that value as <code>json.RawMessage</code> and prints it verbatim, escapes,
+ * key order and spacing included; a value that was parsed and printed again would differ in all three and break
+ * the document's equality with the Sessionizer's, and clip at another byte.
+ *
+ * <p>This is a walk over the line's JSON syntax, not a parser: it only needs the start and the end of each
+ * value, and it leaves the decoding to Gson, which has already accepted the line.
+ */
+final class RawJson {
+ private final String text;
+ private int pos;
+
+ private RawJson(final String text) {
+ this.text = text;
+ }
+
+ /**
+ * @param line one record line, a JSON object
+ * @return the raw <code>data</code> text of each element of the line's <code>parts</code> array, in order,
+ * a literal null included, null for a part without one; empty when the line has no parts or is not the JSON
+ * expected
+ */
+ static List<String> partData(final String line) {
+ try {
+ return new RawJson(line).parts();
+ } catch (final IllegalStateException | StringIndexOutOfBoundsException e) {
+ return new ArrayList<>();
+ }
+ }
+
+ private List<String> parts() {
+ final List<String> out = new ArrayList<>();
+ skipSpace();
+ expect('{');
+ while (true) {
+ skipSpace();
+ if (peek() == '}') {
+ return out;
+ }
+ final String key = string();
+ skipSpace();
+ expect(':');
+ skipSpace();
+ if ("parts".equals(key) && peek() == '[') {
+ pos++;
+ while (true) {
+ skipSpace();
+ if (peek() == ']') {
+ pos++;
+ break;
+ }
+ out.add(partDataOf());
+ skipSpace();
+ if (peek() == ',') {
+ pos++;
+ }
+ }
+ } else {
+ skipValue();
+ }
+ skipSpace();
+ if (peek() == ',') {
+ pos++;
+ }
+ }
+ }
+
+ /**
+ * @return the raw <code>data</code> of the part object at the cursor, or null; the cursor ends after the object
+ */
+ @Nullable
+ private String partDataOf() {
+ String data = null;
+ expect('{');
+ while (true) {
+ skipSpace();
+ if (peek() == '}') {
+ pos++;
+ return data;
+ }
+ final String key = string();
+ skipSpace();
+ expect(':');
+ skipSpace();
+ final int start = pos;
+ skipValue();
+ if ("data".equals(key)) {
+ data = text.substring(start, pos);
+ }
+ skipSpace();
+ if (peek() == ',') {
+ pos++;
+ }
+ }
+ }
+
+ private void skipValue() {
+ final char c = peek();
+ if (c == '"') {
+ string();
+ } else if (c == '{' || c == '[') {
+ final char close = c == '{' ? '}' : ']';
+ pos++;
+ while (true) {
+ skipSpace();
+ if (peek() == close) {
+ pos++;
+ return;
+ }
+ if (c == '{') {
+ string();
+ skipSpace();
+ expect(':');
+ skipSpace();
+ }
+ skipValue();
+ skipSpace();
+ if (peek() == ',') {
+ pos++;
+ }
+ }
+ } else {
+ while (pos < text.length() && ",}] \t\r\n".indexOf(text.charAt(pos)) < 0) {
+ pos++;
+ }
+ }
+ }
+
+ /**
+ * @return the decoded text of the string at the cursor, escapes resolved only as far as a key needs them
+ */
+ private String string() {
+ expect('"');
+ final StringBuilder out = new StringBuilder();
+ while (true) {
+ final char c = text.charAt(pos++);
+ if (c == '"') {
+ return out.toString();
+ }
+ if (c == '\\') {
+ final char e = text.charAt(pos++);
+ if (e == 'u') {
+ out.append((char) Integer.parseInt(text.substring(pos, pos + 4), 16));
+ pos += 4;
+ } else {
+ out.append(e);
+ }
+ } else {
+ out.append(c);
+ }
+ }
+ }
+
+ private void skipSpace() {
+ while (pos < text.length() && " \t\r\n".indexOf(text.charAt(pos)) >= 0) {
+ pos++;
+ }
+ }
+
+ private char peek() {
+ return text.charAt(pos);
+ }
+
+ private void expect(final char c) {
+ if (text.charAt(pos) != c) {
+ throw new IllegalStateException("expected " + c + " at " + pos);
+ }
+ pos++;
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java
new file mode 100644
index 0000000000..df8676cf47
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import com.google.gson.JsonObject;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * A reference into the landed records: the file by <code>seq</code>, the line by <code>row</code>, and, when the
+ * node stands on one part of a record, the part by <code>block</code>.
+ */
+@Getter
+@RequiredArgsConstructor
+@EqualsAndHashCode
+public final class Ref {
+ private final long seq;
+ private final long row;
+ @Nullable
+ private final Integer block;
+
+ @Nullable
+ public static Ref of(@Nullable final JsonObject json) {
+ if (json == null) {
+ return null;
+ }
+ return new Ref(
+ json.get("seq").getAsLong(),
+ json.get("row").getAsLong(),
+ json.has("block") && !json.get("block").isJsonNull() ? json.get("block").getAsInt() : null
+ );
+ }
+
+ /**
+ * @return the reference as a view map, in the order the format page lists the keys
+ */
+ public Map<String, Object> toMap() {
+ final Map<String, Object> out = new LinkedHashMap<>();
+ out.put("seq", seq);
+ out.put("row", row);
+ if (block != null) {
+ out.put("block", block);
+ }
+ return out;
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java
new file mode 100644
index 0000000000..41a4a68f8d
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java
@@ -0,0 +1,332 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+import lombok.Getter;
+
+/**
+ * One Session Data (<code>.sd</code>) file, decoded from its stored bytes: the header line, the records one per
+ * line in source order, and the closing line with the count and the digest.
+ *
+ * <p>Row numbers are line numbers: the header is row 0, the first record is row 1, which is how a Session Flow
+ * reference addresses a record.
+ */
+@Getter
+public final class SessionDataFile {
+ private final Header header;
+ private final List<Record> records;
+ private final int declaredRecords;
+ private final String declaredDigest;
+ private final int lines;
+ private final int bytes;
+ /** sha256 of the whole file, the digest on the wire and the one a round's input digest chains. */
+ private final String fileDigest;
+ /** The earliest and the latest record time in the file, in milliseconds; 0 when no record carries a time. */
+ private final long fromTime;
+ private final long throughTime;
+
+ private SessionDataFile(final Header header, final List<Record> records, final int declaredRecords,
+ final String declaredDigest, final int lines, final int bytes,
+ final String fileDigest, final long fromTime, final long throughTime) {
+ this.header = header;
+ this.records = records;
+ this.declaredRecords = declaredRecords;
+ this.declaredDigest = declaredDigest;
+ this.lines = lines;
+ this.bytes = bytes;
+ this.fileDigest = fileDigest;
+ this.fromTime = fromTime;
+ this.throughTime = throughTime;
+ }
+
+ /**
+ * @param body the file bytes as stored
+ * @return the decoded file
+ * @throws IllegalArgumentException when the first line is not a Session Data header
+ */
+ public static SessionDataFile parse(final byte[] body) {
+ final String text = new String(body, StandardCharsets.UTF_8);
+ final String[] rawLines = text.split("\n", -1);
+ int lineCount = rawLines.length;
+ if (lineCount > 0 && rawLines[lineCount - 1].isEmpty()) {
+ lineCount--;
+ }
+ if (lineCount < 1) {
+ throw new IllegalArgumentException("empty Session Data file");
+ }
+ final JsonObject headerJson = JsonParser.parseString(rawLines[0]).getAsJsonObject();
+ if (!headerJson.has("h")) {
+ throw new IllegalArgumentException("the first line is not a Session Data header");
+ }
+ final Header header = new Header(headerJson);
+ final List<Record> records = new ArrayList<>(Math.max(0, lineCount - 2));
+ int declaredRecords = -1;
+ String declaredDigest = null;
+ long from = 0;
+ long through = 0;
+ // as the Sessionizer's reader: a header it would refuse yields no records, and an empty or undecodable
+ // line ends the records there, so a later row is never read and the rows stay contiguous
+ for (int i = 1; header.isValid() && i < lineCount; i++) {
+ final String line = rawLines[i];
+ if (line.isEmpty()) {
+ break;
+ }
+ final JsonObject json;
+ try {
+ json = JsonParser.parseString(line).getAsJsonObject();
+ } catch (final RuntimeException e) {
+ break;
+ }
+ if (i == lineCount - 1 && "end".equals(string(json, "t"))) {
+ declaredRecords = json.has("records") ? json.get("records").getAsInt() : -1;
+ declaredDigest = string(json, "digest");
+ break;
+ }
+ final Record record = new Record(i, line, json);
+ records.add(record);
+ final long time = record.getTime();
+ if (time != 0) {
+ if (from == 0 || time < from) {
+ from = time;
+ }
+ if (time > through) {
+ through = time;
+ }
+ }
+ }
+ return new SessionDataFile(
+ header, Collections.unmodifiableList(records), declaredRecords, declaredDigest,
+ Digests.countLines(body), body.length, Digests.sha256Hex(body), from, through);
+ }
+
+ /**
+ * @param row the line number, the header being row 0
+ * @return the record on that line, or null when there is none
+ */
+ @Nullable
+ public Record record(final long row) {
+ final int index = (int) row - 1;
+ if (index < 0 || index >= records.size()) {
+ return null;
+ }
+ return records.get(index);
+ }
+
+ @Nullable
+ static String string(final JsonObject json, final String key) {
+ final JsonElement element = json.get(key);
+ return element == null || element.isJsonNull() ? null : element.getAsString();
+ }
+
+ static long longOf(final JsonObject json, final String key) {
+ final JsonElement element = json.get(key);
+ return element == null || element.isJsonNull() ? 0L : element.getAsLong();
+ }
+
+ /**
+ * The header line: what the file is and where its records came from.
+ */
+ @Getter
+ public static final class Header {
+ private final JsonObject json;
+ private final String schema;
+ private final long seq;
+ private final String at;
+ private final String kind;
+ private final String adapter;
+ private final String dialect;
+ private final String src;
+ private final String session;
+ private final String stream;
+ private final String batch;
+
+ Header(final JsonObject json) {
+ this.json = json;
+ this.schema = string(json, "schema");
+ this.seq = longOf(json, "seq");
+ this.at = string(json, "at");
+ this.kind = string(json, "kind");
+ this.adapter = string(json, "adapter");
+ this.dialect = string(json, "dialect");
+ this.src = string(json, "src");
+ this.session = string(json, "session");
+ this.stream = string(json, "stream");
+ this.batch = string(json, "batch");
+ }
+
+ /**
+ * @return whether the Sessionizer's reader would open the file: the envelope version, the schema, the
+ * kind, the session, the source and the dialect are all there
+ */
+ public boolean isValid() {
+ return longOf(json, "h") == 1 && "sd/1".equals(schema) && kind != null && !kind.isEmpty()
+ && session != null && !session.isEmpty() && src != null && !src.isEmpty()
+ && dialect != null && !dialect.isEmpty();
+ }
+ }
+
+ /**
+ * One record, kept as its JSON so any field the viewer wants is one lookup away.
+ */
+ @Getter
+ public static final class Record {
+ private final int row;
+ private final JsonObject json;
+ private final String id;
+ /** When the runtime wrote it, in milliseconds; 0 when the record carries no time. */
+ private final long time;
+ /** The same moment in nanoseconds, the precision the Sessionizer computes intervals with. */
+ private final long timeNanos;
+ private final List<Part> parts;
+
+ Record(final int row, final String line, final JsonObject json) {
+ this.row = row;
+ this.json = json;
+ this.id = string(json, "id");
+ this.timeNanos = Times.nanos(string(json, "time"));
+ this.time = Math.floorDiv(timeNanos, 1_000_000L);
+ final List<Part> list = new ArrayList<>();
+ if (json.has("parts") && json.get("parts").isJsonArray()) {
+ // the raw data text of each part, as the Sessionizer wrote it; see RawJson
+ final List<String> raw = RawJson.partData(line);
+ int i = 0;
+ for (final JsonElement e : json.getAsJsonArray("parts")) {
+ list.add(new Part(e.getAsJsonObject(), i < raw.size() ? raw.get(i) : null));
+ i++;
+ }
+ }
+ this.parts = Collections.unmodifiableList(list);
+ }
+
+ /**
+ * @return the record's readable text: every <code>text</code> part joined by a newline, as the
+ * Sessionizer's <code>Record.Text()</code> returns it
+ */
+ public String text() {
+ final StringBuilder out = new StringBuilder();
+ for (final Part p : parts) {
+ if ("text".equals(p.getKind()) && p.getText() != null && !p.getText().isEmpty()) {
+ if (out.length() > 0) {
+ out.append('\n');
+ }
+ out.append(p.getText());
+ }
+ }
+ return out.toString();
+ }
+
+ /**
+ * @return the record's <code>flags</code>, empty when none
+ */
+ public List<String> flags() {
+ return strings("flags");
+ }
+
+ /**
+ * @return the record's <code>usage</code> object, or null
+ */
+ @Nullable
+ public JsonObject usage() {
+ final JsonElement u = json.get("usage");
+ return u != null && u.isJsonObject() ? u.getAsJsonObject() : null;
+ }
+
+ /**
+ * @return the record's <code>dropped</code> list, or null
+ */
+ @Nullable
+ public JsonArray dropped() {
+ final JsonElement d = json.get("dropped");
+ return d != null && d.isJsonArray() ? d.getAsJsonArray() : null;
+ }
+
+ /**
+ * @return the record's <code>child</code>, or null
+ */
+ @Nullable
+ public String child() {
+ return string(json, "child");
+ }
+
+ private List<String> strings(final String key) {
+ final JsonElement e = json.get(key);
+ if (e == null || !e.isJsonArray()) {
+ return Collections.emptyList();
+ }
+ final List<String> out = new ArrayList<>();
+ for (final JsonElement x : e.getAsJsonArray()) {
+ out.add(x.getAsString());
+ }
+ return out;
+ }
+ }
+
+ /**
+ * One piece of a record: readable text, a call, a result, or data kept whole.
+ */
+ @Getter
+ public static final class Part {
+ private final JsonObject json;
+ private final String kind;
+ private final String text;
+ private final String name;
+ private final String id;
+ private final String of;
+ private final String state;
+ private final int bytes;
+ private final Boolean failed;
+ private final String rawData;
+
+ Part(final JsonObject json, @Nullable final String rawData) {
+ this.json = json;
+ this.rawData = rawData;
+ this.kind = string(json, "k");
+ this.text = string(json, "text");
+ this.name = string(json, "name");
+ this.id = string(json, "id");
+ this.of = string(json, "of");
+ this.state = string(json, "state");
+ this.bytes = (int) longOf(json, "bytes");
+ final JsonElement f = json.get("failed");
+ this.failed = f == null || f.isJsonNull() ? null : f.getAsBoolean();
+ }
+
+ /**
+ * @return the part's <code>data</code> as the Sessionizer wrote it, or null when it has none
+ */
+ @Nullable
+ public String data() {
+ if (rawData != null) {
+ return rawData;
+ }
+ final JsonElement d = json.get("data");
+ // a literal null is kept as the text "null": Go reads it into a raw message and prints it
+ return d == null ? null : d.toString();
+ }
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java
new file mode 100644
index 0000000000..be245619c0
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java
@@ -0,0 +1,419 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import lombok.Getter;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+/**
+ * One Session Flow (<code>.sf</code>) round, decoded from its stored bytes: the header frame, the node, relation
+ * and unresolved frames, and the commit frame whose digest covers every line before it.
+ */
+@Getter
+public final class SessionFlowRound {
+ private final Header header;
+ private final List<Node> nodes;
+ private final List<Relation> relations;
+ private final List<Unresolved> unresolved;
+ /** The digest the commit frame claims. */
+ private final String commitDigest;
+ /** The digest computed over every line before the commit frame; equal to {@link #commitDigest} when intact. */
+ private final String computedDigest;
+ private final int lines;
+ private final int bytes;
+
+ private SessionFlowRound(final Header header, final List<Node> nodes, final List<Relation> relations,
+ final List<Unresolved> unresolved, final String commitDigest,
+ final String computedDigest, final int lines, final int bytes) {
+ this.header = header;
+ this.nodes = nodes;
+ this.relations = relations;
+ this.unresolved = unresolved;
+ this.commitDigest = commitDigest;
+ this.computedDigest = computedDigest;
+ this.lines = lines;
+ this.bytes = bytes;
+ }
+
+ /**
+ * @param body the round bytes as stored
+ * @return the decoded round
+ * @throws IllegalArgumentException when the first frame is not a header or the round has no commit frame
+ */
+ public static SessionFlowRound parse(final byte[] body) {
+ final String text = new String(body, StandardCharsets.UTF_8);
+ final String[] rawLines = text.split("\n", -1);
+ final MessageDigest hashed = Digests.sha256();
+ Header header = null;
+ final List<Node> nodes = new ArrayList<>();
+ final List<Relation> relations = new ArrayList<>();
+ final List<Unresolved> unresolved = new ArrayList<>();
+ String commitDigest = null;
+ JsonObject commitCounts = null;
+ boolean sawCommit = false;
+ // the checks the Sessionizer's own reader makes; a round that fails one is not a round
+ final Map<String, String> ids = new HashMap<>();
+ int lineNo = 0;
+ for (final String line : rawLines) {
+ lineNo++;
+ if (line.isEmpty()) {
+ continue;
+ }
+ final JsonObject json = JsonParser.parseString(line).getAsJsonObject();
+ final String t = SessionDataFile.string(json, "t");
+ if (t == null) {
+ throw new IllegalArgumentException("a Session Flow frame without a type");
+ }
+ if (sawCommit) {
+ throw new IllegalArgumentException("content after the commit frame");
+ }
+ switch (t) {
+ case "header":
+ if (header != null) {
+ throw new IllegalArgumentException("a second header");
+ }
+ header = new Header(json);
+ header.validate();
+ break;
+ case "node": {
+ final Node n = new Node(json);
+ claim(ids, lineNo, "node", n.getId());
+ checkRevision(lineNo, n.getRevision(), header);
+ checkRefs(lineNo, n.getRef(), n.getRefs(), header);
+ nodes.add(n);
+ break;
+ }
+ case "relation": {
+ final Relation rel = new Relation(json);
+ claim(ids, lineNo, "relation", rel.getId());
+ checkRevision(lineNo, rel.getRevision(), header);
+ if (!rel.isTombstone() && (StringUtil.isEmpty(rel.getFrom()) || StringUtil.isEmpty(rel.getTo())
+ || StringUtil.isEmpty(rel.getType()))) {
+ throw new IllegalArgumentException(
+ "line " + lineNo + ": relation " + rel.getId() + " is missing an endpoint or a type");
+ }
+ checkRefs(lineNo, null, rel.getEvidence(), header);
+ relations.add(rel);
+ break;
+ }
+ case "unresolved": {
+ final Unresolved u = new Unresolved(json);
+ claim(ids, lineNo, "unresolved", u.getId());
+ checkRevision(lineNo, u.getRevision(), header);
+ if (!u.isTombstone() && !"open".equals(u.getState()) && !"resolved".equals(u.getState())
+ && !"terminal".equals(u.getState())) {
+ throw new IllegalArgumentException(
+ "line " + lineNo + ": unresolved entry " + u.getId() + " has state " + u.getState());
+ }
+ unresolved.add(u);
+ break;
+ }
+ case "commit":
+ commitDigest = SessionDataFile.string(json, "digest");
+ commitCounts = json.has("counts") && json.get("counts").isJsonObject()
+ ? json.getAsJsonObject("counts") : null;
+ sawCommit = true;
+ continue;
+ default:
+ throw new IllegalArgumentException("unknown frame type " + t);
+ }
+ if (header == null) {
+ throw new IllegalArgumentException("the first frame is not a header");
+ }
+ hashed.update(line.getBytes(StandardCharsets.UTF_8));
+ hashed.update((byte) '\n');
+ }
+ if (header == null) {
+ throw new IllegalArgumentException("the round has no header");
+ }
+ if (!sawCommit) {
+ throw new IllegalArgumentException("the round has no commit frame; it is truncated");
+ }
+ // the commit frame is outside the digest, so its counts are what catches a tampered commit
+ final long claimedNodes = commitCounts == null ? 0 : SessionDataFile.longOf(commitCounts, "nodes");
+ final long claimedRelations = commitCounts == null ? 0 : SessionDataFile.longOf(commitCounts, "relations");
+ final long claimedUnresolved = commitCounts == null ? 0 : SessionDataFile.longOf(commitCounts, "unresolved");
+ if (claimedNodes != nodes.size() || claimedRelations != relations.size()
+ || claimedUnresolved != unresolved.size()) {
+ throw new IllegalArgumentException("counts mismatch: read nodes " + nodes.size() + " relations "
+ + relations.size() + " unresolved " + unresolved.size()
+ + ", round claims nodes " + claimedNodes + " relations "
+ + claimedRelations + " unresolved " + claimedUnresolved);
+ }
+ return new SessionFlowRound(
+ header, Collections.unmodifiableList(nodes), Collections.unmodifiableList(relations),
+ Collections.unmodifiableList(unresolved), commitDigest, Digests.hex(hashed.digest()),
+ Digests.countLines(body), body.length);
+ }
+
+ private static void claim(final Map<String, String> ids, final int line, final String kind, final String id) {
+ if (StringUtil.isEmpty(id)) {
+ throw new IllegalArgumentException("line " + line + ": " + kind + " frame has no id");
+ }
+ final String prev = ids.put(id, kind);
+ if (prev != null) {
+ throw new IllegalArgumentException(
+ "line " + line + ": id " + id + " appears twice in one round (as " + prev + " and " + kind + ")");
+ }
+ }
+
+ /**
+ * An entity names the round that produced it; one that names another was not produced by this round.
+ */
+ private static void checkRevision(final int line, final long revision, final Header header) {
+ if (revision != header.getRound()) {
+ throw new IllegalArgumentException("line " + line + ": revision " + revision + " in round " + header.getRound());
+ }
+ }
+
+ /**
+ * A reference past the range the header declares it read describes evidence the round did not claim to
+ * have seen, and its input digest does not cover it.
+ */
+ private static void checkRefs(final int line, @Nullable final Ref one, final List<Ref> many, final Header header) {
+ final List<Ref> all = new ArrayList<>();
+ if (one != null) {
+ all.add(one);
+ }
+ all.addAll(many);
+ for (final Ref r : all) {
+ if (r.getSeq() == 0 && r.getRow() == 0) {
+ throw new IllegalArgumentException("line " + line + ": a reference to seq 0 row 0 is not a position");
+ }
+ if (r.getSeq() > header.getThroughSeq()) {
+ throw new IllegalArgumentException("line " + line + ": reference to landed sequence " + r.getSeq()
+ + ", past the round's declared " + header.getThroughSeq());
+ }
+ }
+ }
+
+ public boolean isIntact() {
+ return commitDigest != null && commitDigest.equals(computedDigest);
+ }
+
+ /**
+ * The header frame: which chain the round belongs to, where it sits in it, and what it consumed.
+ */
+ @Getter
+ public static final class Header {
+ private final String schema;
+ private final String conversation;
+ private final String session;
+ private final long round;
+ private final String previous;
+ private final long fromSeq;
+ private final long throughSeq;
+ private final String inputDigest;
+ private final String parser;
+ private final String policy;
+ private final String fromTime;
+ private final String throughTime;
+ private final String sessionFromTime;
+ private final String sessionThroughTime;
+
+ Header(final JsonObject json) {
+ this.schema = SessionDataFile.string(json, "schema");
+ this.conversation = SessionDataFile.string(json, "conversation");
+ this.session = SessionDataFile.string(json, "session");
+ this.round = SessionDataFile.longOf(json, "round");
+ this.previous = SessionDataFile.string(json, "previous");
+ this.fromSeq = SessionDataFile.longOf(json, "from_seq");
+ this.throughSeq = SessionDataFile.longOf(json, "through_seq");
+ this.inputDigest = SessionDataFile.string(json, "input_digest");
+ this.parser = SessionDataFile.string(json, "parser");
+ this.policy = SessionDataFile.string(json, "policy");
+ this.fromTime = SessionDataFile.string(json, "from_time");
+ this.throughTime = SessionDataFile.string(json, "through_time");
+ this.sessionFromTime = SessionDataFile.string(json, "session_from_time");
+ this.sessionThroughTime = SessionDataFile.string(json, "session_through_time");
+ }
+
+ /**
+ * The header the Sessionizer's own reader would refuse: it cannot be acted on.
+ */
+ void validate() {
+ if (!"sf/1".equals(schema)) {
+ throw new IllegalArgumentException("unsupported schema " + schema + ", want sf/1");
+ }
+ if (StringUtil.isEmpty(conversation)) {
+ throw new IllegalArgumentException("header missing conversation");
+ }
+ if (StringUtil.isEmpty(session)) {
+ throw new IllegalArgumentException("header missing session");
+ }
+ if (round == 0) {
+ throw new IllegalArgumentException("round must count from 1");
+ }
+ if (round > 1 && StringUtil.isEmpty(previous)) {
+ throw new IllegalArgumentException("round " + round + " has no previous digest; the chain would be unverifiable");
+ }
+ if (round == 1 && StringUtil.isNotEmpty(previous)) {
+ throw new IllegalArgumentException("round 1 must not name a previous digest");
+ }
+ if (StringUtil.isEmpty(parser)) {
+ throw new IllegalArgumentException("header missing parser version");
+ }
+ if (StringUtil.isEmpty(policy)) {
+ throw new IllegalArgumentException("header missing policy version");
+ }
+ if (StringUtil.isEmpty(inputDigest)) {
+ throw new IllegalArgumentException("header missing input digest");
+ }
+ if (fromSeq == 0) {
+ throw new IllegalArgumentException("landed sequences count from 1, so from_seq must not be 0");
+ }
+ if (throughSeq < fromSeq - 1) {
+ throw new IllegalArgumentException("round " + round + " consumes sequences " + fromSeq + ".." + throughSeq + ", which is not a range");
+ }
+ }
+ }
+
+ /**
+ * What every entity frame has: an id, the revision that produced it, and whether it is a tombstone.
+ */
+ @Getter
+ public abstract static class Entity {
+ private final String id;
+ private final long revision;
+ private final boolean tombstone;
+
+ Entity(final JsonObject json) {
+ this.id = SessionDataFile.string(json, "id");
+ this.revision = SessionDataFile.longOf(json, "revision");
+ final JsonElement t = json.get("tombstone");
+ this.tombstone = t != null && !t.isJsonNull() && t.getAsBoolean();
+ }
+ }
+
+ @Getter
+ public static final class Node extends Entity {
+ private final String kind;
+ private final String parent;
+ private final String stream;
+ @Nullable
+ private final Ref ref;
+ private final List<Ref> refs;
+ @Nullable
+ /** The attrs as written, any JSON value, for rendering; null when the frame has none. */
+ private final JsonElement rawAttrs;
+ /** The attrs when they are an object, for lookups. */
+ private final JsonObject attrs;
+
+ Node(final JsonObject json) {
+ super(json);
+ this.kind = SessionDataFile.string(json, "kind");
+ this.parent = SessionDataFile.string(json, "parent");
+ this.stream = SessionDataFile.string(json, "stream");
+ this.ref = json.has("ref") && json.get("ref").isJsonObject() ? Ref.of(json.getAsJsonObject("ref")) : null;
+ final List<Ref> list = new ArrayList<>();
+ if (json.has("refs") && json.get("refs").isJsonArray()) {
+ for (final JsonElement e : json.getAsJsonArray("refs")) {
+ list.add(Ref.of(e.getAsJsonObject()));
+ }
+ }
+ this.refs = Collections.unmodifiableList(list);
+ this.rawAttrs = json.get("attrs");
+ this.attrs = rawAttrs != null && rawAttrs.isJsonObject() ? rawAttrs.getAsJsonObject() : null;
+ }
+
+ @Nullable
+ /**
+ * @return the attr when it is a string, as the Sessionizer's <code>attrString</code>; null for a number,
+ * an object or nothing
+ */
+ public String attr(final String key) {
+ if (attrs == null) {
+ return null;
+ }
+ final JsonElement e = attrs.get(key);
+ return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isString() ? e.getAsString() : null;
+ }
+
+ public double attrNumber(final String key) {
+ if (attrs == null) {
+ return 0;
+ }
+ final JsonElement e = attrs.get(key);
+ return e == null || e.isJsonNull() || !e.isJsonPrimitive() || !e.getAsJsonPrimitive().isNumber()
+ ? 0 : e.getAsDouble();
+ }
+
+ public boolean attrBool(final String key) {
+ if (attrs == null) {
+ return false;
+ }
+ final JsonElement e = attrs.get(key);
+ return e != null && !e.isJsonNull() && e.isJsonPrimitive() && e.getAsJsonPrimitive().isBoolean()
+ && e.getAsBoolean();
+ }
+ }
+
+ @Getter
+ public static final class Relation extends Entity {
+ private final String type;
+ private final String from;
+ private final String to;
+ private final String quality;
+ private final String via;
+ private final List<Ref> evidence;
+
+ Relation(final JsonObject json) {
+ super(json);
+ this.type = SessionDataFile.string(json, "type");
+ this.from = SessionDataFile.string(json, "from");
+ this.to = SessionDataFile.string(json, "to");
+ this.quality = SessionDataFile.string(json, "quality");
+ this.via = SessionDataFile.string(json, "via");
+ final List<Ref> list = new ArrayList<>();
+ if (json.has("evidence") && json.get("evidence").isJsonArray()) {
+ for (final JsonElement e : json.getAsJsonArray("evidence")) {
+ list.add(Ref.of(e.getAsJsonObject()));
+ }
+ }
+ this.evidence = Collections.unmodifiableList(list);
+ }
+ }
+
+ @Getter
+ public static final class Unresolved extends Entity {
+ private final String kind;
+ private final String ref;
+ private final String reason;
+ private final String state;
+
+ Unresolved(final JsonObject json) {
+ super(json);
+ this.kind = SessionDataFile.string(json, "kind");
+ this.ref = SessionDataFile.string(json, "ref");
+ this.reason = SessionDataFile.string(json, "reason");
+ this.state = SessionDataFile.string(json, "state");
+ }
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java
new file mode 100644
index 0000000000..b057f68c66
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import javax.annotation.Nullable;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+/**
+ * Session Data and Session Flow carry RFC 3339 strings, because their bytes are digested. A view carries unix
+ * milliseconds, because it is read and never digested.
+ */
+public final class Times {
+ /**
+ * The stamp in a landed file's name: <code>20260904T033423.840913000Z</code>, from the header's <code>at</code>.
+ */
+ private static final DateTimeFormatter FILE_STAMP =
+ DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss.nnnnnnnnn'Z'").withZone(ZoneOffset.UTC);
+
+ private Times() {
+ }
+
+ /**
+ * @param rfc3339 a time as the runtime wrote it, or null or empty
+ * @return unix milliseconds, or 0 when absent or unparseable
+ */
+ public static long millis(final String rfc3339) {
+ final Instant t = instant(rfc3339);
+ return t == null ? 0L : t.toEpochMilli();
+ }
+
+ /**
+ * @param rfc3339 a time as the Sessionizer writes it, with a zone offset or Z
+ * @return the time in nanoseconds since the epoch, the precision the Sessionizer computes with, or 0
+ */
+ public static long nanos(final String rfc3339) {
+ final Instant t = instant(rfc3339);
+ return t == null ? 0L : t.getEpochSecond() * 1_000_000_000L + t.getNano();
+ }
+
+ /**
+ * RFC 3339 as Go's <code>time.RFC3339Nano</code> reads it: an offset is accepted, not only Z.
+ */
+ @Nullable
+ private static Instant instant(@Nullable final String rfc3339) {
+ if (StringUtil.isEmpty(rfc3339)) {
+ return null;
+ }
+ try {
+ return OffsetDateTime.parse(rfc3339, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant();
+ } catch (final DateTimeParseException e) {
+ return null;
+ }
+ }
+
+ /**
+ * @param rfc3339 a header's <code>at</code>
+ * @return the stamp used in the landed file's name, or null when absent or unparseable
+ */
+ public static String fileStamp(final String rfc3339) {
+ if (StringUtil.isEmpty(rfc3339)) {
+ return null;
+ }
+ final Instant t = instant(rfc3339);
+ return t == null ? null : FILE_STAMP.format(t);
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/ingest/ConversationFileBuilder.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/ingest/ConversationFileBuilder.java
new file mode 100644
index 0000000000..80f0d3f1a3
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/ingest/ConversationFileBuilder.java
@@ -0,0 +1,336 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.ingest;
+
+import com.google.gson.JsonObject;
+import java.nio.charset.StandardCharsets;
+import javax.annotation.Nullable;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.skywalking.apm.network.common.v3.KeyStringValuePair;
+import org.apache.skywalking.apm.network.logging.v3.LogData;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Times;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.analysis.IDManager;
+import org.apache.skywalking.oap.server.core.analysis.Layer;
+import org.apache.skywalking.oap.server.core.analysis.TimeBucket;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionDataRecord;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionFlowRecord;
+import org.apache.skywalking.oap.server.core.analysis.record.Record;
+import org.apache.skywalking.oap.server.core.analysis.worker.RecordStreamProcessor;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.source.LALOutputBuilder;
+import org.apache.skywalking.oap.server.core.source.LogMetadata;
+import org.apache.skywalking.oap.server.core.source.SourceReceiver;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+import org.apache.skywalking.oap.server.telemetry.api.CounterMetrics;
+
+/**
+ * The LAL output builder named by <code>outputType: ConversationFile</code> in <code>lal/ai-agent.yaml</code>.
+ *
+ * <p>The rule's extractor sets the fields below from the record's <code>asz.*</code> attributes, then
+ * {@link #init} takes the body and the service, instance and timestamp the OTLP handler already resolved, and
+ * {@link #complete} verifies the file and dispatches one row to the Session Data or the Session Flow table. One
+ * rule, one builder: the branch on <code>format</code> lives here.
+ *
+ * <p>The service and instance traffic is registered by the log analyzer's own traffic listener for every record
+ * the rule keeps, so this builder registers none.
+ */
+@Slf4j
+public class ConversationFileBuilder implements LALOutputBuilder {
+ public static final String NAME = "ConversationFile";
+ /** The size limit before the module's configuration applies one, and after a test that lowered it. */
+ public static final long DEFAULT_MAX_FILE_BYTES = 15L * 1024 * 1024;
+ private static final String FORMAT_SD = "sd";
+ private static final String FORMAT_SF = "sf";
+
+ private static volatile CounterMetrics ACCEPTED_DATA;
+ private static volatile CounterMetrics ACCEPTED_FLOW;
+ private static volatile CounterMetrics REJECTED_DIGEST;
+ private static volatile CounterMetrics REJECTED_LINES;
+ private static volatile CounterMetrics REJECTED_ATTRIBUTES;
+ private static volatile CounterMetrics REJECTED_SIZE;
+ private static volatile long MAX_FILE_BYTES = DEFAULT_MAX_FILE_BYTES;
+ private static volatile NamingControl NAMING_CONTROL;
+
+ // every record
+ @Getter
+ @Setter
+ private String format;
+ @Getter
+ @Setter
+ private String digest;
+ /** -1 until the extractor sets it; the LAL compiler generates primitive setter calls. */
+ @Getter
+ @Setter
+ private long lines = -1;
+ @Getter
+ @Setter
+ private String throughTime;
+ // sd
+ @Getter
+ @Setter
+ private String session;
+ @Getter
+ @Setter
+ private long seq = -1;
+ // sf
+ @Getter
+ @Setter
+ private String conversation;
+ @Getter
+ @Setter
+ private long round = -1;
+ @Getter
+ @Setter
+ private String sessionFromTime;
+ @Getter
+ @Setter
+ private String sessionThroughTime;
+ @Getter
+ @Setter
+ private String title;
+ @Getter
+ @Setter
+ private long talks;
+ @Getter
+ @Setter
+ private long steps;
+ @Getter
+ @Setter
+ private long streams;
+ @Getter
+ @Setter
+ private long segments;
+ @Getter
+ @Setter
+ private long unresolved;
+
+ // from the handler, through init
+ private String serviceName;
+ private String instanceName;
+ private Layer layer = Layer.AI_AGENT;
+ private long timestamp;
+ private byte[] body;
+ private String fileName;
+
+ /**
+ * Set once by the module provider; the builder itself is created per record by the LAL runtime.
+ *
+ * @param dataAccepted files stored to the Session Data table
+ * @param flowAccepted rounds stored to the Session Flow table
+ * @param digest files rejected because the body's digest is not the declared one
+ * @param lines files rejected because the body's line count is not the declared one
+ * @param attributes files rejected because a required attribute is missing
+ * @param size files rejected because the body is larger than the limit
+ */
+ public static void setMetrics(final CounterMetrics dataAccepted,
+ final CounterMetrics flowAccepted,
+ final CounterMetrics digest,
+ final CounterMetrics lines,
+ final CounterMetrics attributes,
+ final CounterMetrics size) {
+ ACCEPTED_DATA = dataAccepted;
+ ACCEPTED_FLOW = flowAccepted;
+ REJECTED_DIGEST = digest;
+ REJECTED_LINES = lines;
+ REJECTED_ATTRIBUTES = attributes;
+ REJECTED_SIZE = size;
+ }
+
+ /**
+ * The largest body stored. One file over the storage's message limit fails the bulk write it travels in
+ * and every record behind it in that write, so the limit is applied here, where one file is one record.
+ *
+ * @param bytes the limit, in bytes
+ */
+ public static void setMaxFileBytes(final long bytes) {
+ MAX_FILE_BYTES = bytes;
+ }
+
+ /**
+ * Normally resolved from the core module on the first record; a test sets it directly.
+ *
+ * @param control the naming control
+ */
+ public static void setNamingControl(final NamingControl control) {
+ NAMING_CONTROL = control;
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public void init(final LogMetadata metadata, final Object input, final ModuleManager moduleManager) {
+ if (NAMING_CONTROL == null && moduleManager != null) {
+ NAMING_CONTROL = moduleManager.find(CoreModule.NAME).provider().getService(NamingControl.class);
+ }
+ serviceName = metadata.getService();
+ instanceName = metadata.getServiceInstance();
+ if (StringUtil.isNotEmpty(metadata.getLayer())) {
+ layer = Layer.valueOf(metadata.getLayer());
+ }
+ timestamp = metadata.getTimestamp();
+ if (input instanceof LogData.Builder) {
+ final LogData.Builder logData = (LogData.Builder) input;
+ body = logData.getBody().getText().getText().getBytes(StandardCharsets.UTF_8);
+ for (final KeyStringValuePair tag : logData.getTags().getDataList()) {
+ if ("asz.file".equals(tag.getKey())) {
+ fileName = tag.getValue();
+ }
+ }
+ }
+ }
+
+ @Override
+ public void complete(final SourceReceiver sourceReceiver) {
+ if (body == null || StringUtil.isEmpty(format) || StringUtil.isEmpty(digest) || lines < 0) {
+ reject(REJECTED_ATTRIBUTES, "a required attribute is missing");
+ return;
+ }
+ if (body.length > MAX_FILE_BYTES) {
+ reject(REJECTED_SIZE, "the body is " + body.length + " bytes, over the limit of " + MAX_FILE_BYTES);
+ return;
+ }
+ if (!digest.equals(Digests.sha256Hex(body))) {
+ reject(REJECTED_DIGEST, "the body's digest is not the declared " + digest);
+ return;
+ }
+ if (Digests.countLines(body) != lines) {
+ reject(REJECTED_LINES, "the body has " + Digests.countLines(body) + " lines, the record declares " + lines);
+ return;
+ }
+ final String formattedService = NAMING_CONTROL.formatServiceName(serviceName);
+ final String serviceId = IDManager.ServiceID.buildId(formattedService, layer.isNormal());
+ final String instanceId = IDManager.ServiceInstanceID.buildId(
+ serviceId, NAMING_CONTROL.formatInstanceName(StringUtil.isEmpty(instanceName) ? "unknown" : instanceName));
+ switch (format) {
+ case FORMAT_SD:
+ if (StringUtil.isEmpty(session) || seq < 0) {
+ reject(REJECTED_ATTRIBUTES, "a Session Data record without asz.session or asz.seq");
+ return;
+ }
+ dispatch(dataRecord(serviceId, instanceId));
+ count(ACCEPTED_DATA);
+ break;
+ case FORMAT_SF:
+ if (StringUtil.isEmpty(conversation) || round < 0) {
+ reject(REJECTED_ATTRIBUTES, "a Session Flow record without asz.conversation or asz.round");
+ return;
+ }
+ dispatch(flowRecord(serviceId, instanceId));
+ count(ACCEPTED_FLOW);
+ break;
+ default:
+ reject(REJECTED_ATTRIBUTES, "unknown asz.format " + format);
+ break;
+ }
+ }
+
+ /**
+ * Hands a verified row to the record stream. A test overrides it to capture the row.
+ *
+ * @param record the verified row
+ */
+ protected void dispatch(final Record record) {
+ RecordStreamProcessor.getInstance().in(record);
+ }
+
+ private AIAgentSessionDataRecord dataRecord(final String serviceId, final String instanceId) {
+ final AIAgentSessionDataRecord record = new AIAgentSessionDataRecord();
+ record.setServiceId(serviceId);
+ record.setServiceInstanceId(instanceId);
+ record.setSession(session);
+ record.setSeq(seq);
+ record.setDigest(digest);
+ // The file's own through time places it inside the conversation's range; the record's timestamp is the
+ // sender's fallback for a file whose records carry no time.
+ final long fileTime = Times.millis(throughTime);
+ final long ts = fileTime != 0 ? fileTime : timestamp;
+ record.setTimestamp(ts);
+ record.setTimeBucket(TimeBucket.getRecordTimeBucket(ts));
+ record.setBody(body);
+ return record;
+ }
+
+ private AIAgentSessionFlowRecord flowRecord(final String serviceId, final String instanceId) {
+ final AIAgentSessionFlowRecord record = new AIAgentSessionFlowRecord();
+ record.setServiceId(serviceId);
+ record.setServiceInstanceId(instanceId);
+ record.setConversation(conversation);
+ record.setRound(round);
+ record.setSessionFromTime(Times.millis(sessionFromTime));
+ record.setTitle(clip(title, AIAgentSessionFlowRecord.TITLE_MAX_LENGTH));
+ record.setTalks(talks);
+ record.setSteps(steps);
+ record.setStreams(streams);
+ record.setSegments(segments);
+ record.setUnresolved(unresolved);
+ record.setDigest(digest);
+ // The conversation's last activity as of this round is the row's time, so the newest row is the head.
+ final long through = Times.millis(sessionThroughTime);
+ final long ts = through != 0 ? through : timestamp;
+ record.setTimestamp(ts);
+ record.setTimeBucket(TimeBucket.getRecordTimeBucket(ts));
+ record.setBody(body);
+ return record;
+ }
+
+ private void reject(@Nullable final CounterMetrics counter, final String why) {
+ count(counter);
+ log.warn("AI agent conversation file {} from {}/{} rejected: {}", fileName, serviceName, instanceName, why);
+ }
+
+ private static void count(@Nullable final CounterMetrics counter) {
+ if (counter != null) {
+ counter.inc();
+ }
+ }
+
+ @Nullable
+ private static String clip(@Nullable final String value, final int max) {
+ return value == null || value.length() <= max ? value : value.substring(0, max);
+ }
+
+ @Override
+ public String outputToJson() {
+ final JsonObject obj = new JsonObject();
+ obj.addProperty("type", getClass().getSimpleName());
+ obj.addProperty("name", name());
+ obj.addProperty("format", format);
+ obj.addProperty("file", fileName);
+ obj.addProperty("digest", digest);
+ obj.addProperty("lines", lines);
+ obj.addProperty("session", session);
+ obj.addProperty("seq", seq);
+ obj.addProperty("conversation", conversation);
+ obj.addProperty("round", round);
+ obj.addProperty("title", title);
+ obj.addProperty("serviceName", serviceName);
+ obj.addProperty("instanceName", instanceName);
+ obj.addProperty("timestamp", timestamp);
+ obj.addProperty("bytes", body == null ? 0 : body.length);
+ return obj.toString();
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryService.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryService.java
new file mode 100644
index 0000000000..cffd052c28
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryService.java
@@ -0,0 +1,415 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationConfig;
+import org.apache.skywalking.oap.server.ai.agent.conversation.fold.ConversationFold;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.FileNames;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Times;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationFileFormat;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationList;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFile;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFiles;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRow;
+import org.apache.skywalking.oap.server.ai.agent.conversation.view.ConversationViewBuilder;
+import org.apache.skywalking.oap.server.core.analysis.IDManager;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionDataRecord;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionFlowRecord;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.storage.StorageModule;
+import org.apache.skywalking.oap.server.core.storage.query.IAIAgentConversationQueryDAO;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+/**
+ * The read side. A conversation is read in two storage passes and never a read per file: its rounds by
+ * <code>conversation</code> over the whole retention window, then, for each session the head round names, the
+ * files by series id over the range the head round carries, in seq windows that keep one storage response under
+ * the BanyanDB client's inbound cap. The fold, the chain check and the view are built once per head digest and
+ * cached.
+ */
+@Slf4j
+public class ConversationQueryService implements IConversationQueryService {
+ private static final int DEFAULT_LIST_LIMIT = 1000;
+
+ private final ModuleManager moduleManager;
+ private final AIAgentConversationConfig config;
+ private IAIAgentConversationQueryDAO dao;
+
+ public ConversationQueryService(final ModuleManager moduleManager, final AIAgentConversationConfig config) {
+ this.moduleManager = moduleManager;
+ this.config = config;
+ }
+
+ private IAIAgentConversationQueryDAO dao() {
+ if (dao == null) {
+ dao = moduleManager.find(StorageModule.NAME).provider().getService(IAIAgentConversationQueryDAO.class);
+ }
+ return dao;
+ }
+
+ @Override
+ public ConversationList listConversations(final String serviceId,
+ @Nullable final String serviceInstanceId,
+ @Nullable final String conversation,
+ @Nullable final String title,
+ final Duration duration,
+ @Nullable final Integer limit) throws IOException {
+ final int rounds = Math.min(
+ limit == null || limit <= 0 ? DEFAULT_LIST_LIMIT : limit, config.getMaxListLimit());
+ final List<AIAgentSessionFlowRecord> newestFirst = dao().queryRoundsDebuggable(
+ serviceId, serviceInstanceId, StringUtil.isEmpty(conversation) ? null : conversation.trim(),
+ duration, rounds, false);
+ final Map<String, ConversationRow> rows = new LinkedHashMap<>();
+ for (final AIAgentSessionFlowRecord r : newestFirst) {
+ final ConversationRow existing = rows.get(r.getConversation());
+ if (existing != null && existing.getRound() >= r.getRound()) {
+ continue;
+ }
+ rows.put(r.getConversation(), row(r));
+ }
+ // The title is the newest round's, so the match runs on the folded row: a round budget spent on
+ // a conversation stays spent whether or not its title matches.
+ final String needle = StringUtil.isEmpty(title) ? null : title.trim().toLowerCase(Locale.ROOT);
+ final ConversationList list = new ConversationList();
+ final List<ConversationRow> out = new ArrayList<>(rows.size());
+ for (final ConversationRow row : rows.values()) {
+ if (needle != null && !needle.isEmpty()
+ && (row.getTitle() == null || !row.getTitle().toLowerCase(Locale.ROOT).contains(needle))) {
+ continue;
+ }
+ out.add(row);
+ }
+ list.setConversations(out);
+ return list;
+ }
+
+ private static ConversationRow row(final AIAgentSessionFlowRecord r) {
+ final ConversationRow row = new ConversationRow();
+ row.setConversation(r.getConversation());
+ row.setServiceInstanceId(r.getServiceInstanceId());
+ row.setServiceInstanceName(instanceName(r.getServiceInstanceId()));
+ row.setTitle(r.getTitle());
+ row.setRound((int) r.getRound());
+ row.setTalks((int) r.getTalks());
+ row.setSteps((int) r.getSteps());
+ row.setStreams((int) r.getStreams());
+ row.setSegments((int) r.getSegments());
+ row.setUnresolved((int) r.getUnresolved());
+ row.setFrom(r.getSessionFromTime());
+ row.setTo(r.getTimestamp());
+ return row;
+ }
+
+ private static String instanceName(final String instanceId) {
+ if (StringUtil.isEmpty(instanceId)) {
+ return "";
+ }
+ try {
+ return IDManager.ServiceInstanceID.analysisId(instanceId).getName();
+ } catch (final RuntimeException e) {
+ return instanceId;
+ }
+ }
+
+ @Override
+ @Nullable
+ public Map<String, Object> buildConversationView(final String serviceId,
+ @Nullable final String serviceInstanceId,
+ final String conversation) throws IOException {
+ final List<AIAgentSessionFlowRecord> heads =
+ dao().queryRoundsDebuggable(serviceId, serviceInstanceId, conversation, null, 1, false);
+ if (heads.isEmpty()) {
+ return null;
+ }
+ final Chain chain = readChain(serviceId, serviceInstanceId, conversation);
+ return new ConversationViewBuilder(chain.fold, chain.roundInputs, chain.files, chain.problems).build();
+ }
+
+ @Override
+ public ConversationRawFiles getConversationRawFiles(final String serviceId,
+ @Nullable final String serviceInstanceId,
+ final String conversation,
+ @Nullable final List<String> files,
+ final boolean includeBody) throws IOException {
+ final Set<FileNames.Parsed> wanted = new LinkedHashSet<>();
+ if (files != null) {
+ for (final String id : files) {
+ final FileNames.Parsed p = FileNames.parse(id);
+ if (p != null) {
+ wanted.add(p);
+ }
+ }
+ }
+ final List<AIAgentSessionFlowRecord> rounds = readRounds(serviceId, serviceInstanceId, conversation);
+ final ConversationRawFiles out = new ConversationRawFiles();
+ if (rounds.isEmpty()) {
+ out.setErrorReason("no round of conversation " + conversation + " is stored for this service");
+ return out;
+ }
+ final AIAgentSessionFlowRecord headRow = rounds.get(rounds.size() - 1);
+ // the newest round that reads names the session and the range; one that does not read is still exported
+ // below, as stored, and must not block the export of what does
+ SessionFlowRound.Header head = null;
+ for (int i = rounds.size() - 1; i >= 0 && head == null; i--) {
+ try {
+ head = SessionFlowRound.parse(rounds.get(i).getBody()).getHeader();
+ } catch (final RuntimeException e) {
+ log.debug("round {} of conversation {} does not read: {}", rounds.get(i).getRound(), conversation,
+ e.getMessage());
+ }
+ }
+ // the caller's sender, or every sender of the service: a Sessionizer renamed between pushes leaves a
+ // conversation's files under two instances, and a read must see both
+ final String instance = StringUtil.isNotEmpty(serviceInstanceId) ? serviceInstanceId : null;
+ final long from = head == null ? 0 : Times.millis(head.getSessionFromTime());
+ final long to = rangeEnd(head == null ? null : head.getSessionThroughTime(), headRow.getTimestamp());
+
+ // Session Data files first, then the rounds, each list in its own order.
+ final Set<String> sessions = new LinkedHashSet<>();
+ if (head != null && StringUtil.isNotEmpty(head.getSession())) {
+ sessions.add(head.getSession());
+ }
+ for (final FileNames.Parsed p : wanted) {
+ if (p.isDataFile()) {
+ sessions.add(p.getSession());
+ }
+ }
+ for (final String session : sessions) {
+ final Set<Long> seqs = new HashSet<>();
+ boolean all = files == null;
+ for (final FileNames.Parsed p : wanted) {
+ if (p.isDataFile() && session.equals(p.getSession())) {
+ seqs.add(p.getSeq());
+ }
+ }
+ if (files != null && seqs.isEmpty()) {
+ continue;
+ }
+ final long throughSeq = all ? (head == null ? 0 : head.getThroughSeq())
+ : seqs.stream().mapToLong(Long::longValue).max().orElse(0);
+ final long fromSeq = all ? 1 : seqs.stream().mapToLong(Long::longValue).min().orElse(1);
+ final Set<Long> seen = new HashSet<>();
+ for (final AIAgentSessionDataRecord f : readFiles(serviceId, instance, session, from, to, fromSeq, throughSeq)) {
+ if (!all && !seqs.contains(f.getSeq()) || !seen.add(f.getSeq())) {
+ continue;
+ }
+ final SessionDataFile parsed = SessionDataFile.parse(f.getBody());
+ final ConversationRawFile raw = new ConversationRawFile();
+ raw.setId(FileNames.dataFile(parsed.getHeader()));
+ raw.setFormat(ConversationFileFormat.SD);
+ raw.setSession(session);
+ raw.setSeq((int) f.getSeq());
+ raw.setDigest(f.getDigest());
+ raw.setBytes(f.getBody().length);
+ raw.setTimestamp(f.getTimestamp());
+ if (includeBody) {
+ raw.setBody(new String(f.getBody(), StandardCharsets.UTF_8));
+ }
+ out.getFiles().add(raw);
+ }
+ }
+ for (final AIAgentSessionFlowRecord r : rounds) {
+ if (files != null) {
+ boolean named = false;
+ for (final FileNames.Parsed p : wanted) {
+ if (!p.isDataFile() && p.getRound() == r.getRound()) {
+ named = true;
+ break;
+ }
+ }
+ if (!named) {
+ continue;
+ }
+ }
+ String commit;
+ try {
+ commit = SessionFlowRound.parse(r.getBody()).getCommitDigest();
+ } catch (final RuntimeException e) {
+ // a round that does not read has no commit digest to be named by; the digest of its file names it
+ commit = r.getDigest();
+ }
+ final ConversationRawFile raw = new ConversationRawFile();
+ raw.setId(FileNames.roundFile(conversation, r.getRound(), commit));
+ raw.setFormat(ConversationFileFormat.SF);
+ raw.setRound((int) r.getRound());
+ raw.setDigest(r.getDigest());
+ raw.setBytes(r.getBody().length);
+ raw.setTimestamp(r.getTimestamp());
+ if (includeBody) {
+ raw.setBody(new String(r.getBody(), StandardCharsets.UTF_8));
+ }
+ out.getFiles().add(raw);
+ }
+ return out;
+ }
+
+ // ---------------------------------------------------------------- the two-pass read
+
+ private static final class Chain {
+ final ConversationFold fold = new ConversationFold();
+ /** Every stored round in number order, readable or not, for the document's listing. */
+ final List<ConversationViewBuilder.RoundInput> roundInputs = new ArrayList<>();
+ final Map<Long, SessionDataFile> files = new TreeMap<>();
+ /** What stopped the fold short of the chain's last round, in words, as the Sessionizer's FoldPartial. */
+ final List<String> problems = new ArrayList<>();
+ }
+
+ /**
+ * Every round of a conversation, in round order, read window by window up to the head: a round is up to
+ * 2 MiB and a long conversation has hundreds, so one read of them all would not fit a storage response.
+ * The head is the highest round number stored, read directly and fixed first, so a round landing during the
+ * read is left for the next call; it is not the newest row by time, because the Sessionizer can write a
+ * later round that carries no later activity, over metadata or older records. A round stored more than once, by two
+ * senders or by a redelivery, is kept once, the first copy.
+ */
+ private List<AIAgentSessionFlowRecord> readRounds(final String serviceId, @Nullable final String instance,
+ final String conversation) throws IOException {
+ final long headRound = dao().queryHeadRoundDebuggable(serviceId, instance, conversation);
+ if (headRound == 0) {
+ return new ArrayList<>();
+ }
+ final Map<Long, AIAgentSessionFlowRecord> byRound = new TreeMap<>();
+ final int window = config.getRoundReadWindow();
+ for (long start = 1; start <= headRound; start += window) {
+ final long end = Math.min(headRound, start + window - 1);
+ for (final AIAgentSessionFlowRecord r : dao().queryRoundsByNumberDebuggable(
+ serviceId, instance, conversation, start, end, config.getMaxResponseBytes())) {
+ byRound.putIfAbsent(r.getRound(), r);
+ }
+ }
+ return new ArrayList<>(byRound.values());
+ }
+
+ /**
+ * The fold covers every stored round that reads and folds. A gap, a round that does not read, or one the fold
+ * refuses is skipped and the chain resumes at the next stored round, so the document shows as much as landed;
+ * what was skipped is carried in words, and the view builder names the gaps among the rounds it lists.
+ * Every stored round is listed, readable or not.
+ */
+ private Chain readChain(final String serviceId, @Nullable final String serviceInstanceId,
+ final String conversation) throws IOException {
+ final Chain chain = new Chain();
+ final List<AIAgentSessionFlowRecord> rounds = readRounds(serviceId, serviceInstanceId, conversation);
+ final String instance = StringUtil.isNotEmpty(serviceInstanceId) ? serviceInstanceId : null;
+ long throughSeq = 0;
+ AIAgentSessionFlowRecord headRow = null;
+ for (final AIAgentSessionFlowRecord r : rounds) {
+ SessionFlowRound parsed = null;
+ String unreadable = null;
+ try {
+ parsed = SessionFlowRound.parse(r.getBody());
+ if (!parsed.isIntact()) {
+ unreadable = "sessionflow: digest mismatch: computed " + first12(parsed.getComputedDigest())
+ + ", round claims " + first12(parsed.getCommitDigest());
+ }
+ } catch (final RuntimeException e) {
+ unreadable = "sessionflow: " + e.getMessage();
+ }
+ if (unreadable != null) {
+ chain.roundInputs.add(ConversationViewBuilder.RoundInput.unreadable(r.getRound(), unreadable));
+ chain.problems.add("round " + r.getRound() + " does not read: " + unreadable);
+ continue;
+ }
+ chain.roundInputs.add(new ConversationViewBuilder.RoundInput(parsed, r.getDigest()));
+ throughSeq = Math.max(throughSeq, parsed.getHeader().getThroughSeq());
+ final String refused = parsed.getHeader().getRound() == chain.fold.getRound() + 1
+ ? chain.fold.apply(parsed) : chain.fold.applyAfterGap(parsed);
+ if (refused != null) {
+ chain.problems.add("round " + r.getRound() + " does not fold: " + refused);
+ continue;
+ }
+ headRow = r;
+ }
+ if (headRow == null) {
+ return chain;
+ }
+ // every landed file of the conversation's sessions, as far as any listed round reaches
+ final long from = Times.millis(chain.fold.getSessionFromTime());
+ final long to = rangeEnd(chain.fold.getSessionThroughTime(), headRow.getTimestamp());
+ log.debug("files of conversation {} read over {}..{} for seq 1..{}, from the range of round {}",
+ conversation, from, to, throughSeq, chain.fold.getRound());
+ final Set<String> sessions = new LinkedHashSet<>();
+ if (StringUtil.isNotEmpty(chain.fold.getSession())) {
+ sessions.add(chain.fold.getSession());
+ }
+ for (final SessionFlowRound.Node n : chain.fold.nodesOfKind("session")) {
+ final String id = n.getId();
+ sessions.add(id.startsWith("session/") ? id.substring("session/".length()) : id);
+ }
+ for (final String session : sessions) {
+ for (final AIAgentSessionDataRecord f : readFiles(serviceId, instance, session, from, to, 1, throughSeq)) {
+ if (chain.files.containsKey(f.getSeq())) {
+ // the same file under two senders; the chain check judges the copy that was kept
+ continue;
+ }
+ try {
+ chain.files.put(f.getSeq(), SessionDataFile.parse(f.getBody()));
+ } catch (final RuntimeException e) {
+ // as the Sessionizer's reader, a file that does not decode contributes no records and is
+ // named by the chain check as missing
+ chain.problems.add("file seq " + f.getSeq() + " of session " + session + " cannot be decoded: "
+ + e.getMessage());
+ }
+ }
+ }
+ return chain;
+ }
+
+ private static String first12(@Nullable final String s) {
+ return s == null ? "" : s.substring(0, Math.min(12, s.length()));
+ }
+
+ private List<AIAgentSessionDataRecord> readFiles(final String serviceId, @Nullable final String instance,
+ final String session, final long from, final long to,
+ final long fromSeq, final long throughSeq) throws IOException {
+ final List<AIAgentSessionDataRecord> out = new ArrayList<>();
+ final int window = config.getFileReadWindow();
+ for (long start = fromSeq; start <= throughSeq; start += window) {
+ final long end = Math.min(throughSeq, start + window - 1);
+ out.addAll(dao().queryFilesDebuggable(
+ serviceId, instance, session, from, to, start, end, config.getMaxResponseBytes()));
+ }
+ return out;
+ }
+
+ /**
+ * The end of the files read: the conversation's last activity as of the head round, or the head row's own
+ * timestamp when the header carries none. A file is stamped at or before that moment.
+ */
+ private static long rangeEnd(@Nullable final String sessionThroughTime, final long headRowTimestamp) {
+ final long through = Times.millis(sessionThroughTime);
+ return Math.max(through, headRowTimestamp);
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/IConversationQueryService.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/IConversationQueryService.java
new file mode 100644
index 0000000000..8922febf51
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/IConversationQueryService.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationList;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFiles;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.library.module.Service;
+
+/**
+ * The two GraphQL operations of <code>ai-agent-conversation.graphqls</code> and the document behind the
+ * conversation view route.
+ */
+public interface IConversationQueryService extends Service {
+ /**
+ * One row per conversation, from the newest round's attributes; nothing decoded.
+ *
+ * @param serviceId the service
+ * @param serviceInstanceId the sender, or null
+ * @param conversation one conversation by id, or null for every conversation of the service
+ * @param title text the row's title must contain, case-insensitively, or null
+ * @param duration the window
+ * @param limit at most this many rounds read, newest first, before folding; null for the default
+ * @return the rows, newest first
+ * @throws IOException on a storage failure
+ */
+ ConversationList listConversations(String serviceId, @Nullable String serviceInstanceId,
+ @Nullable String conversation, @Nullable String title, Duration duration,
+ @Nullable Integer limit) throws IOException;
+
+ /**
+ * The whole conversation, once, as one <code>asz.view</code> document, built on every call.
+ *
+ * @param serviceId the service
+ * @param serviceInstanceId the sender, or null
+ * @param conversation the conversation
+ * @return the document as ordered maps, or null when the service stores no round of the conversation
+ * @throws IOException on a storage failure
+ */
+ @Nullable
+ Map<String, Object> buildConversationView(String serviceId, @Nullable String serviceInstanceId,
+ String conversation) throws IOException;
+
+ /**
+ * Every landed file and round of a conversation as stored, or only the named ones.
+ *
+ * @param serviceId the service
+ * @param serviceInstanceId the sender, or null
+ * @param conversation the conversation
+ * @param files only these file ids, or null for every file
+ * @param includeBody whether the caller selected the body field
+ * @return the files
+ * @throws IOException on a storage failure
+ */
+ ConversationRawFiles getConversationRawFiles(String serviceId, @Nullable String serviceInstanceId,
+ String conversation, @Nullable List<String> files,
+ boolean includeBody) throws IOException;
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/CompressResponse.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/CompressResponse.java
new file mode 100644
index 0000000000..a437745edf
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/CompressResponse.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.http;
+
+import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.server.ServiceRequestContext;
+import com.linecorp.armeria.server.DecoratingHttpServiceFunction;
+import com.linecorp.armeria.server.encoding.EncodingService;
+import java.util.function.Function;
+
+/**
+ * Compresses a JSON or YAML response when the client's <code>Accept-Encoding</code> allows it, chunk by chunk,
+ * so a streamed document stays streamed. A document is repetitive text and shrinks several times over.
+ */
+public final class CompressResponse implements DecoratingHttpServiceFunction {
+ private static final Function<? super HttpService, EncodingService> ENCODING = EncodingService.builder()
+ .encodableContentTypes(ConversationViewHandler.JSON, ConversationViewHandler.YAML)
+ .newDecorator();
+
+ @Override
+ public HttpResponse serve(final HttpService delegate, final ServiceRequestContext ctx, final HttpRequest req)
+ throws Exception {
+ return delegate.decorate(ENCODING).serve(ctx, req);
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationViewHandler.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationViewHandler.java
new file mode 100644
index 0000000000..a05c97a4a8
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationViewHandler.java
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.http;
+
+import com.google.gson.JsonObject;
+import com.linecorp.armeria.common.HttpData;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpResponseWriter;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.common.ResponseHeaders;
+import com.linecorp.armeria.common.util.TimeoutMode;
+import com.linecorp.armeria.server.ServiceRequestContext;
+import com.linecorp.armeria.server.annotation.Decorator;
+import com.linecorp.armeria.server.annotation.Get;
+import com.linecorp.armeria.server.annotation.Header;
+import com.linecorp.armeria.server.annotation.Param;
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.IConversationQueryService;
+import org.apache.skywalking.oap.server.ai.agent.conversation.view.ViewJson;
+import org.apache.skywalking.oap.server.ai.agent.conversation.view.ViewYaml;
+import org.apache.skywalking.oap.server.core.analysis.IDManager;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+/**
+ * <code>GET /ai-agent/conversations/{conversation}/v1/view</code>: the whole conversation as one
+ * <code>asz.view</code> 1.0 document, streamed. It lives on the core HTTP server beside <code>/graphql</code>,
+ * and is not a GraphQL query because the document is as large as the conversation, tens of megabytes for a
+ * long one: it is written to the response as it is rendered, never held whole as one string, compressed when
+ * the client allows, and given its own request timeout in place of the server's default.
+ *
+ * <p>Query parameters: <code>service</code>, the service name, or <code>serviceId</code>; optionally
+ * <code>instance</code>, the sender's instance name. What the body is, the HTTP layer says: the media type
+ * names the document format and its version, <code>application/vnd.skywalking.asz.view+json; version=1.0</code>, or the
+ * <code>+yaml</code> twin when <code>Accept</code> asks for YAML. The document's own first two keys repeat it.
+ *
+ * <p>Status: 200 with the document; 400 when no service is named; 404 when the service stores no round of the
+ * conversation; 500 on a storage failure. An error is <code>application/problem+json</code> (RFC 9457):
+ * <code>{"type": "about:blank", "title": "...", "status": 404, "detail": "..."}</code>.
+ */
+@Slf4j
+public class ConversationViewHandler {
+ public static final String PATH = "/ai-agent/conversations/{conversation}/v1/view";
+ /** The document format and version as a media type; the format is the type, the version a parameter. */
+ static final MediaType JSON = MediaType.parse("application/vnd.skywalking.asz.view+json");
+ static final MediaType YAML = MediaType.parse("application/vnd.skywalking.asz.view+yaml");
+ private static final String VERSION_PARAMETER = "version";
+ private static final MediaType PROBLEM = MediaType.parse("application/problem+json").withCharset(StandardCharsets.UTF_8);
+ private static final MediaType JSON_UTF_8 = JSON.withParameter(VERSION_PARAMETER, ViewYaml.VERSION).withCharset(StandardCharsets.UTF_8);
+ private static final MediaType YAML_UTF_8 = YAML.withParameter(VERSION_PARAMETER, ViewYaml.VERSION).withCharset(StandardCharsets.UTF_8);
+ /** Bytes rendered between two writes to the response. */
+ private static final int CHUNK_CHARS = 64 * 1024;
+
+ private final IConversationQueryService service;
+ private final Duration timeout;
+
+ public ConversationViewHandler(final IConversationQueryService service, final Duration timeout) {
+ this.service = service;
+ this.timeout = timeout;
+ }
+
+ @Get(PATH)
+ @Decorator(CompressResponse.class)
+ public HttpResponse view(final ServiceRequestContext ctx,
+ @Param("conversation") final String conversation,
+ @Param("service") @Nullable final String serviceName,
+ @Param("serviceId") @Nullable final String serviceIdParam,
+ @Param("instance") @Nullable final String instanceName,
+ @Header("Accept") @Nullable final String accept) {
+ final String serviceId;
+ if (StringUtil.isNotEmpty(serviceIdParam)) {
+ serviceId = serviceIdParam;
+ } else if (StringUtil.isNotEmpty(serviceName)) {
+ serviceId = IDManager.ServiceID.buildId(serviceName, true);
+ } else {
+ return HttpResponse.of(HttpStatus.BAD_REQUEST, PROBLEM, problem(HttpStatus.BAD_REQUEST, "service or serviceId is required"));
+ }
+ final String instanceId = StringUtil.isEmpty(instanceName)
+ ? null : IDManager.ServiceInstanceID.buildId(serviceId, instanceName);
+ final boolean yaml = accept != null && accept.contains("yaml");
+
+ ctx.setRequestTimeout(TimeoutMode.SET_FROM_NOW, timeout);
+ final HttpResponseWriter res = HttpResponse.streaming();
+ ctx.blockingTaskExecutor().execute(() -> stream(res, serviceId, instanceId, conversation, yaml));
+ return res;
+ }
+
+ private void stream(final HttpResponseWriter res, final String serviceId, @Nullable final String instanceId,
+ final String conversation, final boolean yaml) {
+ final Map<String, Object> doc;
+ try {
+ doc = service.buildConversationView(serviceId, instanceId, conversation);
+ } catch (final Exception e) {
+ // a storage client can surface a checked failure it never declared; whatever it is, the response
+ // must say so, or the caller waits for the request timeout
+ log.error("AI agent conversation {} of service {} could not be read", conversation, serviceId, e);
+ problem(res, HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
+ return;
+ }
+ if (doc == null) {
+ problem(res, HttpStatus.NOT_FOUND, "no round of conversation " + conversation + " is stored for this service");
+ return;
+ }
+ res.write(ResponseHeaders.builder(HttpStatus.OK).contentType(yaml ? YAML_UTF_8 : JSON_UTF_8).build());
+ try (Writer out = new ChunkWriter(res, timeout)) {
+ if (yaml) {
+ ViewYaml.write(doc, out);
+ } else {
+ ViewJson.write(doc, out);
+ }
+ } catch (final IOException e) {
+ log.debug("AI agent conversation {} response ended early: {}", conversation, e.getMessage());
+ res.close(e);
+ return;
+ }
+ res.close();
+ }
+
+ private static void problem(final HttpResponseWriter res, final HttpStatus status, @Nullable final String detail) {
+ res.write(ResponseHeaders.builder(status).contentType(PROBLEM).build());
+ res.write(HttpData.ofUtf8(problem(status, detail)));
+ res.close();
+ }
+
+ /**
+ * @return an RFC 9457 problem: the status and its reason phrase, and what went wrong in words
+ */
+ private static String problem(final HttpStatus status, @Nullable final String detail) {
+ final JsonObject body = new JsonObject();
+ body.addProperty("type", "about:blank");
+ body.addProperty("title", status.reasonPhrase());
+ body.addProperty("status", status.code());
+ body.addProperty("detail", detail == null ? "" : detail);
+ return body.toString();
+ }
+
+ /**
+ * Hands the rendered text to the response in UTF-8 chunks and waits for each to be consumed before
+ * rendering more, so a slow client holds back the render instead of growing a buffer. A chunk never ends
+ * between the two halves of a surrogate pair: a trailing high surrogate waits for the next chunk, so every
+ * code point is encoded whole. A client that went away ends the render with an IOException at the next
+ * chunk.
+ */
+ static final class ChunkWriter extends Writer {
+ private final HttpResponseWriter res;
+ private final Duration timeout;
+ private final StringBuilder buffer = new StringBuilder(CHUNK_CHARS + 1024);
+
+ ChunkWriter(final HttpResponseWriter res, final Duration timeout) {
+ this.res = res;
+ this.timeout = timeout;
+ }
+
+ @Override
+ public void write(final char[] cbuf, final int off, final int len) throws IOException {
+ int from = off;
+ int left = len;
+ while (left > 0) {
+ final int take = Math.min(left, CHUNK_CHARS - buffer.length());
+ buffer.append(cbuf, from, take);
+ from += take;
+ left -= take;
+ if (buffer.length() >= CHUNK_CHARS) {
+ flush();
+ }
+ }
+ }
+
+ @Override
+ public void flush() throws IOException {
+ write(false);
+ }
+
+ private void write(final boolean last) throws IOException {
+ int end = buffer.length();
+ if (!last && end > 0 && Character.isHighSurrogate(buffer.charAt(end - 1))) {
+ end--;
+ }
+ if (end == 0) {
+ return;
+ }
+ if (!res.tryWrite(HttpData.ofUtf8(buffer.substring(0, end)))) {
+ throw new IOException("the response is closed");
+ }
+ buffer.delete(0, end);
+ try {
+ res.whenConsumed().get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("interrupted while the client read the response", e);
+ } catch (final ExecutionException | TimeoutException e) {
+ throw new IOException("the client stopped reading the response", e);
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ write(true);
+ }
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationCondition.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationCondition.java
new file mode 100644
index 0000000000..5804d5caf6
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationCondition.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.input;
+
+import lombok.Data;
+import org.apache.skywalking.oap.server.core.query.input.InstanceCondition;
+import org.apache.skywalking.oap.server.core.query.input.ServiceCondition;
+
+@Data
+public class ConversationCondition {
+ private ServiceCondition service;
+ private String conversation;
+ private InstanceCondition instance;
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationListCondition.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationListCondition.java
new file mode 100644
index 0000000000..0f68ab963a
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationListCondition.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.input;
+
+import lombok.Data;
+import org.apache.skywalking.oap.server.core.query.input.InstanceCondition;
+import org.apache.skywalking.oap.server.core.query.input.ServiceCondition;
+
+@Data
+public class ConversationListCondition {
+ private ServiceCondition service;
+ private InstanceCondition instance;
+ private String conversation;
+ private String title;
+ private Integer limit;
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationFileFormat.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationFileFormat.java
new file mode 100644
index 0000000000..57973ead73
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationFileFormat.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.type;
+
+/**
+ * Which of the two landed formats a raw file is.
+ */
+public enum ConversationFileFormat {
+ /** Session Data: the records of one stream, an agent's meta file, a run journal, a workflow manifest or script. */
+ SD,
+ /** Session Flow: one round of the conversation's chain. */
+ SF
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationList.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationList.java
new file mode 100644
index 0000000000..9af0abb3d2
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationList.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.type;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTrace;
+
+@Data
+public class ConversationList {
+ private String errorReason;
+ private List<ConversationRow> conversations = new ArrayList<>();
+ private DebuggingTrace debuggingTrace;
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFile.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFile.java
new file mode 100644
index 0000000000..a0fa97efe1
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFile.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.type;
+
+import lombok.Data;
+
+/**
+ * One landed file or round, as stored.
+ */
+@Data
+public class ConversationRawFile {
+ private String id;
+ private ConversationFileFormat format;
+ private String session;
+ private Integer seq;
+ private Integer round;
+ private String digest;
+ private int bytes;
+ private long timestamp;
+ private String body;
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFiles.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFiles.java
new file mode 100644
index 0000000000..341179e50d
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFiles.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.type;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTrace;
+
+@Data
+public class ConversationRawFiles {
+ private String errorReason;
+ private List<ConversationRawFile> files = new ArrayList<>();
+ private DebuggingTrace debuggingTrace;
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRow.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRow.java
new file mode 100644
index 0000000000..8f166a76a3
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRow.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.type;
+
+import lombok.Data;
+
+/**
+ * One row per conversation on the list page, from the newest round's attributes.
+ */
+@Data
+public class ConversationRow {
+ private String conversation;
+ private String serviceInstanceId;
+ private String serviceInstanceName;
+ private String title;
+ private int round;
+ private int talks;
+ private int steps;
+ private int streams;
+ private int segments;
+ private int unresolved;
+ private long from;
+ private long to;
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ConversationViewBuilder.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ConversationViewBuilder.java
new file mode 100644
index 0000000000..61ac65c2c5
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ConversationViewBuilder.java
@@ -0,0 +1,1425 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.view;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+import lombok.Getter;
+import org.apache.skywalking.oap.server.ai.agent.conversation.fold.ConversationFold;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.FileNames;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Ref;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Times;
+import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+/**
+ * Builds the <code>asz.view</code> 1.0 document of one conversation, key for key as the Sessionizer's
+ * <code>pkg/sessionview</code> defines it and its <code>internal/view</code> builds it, so the document the OAP
+ * answers with equals the one <code>asz conversation -json</code> prints for the same files.
+ *
+ * <p>The document is deterministic: no wall-clock time, keys in the order the format page lists them, lists in
+ * record order. Keys a node has no value for are absent; the fixed keys of rounds and files are null when absent.
+ */
+public final class ConversationViewBuilder {
+ /** The readable text a node carries is clipped to this many bytes; the full size is in <code>bytes</code>. */
+ static final int PREVIEW_BYTES = 2000;
+ private static final int MAX_DEPTH = 12;
+ private static final Pattern INTEGER_LITERAL = Pattern.compile("-?\\d+");
+ /** A parent walk stops here; a well-formed fold is far shallower, a malformed one must not loop. */
+ private static final int MAX_ANCESTORS = 64;
+ static final String STATE_VERIFIED = "verified";
+ static final String STATE_INCOMPLETE = "incomplete";
+ static final String STATE_MISMATCH = "mismatch";
+
+ private final ConversationFold fold;
+ private final List<RoundInput> rounds;
+ private final Map<Long, SessionDataFile> files;
+ private final List<String> problems;
+ /** Every timed record's moment in nanoseconds, the precision the Sessionizer computes intervals with. */
+ private final Map<Ref, Long> at = new HashMap<>();
+
+ /**
+ * @param fold the fold of the rounds, in order
+ * @param rounds every stored round in number order, decoded or with the reason it does not read
+ * @param files the session's landed files by seq
+ * @param problems what stopped the fold short of the chain's last round, in words; they lead the document's
+ * problems, and a fold that stopped short leaves the document incomplete at best
+ */
+ public ConversationViewBuilder(final ConversationFold fold,
+ final List<RoundInput> rounds,
+ final Map<Long, SessionDataFile> files,
+ final List<String> problems) {
+ this.fold = fold;
+ this.rounds = rounds;
+ this.files = files;
+ this.problems = problems;
+ for (final SessionDataFile f : files.values()) {
+ for (final SessionDataFile.Record r : f.getRecords()) {
+ if (r.getTimeNanos() != 0) {
+ at.put(new Ref(f.getHeader().getSeq(), r.getRow(), null), r.getTimeNanos());
+ }
+ }
+ }
+ }
+
+ /**
+ * @return the document as ordered maps
+ */
+ public Map<String, Object> build() {
+ final Overview o = overview();
+ final Chain chain = chain();
+
+ final Map<String, Object> doc = new LinkedHashMap<>();
+ doc.put("format", ViewYaml.FORMAT);
+ doc.put("version", ViewYaml.VERSION);
+ doc.put("conversation", nullToEmpty(fold.getConversation()));
+ doc.put("sessions", sessions());
+ final Map<String, Object> head = new LinkedHashMap<>();
+ head.put("round", fold.getRound());
+ head.put("digest", nullToEmpty(fold.getDigest()));
+ doc.put("head", head);
+ doc.put("parser", nullToEmpty(fold.getParser()));
+ doc.put("policy", nullToEmpty(fold.getPolicy()));
+
+ final Map<String, Object> summary = new LinkedHashMap<>();
+ summary.put("title", o.title);
+ summary.put("state", chain.state);
+ summary.put("problems", chain.problems);
+ summary.put("talks", o.talks.size());
+ summary.put("steps", stepNodes());
+ summary.put("streams", o.streams.size());
+ summary.put("segments", o.segments.size());
+ summary.put("rounds", chain.rounds.size());
+ summary.put("unresolved", fold.openUnresolved().size());
+ final SessionFlowRound.Node sessionNode = fold.node(sessionNodeId());
+ summary.put("from", sessionNode == null ? 0L : Times.millis(sessionNode.attr("from_time")));
+ summary.put("to", sessionNode == null ? 0L : Times.millis(sessionNode.attr("through_time")));
+ summary.put("kinds", o.kinds);
+ summary.put("relation_types", o.relationTypes);
+ summary.put("quality", o.quality);
+ doc.put("summary", summary);
+ doc.put("rounds", chain.rounds);
+ final List<Map<String, Object>> fileList = files();
+ fileList.addAll(chain.roundFiles);
+ doc.put("files", fileList);
+ doc.put("streams", o.streams);
+ doc.put("segments", o.segments);
+ doc.put("talks", talks(o));
+ doc.put("loose", loose());
+ doc.put("relations", relations());
+ doc.put("unresolved", unresolved());
+ return doc;
+ }
+
+ /**
+ * The conversation's own session first, then every other session node the fold holds, by node id, as the
+ * Sessionizer lists them.
+ */
+ private List<String> sessions() {
+ final List<String> out = new ArrayList<>();
+ out.add(nullToEmpty(fold.getSession()));
+ final List<String> others = new ArrayList<>();
+ for (final SessionFlowRound.Node n : fold.nodesOfKind("session")) {
+ final String id = n.getId();
+ final String s = id.startsWith("session/") ? id.substring("session/".length()) : id;
+ if (!s.equals(fold.getSession()) && !others.contains(s)) {
+ others.add(s);
+ }
+ }
+ Collections.sort(others);
+ out.addAll(others);
+ return out;
+ }
+
+ /**
+ * The id of the session node, as the Sessionizer joins ids: a slash inside a part becomes an underscore.
+ */
+ private String sessionNodeId() {
+ return "session/" + nullToEmpty(fold.getSession()).replace('/', '_');
+ }
+
+ private int stepNodes() {
+ int steps = 0;
+ for (final SessionFlowRound.Node n : fold.getNodes().values()) {
+ if (isStep(n.getKind())) {
+ steps++;
+ }
+ }
+ return steps;
+ }
+
+ // ---------------------------------------------------------------- the chain check
+
+ private static final class Chain {
+ final List<Map<String, Object>> rounds = new ArrayList<>();
+ final List<Map<String, Object>> roundFiles = new ArrayList<>();
+ final List<String> problems = new ArrayList<>();
+ String state = STATE_VERIFIED;
+
+ void incomplete(final String problem) {
+ problems.add(problem);
+ if (STATE_VERIFIED.equals(state)) {
+ state = STATE_INCOMPLETE;
+ }
+ }
+
+ void mismatch(final String problem) {
+ problems.add(problem);
+ state = STATE_MISMATCH;
+ }
+ }
+
+ /**
+ * Each round must follow the round before by number, name its commit digest, continue its seq window, have
+ * every file of that window, and chain their digests to its own input digest; a missing round or file leaves
+ * the chain incomplete, a wrong digest is a mismatch. A round that does not read is a mismatch and is not
+ * listed. The fold's own problems lead, and a fold that skipped anything is incomplete at best.
+ *
+ * <p>Absent rounds are named once, as the range before the round that follows them, and the chain restarts
+ * there: that round is listed unverified, the ones after it verify against it. Absent files are named once
+ * per run of seqs, so a conversation whose files did not land is one line, not one per file.
+ */
+ private Chain chain() {
+ final Chain chain = new Chain();
+ String prevDigest = "";
+ String prevInput = "";
+ long prevThrough = 0;
+ long prevRound = 0;
+ final List<long[]> missingFiles = new ArrayList<>();
+ for (final RoundInput in : rounds) {
+ if (in.error != null) {
+ chain.mismatch("round " + in.number + ": " + in.error);
+ continue;
+ }
+ final SessionFlowRound r = in.round;
+ final SessionFlowRound.Header h = r.getHeader();
+ boolean ok = true;
+ if (h.getRound() != prevRound + 1) {
+ // absent rounds: this one cannot link to what is not there, so the chain restarts at it
+ final long from = prevRound + 1;
+ final long through = h.getRound() - 1;
+ chain.incomplete((from == through ? "round " + from + " is" : "rounds " + from + "-" + through + " are")
+ + " missing before round " + h.getRound());
+ ok = false;
+ prevThrough = h.getFromSeq() - 1;
+ } else if (!nullToEmpty(h.getPrevious()).equals(prevDigest)) {
+ chain.mismatch("round " + h.getRound() + " names previous " + first12(h.getPrevious())
+ + ", the round before is " + first12(prevDigest));
+ ok = false;
+ }
+ if (h.getFromSeq() != prevThrough + 1) {
+ chain.incomplete("round " + h.getRound() + " starts at seq " + h.getFromSeq()
+ + ", the round before ended at " + prevThrough);
+ ok = false;
+ }
+ final List<String> added = new ArrayList<>();
+ for (long seq = h.getFromSeq(); seq <= h.getThroughSeq(); seq++) {
+ final SessionDataFile f = files.get(seq);
+ if (f == null) {
+ missingFiles.add(new long[] {h.getRound(), seq});
+ ok = false;
+ continue;
+ }
+ added.add(f.getFileDigest());
+ }
+ if (ok && !Digests.chainInputDigest(prevInput, added).equals(h.getInputDigest())) {
+ chain.mismatch("round " + h.getRound() + ": the input digest does not match the landed files");
+ ok = false;
+ }
+ final Map<String, Object> m = new LinkedHashMap<>();
+ m.put("round", h.getRound());
+ m.put("digest", nullToEmpty(r.getCommitDigest()));
+ m.put("previous", StringUtil.isEmpty(h.getPrevious()) ? null : h.getPrevious());
+ m.put("from_seq", h.getFromSeq());
+ m.put("through_seq", h.getThroughSeq());
+ m.put("input_digest", nullToEmpty(h.getInputDigest()));
+ m.put("from_time", millisOrNull(h.getFromTime()));
+ m.put("through_time", millisOrNull(h.getThroughTime()));
+ m.put("verified", ok);
+ chain.rounds.add(m);
+
+ final Map<String, Object> f = new LinkedHashMap<>();
+ f.put("file", FileNames.roundFile(h.getConversation(), h.getRound(), r.getCommitDigest()));
+ f.put("format", "sf");
+ f.put("kind", "round");
+ f.put("seq", null);
+ f.put("round", h.getRound());
+ f.put("stream", null);
+ f.put("run", null);
+ f.put("lines", r.getLines());
+ f.put("bytes", r.getBytes());
+ f.put("digest", nullToEmpty(in.fileDigest));
+ f.put("from_time", millisOrNull(h.getFromTime()));
+ f.put("through_time", millisOrNull(h.getThroughTime()));
+ chain.roundFiles.add(f);
+
+ prevDigest = nullToEmpty(r.getCommitDigest());
+ prevInput = nullToEmpty(h.getInputDigest());
+ prevThrough = h.getThroughSeq();
+ prevRound = h.getRound();
+ }
+ for (final String problem : missingFileProblems(missingFiles)) {
+ chain.incomplete(problem);
+ }
+ if (!problems.isEmpty()) {
+ chain.problems.addAll(0, problems);
+ if (STATE_VERIFIED.equals(chain.state)) {
+ chain.state = STATE_INCOMPLETE;
+ }
+ }
+ return chain;
+ }
+
+ /**
+ * @param missing the round and seq of every landed file a round names and the read did not find, in chain order
+ * @return one problem per run of consecutive seqs, worded as one file when the run is one
+ */
+ private static List<String> missingFileProblems(final List<long[]> missing) {
+ final List<String> out = new ArrayList<>();
+ int i = 0;
+ while (i < missing.size()) {
+ int j = i;
+ while (j + 1 < missing.size() && missing.get(j + 1)[1] == missing.get(j)[1] + 1) {
+ j++;
+ }
+ final long firstRound = missing.get(i)[0];
+ final long lastRound = missing.get(j)[0];
+ final long firstSeq = missing.get(i)[1];
+ final long lastSeq = missing.get(j)[1];
+ out.add((firstRound == lastRound ? "round " + firstRound : "rounds " + firstRound + "-" + lastRound)
+ + (firstSeq == lastSeq ? ": landed file seq " + firstSeq + " is missing"
+ : ": landed files seq " + firstSeq + "-" + lastSeq + " are missing"));
+ i = j + 1;
+ }
+ return out;
+ }
+
+ private List<Map<String, Object>> files() {
+ final List<Map<String, Object>> out = new ArrayList<>();
+ final List<Long> seqs = new ArrayList<>(files.keySet());
+ Collections.sort(seqs);
+ for (final Long seq : seqs) {
+ final SessionDataFile f = files.get(seq);
+ final SessionDataFile.Header h = f.getHeader();
+ final Map<String, Object> m = new LinkedHashMap<>();
+ m.put("file", FileNames.dataFile(h));
+ m.put("format", "sd");
+ m.put("kind", nullToEmpty(h.getKind()));
+ m.put("seq", h.getSeq());
+ m.put("round", null);
+ m.put("stream", StringUtil.isEmpty(h.getStream()) ? null : h.getStream());
+ m.put("run", StringUtil.isEmpty(h.getBatch()) ? null : h.getBatch());
+ m.put("lines", f.getLines());
+ m.put("bytes", f.getBytes());
+ m.put("digest", f.getFileDigest());
+ m.put("from_time", f.getFromTime() == 0 ? null : f.getFromTime());
+ m.put("through_time", f.getFromTime() == 0 ? null : f.getThroughTime());
+ out.add(m);
+ }
+ return out;
+ }
+
+ // ---------------------------------------------------------------- overview
+
+ private static final class TalkRow {
+ String label = "";
+ int runs;
+ int steps;
+ int tools;
+ long from;
+ long to;
+ boolean child;
+ String segment = "";
+ String reply = "";
+ List<Ref> labelAt = Collections.emptyList();
+ Ref replyAt;
+ SessionFlowRound.Node node;
+ }
+
+ private static final class Overview {
+ String title = "";
+ Map<String, Object> kinds;
+ Map<String, Object> relationTypes;
+ Map<String, Object> quality;
+ List<TalkRow> talks;
+ List<Map<String, Object>> streams;
+ List<Map<String, Object>> segments;
+ }
+
+ private Overview overview() {
+ final Overview o = new Overview();
+ final TreeMap<String, Object> kinds = new TreeMap<>();
+ for (final SessionFlowRound.Node n : fold.getNodes().values()) {
+ kinds.merge(nullToEmpty(n.getKind()), 1, (a, b) -> (Integer) a + (Integer) b);
+ if ("session".equals(n.getKind())) {
+ o.title = nullToEmpty(n.attr("title"));
+ }
+ }
+ final TreeMap<String, Object> quality = new TreeMap<>();
+ final TreeMap<String, Object> rels = new TreeMap<>();
+ for (final SessionFlowRound.Relation r : fold.getRelations().values()) {
+ rels.merge(nullToEmpty(r.getType()), 1, (a, b) -> (Integer) a + (Integer) b);
+ quality.merge(nullToEmpty(r.getQuality()), 1, (a, b) -> (Integer) a + (Integer) b);
+ }
+ o.kinds = kinds;
+ o.relationTypes = rels;
+ o.quality = quality;
+
+ final Map<String, Boolean> childStream = new HashMap<>();
+ for (final SessionFlowRound.Node st : streamNodes()) {
+ if (!"main".equals(st.attr("role"))) {
+ childStream.put(st.getStream(), true);
+ }
+ }
+ final Map<String, String> inSegment = new HashMap<>();
+ for (final SessionFlowRound.Relation r : fold.getRelations().values()) {
+ if ("in_segment".equals(r.getType())) {
+ inSegment.put(r.getFrom(), r.getTo());
+ }
+ }
+ final List<TalkRow> talks = new ArrayList<>();
+ for (final SessionFlowRound.Node t : fold.nodesOfKind("talk")) {
+ final TalkRow row = new TalkRow();
+ row.node = t;
+ row.child = childStream.containsKey(t.getStream());
+ final long[] span = span(t);
+ row.from = span[0];
+ row.to = span[1];
+ row.segment = inSegment.getOrDefault(t.getId(), "");
+ walkCounts(t.getId(), row);
+ row.labelAt = labelRefs(t);
+ row.replyAt = replyRef(t);
+ talks.add(row);
+ }
+ for (final TalkRow row : talks) {
+ for (final Ref r : row.labelAt) {
+ final String text = readableAt(r);
+ if (trimSpace(text).startsWith("{\"type\":\"deferred_tools_delta\"")) {
+ continue;
+ }
+ if (!text.isEmpty()) {
+ row.label = clip(text);
+ break;
+ }
+ }
+ if (row.replyAt != null) {
+ row.reply = clip(readableAt(row.replyAt));
+ }
+ }
+ o.talks = talks;
+ o.streams = streamRows(talks);
+ o.segments = segmentRows(talks);
+ return o;
+ }
+
+ private void walkCounts(final String id, final TalkRow row) {
+ for (final SessionFlowRound.Node k : fold.children(id)) {
+ if ("run".equals(k.getKind())) {
+ row.runs++;
+ }
+ if (isStep(k.getKind())) {
+ row.steps++;
+ }
+ if ("tool".equals(k.getKind()) || "agent.call".equals(k.getKind())) {
+ row.tools++;
+ }
+ walkCounts(k.getId(), row);
+ }
+ }
+
+ private List<Ref> labelRefs(final SessionFlowRound.Node t) {
+ final List<Ref> inj = new ArrayList<>();
+ final Ref[] ext = new Ref[1];
+ walkLabel(t.getId(), inj, ext);
+ if (ext[0] != null) {
+ return Collections.singletonList(ext[0]);
+ }
+ return inj;
+ }
+
+ private boolean walkLabel(final String id, final List<Ref> inj, final Ref[] ext) {
+ for (final SessionFlowRound.Node k : fold.children(id)) {
+ if ("message.external".equals(k.getKind()) && k.getRef() != null) {
+ ext[0] = k.getRef();
+ return true;
+ }
+ if ("context.injection".equals(k.getKind()) && k.getRef() != null && inj.size() < 3) {
+ inj.add(k.getRef());
+ }
+ if (walkLabel(k.getId(), inj, ext)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Nullable
+ private Ref replyRef(final SessionFlowRound.Node t) {
+ final Ref[] found = new Ref[1];
+ walkReply(t.getId(), found);
+ return found[0];
+ }
+
+ private void walkReply(final String id, final Ref[] found) {
+ for (final SessionFlowRound.Node k : fold.children(id)) {
+ if (("message.assistant".equals(k.getKind()) || "agent.output".equals(k.getKind())) && k.getRef() != null) {
+ found[0] = k.getRef();
+ }
+ walkReply(k.getId(), found);
+ }
+ }
+
+ private List<Map<String, Object>> segmentRows(final List<TalkRow> talks) {
+ final Map<String, long[]> span = new HashMap<>();
+ final Map<String, Integer> count = new HashMap<>();
+ for (final TalkRow t : talks) {
+ if (t.segment.isEmpty()) {
+ continue;
+ }
+ count.merge(t.segment, 1, Integer::sum);
+ final long[] s = span.computeIfAbsent(t.segment, x -> new long[2]);
+ if (t.from != 0 && (s[0] == 0 || t.from < s[0])) {
+ s[0] = t.from;
+ }
+ if (t.to > s[1]) {
+ s[1] = t.to;
+ }
+ }
+ final List<Map<String, Object>> out = new ArrayList<>();
+ for (final SessionFlowRound.Node n : fold.nodesOfKind("segment")) {
+ final long[] s = span.getOrDefault(n.getId(), new long[2]);
+ final Map<String, Object> m = new LinkedHashMap<>();
+ m.put("id", n.getId());
+ m.put("state", nullToEmpty(n.attr("state")));
+ m.put("committable", n.attrBool("committable"));
+ m.put("talks", count.getOrDefault(n.getId(), 0));
+ m.put("from", s[0]);
+ m.put("to", s[1]);
+ out.add(m);
+ }
+ return out;
+ }
+
+ private List<SessionFlowRound.Node> streamNodes() {
+ final List<SessionFlowRound.Node> out = new ArrayList<>(fold.nodesOfKind("stream"));
+ out.sort((a, b) -> {
+ final boolean ma = "main".equals(a.attr("role"));
+ final boolean mb = "main".equals(b.attr("role"));
+ if (ma != mb) {
+ return ma ? -1 : 1;
+ }
+ return ConversationFold.compare(a, b);
+ });
+ return out;
+ }
+
+ private List<Map<String, Object>> streamRows(final List<TalkRow> talks) {
+ final Map<String, String> firstTalk = new HashMap<>();
+ final Map<String, Integer> steps = new HashMap<>();
+ for (final TalkRow t : talks) {
+ final String stream = nullToEmpty(t.node.getStream());
+ firstTalk.putIfAbsent(stream, t.node.getId());
+ steps.merge(stream, t.steps, Integer::sum);
+ }
+ final Map<String, String> parent = new HashMap<>();
+ final Map<String, List<Map<String, Object>>> openedBy = new HashMap<>();
+ for (final SessionFlowRound.Relation r : fold.getRelations().values()) {
+ if (!"starts".equals(r.getType())) {
+ continue;
+ }
+ final SessionFlowRound.Node n = fold.node(r.getFrom());
+ if (n != null && StringUtil.isNotEmpty(n.getStream())) {
+ parent.put(r.getTo(), n.getStream());
+ final Map<String, Object> origin = new LinkedHashMap<>();
+ origin.put("step", r.getFrom());
+ origin.put("stream", n.getStream());
+ origin.put("talk", talkOf(r.getFrom()));
+ origin.put("quality", nullToEmpty(r.getQuality()));
+ openedBy.computeIfAbsent(r.getTo(), x -> new ArrayList<>()).add(origin);
+ }
+ }
+ final Map<String, String> journalNames = journalNames();
+ final List<Map<String, Object>> out = new ArrayList<>();
+ for (final SessionFlowRound.Node st : streamNodes()) {
+ String label = nullToEmpty(st.attr("label"));
+ String namedBy = "";
+ if (label.isEmpty() && journalNames.containsKey(st.getStream())) {
+ label = journalNames.get(st.getStream());
+ namedBy = "journal";
+ }
+ final Map<String, Object> m = new LinkedHashMap<>();
+ m.put("id", st.getId());
+ m.put("name", nullToEmpty(st.getStream()));
+ m.put("role", nullToEmpty(st.attr("role")));
+ m.put("label", label);
+ m.put("parent", parent.getOrDefault(st.getId(), ""));
+ m.put("records", (int) st.attrNumber("records"));
+ m.put("steps", steps.getOrDefault(nullToEmpty(st.getStream()), 0));
+ m.put("talk", firstTalk.getOrDefault(nullToEmpty(st.getStream()), ""));
+ m.put("named_by", namedBy);
+ m.put("opened_by", openedBy.getOrDefault(st.getId(), new ArrayList<>()));
+ out.add(m);
+ }
+ return out;
+ }
+
+ /**
+ * A child stream a workflow started has no label of its own; its journal's result row names it.
+ */
+ private Map<String, String> journalNames() {
+ final Map<String, String> names = new HashMap<>();
+ final List<Long> seqs = new ArrayList<>(files.keySet());
+ Collections.sort(seqs);
+ for (final Long seq : seqs) {
+ final SessionDataFile f = files.get(seq);
+ if (StringUtil.isEmpty(f.getHeader().getBatch())) {
+ continue;
+ }
+ for (final SessionDataFile.Record rec : f.getRecords()) {
+ final String child = rec.child();
+ if (StringUtil.isEmpty(child) || names.containsKey(child)) {
+ continue;
+ }
+ for (final String raw : candidates(rec)) {
+ final JsonObject row;
+ try {
+ final JsonElement parsed = JsonParser.parseString(raw);
+ if (!parsed.isJsonObject()) {
+ continue;
+ }
+ row = parsed.getAsJsonObject();
+ } catch (final RuntimeException e) {
+ continue;
+ }
+ // the row must decode as Go's typed struct, or the candidate is skipped
+ if (!isStringOrAbsent(row, "type") || !isObjectOrAbsent(row, "result")) {
+ continue;
+ }
+ if (!"result".equals(string(row, "type"))) {
+ continue;
+ }
+ final JsonObject result = row.has("result") && row.get("result").isJsonObject()
+ ? row.getAsJsonObject("result") : new JsonObject();
+ if (!isStringOrAbsent(result, "surface") || !isStringOrAbsent(result, "summary")
+ || !isStringOrAbsent(result, "verdict") || !isClaimsOrAbsent(result)) {
+ continue;
+ }
+ String name = string(result, "surface");
+ if (StringUtil.isEmpty(name)) {
+ name = string(result, "summary");
+ }
+ if (StringUtil.isEmpty(name) && StringUtil.isNotEmpty(string(result, "verdict"))) {
+ name = string(result, "verdict");
+ final JsonElement refuted = result.get("refuted_claims");
+ if (refuted != null && refuted.isJsonArray() && refuted.getAsJsonArray().size() > 0) {
+ final JsonElement first = refuted.getAsJsonArray().get(0);
+ if (first.isJsonObject() && StringUtil.isNotEmpty(string(first.getAsJsonObject(), "claim"))) {
+ name += " · " + string(first.getAsJsonObject(), "claim");
+ }
+ }
+ }
+ name = shortName(name);
+ if (!name.isEmpty()) {
+ names.put(child, name);
+ }
+ break;
+ }
+ }
+ }
+ return names;
+ }
+
+ private static boolean isObjectOrAbsent(final JsonObject json, final String key) {
+ final JsonElement e = json.get(key);
+ return e == null || e.isJsonNull() || e.isJsonObject();
+ }
+
+ /**
+ * @return whether <code>refuted_claims</code> is absent or a list of objects whose <code>claim</code> is a
+ * string, the shape Go decodes
+ */
+ private static boolean isClaimsOrAbsent(final JsonObject result) {
+ final JsonElement e = result.get("refuted_claims");
+ if (e == null || e.isJsonNull()) {
+ return true;
+ }
+ if (!e.isJsonArray()) {
+ return false;
+ }
+ for (final JsonElement x : e.getAsJsonArray()) {
+ if (!x.isJsonObject() || !isStringOrAbsent(x.getAsJsonObject(), "claim")) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static String shortName(@Nullable final String s) {
+ if (s == null) {
+ return "";
+ }
+ final String joined = String.join(" ", fields(s));
+ final int limit = 160;
+ final byte[] bytes = joined.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length <= limit) {
+ return joined;
+ }
+ // Go backs up to a rune boundary here, unlike clip, so no replacement character
+ int cut = limit;
+ while (cut > 0 && (bytes[cut] & 0xC0) == 0x80) {
+ cut--;
+ }
+ return new String(bytes, 0, cut, StandardCharsets.UTF_8) + "…";
+ }
+
+ /**
+ * The text split on Unicode white space, as Go's <code>strings.Fields</code>, so a no-break space splits too.
+ */
+ private static List<String> fields(final String s) {
+ final List<String> out = new ArrayList<>();
+ final StringBuilder word = new StringBuilder();
+ for (int i = 0; i < s.length(); ) {
+ final int cp = s.codePointAt(i);
+ if (isSpace(cp)) {
+ if (word.length() > 0) {
+ out.add(word.toString());
+ word.setLength(0);
+ }
+ } else {
+ word.appendCodePoint(cp);
+ }
+ i += Character.charCount(cp);
+ }
+ if (word.length() > 0) {
+ out.add(word.toString());
+ }
+ return out;
+ }
+
+ /**
+ * Go's <code>unicode.IsSpace</code>: the Latin-1 spaces and the Unicode space separators.
+ */
+ private static boolean isSpace(final int cp) {
+ return cp == ' ' || cp == '\t' || cp == '\n' || cp == '\u000B' || cp == '\f' || cp == '\r' || cp == 0x85
+ || cp == 0xA0 || cp == 0x2028 || cp == 0x2029 || Character.getType(cp) == Character.SPACE_SEPARATOR;
+ }
+
+ /**
+ * Go's <code>strings.TrimSpace</code>.
+ */
+ private static String trimSpace(final String s) {
+ int start = 0;
+ int end = s.length();
+ while (start < end) {
+ final int cp = s.codePointAt(start);
+ if (!isSpace(cp)) {
+ break;
+ }
+ start += Character.charCount(cp);
+ }
+ while (end > start) {
+ final int cp = s.codePointBefore(end);
+ if (!isSpace(cp)) {
+ break;
+ }
+ end -= Character.charCount(cp);
+ }
+ return s.substring(start, end);
+ }
+
+ private String talkOf(final String start) {
+ String id = start;
+ for (int i = 0; i < 24 && StringUtil.isNotEmpty(id); i++) {
+ final SessionFlowRound.Node n = fold.node(id);
+ if (n == null) {
+ return "";
+ }
+ if ("talk".equals(n.getKind())) {
+ return n.getId();
+ }
+ id = n.getParent();
+ }
+ return "";
+ }
+
+ // ---------------------------------------------------------------- talks and their trees
+
+ private List<Map<String, Object>> talks(final Overview o) {
+ final List<Map<String, Object>> out = new ArrayList<>();
+ for (final TalkRow row : o.talks) {
+ out.add(step(row.node, 0, row));
+ }
+ return out;
+ }
+
+ /**
+ * One node, keys in the order of <code>sessionview.Node</code>, empty values absent. A talk carries its row's
+ * summary keys between the record keys and the tool keys.
+ */
+ private Map<String, Object> step(final SessionFlowRound.Node n, final int depth, @Nullable final TalkRow talk) {
+ final Map<String, Object> out = new LinkedHashMap<>();
+ out.put("id", n.getId());
+ out.put("kind", nullToEmpty(n.getKind()));
+ if (StringUtil.isNotEmpty(n.getParent())) {
+ out.put("parent", n.getParent());
+ }
+ if (StringUtil.isNotEmpty(n.getStream())) {
+ out.put("stream", n.getStream());
+ }
+ out.put("at", time(n));
+ if (n.getRef() != null) {
+ out.put("ref", n.getRef().toMap());
+ }
+ if (!n.getRefs().isEmpty()) {
+ final List<Map<String, Object>> refs = new ArrayList<>();
+ for (final Ref r : n.getRefs()) {
+ refs.add(r.toMap());
+ }
+ out.put("refs", refs);
+ }
+ if (n.getRawAttrs() != null) {
+ // as the Sessionizer prints the raw attrs: an empty object stays {}, an explicit null stays null
+ out.put("attrs", jsonToValue(n.getRawAttrs()));
+ }
+
+ // text, state, bytes; then usage, flags, dropped; then the talk keys; then the tool keys
+ final Map<String, Object> content = new LinkedHashMap<>();
+ final Map<String, Object> tool = new LinkedHashMap<>();
+ if (n.getRef() != null && carriesContent(n.getKind())) {
+ final SessionDataFile.Record rec = record(n.getRef());
+ if (rec != null) {
+ fill(content, tool, rec, n.getRef().getBlock());
+ fillDuration(content, tool, n, rec);
+ if (!rec.flags().isEmpty()) {
+ content.put("flags", new ArrayList<>(rec.flags()));
+ }
+ final JsonArray dropped = rec.dropped();
+ if (dropped != null && dropped.size() > 0) {
+ final List<Object> drops = new ArrayList<>();
+ for (final JsonElement d : dropped) {
+ drops.add(d.isJsonObject() ? jsonToMap(d.getAsJsonObject()) : d.toString());
+ }
+ content.put("dropped", drops);
+ }
+ }
+ for (int i = 1; i < n.getRefs().size(); i++) {
+ final Ref r = n.getRefs().get(i);
+ final SessionDataFile.Record rr = record(r);
+ if (rr != null && !tool.containsKey("result")) {
+ fillResult(tool, rr, r.getBlock());
+ }
+ }
+ fillRequestToResult(tool, n);
+ }
+ final JsonObject usage = usageAt(n);
+ if (usage != null) {
+ // as the Sessionizer prints the record's raw usage object, an empty one included
+ content.put("usage", jsonToMap(usage));
+ }
+ // sessionview.Node lists text, state, bytes, then usage, flags, dropped
+ for (final String key : new String[] {"text", "state", "bytes", "usage", "flags", "dropped"}) {
+ if (content.containsKey(key)) {
+ out.put(key, content.get(key));
+ }
+ }
+ if (talk != null) {
+ if (!talk.label.isEmpty()) {
+ out.put("label", talk.label);
+ }
+ if (!talk.reply.isEmpty()) {
+ out.put("reply", talk.reply);
+ }
+ if (talk.runs != 0) {
+ out.put("runs", talk.runs);
+ }
+ if (talk.steps != 0) {
+ out.put("steps", talk.steps);
+ }
+ if (talk.tools != 0) {
+ out.put("tools", talk.tools);
+ }
+ if (talk.from != 0) {
+ out.put("from", talk.from);
+ }
+ if (talk.to != 0) {
+ out.put("to", talk.to);
+ }
+ if (talk.child) {
+ out.put("child", true);
+ }
+ if (!talk.segment.isEmpty()) {
+ out.put("segment", talk.segment);
+ }
+ }
+ for (final String key : new String[] {
+ "name", "failed", "result", "result_state", "result_bytes", "request_to_result_ms", "request_to_result_join",
+ "duration_ms", "duration_measured_by"}) {
+ if (tool.containsKey(key)) {
+ out.put(key, tool.get(key));
+ }
+ }
+ if (depth < MAX_DEPTH) {
+ final List<Map<String, Object>> children = new ArrayList<>();
+ for (final SessionFlowRound.Node k : fold.children(n.getId())) {
+ children.add(step(k, depth + 1, null));
+ }
+ if (!children.isEmpty()) {
+ out.put("children", children);
+ }
+ }
+ // every relation touching the node, by relation id and then direction, as the Sessionizer lists them
+ final List<Map<String, Object>> edges = new ArrayList<>();
+ for (final SessionFlowRound.Relation r : fold.relationsFrom(n.getId())) {
+ edges.add(edge(r, r.getTo(), "out"));
+ }
+ for (final SessionFlowRound.Relation r : fold.relationsTo(n.getId())) {
+ edges.add(edge(r, r.getFrom(), "in"));
+ }
+ edges.sort(Comparator.comparing((Map<String, Object> e) -> (String) e.get("id"))
+ .thenComparing(e -> (String) e.get("dir")));
+ for (final Map<String, Object> e : edges) {
+ e.remove("id");
+ }
+ if (!edges.isEmpty()) {
+ out.put("edges", edges);
+ }
+ return out;
+ }
+
+ /**
+ * The runs and steps no talk contains, as trees from their highest such ancestor, in record order: a step is
+ * contained when a talk is above it; one whose ancestors are only structure, the session, a stream, an epoch
+ * or a segment, is loose. With <code>talks</code> this holds every run and step of the fold.
+ */
+ private List<Map<String, Object>> loose() {
+ final Map<String, SessionFlowRound.Node> roots = new HashMap<>();
+ for (final SessionFlowRound.Node n : fold.getNodes().values()) {
+ if (!"run".equals(n.getKind()) && !isStep(n.getKind())) {
+ continue;
+ }
+ SessionFlowRound.Node top = n;
+ boolean covered = false;
+ SessionFlowRound.Node cur = n;
+ for (int i = 0; cur != null && i < MAX_ANCESTORS; i++) {
+ if ("talk".equals(cur.getKind())) {
+ covered = true;
+ break;
+ }
+ if ("run".equals(cur.getKind()) || isStep(cur.getKind())) {
+ top = cur;
+ }
+ cur = StringUtil.isEmpty(cur.getParent()) ? null : fold.node(cur.getParent());
+ }
+ if (!covered) {
+ roots.put(top.getId(), top);
+ }
+ }
+ final List<SessionFlowRound.Node> ordered = new ArrayList<>(roots.values());
+ ordered.sort(ConversationFold::compare);
+ final List<Map<String, Object>> out = new ArrayList<>();
+ for (final SessionFlowRound.Node n : ordered) {
+ out.add(step(n, 0, null));
+ }
+ return out;
+ }
+
+ private static Map<String, Object> edge(final SessionFlowRound.Relation r, final String other, final String dir) {
+ final Map<String, Object> e = new LinkedHashMap<>();
+ // the relation id orders the edges and is removed before the edge is emitted
+ e.put("id", nullToEmpty(r.getId()));
+ e.put("type", nullToEmpty(r.getType()));
+ e.put("other", nullToEmpty(other));
+ e.put("dir", dir);
+ e.put("quality", nullToEmpty(r.getQuality()));
+ if (StringUtil.isNotEmpty(r.getVia())) {
+ e.put("via", r.getVia());
+ }
+ return e;
+ }
+
+ private static void fill(final Map<String, Object> content, final Map<String, Object> tool,
+ final SessionDataFile.Record rec, @Nullable final Integer block) {
+ SessionDataFile.Part p = null;
+ if (block != null && block < rec.getParts().size()) {
+ p = rec.getParts().get(block);
+ } else if (rec.getParts().size() == 1) {
+ p = rec.getParts().get(0);
+ }
+ if (p == null) {
+ final String text = clip(readable(rec));
+ if (!text.isEmpty()) {
+ content.put("text", text);
+ }
+ return;
+ }
+ if (StringUtil.isNotEmpty(p.getName())) {
+ tool.put("name", p.getName());
+ }
+ if (p.getFailed() != null) {
+ tool.put("failed", p.getFailed());
+ }
+ if (StringUtil.isNotEmpty(p.getState())) {
+ content.put("state", p.getState());
+ }
+ if (p.getBytes() != 0) {
+ content.put("bytes", p.getBytes());
+ }
+ final String data = p.data();
+ if (StringUtil.isNotEmpty(p.getText())) {
+ content.put("text", clip(p.getText()));
+ } else if (data != null) {
+ final String t = readable(rec);
+ if (!t.isEmpty() && !t.equals(data.trim())) {
+ content.put("text", clip(t));
+ } else {
+ content.put("text", clip(data));
+ }
+ }
+ }
+
+ @Nullable
+ private JsonObject usageAt(final SessionFlowRound.Node n) {
+ if (!"llm.call".equals(n.getKind()) || n.getAttrs() == null || !n.getAttrs().has("usage_at")
+ || !n.getAttrs().get("usage_at").isJsonObject()) {
+ return null;
+ }
+ final SessionDataFile.Record at = record(Ref.of(n.getAttrs().getAsJsonObject("usage_at")));
+ return at == null ? null : at.usage();
+ }
+
+ private static void fillResult(final Map<String, Object> tool, final SessionDataFile.Record rec,
+ @Nullable final Integer block) {
+ SessionDataFile.Part p = null;
+ if (block != null && block < rec.getParts().size()) {
+ p = rec.getParts().get(block);
+ } else {
+ for (final SessionDataFile.Part x : rec.getParts()) {
+ if ("result".equals(x.getKind())) {
+ p = x;
+ break;
+ }
+ }
+ }
+ if (p == null) {
+ return;
+ }
+ // assigned, not merged: a later result record with no state or size clears the earlier one's, as Go does
+ if (StringUtil.isNotEmpty(p.getState())) {
+ tool.put("result_state", p.getState());
+ } else {
+ tool.remove("result_state");
+ }
+ if (p.getBytes() != 0) {
+ tool.put("result_bytes", p.getBytes());
+ } else {
+ tool.remove("result_bytes");
+ }
+ if (p.getFailed() != null && !tool.containsKey("failed")) {
+ tool.put("failed", p.getFailed());
+ }
+ final String data = p.data();
+ if (StringUtil.isNotEmpty(p.getText())) {
+ tool.put("result", clip(p.getText()));
+ } else if (data != null) {
+ tool.put("result", clip(data));
+ }
+ }
+
+ private void fillRequestToResult(final Map<String, Object> tool, final SessionFlowRound.Node n) {
+ if (n.getRefs().size() < 2) {
+ return;
+ }
+ final String join = n.attr("result_join");
+ if (!"exact_unique".equals(join)) {
+ return;
+ }
+ // in nanoseconds, as the Sessionizer subtracts them, floored to milliseconds only at the end
+ final long from = nanosAt(n.getRefs().get(0));
+ final long to = nanosAt(n.getRefs().get(1));
+ if (from == 0 || to == 0 || to < from) {
+ return;
+ }
+ final long ms = (to - from) / 1_000_000L;
+ if (ms != 0) {
+ // Go leaves a zero interval out (omitempty) and keeps the join
+ tool.put("request_to_result_ms", ms);
+ }
+ tool.put("request_to_result_join", join);
+ }
+
+ private static void fillDuration(final Map<String, Object> content, final Map<String, Object> tool,
+ final SessionFlowRound.Node n, final SessionDataFile.Record rec) {
+ if (!"turn.duration".equals(n.getKind())) {
+ return;
+ }
+ long durationMs = 0;
+ for (final String raw : candidates(rec)) {
+ try {
+ final JsonElement e = JsonParser.parseString(raw);
+ if (e.isJsonObject() && e.getAsJsonObject().has("durationMs")) {
+ final JsonElement d = e.getAsJsonObject().get("durationMs");
+ if (!d.isJsonPrimitive() || !d.getAsJsonPrimitive().isNumber()
+ || !INTEGER_LITERAL.matcher(d.getAsString()).matches()) {
+ // not the integer Go decodes into; the candidate is skipped
+ continue;
+ }
+ durationMs = Long.parseLong(d.getAsString());
+ if (durationMs != 0) {
+ break;
+ }
+ }
+ } catch (final RuntimeException ignored) {
+ // not JSON; the next candidate may be
+ }
+ }
+ if (durationMs == 0) {
+ return;
+ }
+ tool.put("duration_ms", durationMs);
+ final String measuredBy = n.attr("measured_by");
+ if (StringUtil.isNotEmpty(measuredBy)) {
+ tool.put("duration_measured_by", measuredBy);
+ }
+ content.remove("text");
+ }
+
+ private List<Map<String, Object>> relations() {
+ final List<SessionFlowRound.Relation> sorted = new ArrayList<>(fold.getRelations().values());
+ sorted.sort(Comparator.comparing(SessionFlowRound.Relation::getId));
+ final List<Map<String, Object>> out = new ArrayList<>();
+ for (final SessionFlowRound.Relation r : sorted) {
+ final Map<String, Object> m = new LinkedHashMap<>();
+ m.put("id", r.getId());
+ m.put("type", nullToEmpty(r.getType()));
+ m.put("from", nullToEmpty(r.getFrom()));
+ m.put("to", nullToEmpty(r.getTo()));
+ m.put("quality", nullToEmpty(r.getQuality()));
+ if (StringUtil.isNotEmpty(r.getVia())) {
+ m.put("via", r.getVia());
+ }
+ if (!r.getEvidence().isEmpty()) {
+ final List<Map<String, Object>> evidence = new ArrayList<>();
+ for (final Ref ref : r.getEvidence()) {
+ evidence.add(ref.toMap());
+ }
+ m.put("evidence", evidence);
+ }
+ out.add(m);
+ }
+ return out;
+ }
+
+ private List<Map<String, Object>> unresolved() {
+ final List<SessionFlowRound.Unresolved> sorted = new ArrayList<>(fold.getUnresolved().values());
+ sorted.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId));
+ final List<Map<String, Object>> out = new ArrayList<>();
+ for (final SessionFlowRound.Unresolved u : sorted) {
+ final Map<String, Object> m = new LinkedHashMap<>();
+ m.put("id", u.getId());
+ m.put("kind", nullToEmpty(u.getKind()));
+ m.put("ref", nullToEmpty(u.getRef()));
+ m.put("reason", nullToEmpty(u.getReason()));
+ m.put("state", nullToEmpty(u.getState()));
+ out.add(m);
+ }
+ return out;
+ }
+
+ // ---------------------------------------------------------------- records and times
+
+ @Nullable
+ private SessionDataFile.Record record(@Nullable final Ref ref) {
+ if (ref == null) {
+ return null;
+ }
+ final SessionDataFile f = files.get(ref.getSeq());
+ return f == null ? null : f.record(ref.getRow());
+ }
+
+ private long timeAt(@Nullable final Ref ref) {
+ // floored, as Go's UnixMilli, so a moment before 1970 rounds the same way; a duration truncates instead
+ return Math.floorDiv(nanosAt(ref), 1_000_000L);
+ }
+
+ private long nanosAt(@Nullable final Ref ref) {
+ if (ref == null) {
+ return 0;
+ }
+ final Long t = at.get(new Ref(ref.getSeq(), ref.getRow(), null));
+ return t == null ? 0 : t;
+ }
+
+ private long time(final SessionFlowRound.Node n) {
+ return timeAt(n.getRef());
+ }
+
+ private long[] span(final SessionFlowRound.Node n) {
+ final long[] lohi = new long[2];
+ walkSpan(n, lohi);
+ return lohi;
+ }
+
+ private void walkSpan(final SessionFlowRound.Node x, final long[] lohi) {
+ final long t = time(x);
+ if (t != 0) {
+ if (lohi[0] == 0 || t < lohi[0]) {
+ lohi[0] = t;
+ }
+ if (t > lohi[1]) {
+ lohi[1] = t;
+ }
+ }
+ for (final SessionFlowRound.Node k : fold.children(x.getId())) {
+ walkSpan(k, lohi);
+ }
+ }
+
+ private String readableAt(final Ref ref) {
+ final SessionDataFile.Record rec = record(ref);
+ return rec == null ? "" : readable(rec);
+ }
+
+ /**
+ * The readable text of a record: its text parts, or, for a queued command, the prompt texts inside its data.
+ */
+ static String readable(final SessionDataFile.Record rec) {
+ final String t = trimSpace(rec.text());
+ if (!t.isEmpty()) {
+ return t;
+ }
+ for (final String raw : candidates(rec)) {
+ try {
+ final JsonElement e = JsonParser.parseString(raw);
+ if (!e.isJsonObject() || !"queued_command".equals(string(e.getAsJsonObject(), "type"))) {
+ continue;
+ }
+ final JsonElement prompt = e.getAsJsonObject().get("prompt");
+ if (prompt == null || !prompt.isJsonArray()) {
+ continue;
+ }
+ // the whole candidate is the shape Go decodes, or it is skipped: every element an object whose
+ // text, when present, is a string
+ final List<String> out = new ArrayList<>();
+ boolean shaped = true;
+ for (final JsonElement p : prompt.getAsJsonArray()) {
+ if (!p.isJsonObject() || !isStringOrAbsent(p.getAsJsonObject(), "text")) {
+ shaped = false;
+ break;
+ }
+ if (StringUtil.isNotEmpty(string(p.getAsJsonObject(), "text"))) {
+ out.add(string(p.getAsJsonObject(), "text"));
+ }
+ }
+ if (shaped && !out.isEmpty()) {
+ return trimSpace(String.join("\n", out));
+ }
+ } catch (final RuntimeException ignored) {
+ // not JSON; the next candidate may be
+ }
+ }
+ return "";
+ }
+
+ private static boolean isStringOrAbsent(final JsonObject json, final String key) {
+ final JsonElement e = json.get(key);
+ return e == null || e.isJsonNull() || e.isJsonPrimitive() && e.getAsJsonPrimitive().isString();
+ }
+
+ static List<String> candidates(final SessionDataFile.Record rec) {
+ final List<String> out = new ArrayList<>();
+ for (final SessionDataFile.Part p : rec.getParts()) {
+ final String data = p.data();
+ if (data != null) {
+ out.add(data);
+ }
+ if (StringUtil.isNotEmpty(p.getText())) {
+ out.add(p.getText());
+ }
+ }
+ final String t = rec.text();
+ if (!t.isEmpty()) {
+ out.add(t);
+ }
+ return out;
+ }
+
+ static boolean isStep(final String kind) {
+ switch (nullToEmpty(kind)) {
+ case "session":
+ case "segment":
+ case "stream":
+ case "epoch":
+ case "talk":
+ case "run":
+ return false;
+ default:
+ return true;
+ }
+ }
+
+ static boolean carriesContent(final String kind) {
+ switch (nullToEmpty(kind)) {
+ case "llm.call":
+ case "session":
+ case "segment":
+ case "stream":
+ case "epoch":
+ case "talk":
+ case "run":
+ return false;
+ default:
+ return true;
+ }
+ }
+
+ static String clip(final String t) {
+ return clipBytes(t, PREVIEW_BYTES);
+ }
+
+ /**
+ * The longest prefix of whole characters within the byte budget, as the Sessionizer clips, so a preview never
+ * ends in a broken character.
+ */
+ private static String clipBytes(final String t, final int limit) {
+ if (t == null) {
+ return "";
+ }
+ final byte[] bytes = t.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length <= limit) {
+ return t;
+ }
+ int cut = limit;
+ while (cut > 0 && (bytes[cut] & 0xC0) == 0x80) {
+ cut--;
+ }
+ return new String(bytes, 0, cut, StandardCharsets.UTF_8);
+ }
+
+ static Map<String, Object> jsonToMap(final JsonObject json) {
+ final Map<String, Object> out = new LinkedHashMap<>();
+ for (final Map.Entry<String, JsonElement> e : json.entrySet()) {
+ out.put(e.getKey(), jsonToValue(e.getValue()));
+ }
+ return out;
+ }
+
+ private static Object jsonToValue(final JsonElement e) {
+ if (e == null || e.isJsonNull()) {
+ return null;
+ }
+ if (e.isJsonObject()) {
+ return jsonToMap(e.getAsJsonObject());
+ }
+ if (e.isJsonArray()) {
+ final List<Object> list = new ArrayList<>();
+ for (final JsonElement x : e.getAsJsonArray()) {
+ list.add(jsonToValue(x));
+ }
+ return list;
+ }
+ if (e.getAsJsonPrimitive().isBoolean()) {
+ return e.getAsBoolean();
+ }
+ if (e.getAsJsonPrimitive().isNumber()) {
+ // as written: Go prints the raw attrs, so 1.0 stays 1.0 and 1 stays 1
+ final String literal = e.getAsString();
+ if (INTEGER_LITERAL.matcher(literal).matches()) {
+ try {
+ return Long.parseLong(literal);
+ } catch (final NumberFormatException ignored) {
+ return new BigInteger(literal);
+ }
+ }
+ return new BigDecimal(literal);
+ }
+ return e.getAsString();
+ }
+
+ @Nullable
+ private static Long millisOrNull(@Nullable final String rfc3339) {
+ // absent is null; present but unreadable is 0, as the Sessionizer's millisPtr
+ return StringUtil.isEmpty(rfc3339) ? null : Long.valueOf(Times.millis(rfc3339));
+ }
+
+ @Nullable
+ private static String string(final JsonObject json, final String key) {
+ final JsonElement e = json.get(key);
+ return e == null || e.isJsonNull() || !e.isJsonPrimitive() ? null : e.getAsString();
+ }
+
+ private static String nullToEmpty(@Nullable final String s) {
+ return s == null ? "" : s;
+ }
+
+ private static String first12(@Nullable final String s) {
+ return s == null ? "" : s.substring(0, Math.min(12, s.length()));
+ }
+
+ /**
+ * One stored round: decoded, with what the store knows about it beyond its bytes, or the reason it does not
+ * read, in the Sessionizer's words.
+ */
+ @Getter
+ public static final class RoundInput {
+ private final long number;
+ private final SessionFlowRound round;
+ private final String fileDigest;
+ private final String error;
+
+ public RoundInput(final SessionFlowRound round, final String fileDigest) {
+ this.number = round.getHeader().getRound();
+ this.round = round;
+ this.fileDigest = fileDigest;
+ this.error = null;
+ }
+
+ private RoundInput(final long number, final String error) {
+ this.number = number;
+ this.round = null;
+ this.fileDigest = null;
+ this.error = error;
+ }
+
+ /**
+ * @param number the round's number as stored
+ * @param error why it does not read
+ * @return a round the chain lists as a problem and never folds
+ */
+ public static RoundInput unreadable(final long number, final String error) {
+ return new RoundInput(number, error);
+ }
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ViewJson.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ViewJson.java
new file mode 100644
index 0000000000..8668bd3c69
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ViewJson.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.view;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.io.Writer;
+import java.util.Map;
+
+/**
+ * Writes an <code>asz.view</code> document as JSON, keys in insertion order and absent values as null, the way
+ * <code>asz conversation -json</code> and the Sessionizer's viewer print it.
+ */
+public final class ViewJson {
+ private static final Gson GSON = new GsonBuilder().serializeNulls().disableHtmlEscaping().create();
+
+ private ViewJson() {
+ }
+
+ /**
+ * Streams the document into the writer as it is walked; nothing is buffered whole.
+ *
+ * @param document the document, as ordered maps, lists, strings, numbers and booleans
+ * @param out where the JSON goes
+ */
+ public static void write(final Map<String, Object> document, final Writer out) {
+ GSON.toJson(document, out);
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ViewYaml.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ViewYaml.java
new file mode 100644
index 0000000000..523208a4d9
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ViewYaml.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.view;
+
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Map;
+import org.yaml.snakeyaml.DumperOptions;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+import org.yaml.snakeyaml.representer.Representer;
+
+/**
+ * Writes an <code>asz.view</code> document as YAML: block style, two-space indent, keys in insertion order, no
+ * anchors and no type tags, so the same document gives the same bytes wherever it is rendered.
+ */
+public final class ViewYaml {
+ public static final String FORMAT = "asz.view";
+ public static final String VERSION = "1.0";
+
+ private ViewYaml() {
+ }
+
+ /**
+ * @param document the document, as ordered maps, lists, strings, numbers and booleans
+ * @return the YAML text
+ */
+ public static String dump(final Map<String, Object> document) {
+ final StringWriter out = new StringWriter();
+ write(document, out);
+ return out.toString();
+ }
+
+ /**
+ * Streams the document into the writer as it is walked; nothing is buffered whole.
+ *
+ * @param document the document, as ordered maps, lists, strings, numbers and booleans
+ * @param out where the YAML goes
+ */
+ public static void write(final Map<String, Object> document, final Writer out) {
+ final DumperOptions options = new DumperOptions();
+ options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
+ options.setIndent(2);
+ options.setIndicatorIndent(0);
+ options.setPrettyFlow(false);
+ options.setSplitLines(false);
+ options.setWidth(Integer.MAX_VALUE);
+ options.setLineBreak(DumperOptions.LineBreak.UNIX);
+ options.setAllowUnicode(true);
+ final Representer representer = new Representer(options);
+ final Yaml yaml = new Yaml(new SafeConstructor(new LoaderOptions()), representer, options);
+ yaml.dump(document, out);
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.source.LALOutputBuilder b/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.source.LALOutputBuilder
new file mode 100644
index 0000000000..7538d17425
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.source.LALOutputBuilder
@@ -0,0 +1,18 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+org.apache.skywalking.oap.server.ai.agent.conversation.ingest.ConversationFileBuilder
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.library.module.ModuleDefine b/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.library.module.ModuleDefine
new file mode 100644
index 0000000000..202ce8d154
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.library.module.ModuleDefine
@@ -0,0 +1,18 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationModule
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.library.module.ModuleProvider b/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.library.module.ModuleProvider
new file mode 100644
index 0000000000..8efb6f92bc
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.library.module.ModuleProvider
@@ -0,0 +1,18 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationProvider
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentLalRuleTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentLalRuleTest.java
new file mode 100644
index 0000000000..a56a3a7e93
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentLalRuleTest.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import javassist.ClassPool;
+import org.apache.skywalking.apm.network.common.v3.KeyStringValuePair;
+import org.apache.skywalking.apm.network.logging.v3.LogData;
+import org.apache.skywalking.apm.network.logging.v3.LogDataBody;
+import org.apache.skywalking.apm.network.logging.v3.LogTags;
+import org.apache.skywalking.apm.network.logging.v3.TextLog;
+import org.apache.skywalking.oap.log.analyzer.v2.compiler.LALClassGenerator;
+import org.apache.skywalking.oap.log.analyzer.v2.dsl.ExecutionContext;
+import org.apache.skywalking.oap.log.analyzer.v2.dsl.LalExpression;
+import org.apache.skywalking.oap.log.analyzer.v2.dsl.spec.filter.FilterSpec;
+import org.apache.skywalking.oap.log.analyzer.v2.module.LogAnalyzerModule;
+import org.apache.skywalking.oap.log.analyzer.v2.provider.LogAnalyzerModuleConfig;
+import org.apache.skywalking.oap.log.analyzer.v2.provider.LogAnalyzerModuleProvider;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.ingest.ConversationFileBuilder;
+import org.apache.skywalking.oap.server.core.CoreModule;
+import org.apache.skywalking.oap.server.core.analysis.Layer;
+import org.apache.skywalking.oap.server.core.config.ConfigService;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.config.group.EndpointNameGrouping;
+import org.apache.skywalking.oap.server.core.source.LogMetadata;
+import org.apache.skywalking.oap.server.core.source.SourceReceiver;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.library.module.ModuleProviderHolder;
+import org.apache.skywalking.oap.server.library.module.ModuleServiceHolder;
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * The bundled rule must compile against the builder: every extractor field is a setter on
+ * {@link ConversationFileBuilder}, checked by the LAL compiler, and the rule names the layer and the output type
+ * the module registers. It must also read a round from before the list attributes existed, which travels
+ * without them; an absent tag reads as an empty string, and an empty string is not a number.
+ */
+public class AIAgentLalRuleTest {
+ private static final Path RULE = Paths.get("..", "..", "server-starter", "src", "main", "resources", "lal", "ai-agent.yaml");
+ private static final String CONVERSATION = "7a3c882e-0dc0-46a0-b814-6613d24b7ac2";
+ private static final long SENT_AT = 1757100000000L;
+
+ @Test
+ public void theBundledRuleCompilesAgainstTheBuilder() throws Exception {
+ assertNotNull(compileBundledRule());
+ }
+
+ @Test
+ public void aRoundFromBeforeTheListAttributesReads() throws Exception {
+ final LalExpression rule = compileBundledRule();
+ final byte[] body = Fixtures.bytes(Fixtures.ROUND_FILE);
+
+ final ConversationFileBuilder before = run(rule, round(body, false));
+ assertEquals("sf", before.getFormat());
+ assertEquals(Digests.sha256Hex(body), before.getDigest());
+ assertEquals(Digests.countLines(body), before.getLines());
+ assertEquals(CONVERSATION, before.getConversation());
+ assertEquals(198L, before.getRound());
+ assertEquals("2026-09-04T03:40:45.971Z", before.getSessionFromTime());
+ assertEquals("", before.getTitle());
+ assertEquals(0L, before.getTalks());
+ assertEquals(0L, before.getSteps());
+ assertEquals(0L, before.getStreams());
+ assertEquals(0L, before.getSegments());
+ assertEquals(0L, before.getUnresolved());
+
+ final ConversationFileBuilder after = run(rule, round(body, true));
+ assertEquals(198L, after.getRound());
+ assertEquals("AI agent group/table design", after.getTitle());
+ assertEquals(357L, after.getTalks());
+ assertEquals(16121L, after.getSteps());
+ assertEquals(132L, after.getStreams());
+ assertEquals(50L, after.getSegments());
+ assertEquals(0L, after.getUnresolved());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static LalExpression compileBundledRule() throws Exception {
+ assertTrue(Files.exists(RULE), "the bundled rule is at " + RULE.toAbsolutePath());
+ final Map<String, Object> yaml = new Yaml().load(new String(Files.readAllBytes(RULE), StandardCharsets.UTF_8));
+ final List<Map<String, Object>> rules = (List<Map<String, Object>>) yaml.get("rules");
+ assertEquals(1, rules.size());
+ final Map<String, Object> rule = rules.get(0);
+ assertEquals("AI_AGENT", rule.get("layer"));
+ assertEquals(ConversationFileBuilder.NAME, rule.get("outputType"));
+
+ final LALClassGenerator generator = new LALClassGenerator(new ClassPool(true));
+ generator.setOutputType(ConversationFileBuilder.class);
+ generator.setClassNameHint("ai_agent_rule_test");
+ return generator.compile((String) rule.get("dsl"));
+ }
+
+ /**
+ * Runs the rule over one record as the log analyzer does, up to the sink: the extractor fills the builder the
+ * generated class installs on the context. The sink listeners are left out, so nothing is dispatched.
+ */
+ private static ConversationFileBuilder run(final LalExpression rule, final LogData.Builder record) throws Exception {
+ final FilterSpec spec = new FilterSpec(moduleManager(), new LogAnalyzerModuleConfig());
+ final Field sinkListeners = FilterSpec.class.getDeclaredField("sinkListenerFactories");
+ sinkListeners.setAccessible(true);
+ sinkListeners.set(spec, Collections.emptyList());
+ final LogMetadata metadata = LogMetadata.builder()
+ .service("Claude Code")
+ .serviceInstance("wusheng@host")
+ .layer(Layer.AI_AGENT.name())
+ .timestamp(SENT_AT)
+ .build();
+ final ExecutionContext ctx = new ExecutionContext().init(metadata, record);
+ rule.execute(spec, ctx);
+ return (ConversationFileBuilder) ctx.output();
+ }
+
+ /**
+ * A round as the Sessionizer pushes it: the list attributes only when its header carries them.
+ */
+ private static LogData.Builder round(final byte[] body, final boolean listed) {
+ final LogTags.Builder tags = LogTags.newBuilder()
+ .addData(tag("asz.format", "sf"))
+ .addData(tag("asz.file", "_conversations/" + CONVERSATION + "/rounds/r000198-77272f5c0ed9.sf"))
+ .addData(tag("asz.file.digest", Digests.sha256Hex(body)))
+ .addData(tag("asz.lines", String.valueOf(Digests.countLines(body))))
+ .addData(tag("asz.session", CONVERSATION))
+ .addData(tag("asz.conversation", CONVERSATION))
+ .addData(tag("asz.round", "198"))
+ .addData(tag("asz.session.from_time", "2026-09-04T03:40:45.971Z"))
+ .addData(tag("asz.session.through_time", "2026-09-04T09:17:37.559Z"));
+ if (listed) {
+ tags.addData(tag("asz.conversation.title", "AI agent group/table design"))
+ .addData(tag("asz.conversation.talks", "357"))
+ .addData(tag("asz.conversation.steps", "16121"))
+ .addData(tag("asz.conversation.streams", "132"))
+ .addData(tag("asz.conversation.segments", "50"))
+ .addData(tag("asz.conversation.unresolved", "0"));
+ }
+ return LogData.newBuilder()
+ .setBody(LogDataBody.newBuilder().setText(
+ TextLog.newBuilder().setText(new String(body, StandardCharsets.UTF_8))))
+ .setTags(tags);
+ }
+
+ private static KeyStringValuePair.Builder tag(final String key, final String value) {
+ return KeyStringValuePair.newBuilder().setKey(key).setValue(value);
+ }
+
+ /**
+ * What the rule and its filter spec look up: the core services the sink and the builder use, and the log
+ * analyzer's own provider for the metric converters.
+ */
+ private static ModuleManager moduleManager() {
+ final ModuleManager manager = mock(ModuleManager.class);
+ when(manager.find(anyString())).thenReturn(mock(ModuleProviderHolder.class));
+
+ final ModuleProviderHolder analyzer = mock(ModuleProviderHolder.class);
+ final LogAnalyzerModuleProvider provider = mock(LogAnalyzerModuleProvider.class);
+ when(provider.getMetricConverts()).thenReturn(Collections.emptyList());
+ when(analyzer.provider()).thenReturn(provider);
+ when(manager.find(LogAnalyzerModule.NAME)).thenReturn(analyzer);
+
+ final ModuleProviderHolder core = mock(ModuleProviderHolder.class);
+ final ModuleServiceHolder coreServices = mock(ModuleServiceHolder.class);
+ when(core.provider()).thenReturn(coreServices);
+ when(manager.find(CoreModule.NAME)).thenReturn(core);
+ when(coreServices.getService(SourceReceiver.class)).thenReturn(mock(SourceReceiver.class));
+ final ConfigService config = mock(ConfigService.class);
+ when(config.getSearchableLogsTags()).thenReturn("");
+ when(coreServices.getService(ConfigService.class)).thenReturn(config);
+ when(coreServices.getService(NamingControl.class))
+ .thenReturn(new NamingControl(70, 70, 150, new EndpointNameGrouping()));
+ return manager;
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ConversationFileBuilderTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ConversationFileBuilderTest.java
new file mode 100644
index 0000000000..c63f007c0c
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ConversationFileBuilderTest.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.skywalking.apm.network.common.v3.KeyStringValuePair;
+import org.apache.skywalking.apm.network.logging.v3.LogData;
+import org.apache.skywalking.apm.network.logging.v3.LogDataBody;
+import org.apache.skywalking.apm.network.logging.v3.LogTags;
+import org.apache.skywalking.apm.network.logging.v3.TextLog;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Times;
+import org.apache.skywalking.oap.server.ai.agent.conversation.ingest.ConversationFileBuilder;
+import org.apache.skywalking.oap.server.core.analysis.IDManager;
+import org.apache.skywalking.oap.server.core.analysis.Layer;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionDataRecord;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionFlowRecord;
+import org.apache.skywalking.oap.server.core.analysis.record.Record;
+import org.apache.skywalking.oap.server.core.config.NamingControl;
+import org.apache.skywalking.oap.server.core.config.group.EndpointNameGrouping;
+import org.apache.skywalking.oap.server.core.source.LogMetadata;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ConversationFileBuilderTest {
+ private static final long SENT_AT = 1788503495903L;
+
+ /** Captures what the builder would hand to the record stream. */
+ private static final class Capturing extends ConversationFileBuilder {
+ final List<Record> dispatched = new ArrayList<>();
+
+ @Override
+ protected void dispatch(final Record record) {
+ dispatched.add(record);
+ }
+ }
+
+ @BeforeAll
+ public static void namingControl() {
+ ConversationFileBuilder.setNamingControl(new NamingControl(70, 70, 150, new EndpointNameGrouping()));
+ }
+
+ private static LogData.Builder input(final byte[] body, final String fileName) {
+ return LogData.newBuilder()
+ .setBody(LogDataBody.newBuilder().setText(
+ TextLog.newBuilder().setText(new String(body, StandardCharsets.UTF_8))))
+ .setTags(LogTags.newBuilder().addData(
+ KeyStringValuePair.newBuilder().setKey("asz.file").setValue(fileName)));
+ }
+
+ private static LogMetadata metadata() {
+ return LogMetadata.builder()
+ .service("Claude Code")
+ .serviceInstance("1748f643-fc41-4507-bc27-86fcc011c4a4")
+ .layer(Layer.AI_AGENT.name())
+ .timestamp(SENT_AT)
+ .build();
+ }
+
+ @Test
+ public void aVerifiedDataFileBecomesASessionDataRow() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.DATA_FILES[0]);
+ final Capturing b = new Capturing();
+ b.setFormat("sd");
+ b.setDigest(Digests.sha256Hex(body));
+ b.setLines(18L);
+ b.setSession(Fixtures.SESSION);
+ b.setSeq(1L);
+ b.setThroughTime("2026-01-01T00:00:11.100Z");
+ b.init(metadata(), input(body, "x/streams/main/transcript-…-000001.sd"), null);
+ b.complete(null);
+
+ assertEquals(1, b.dispatched.size());
+ final AIAgentSessionDataRecord row = (AIAgentSessionDataRecord) b.dispatched.get(0);
+ assertEquals(IDManager.ServiceID.buildId("Claude Code", true), row.getServiceId());
+ assertEquals(IDManager.ServiceInstanceID.buildId(row.getServiceId(), "1748f643-fc41-4507-bc27-86fcc011c4a4"),
+ row.getServiceInstanceId());
+ assertEquals(Fixtures.SESSION, row.getSession());
+ assertEquals(1L, row.getSeq());
+ assertEquals(Digests.sha256Hex(body), row.getDigest());
+ assertEquals(Times.millis("2026-01-01T00:00:11.100Z"), row.getTimestamp());
+ assertArrayEquals(body, row.getBody());
+ assertEquals(
+ AIAgentSessionDataRecord.ownerHash(row.getServiceId(), row.getServiceInstanceId()) + "_" + Digests.sha256Hex(body),
+ row.id().build());
+ }
+
+ @Test
+ public void aFileWithoutARecordTimeIsStampedWithTheRecordsTime() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.DATA_FILES[2]);
+ final Capturing b = new Capturing();
+ b.setFormat("sd");
+ b.setDigest(Digests.sha256Hex(body));
+ b.setLines(3L);
+ b.setSession(Fixtures.SESSION);
+ b.setSeq(3L);
+ b.init(metadata(), input(body, "meta"), null);
+ b.complete(null);
+ assertEquals(SENT_AT, ((AIAgentSessionDataRecord) b.dispatched.get(0)).getTimestamp());
+ }
+
+ @Test
+ public void aVerifiedRoundBecomesASessionFlowRowStampedWithTheConversationsLastActivity() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.ROUND_FILE);
+ final Capturing b = new Capturing();
+ b.setFormat("sf");
+ b.setDigest(Digests.sha256Hex(body));
+ b.setLines(45L);
+ b.setConversation(Fixtures.SESSION);
+ b.setRound(1L);
+ b.setSessionFromTime("2026-01-01T00:00:00Z");
+ b.setSessionThroughTime("2026-01-01T00:00:11.1Z");
+ b.setTitle("build and check");
+ b.setTalks(3L);
+ b.setSteps(21L);
+ b.setStreams(3L);
+ b.setSegments(1L);
+ b.setUnresolved(2L);
+ b.init(metadata(), input(body, "_conversations/x/rounds/r000001-3ad0dcd4cd53.sf"), null);
+ b.complete(null);
+
+ final AIAgentSessionFlowRecord row = (AIAgentSessionFlowRecord) b.dispatched.get(0);
+ assertEquals(Fixtures.SESSION, row.getConversation());
+ assertEquals(1L, row.getRound());
+ assertEquals("build and check", row.getTitle());
+ assertEquals(21L, row.getSteps());
+ assertEquals(Times.millis("2026-01-01T00:00:00Z"), row.getSessionFromTime());
+ assertEquals(Times.millis("2026-01-01T00:00:11.1Z"), row.getTimestamp());
+ assertArrayEquals(body, row.getBody());
+ }
+
+ @Test
+ public void aDigestMismatchIsNeverStored() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.DATA_FILES[0]);
+ final Capturing b = new Capturing();
+ b.setFormat("sd");
+ b.setDigest("0000000000000000000000000000000000000000000000000000000000000000");
+ b.setLines(18L);
+ b.setSession(Fixtures.SESSION);
+ b.setSeq(1L);
+ b.init(metadata(), input(body, "f"), null);
+ b.complete(null);
+ assertTrue(b.dispatched.isEmpty());
+ }
+
+ @Test
+ public void aLineCountMismatchIsNeverStored() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.DATA_FILES[0]);
+ final Capturing b = new Capturing();
+ b.setFormat("sd");
+ b.setDigest(Digests.sha256Hex(body));
+ b.setLines(17L);
+ b.setSession(Fixtures.SESSION);
+ b.setSeq(1L);
+ b.init(metadata(), input(body, "f"), null);
+ b.complete(null);
+ assertTrue(b.dispatched.isEmpty());
+ }
+
+ @Test
+ public void aFileOverTheSizeLimitIsNeverStored() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.DATA_FILES[0]);
+ ConversationFileBuilder.setMaxFileBytes(body.length - 1);
+ try {
+ final Capturing b = new Capturing();
+ b.setFormat("sd");
+ b.setDigest(Digests.sha256Hex(body));
+ b.setLines(18L);
+ b.setSession(Fixtures.SESSION);
+ b.setSeq(1L);
+ b.init(metadata(), input(body, "f"), null);
+ b.complete(null);
+ assertTrue(b.dispatched.isEmpty());
+ } finally {
+ ConversationFileBuilder.setMaxFileBytes(ConversationFileBuilder.DEFAULT_MAX_FILE_BYTES);
+ }
+ }
+
+ @Test
+ public void aRecordWithoutItsKeysIsNeverStored() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.ROUND_FILE);
+ final Capturing b = new Capturing();
+ b.setFormat("sf");
+ b.setDigest(Digests.sha256Hex(body));
+ b.setLines(45L);
+ b.init(metadata(), input(body, "f"), null);
+ b.complete(null);
+ assertTrue(b.dispatched.isEmpty());
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ConversationViewBuilderTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ConversationViewBuilderTest.java
new file mode 100644
index 0000000000..b3e5d437a2
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ConversationViewBuilderTest.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.apache.skywalking.oap.server.ai.agent.conversation.fold.ConversationFold;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
+import org.apache.skywalking.oap.server.ai.agent.conversation.view.ConversationViewBuilder;
+import org.apache.skywalking.oap.server.ai.agent.conversation.view.ViewYaml;
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ConversationViewBuilderTest {
+ private static final Gson GSON = new GsonBuilder().serializeNulls().create();
+
+ private static Map<String, Object> view(final byte[] roundBytes, final Map<Long, SessionDataFile> files,
+ final List<String> problems) {
+ final SessionFlowRound round = SessionFlowRound.parse(roundBytes);
+ final ConversationFold fold = new ConversationFold();
+ final List<ConversationViewBuilder.RoundInput> rounds = new ArrayList<>();
+ final List<String> all = new ArrayList<>(problems);
+ if (round.isIntact()) {
+ fold.apply(round);
+ rounds.add(new ConversationViewBuilder.RoundInput(round, Digests.sha256Hex(roundBytes)));
+ } else {
+ // as the query service reports a round the Sessionizer's reader would refuse
+ final String why = "sessionflow: digest mismatch";
+ rounds.add(ConversationViewBuilder.RoundInput.unreadable(1, why));
+ all.add("round 1 does not read: " + why);
+ }
+ return new ConversationViewBuilder(fold, rounds, files, all).build();
+ }
+
+ /**
+ * The document the OAP builds equals, key for key and in key order, the one <code>asz conversation -json</code>
+ * printed for the same three files and one round.
+ */
+ @Test
+ public void buildsTheSameDocumentAsTheSessionizer() throws Exception {
+ final Map<String, Object> doc = view(Fixtures.bytes(Fixtures.ROUND_FILE), Fixtures.dataFiles(), Collections.emptyList());
+ final JsonElement expected = JsonParser.parseString(
+ new String(Fixtures.bytes(Fixtures.VIEW_EXAMPLE_JSON), StandardCharsets.UTF_8));
+ final JsonElement actual = GSON.toJsonTree(doc);
+ // structural equality first, for a readable failure
+ assertEquals(expected, actual);
+ // then the key order, which the format page fixes
+ assertEquals(GSON.toJson(expected), GSON.toJson(actual));
+ }
+
+ @Test
+ public void yamlIsDeterministicAndReadsBackAsTheSameDocument() throws Exception {
+ final Map<String, Object> doc = view(Fixtures.bytes(Fixtures.ROUND_FILE), Fixtures.dataFiles(), Collections.emptyList());
+ final String yaml = ViewYaml.dump(doc);
+ assertTrue(yaml.startsWith("format: asz.view\nversion: '1.0'\nconversation: " + Fixtures.SESSION + "\n"), yaml.substring(0, 80));
+ assertEquals(yaml, ViewYaml.dump(view(Fixtures.bytes(Fixtures.ROUND_FILE), Fixtures.dataFiles(), Collections.emptyList())));
+ assertFalse(yaml.contains("&id"), "no anchors");
+ assertFalse(yaml.contains("!!"), "no type tags");
+ final Object reloaded = new Yaml().load(yaml);
+ assertEquals(GSON.toJsonTree(doc), GSON.toJsonTree(reloaded));
+ // and the YAML the Sessionizer prints for the same conversation reads back as the same document
+ final Object theirs = new Yaml().load(new String(Fixtures.bytes("asz-view-example.yaml"), StandardCharsets.UTF_8));
+ assertEquals(GSON.toJsonTree(theirs), GSON.toJsonTree(reloaded));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void aMissingFileIsIncompleteAndATamperedRoundIsAMismatch() throws Exception {
+ final Map<Long, SessionDataFile> files = Fixtures.dataFiles();
+ files.remove(2L);
+ final Map<String, Object> incomplete = view(Fixtures.bytes(Fixtures.ROUND_FILE), files, Collections.emptyList());
+ final Map<String, Object> summary = (Map<String, Object>) incomplete.get("summary");
+ assertEquals("incomplete", summary.get("state"));
+ assertEquals(Collections.singletonList("round 1: landed file seq 2 is missing"), summary.get("problems"));
+ final List<Map<String, Object>> rounds = (List<Map<String, Object>>) incomplete.get("rounds");
+ assertEquals(Boolean.FALSE, rounds.get(0).get("verified"));
+ // the rest of the document still holds what could be folded
+ assertEquals(3, ((List<?>) incomplete.get("talks")).size());
+
+ final String tampered = new String(Fixtures.bytes(Fixtures.ROUND_FILE), StandardCharsets.UTF_8)
+ .replace("\"trigger\":\"external\"", "\"trigger\":\"exterior\"");
+ final Map<String, Object> mismatch = view(
+ tampered.getBytes(StandardCharsets.UTF_8), Fixtures.dataFiles(), Collections.emptyList());
+ assertEquals("mismatch", ((Map<String, Object>) mismatch.get("summary")).get("state"));
+ assertEquals(0, ((List<?>) mismatch.get("rounds")).size());
+ }
+
+ /**
+ * Rounds 2 to 4 never landed: the chain resumes at round 5, which is listed unverified because nothing links
+ * it to what is absent, the gap and the files round 5 names are each one problem, and what round 1 folded is
+ * still in the document.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void theChainResumesAfterAGapAndNamesItOnce() throws Exception {
+ final SessionFlowRound first = Fixtures.round();
+ final SessionFlowRound fifth = SessionFlowRound.parse(
+ Fixtures.emptyRound(first, 5, UNKNOWN_DIGEST, 4, 6, first.getHeader().getParser()));
+ final ConversationFold fold = new ConversationFold();
+ assertNull(fold.apply(first));
+ assertNull(fold.applyAfterGap(fifth));
+ assertEquals(5L, fold.getRound());
+ assertEquals(fifth.getCommitDigest(), fold.getDigest());
+ // the hand-built round names no session range; the fold keeps the one round 1 gave, so the file read
+ // that follows stays bounded to it rather than starting at the epoch
+ assertEquals(first.getHeader().getSessionFromTime(), fold.getSessionFromTime());
+ assertEquals(first.getHeader().getSessionThroughTime(), fold.getSessionThroughTime());
+
+ final List<ConversationViewBuilder.RoundInput> rounds = new ArrayList<>();
+ rounds.add(new ConversationViewBuilder.RoundInput(first, Digests.sha256Hex(Fixtures.bytes(Fixtures.ROUND_FILE))));
+ rounds.add(new ConversationViewBuilder.RoundInput(fifth, UNKNOWN_DIGEST));
+ final Map<String, Object> doc = new ConversationViewBuilder(
+ fold, rounds, Fixtures.dataFiles(), Collections.emptyList()).build();
+ final Map<String, Object> summary = (Map<String, Object>) doc.get("summary");
+ assertEquals("incomplete", summary.get("state"));
+ assertEquals(
+ Arrays.asList("rounds 2-4 are missing before round 5", "round 5: landed files seq 4-6 are missing"),
+ summary.get("problems"));
+ assertEquals(5L, ((Map<String, Object>) doc.get("head")).get("round"));
+ final List<Map<String, Object>> listed = (List<Map<String, Object>>) doc.get("rounds");
+ assertEquals(2, listed.size());
+ assertEquals(Boolean.TRUE, listed.get(0).get("verified"));
+ assertEquals(Boolean.FALSE, listed.get(1).get("verified"));
+ assertEquals(3, ((List<?>) doc.get("talks")).size());
+ }
+
+ private static final String UNKNOWN_DIGEST = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+ /**
+ * A round after a gap that another parser produced is refused, as in order it would be, and the head stays
+ * at the last round folded rather than at the absent one the refused round claims to follow.
+ */
+ @Test
+ public void aRefusedRoundAfterAGapLeavesTheHeadWhereItWas() throws Exception {
+ final SessionFlowRound first = Fixtures.round();
+ final ConversationFold fold = new ConversationFold();
+ assertNull(fold.apply(first));
+ final SessionFlowRound foreign = SessionFlowRound.parse(
+ Fixtures.emptyRound(first, 3, UNKNOWN_DIGEST, 4, 4, first.getHeader().getParser() + "-other"));
+ final String refused = fold.applyAfterGap(foreign);
+ assertNotNull(refused);
+ assertTrue(refused.contains("parser"), refused);
+ assertEquals(1L, fold.getRound());
+ assertEquals(first.getCommitDigest(), fold.getDigest());
+ assertEquals(first.getHeader().getParser(), fold.getParser());
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/Fixtures.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/Fixtures.java
new file mode 100644
index 0000000000..64a8043695
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/Fixtures.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.TreeMap;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
+
+/**
+ * The Sessionizer's fixture scenario <code>tests/scenarios/fixture.yaml</code>, built with
+ * <code>asz scenario build --format sd --at 2026-01-01T00:00:00Z</code> and parsed: three Session Data files and one
+ * round, byte for byte as the Sessionizer wrote them, so every digest is real, and the <code>asz.view</code>
+ * document <code>asz conversation -json</code> printed for them, which is the document the OAP must equal.
+ */
+public final class Fixtures {
+ public static final String SESSION = "00000001-0000-4000-8000-000000000001";
+ public static final String[] DATA_FILES = {
+ "transcript-20260101T000000.000000000Z-000001.sd",
+ "transcript-20260101T000000.000000000Z-000002.sd",
+ "meta-20260101T000000.000000000Z-000003.sd",
+ };
+ public static final String CHILD_STREAM = "a0a10ef0666c4dc7e";
+ public static final String ROUND_FILE = "r000001-3ad0dcd4cd53.sf";
+ public static final String VIEW_EXAMPLE_JSON = "asz-view-example.json";
+
+ private Fixtures() {
+ }
+
+ public static byte[] bytes(final String name) throws IOException {
+ try (InputStream in = Fixtures.class.getResourceAsStream("/fixtures/" + name)) {
+ if (in == null) {
+ throw new IOException("no fixture " + name);
+ }
+ return in.readAllBytes();
+ }
+ }
+
+ public static Map<Long, SessionDataFile> dataFiles() throws IOException {
+ final Map<Long, SessionDataFile> out = new TreeMap<>();
+ for (final String name : DATA_FILES) {
+ final SessionDataFile f = SessionDataFile.parse(bytes(name));
+ out.put(f.getHeader().getSeq(), f);
+ }
+ return out;
+ }
+
+ public static SessionFlowRound round() throws IOException {
+ return SessionFlowRound.parse(bytes(ROUND_FILE));
+ }
+
+ /**
+ * A round with no entities of its own, signed like a real one: it continues the fixture's conversation, session
+ * and policy under the parser given, names the previous digest given, or none for round 1, and claims the seq
+ * window given. For chain tests that need a round the fixture set does not hold.
+ *
+ * @param like the round whose conversation, session and policy are kept
+ * @param round the round number
+ * @param previous the previous round's commit digest, or null for round 1
+ * @param fromSeq the first seq of the window
+ * @param throughSeq the last seq of the window
+ * @param parser the parser version the header names
+ * @return the round's bytes
+ */
+ public static byte[] emptyRound(final SessionFlowRound like, final long round, final String previous,
+ final long fromSeq, final long throughSeq, final String parser) {
+ final SessionFlowRound.Header h = like.getHeader();
+ final String header = "{\"t\":\"header\",\"schema\":\"sf/1\",\"conversation\":\"" + h.getConversation()
+ + "\",\"session\":\"" + h.getSession() + "\",\"round\":" + round
+ + (previous == null ? "" : ",\"previous\":\"" + previous + "\"")
+ + ",\"from_seq\":" + fromSeq + ",\"through_seq\":" + throughSeq
+ + ",\"input_digest\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\""
+ + ",\"parser\":\"" + parser + "\",\"policy\":\"" + h.getPolicy() + "\"}";
+ final String commit = "{\"t\":\"commit\",\"digest\":\""
+ + Digests.sha256Hex((header + "\n").getBytes(StandardCharsets.UTF_8)) + "\"}";
+ return (header + "\n" + commit + "\n").getBytes(StandardCharsets.UTF_8);
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java
new file mode 100644
index 0000000000..550754a32f
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.FileNames;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Times;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class SessionFormatsTest {
+ @Test
+ public void dataFilesDecodeAndTheirDigestsAreTheSessionizersDigests() throws Exception {
+ final Map<Long, SessionDataFile> files = Fixtures.dataFiles();
+ assertEquals(3, files.size());
+ final SessionDataFile main = files.get(1L);
+ assertEquals("sd/1", main.getHeader().getSchema());
+ assertEquals("transcript", main.getHeader().getKind());
+ assertEquals("main", main.getHeader().getStream());
+ assertEquals(16, main.getRecords().size());
+ assertEquals(16, main.getDeclaredRecords());
+ assertEquals(18, main.getLines());
+ assertEquals(5868, main.getBytes());
+ // the digests asz conversation -json printed for the same files
+ assertEquals("944ed3d1209c76afb98f0622eb9239f95adb79fd0d0262d634c2f20bc041b1f7", main.getFileDigest());
+ assertEquals("39b8a446c3a87d2b8da5210bba7f66ee2fd04caccea1fa0a4efdc3345b7ff7ca", files.get(2L).getFileDigest());
+ assertEquals("de085eda31cdf99231fb212cb8fefbbcf95bd4f88c007c70446ecc652db3b005", files.get(3L).getFileDigest());
+ // row 2 is the person's input
+ assertEquals("run the build", main.record(2).text());
+ assertEquals(1767225600000L, main.record(2).getTime());
+ assertNull(main.record(0));
+ assertNull(main.record(17));
+ // the file's record time range, as the example's files[0]
+ assertEquals(1767225600000L, main.getFromTime());
+ assertEquals(1767225611100L, main.getThroughTime());
+ // a meta file carries no timed record
+ assertEquals(0L, files.get(3L).getFromTime());
+ }
+
+ @Test
+ public void closingLineDigestCoversHeaderAndRecords() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.DATA_FILES[0]);
+ final String text = new String(body, StandardCharsets.UTF_8);
+ final int lastLine = text.lastIndexOf('\n', text.length() - 2);
+ final String covered = text.substring(0, lastLine + 1);
+ final SessionDataFile f = SessionDataFile.parse(body);
+ assertEquals(f.getDeclaredDigest(), Digests.sha256Hex(covered.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ public void roundDecodesAndItsCommitDigestVerifies() throws Exception {
+ final SessionFlowRound r = Fixtures.round();
+ assertTrue(r.isIntact());
+ assertEquals(1, r.getHeader().getRound());
+ assertEquals(Fixtures.SESSION, r.getHeader().getConversation());
+ assertEquals(1, r.getHeader().getFromSeq());
+ assertEquals(3, r.getHeader().getThroughSeq());
+ assertEquals(45, r.getLines());
+ assertEquals(43, r.getNodes().size() + r.getRelations().size() + r.getUnresolved().size());
+ assertEquals("3ad0dcd4cd53fa06c502a2649b8788bfbe0a382bbc72d7f1f0fe68d9a18e96e2", r.getCommitDigest());
+ assertEquals(Times.millis("2026-01-01T00:00:00Z"), Times.millis(r.getHeader().getSessionFromTime()));
+ }
+
+ @Test
+ public void inputDigestChainsTheFileDigests() throws Exception {
+ final Map<Long, SessionDataFile> files = Fixtures.dataFiles();
+ final List<String> added = new ArrayList<>();
+ for (final SessionDataFile f : files.values()) {
+ added.add(f.getFileDigest());
+ }
+ assertEquals("6872d48ef5d3e736d0bd9f5bc03844653fb304b4b98b9ef6a27342478d950c1b", Digests.chainInputDigest("", added));
+ assertEquals(Fixtures.round().getHeader().getInputDigest(), Digests.chainInputDigest("", added));
+ }
+
+ @Test
+ public void tamperedRoundIsNotIntact() throws Exception {
+ final byte[] body = Fixtures.bytes(Fixtures.ROUND_FILE);
+ final String text = new String(body, StandardCharsets.UTF_8).replace("\"trigger\":\"external\"", "\"trigger\":\"exterior\"");
+ final SessionFlowRound r = SessionFlowRound.parse(text.getBytes(StandardCharsets.UTF_8));
+ assertFalse(r.isIntact());
+ }
+
+ @Test
+ public void fileNamesFollowTheStorageRootLayout() throws Exception {
+ final Map<Long, SessionDataFile> files = Fixtures.dataFiles();
+ assertEquals(Fixtures.SESSION + "/streams/main/" + Fixtures.DATA_FILES[0],
+ FileNames.dataFile(files.get(1L).getHeader()));
+ assertEquals(Fixtures.SESSION + "/streams/" + Fixtures.CHILD_STREAM + "/" + Fixtures.DATA_FILES[1],
+ FileNames.dataFile(files.get(2L).getHeader()));
+ assertEquals(Fixtures.SESSION + "/streams/" + Fixtures.CHILD_STREAM + "/" + Fixtures.DATA_FILES[2],
+ FileNames.dataFile(files.get(3L).getHeader()));
+ final SessionFlowRound r = Fixtures.round();
+ assertEquals("_conversations/" + Fixtures.SESSION + "/rounds/" + Fixtures.ROUND_FILE,
+ FileNames.roundFile(r.getHeader().getConversation(), 1, r.getCommitDigest()));
+
+ final FileNames.Parsed data = FileNames.parse(Fixtures.SESSION + "/streams/main/" + Fixtures.DATA_FILES[0]);
+ assertNotNull(data);
+ assertTrue(data.isDataFile());
+ assertEquals(Fixtures.SESSION, data.getSession());
+ assertEquals(1, data.getSeq());
+ final FileNames.Parsed round = FileNames.parse("_conversations/" + Fixtures.SESSION + "/rounds/" + Fixtures.ROUND_FILE);
+ assertNotNull(round);
+ assertFalse(round.isDataFile());
+ assertEquals(1, round.getRound());
+ assertNull(FileNames.parse("not/a/file"));
+ }
+
+ @Test
+ public void lineCountIsTheNewlineCount() throws Exception {
+ assertEquals(18, Digests.countLines(Fixtures.bytes(Fixtures.DATA_FILES[0])));
+ assertEquals(45, Digests.countLines(Fixtures.bytes(Fixtures.ROUND_FILE)));
+ }
+
+ /**
+ * A part's data is rendered as the Sessionizer wrote it: escapes, key order and spacing kept, the way Go
+ * prints a raw message, so the document equals the Sessionizer's byte for byte.
+ */
+ @Test
+ public void partDataIsTheRawTextTheSessionizerWrote() {
+ final String data = "{\"z\": 1, \"a\":\"caf\\u00e9 \\/ <b>\", \"n\":1.50, \"list\":[ 1,2 ]}";
+ final String line = "{\"id\":\"r1\",\"time\":\"2026-01-01T00:00:00Z\",\"parts\":["
+ + "{\"k\":\"text\",\"text\":\"hi \\\"there\\\"\"},"
+ + "{\"k\":\"call\",\"name\":\"Bash\",\"data\":" + data + "},"
+ + "{\"k\":\"result\",\"data\":null},"
+ + "{\"k\":\"result\",\"data\":\"plain\"}]}";
+ final byte[] file = ("{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"kind\":\"transcript\",\"session\":\"s\",\"stream\":\"main\","
+ + "\"src\":\"x\",\"dialect\":\"mock/1\"}\n"
+ + line + "\n{\"t\":\"end\",\"records\":1,\"digest\":\"0\"}\n").getBytes(StandardCharsets.UTF_8);
+ final SessionDataFile parsed = SessionDataFile.parse(file);
+ final List<SessionDataFile.Part> parts = parsed.getRecords().get(0).getParts();
+ assertEquals(4, parts.size());
+ assertNull(parts.get(0).data());
+ assertEquals(data, parts.get(1).data());
+ // a literal null is the text "null", as Go keeps it in a raw message and prints it
+ assertEquals("null", parts.get(2).data());
+ assertEquals("\"plain\"", parts.get(3).data());
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryServiceTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryServiceTest.java
new file mode 100644
index 0000000000..22d2a73737
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryServiceTest.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationConfig;
+import org.apache.skywalking.oap.server.ai.agent.conversation.Fixtures;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.FileNames;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationFileFormat;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFile;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFiles;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionDataRecord;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionFlowRecord;
+import org.apache.skywalking.oap.server.core.storage.StorageModule;
+import org.apache.skywalking.oap.server.core.storage.query.IAIAgentConversationQueryDAO;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+import org.apache.skywalking.oap.server.library.module.ModuleProviderHolder;
+import org.apache.skywalking.oap.server.library.module.ModuleServiceHolder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The reads of the query service over a storage that answers from memory: the head is what the storage names
+ * as the highest round, whatever page the list would read, and the raw-file export serves every stored round,
+ * readable or not. The service reads through the interface's tracing wrappers, so those are what the mock answers.
+ */
+public class ConversationQueryServiceTest {
+ private static final String SERVICE = "Q2xhdWRlIENvZGU=.1";
+ private static final long SENT_AT = 1757100000000L;
+
+ private static ConversationQueryService service(final IAIAgentConversationQueryDAO dao,
+ final AIAgentConversationConfig config) {
+ final ModuleManager manager = mock(ModuleManager.class);
+ final ModuleProviderHolder storage = mock(ModuleProviderHolder.class);
+ final ModuleServiceHolder services = mock(ModuleServiceHolder.class);
+ when(manager.find(StorageModule.NAME)).thenReturn(storage);
+ when(storage.provider()).thenReturn(services);
+ when(services.getService(IAIAgentConversationQueryDAO.class)).thenReturn(dao);
+ return new ConversationQueryService(manager, config);
+ }
+
+ private static AIAgentSessionFlowRecord storedRound(final String conversation, final long round, final byte[] body) {
+ final AIAgentSessionFlowRecord r = new AIAgentSessionFlowRecord();
+ r.setServiceId(SERVICE);
+ r.setConversation(conversation);
+ r.setRound(round);
+ r.setBody(body);
+ r.setDigest(Digests.sha256Hex(body));
+ r.setTimestamp(SENT_AT + round);
+ return r;
+ }
+
+ private static List<AIAgentSessionDataRecord> storedFiles() throws Exception {
+ final List<AIAgentSessionDataRecord> files = new ArrayList<>();
+ for (int i = 0; i < Fixtures.DATA_FILES.length; i++) {
+ final byte[] body = Fixtures.bytes(Fixtures.DATA_FILES[i]);
+ final AIAgentSessionDataRecord f = new AIAgentSessionDataRecord();
+ f.setServiceId(SERVICE);
+ f.setSession(Fixtures.SESSION);
+ f.setSeq(i + 1);
+ f.setBody(body);
+ f.setDigest(Digests.sha256Hex(body));
+ f.setTimestamp(SENT_AT);
+ files.add(f);
+ }
+ return files;
+ }
+
+ /**
+ * Two stored rounds and a list page of one: the head is still round 2, because it is read as the highest
+ * round stored and not off the page, so the export names both rounds.
+ */
+ @Test
+ public void theHeadIsTheHighestRoundStoredNotTheNewestOfAListPage() throws Exception {
+ final SessionFlowRound first = Fixtures.round();
+ final String conversation = first.getHeader().getConversation();
+ final byte[] second = Fixtures.emptyRound(first, 2, first.getCommitDigest(), 4, 4, first.getHeader().getParser());
+ final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class);
+ when(dao.queryHeadRoundDebuggable(eq(SERVICE), any(), eq(conversation))).thenReturn(2L);
+ when(dao.queryRoundsByNumberDebuggable(eq(SERVICE), any(), eq(conversation), anyLong(), anyLong(), anyInt()))
+ .thenReturn(Arrays.asList(
+ storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)), storedRound(conversation, 2, second)));
+ when(dao.queryFilesDebuggable(anyString(), any(), anyString(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt()))
+ .thenReturn(storedFiles());
+ final AIAgentConversationConfig config = new AIAgentConversationConfig();
+ config.setMaxListLimit(1);
+
+ final ConversationRawFiles out = service(dao, config)
+ .getConversationRawFiles(SERVICE, null, conversation, null, false);
+
+ assertNull(out.getErrorReason());
+ final List<Long> rounds = new ArrayList<>();
+ for (final ConversationRawFile f : out.getFiles()) {
+ if (f.getFormat() == ConversationFileFormat.SF) {
+ rounds.add((long) f.getRound());
+ }
+ }
+ assertEquals(Arrays.asList(1L, 2L), rounds);
+ assertEquals(Fixtures.DATA_FILES.length + 2, out.getFiles().size());
+ verify(dao, never()).queryRoundsDebuggable(anyString(), any(), any(), any(), anyInt(), eq(false));
+ }
+
+ /**
+ * The newest stored round is not a round at all, though ingest could not tell: the export still lists it, named
+ * by the digest of its file, serves the readable round before it, and honours a selection of that round alone.
+ */
+ @Test
+ public void anUnreadableNewestRoundDoesNotBlockTheExport() throws Exception {
+ final SessionFlowRound first = Fixtures.round();
+ final String conversation = first.getHeader().getConversation();
+ final byte[] truncated = "{\"t\":\"header\",\"schema\":\"sf/1\"".getBytes(StandardCharsets.UTF_8);
+ final AIAgentSessionFlowRecord broken = storedRound(conversation, 2, truncated);
+ final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class);
+ when(dao.queryHeadRoundDebuggable(eq(SERVICE), any(), eq(conversation))).thenReturn(2L);
+ when(dao.queryRoundsByNumberDebuggable(eq(SERVICE), any(), eq(conversation), anyLong(), anyLong(), anyInt()))
+ .thenReturn(Arrays.asList(storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)), broken));
+ when(dao.queryFilesDebuggable(anyString(), any(), anyString(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt()))
+ .thenReturn(storedFiles());
+ final ConversationQueryService service = service(dao, new AIAgentConversationConfig());
+
+ final String firstId = FileNames.roundFile(conversation, 1, first.getCommitDigest());
+ final ConversationRawFiles selected = service.getConversationRawFiles(
+ SERVICE, null, conversation, Collections.singletonList(firstId), true);
+ assertNull(selected.getErrorReason());
+ assertEquals(1, selected.getFiles().size());
+ assertEquals(firstId, selected.getFiles().get(0).getId());
+ assertEquals(new String(Fixtures.bytes(Fixtures.ROUND_FILE), StandardCharsets.UTF_8), selected.getFiles().get(0).getBody());
+
+ final ConversationRawFiles all = service.getConversationRawFiles(SERVICE, null, conversation, null, false);
+ assertNull(all.getErrorReason());
+ assertEquals(Fixtures.DATA_FILES.length + 2, all.getFiles().size());
+ final ConversationRawFile last = all.getFiles().get(all.getFiles().size() - 1);
+ assertEquals(2, last.getRound());
+ assertTrue(last.getId().endsWith(broken.getDigest().substring(0, 12) + ".sf"), last.getId());
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationViewHandlerTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationViewHandlerTest.java
new file mode 100644
index 0000000000..f04cc8d8b7
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationViewHandlerTest.java
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.query.http;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpData;
+import com.linecorp.armeria.common.HttpHeaderNames;
+import com.linecorp.armeria.common.HttpMethod;
+import com.linecorp.armeria.common.HttpObject;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpResponseWriter;
+import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.common.RequestHeaders;
+import com.linecorp.armeria.common.RequestHeadersBuilder;
+import com.linecorp.armeria.common.ResponseHeaders;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.testing.junit5.server.ServerExtension;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.zip.GZIPInputStream;
+import javax.annotation.Nullable;
+import org.apache.skywalking.oap.server.ai.agent.conversation.Fixtures;
+import org.apache.skywalking.oap.server.ai.agent.conversation.fold.ConversationFold;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.IConversationQueryService;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationList;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFiles;
+import org.apache.skywalking.oap.server.ai.agent.conversation.view.ConversationViewBuilder;
+import org.apache.skywalking.oap.server.core.analysis.IDManager;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.yaml.snakeyaml.Yaml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The route on a real server: the fixture conversation comes back as the same document the Sessionizer prints,
+ * in both bodies, compressed or not, in more than one chunk, and the error paths answer with their statuses.
+ */
+public class ConversationViewHandlerTest {
+ private static final Gson GSON = new GsonBuilder().serializeNulls().disableHtmlEscaping().create();
+ private static final String SERVICE = "agent";
+ private static final String SERVICE_ID = IDManager.ServiceID.buildId(SERVICE, true);
+ private static final Map<String, Object> DOC = fixtureDocument();
+ private static final AtomicReference<String> LAST_INSTANCE_ID = new AtomicReference<>();
+
+ private static final IConversationQueryService STUB = new IConversationQueryService() {
+ @Override
+ public ConversationList listConversations(final String serviceId, @Nullable final String serviceInstanceId,
+ @Nullable final String conversation, @Nullable final String title,
+ final Duration duration, @Nullable final Integer limit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ @Nullable
+ public Map<String, Object> buildConversationView(final String serviceId,
+ @Nullable final String serviceInstanceId,
+ final String conversation) throws IOException {
+ LAST_INSTANCE_ID.set(serviceInstanceId);
+ if ("broken".equals(conversation)) {
+ throw new IOException("storage is down");
+ }
+ return SERVICE_ID.equals(serviceId) && Fixtures.SESSION.equals(conversation) ? DOC : null;
+ }
+
+ @Override
+ public ConversationRawFiles getConversationRawFiles(final String serviceId,
+ @Nullable final String serviceInstanceId,
+ final String conversation,
+ @Nullable final List<String> files,
+ final boolean includeBody) {
+ throw new UnsupportedOperationException();
+ }
+ };
+
+ @RegisterExtension
+ static final ServerExtension SERVER = new ServerExtension() {
+ @Override
+ protected void configure(final ServerBuilder sb) {
+ sb.annotatedService(new ConversationViewHandler(STUB, java.time.Duration.ofSeconds(30)));
+ }
+ };
+
+ private static Map<String, Object> fixtureDocument() {
+ try {
+ final byte[] bytes = Fixtures.bytes(Fixtures.ROUND_FILE);
+ final SessionFlowRound round = SessionFlowRound.parse(bytes);
+ final ConversationFold fold = new ConversationFold();
+ fold.apply(round);
+ final List<ConversationViewBuilder.RoundInput> rounds = new ArrayList<>();
+ rounds.add(new ConversationViewBuilder.RoundInput(round, Digests.sha256Hex(bytes)));
+ return new ConversationViewBuilder(fold, rounds, Fixtures.dataFiles(), new ArrayList<>()).build();
+ } catch (final IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static String path(final String conversation) {
+ return "/ai-agent/conversations/" + conversation + "/v1/view?service=" + SERVICE;
+ }
+
+ private static AggregatedHttpResponse get(final String path, final String... headers) {
+ final RequestHeadersBuilder req = RequestHeaders.builder(HttpMethod.GET, path);
+ for (int i = 0; i < headers.length; i += 2) {
+ req.add(headers[i], headers[i + 1]);
+ }
+ return WebClient.of(SERVER.httpUri()).execute(req.build()).aggregate().join();
+ }
+
+ @Test
+ public void jsonIsTheSessionizersDocument() throws Exception {
+ final AggregatedHttpResponse res = get(path(Fixtures.SESSION));
+ assertEquals(200, res.status().code());
+ assertEquals("application/vnd.skywalking.asz.view+json; version=1.0; charset=utf-8", String.valueOf(res.contentType()));
+ final JsonElement expected = JsonParser.parseString(
+ new String(Fixtures.bytes(Fixtures.VIEW_EXAMPLE_JSON), StandardCharsets.UTF_8));
+ assertEquals(expected, JsonParser.parseString(res.contentUtf8()));
+ // the key order too, as the format page fixes it
+ assertEquals(GSON.toJson(expected), res.contentUtf8());
+ }
+
+ @Test
+ public void yamlOnAccept() {
+ final AggregatedHttpResponse res = get(path(Fixtures.SESSION), "accept", "application/yaml");
+ assertEquals(200, res.status().code());
+ assertEquals("application/vnd.skywalking.asz.view+yaml; version=1.0; charset=utf-8", String.valueOf(res.contentType()));
+ assertTrue(res.contentUtf8().startsWith("format: asz.view\nversion: '1.0'\n"));
+ assertEquals(GSON.toJsonTree(DOC), GSON.toJsonTree(new Yaml().load(res.contentUtf8())));
+ }
+
+ @Test
+ public void gzipOnAcceptEncoding() throws Exception {
+ final AggregatedHttpResponse res = get(path(Fixtures.SESSION), "accept-encoding", "gzip");
+ assertEquals(200, res.status().code());
+ assertEquals("gzip", res.headers().get(HttpHeaderNames.CONTENT_ENCODING));
+ final byte[] inflated;
+ try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(res.content().array()))) {
+ inflated = in.readAllBytes();
+ }
+ assertEquals(GSON.toJsonTree(DOC), JsonParser.parseString(new String(inflated, StandardCharsets.UTF_8)));
+ assertTrue(res.content().length() < inflated.length / 3, "compressed " + res.content().length() + " of " + inflated.length);
+ }
+
+ /**
+ * The render is handed to the response chunk by chunk, so a document never exists whole in memory.
+ */
+ @Test
+ public void theRenderIsWrittenInChunks() throws Exception {
+ final HttpResponseWriter res = HttpResponse.streaming();
+ final CompletableFuture<List<HttpObject>> collected = res.collect();
+ res.write(ResponseHeaders.of(200));
+ final char[] text = new char[300 * 1024];
+ Arrays.fill(text, 'x');
+ try (Writer out = new ConversationViewHandler.ChunkWriter(res, java.time.Duration.ofSeconds(10))) {
+ out.write(text);
+ }
+ res.close();
+ int chunks = 0;
+ long bytes = 0;
+ for (final HttpObject o : collected.get(10, TimeUnit.SECONDS)) {
+ if (o instanceof HttpData) {
+ chunks++;
+ bytes += ((HttpData) o).length();
+ }
+ }
+ assertEquals(text.length, bytes);
+ assertTrue(chunks >= 4, "chunks: " + chunks);
+ }
+
+ /**
+ * A chunk boundary never falls between the two halves of a surrogate pair, so a four-byte character at the
+ * boundary reaches the client whole.
+ */
+ @Test
+ public void aCodePointAtTheChunkBoundaryStaysWhole() throws Exception {
+ final HttpResponseWriter res = HttpResponse.streaming();
+ final CompletableFuture<List<HttpObject>> collected = res.collect();
+ res.write(ResponseHeaders.of(200));
+ final StringBuilder text = new StringBuilder();
+ for (int i = 0; i < 64 * 1024 - 1; i++) {
+ text.append('x');
+ }
+ text.append("\uD83D\uDE00").append("tail");
+ try (Writer out = new ConversationViewHandler.ChunkWriter(res, java.time.Duration.ofSeconds(10))) {
+ out.write(text.toString());
+ }
+ res.close();
+ final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ int chunks = 0;
+ for (final HttpObject o : collected.get(10, TimeUnit.SECONDS)) {
+ if (o instanceof HttpData) {
+ chunks++;
+ bytes.write(((HttpData) o).array());
+ }
+ }
+ assertEquals(text.toString(), new String(bytes.toByteArray(), StandardCharsets.UTF_8));
+ assertTrue(chunks >= 2, "chunks: " + chunks);
+ }
+
+ @Test
+ public void theInstanceParameterNamesTheSender() {
+ get(path(Fixtures.SESSION) + "&instance=sender-1");
+ assertEquals(IDManager.ServiceInstanceID.buildId(SERVICE_ID, "sender-1"), LAST_INSTANCE_ID.get());
+ }
+
+ @Test
+ public void statusesOfTheErrorPaths() {
+ assertEquals(404, get(path("no-such-conversation")).status().code());
+ assertEquals(400, get("/ai-agent/conversations/" + Fixtures.SESSION + "/v1/view").status().code());
+ final AggregatedHttpResponse broken = get(path("broken"));
+ assertEquals(500, broken.status().code());
+ assertTrue(broken.contentType().is(MediaType.parse("application/problem+json")), String.valueOf(broken.contentType()));
+ assertEquals(
+ "{\"type\":\"about:blank\",\"title\":\"Internal Server Error\",\"status\":500,\"detail\":\"storage is down\"}",
+ broken.contentUtf8());
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/.gitattributes b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/.gitattributes
new file mode 100644
index 0000000000..3b53af8fed
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/.gitattributes
@@ -0,0 +1,3 @@
+# The Sessionizer wrote these files byte for byte, and the tests check their digests: no line-ending conversion
+# on any platform, or a Windows checkout turns every LF into CRLF and every digest fails.
+* -text
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/asz-view-example.json b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/asz-view-example.json
new file mode 100644
index 0000000000..cc1ea3a50f
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/asz-view-example.json
@@ -0,0 +1,1043 @@
+{
+ "format": "asz.view",
+ "version": "1.0",
+ "conversation": "00000001-0000-4000-8000-000000000001",
+ "sessions": [
+ "00000001-0000-4000-8000-000000000001"
+ ],
+ "head": {
+ "round": 1,
+ "digest": "3ad0dcd4cd53fa06c502a2649b8788bfbe0a382bbc72d7f1f0fe68d9a18e96e2"
+ },
+ "parser": "v1",
+ "policy": "v1+idle=10m0s",
+ "summary": {
+ "title": "build and check",
+ "state": "verified",
+ "problems": [],
+ "talks": 3,
+ "steps": 21,
+ "streams": 2,
+ "segments": 1,
+ "rounds": 1,
+ "unresolved": 0,
+ "from": 1767225600000,
+ "to": 1767225611100,
+ "kinds": {
+ "agent.call": 1,
+ "agent.launch_ack": 1,
+ "agent.output": 1,
+ "context.injection": 1,
+ "epoch": 3,
+ "epoch.boundary": 1,
+ "epoch.summary": 1,
+ "llm.call": 6,
+ "message.assistant": 3,
+ "message.external": 1,
+ "message.synthetic": 1,
+ "run": 4,
+ "runtime.notification": 1,
+ "segment": 1,
+ "session": 1,
+ "stream": 2,
+ "talk": 3,
+ "thinking": 1,
+ "tool": 2
+ },
+ "relation_types": {
+ "ends_with": 1,
+ "follows": 1,
+ "in_segment": 3,
+ "reports": 1,
+ "starts": 1,
+ "summarizes": 1
+ },
+ "quality": {
+ "exact_unique": 7,
+ "strong_inference": 1
+ }
+ },
+ "rounds": [
+ {
+ "round": 1,
+ "digest": "3ad0dcd4cd53fa06c502a2649b8788bfbe0a382bbc72d7f1f0fe68d9a18e96e2",
+ "previous": null,
+ "from_seq": 1,
+ "through_seq": 3,
+ "input_digest": "6872d48ef5d3e736d0bd9f5bc03844653fb304b4b98b9ef6a27342478d950c1b",
+ "from_time": 1767225600000,
+ "through_time": 1767225611100,
+ "verified": true
+ }
+ ],
+ "files": [
+ {
+ "file": "00000001-0000-4000-8000-000000000001/streams/main/transcript-20260101T000000.000000000Z-000001.sd",
+ "format": "sd",
+ "kind": "transcript",
+ "seq": 1,
+ "round": null,
+ "stream": "main",
+ "run": null,
+ "lines": 18,
+ "bytes": 5868,
+ "digest": "944ed3d1209c76afb98f0622eb9239f95adb79fd0d0262d634c2f20bc041b1f7",
+ "from_time": 1767225600000,
+ "through_time": 1767225611100
+ },
+ {
+ "file": "00000001-0000-4000-8000-000000000001/streams/a0a10ef0666c4dc7e/transcript-20260101T000000.000000000Z-000002.sd",
+ "format": "sd",
+ "kind": "transcript",
+ "seq": 2,
+ "round": null,
+ "stream": "a0a10ef0666c4dc7e",
+ "run": null,
+ "lines": 4,
+ "bytes": 995,
+ "digest": "39b8a446c3a87d2b8da5210bba7f66ee2fd04caccea1fa0a4efdc3345b7ff7ca",
+ "from_time": 1767225606100,
+ "through_time": 1767225607100
+ },
+ {
+ "file": "00000001-0000-4000-8000-000000000001/streams/a0a10ef0666c4dc7e/meta-20260101T000000.000000000Z-000003.sd",
+ "format": "sd",
+ "kind": "agent_meta",
+ "seq": 3,
+ "round": null,
+ "stream": "a0a10ef0666c4dc7e",
+ "run": null,
+ "lines": 3,
+ "bytes": 676,
+ "digest": "de085eda31cdf99231fb212cb8fefbbcf95bd4f88c007c70446ecc652db3b005",
+ "from_time": null,
+ "through_time": null
+ },
+ {
+ "file": "_conversations/00000001-0000-4000-8000-000000000001/rounds/r000001-3ad0dcd4cd53.sf",
+ "format": "sf",
+ "kind": "round",
+ "seq": null,
+ "round": 1,
+ "stream": null,
+ "run": null,
+ "lines": 45,
+ "bytes": 10468,
+ "digest": "4f33cf8e85afcf104c823ea12be797059b4d3d10abbbb2ceb5916c2bd7e81d73",
+ "from_time": 1767225600000,
+ "through_time": 1767225611100
+ }
+ ],
+ "streams": [
+ {
+ "id": "stream/main",
+ "name": "main",
+ "role": "main",
+ "label": "",
+ "parent": "",
+ "records": 16,
+ "steps": 16,
+ "talk": "talk/main/s1-cycle",
+ "named_by": "",
+ "opened_by": []
+ },
+ {
+ "id": "stream/a0a10ef0666c4dc7e",
+ "name": "a0a10ef0666c4dc7e",
+ "role": "child",
+ "label": "checker",
+ "parent": "main",
+ "records": 3,
+ "steps": 3,
+ "talk": "talk/a0a10ef0666c4dc7e",
+ "named_by": "",
+ "opened_by": [
+ {
+ "step": "tool/s5-tool",
+ "stream": "main",
+ "talk": "talk/main/s1-cycle",
+ "quality": "exact_unique"
+ }
+ ]
+ }
+ ],
+ "segments": [
+ {
+ "id": "segment/at_1_2",
+ "state": "open",
+ "committable": false,
+ "talks": 3,
+ "from": 1767225600000,
+ "to": 1767225610700
+ }
+ ],
+ "talks": [
+ {
+ "id": "talk/main/s1-cycle",
+ "kind": "talk",
+ "parent": "epoch/main/0",
+ "stream": "main",
+ "at": 1767225600000,
+ "ref": {
+ "seq": 1,
+ "row": 2
+ },
+ "attrs": {
+ "loops": 2,
+ "runs": 2,
+ "trigger": "external"
+ },
+ "label": "run the build",
+ "reply": "Build passed and tests are green.",
+ "runs": 2,
+ "steps": 16,
+ "tools": 3,
+ "from": 1767225600000,
+ "to": 1767225610100,
+ "segment": "segment/at_1_2",
+ "children": [
+ {
+ "id": "run/talk_main_s1-cycle/s1-cycle",
+ "kind": "run",
+ "parent": "talk/main/s1-cycle",
+ "stream": "main",
+ "at": 1767225600000,
+ "ref": {
+ "seq": 1,
+ "row": 2
+ },
+ "attrs": {
+ "trigger": "external"
+ },
+ "children": [
+ {
+ "id": "input/1/2",
+ "kind": "message.external",
+ "parent": "run/talk_main_s1-cycle/s1-cycle",
+ "stream": "main",
+ "at": 1767225600000,
+ "ref": {
+ "seq": 1,
+ "row": 2
+ },
+ "text": "run the build",
+ "state": "available",
+ "bytes": 13,
+ "flags": [
+ "external_input"
+ ]
+ },
+ {
+ "id": "inject/1/3",
+ "kind": "context.injection",
+ "parent": "run/talk_main_s1-cycle/s1-cycle",
+ "stream": "main",
+ "at": 1767225600100,
+ "ref": {
+ "seq": 1,
+ "row": 3
+ },
+ "text": "skills: 1",
+ "state": "available",
+ "bytes": 9,
+ "flags": [
+ "injected"
+ ]
+ },
+ {
+ "id": "call/s3-call",
+ "kind": "llm.call",
+ "parent": "run/talk_main_s1-cycle/s1-cycle",
+ "stream": "main",
+ "at": 1767225601000,
+ "ref": {
+ "seq": 1,
+ "row": 4
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 4
+ },
+ {
+ "seq": 1,
+ "row": 5
+ },
+ {
+ "seq": 1,
+ "row": 6
+ }
+ ],
+ "attrs": {
+ "fragments": 3,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 6,
+ "seq": 1
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 2,
+ "out": 50,
+ "cache_read": 900,
+ "cache_write": 100
+ },
+ "children": [
+ {
+ "id": "think/1/4:0",
+ "kind": "thinking",
+ "parent": "call/s3-call",
+ "stream": "main",
+ "at": 1767225601000,
+ "ref": {
+ "seq": 1,
+ "row": 4,
+ "block": 0
+ },
+ "state": "unavailable",
+ "dropped": [
+ {
+ "what": "reasoning signature",
+ "bytes": 3,
+ "why": "a provider verifies it; a reader cannot read it"
+ }
+ ]
+ },
+ {
+ "id": "msg/1/5:0",
+ "kind": "message.assistant",
+ "parent": "call/s3-call",
+ "stream": "main",
+ "at": 1767225601100,
+ "ref": {
+ "seq": 1,
+ "row": 5,
+ "block": 0
+ },
+ "text": "Building now.",
+ "state": "available",
+ "bytes": 13
+ },
+ {
+ "id": "tool/tool-run-make-build",
+ "kind": "tool",
+ "parent": "call/s3-call",
+ "stream": "main",
+ "at": 1767225601200,
+ "ref": {
+ "seq": 1,
+ "row": 6,
+ "block": 0
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 6,
+ "block": 0
+ },
+ {
+ "seq": 1,
+ "row": 7,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "name": "Bash",
+ "result": "available",
+ "result_join": "exact_unique",
+ "timing": "unavailable"
+ },
+ "text": "{\"command\":\"make build\",\"description\":\"build the project\"}",
+ "state": "available",
+ "bytes": 58,
+ "flags": [
+ "finished"
+ ],
+ "name": "Bash",
+ "result": "build succeeded",
+ "result_state": "available",
+ "result_bytes": 15,
+ "request_to_result_ms": 800,
+ "request_to_result_join": "exact_unique"
+ }
+ ]
+ },
+ {
+ "id": "call/s4-call",
+ "kind": "llm.call",
+ "parent": "run/talk_main_s1-cycle/s1-cycle",
+ "stream": "main",
+ "at": 1767225603000,
+ "ref": {
+ "seq": 1,
+ "row": 8
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 8
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 8,
+ "seq": 1
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 2,
+ "out": 10,
+ "cache_read": 900,
+ "cache_write": 100
+ },
+ "children": [
+ {
+ "id": "tool/srvtool-websearch",
+ "kind": "tool",
+ "parent": "call/s4-call",
+ "stream": "main",
+ "at": 1767225603000,
+ "ref": {
+ "seq": 1,
+ "row": 8,
+ "block": 0
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 8,
+ "block": 0
+ },
+ {
+ "seq": 1,
+ "row": 9,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "name": "WebSearch",
+ "result": "available",
+ "result_join": "exact_unique",
+ "timing": "unavailable"
+ },
+ "text": "{\"query\":\"go build cache\"}",
+ "state": "available",
+ "bytes": 26,
+ "flags": [
+ "finished"
+ ],
+ "name": "WebSearch",
+ "result": "search results",
+ "result_state": "available",
+ "result_bytes": 14,
+ "request_to_result_ms": 1000,
+ "request_to_result_join": "exact_unique"
+ }
+ ]
+ },
+ {
+ "id": "call/s5-call",
+ "kind": "llm.call",
+ "parent": "run/talk_main_s1-cycle/s1-cycle",
+ "stream": "main",
+ "at": 1767225605000,
+ "ref": {
+ "seq": 1,
+ "row": 10
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 10
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 10,
+ "seq": 1
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 2,
+ "out": 20,
+ "cache_read": 900,
+ "cache_write": 100
+ },
+ "children": [
+ {
+ "id": "tool/s5-tool",
+ "kind": "agent.call",
+ "parent": "call/s5-call",
+ "stream": "main",
+ "at": 1767225605000,
+ "ref": {
+ "seq": 1,
+ "row": 10,
+ "block": 0
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 10,
+ "block": 0
+ },
+ {
+ "seq": 1,
+ "row": 11,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "name": "Agent",
+ "result": "available",
+ "result_join": "exact_unique",
+ "timing": "unavailable"
+ },
+ "text": "{\"description\":\"checker\",\"prompt\":\"check the tests\"}",
+ "state": "available",
+ "bytes": 52,
+ "flags": [
+ "finished"
+ ],
+ "name": "Agent",
+ "result": "launched",
+ "result_state": "available",
+ "result_bytes": 8,
+ "request_to_result_ms": 100,
+ "request_to_result_join": "exact_unique",
+ "edges": [
+ {
+ "type": "starts",
+ "other": "stream/a0a10ef0666c4dc7e",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "parent tool result"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "ack/1/11",
+ "kind": "agent.launch_ack",
+ "parent": "run/talk_main_s1-cycle/s1-cycle",
+ "stream": "main",
+ "at": 1767225605100,
+ "ref": {
+ "seq": 1,
+ "row": 11
+ },
+ "text": "launched",
+ "state": "available",
+ "bytes": 8,
+ "flags": [
+ "launch_ack"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "run/talk_main_s1-cycle/s5-cycle-notification",
+ "kind": "run",
+ "parent": "talk/main/s1-cycle",
+ "stream": "main",
+ "at": 1767225608100,
+ "ref": {
+ "seq": 1,
+ "row": 12
+ },
+ "attrs": {
+ "trigger": "notification"
+ },
+ "children": [
+ {
+ "id": "notify/1/12",
+ "kind": "runtime.notification",
+ "parent": "run/talk_main_s1-cycle/s5-cycle-notification",
+ "stream": "main",
+ "at": 1767225608100,
+ "ref": {
+ "seq": 1,
+ "row": 12
+ },
+ "text": "\u003ctask-notification\u003e\n\u003ctask-id\u003ea0a10ef0666c4dc7e\u003c/task-id\u003e\n\u003ctool-use-id\u003es5-tool\u003c/tool-use-id\u003e\n\u003cstatus\u003ecompleted\u003c/status\u003e\n\u003c/task-notification\u003e",
+ "state": "available",
+ "bytes": 139,
+ "edges": [
+ {
+ "type": "reports",
+ "other": "stream/a0a10ef0666c4dc7e",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "task id on the notification"
+ }
+ ]
+ },
+ {
+ "id": "call/s6-call",
+ "kind": "llm.call",
+ "parent": "run/talk_main_s1-cycle/s5-cycle-notification",
+ "stream": "main",
+ "at": 1767225609100,
+ "ref": {
+ "seq": 1,
+ "row": 13
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 13
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 13,
+ "seq": 1
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 2,
+ "out": 30,
+ "cache_read": 900,
+ "cache_write": 100
+ },
+ "children": [
+ {
+ "id": "msg/1/13:0",
+ "kind": "message.assistant",
+ "parent": "call/s6-call",
+ "stream": "main",
+ "at": 1767225609100,
+ "ref": {
+ "seq": 1,
+ "row": 13,
+ "block": 0
+ },
+ "text": "Build passed and tests are green.",
+ "state": "available",
+ "bytes": 33,
+ "flags": [
+ "finished"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "call/s7-synthetic-call",
+ "kind": "llm.call",
+ "parent": "run/talk_main_s1-cycle/s5-cycle-notification",
+ "stream": "main",
+ "at": 1767225610100,
+ "ref": {
+ "seq": 1,
+ "row": 14
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 14
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "stop_reason": "unavailable",
+ "usage": "unavailable",
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "children": [
+ {
+ "id": "msg/1/14:0",
+ "kind": "message.synthetic",
+ "parent": "call/s7-synthetic-call",
+ "stream": "main",
+ "at": 1767225610100,
+ "ref": {
+ "seq": 1,
+ "row": 14,
+ "block": 0
+ },
+ "text": "API Error: Connection lost mid-response.",
+ "state": "available",
+ "bytes": 40,
+ "flags": [
+ "synthetic"
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "edges": [
+ {
+ "type": "in_segment",
+ "other": "segment/at_1_2",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "activity window"
+ }
+ ]
+ },
+ {
+ "id": "talk/main/s8-cycle-compact",
+ "kind": "talk",
+ "parent": "epoch/main/s8-boundary",
+ "stream": "main",
+ "at": 1767225610700,
+ "ref": {
+ "seq": 1,
+ "row": 16
+ },
+ "attrs": {
+ "loops": 1,
+ "runs": 1,
+ "trigger": "external"
+ },
+ "runs": 1,
+ "from": 1767225610700,
+ "to": 1767225610700,
+ "segment": "segment/at_1_2",
+ "children": [
+ {
+ "id": "run/talk_main_s8-cycle-compact/s8-cycle-compact",
+ "kind": "run",
+ "parent": "talk/main/s8-cycle-compact",
+ "stream": "main",
+ "at": 1767225610700,
+ "ref": {
+ "seq": 1,
+ "row": 16
+ },
+ "attrs": {
+ "trigger": "external"
+ }
+ }
+ ],
+ "edges": [
+ {
+ "type": "in_segment",
+ "other": "segment/at_1_2",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "activity window"
+ }
+ ]
+ },
+ {
+ "id": "talk/a0a10ef0666c4dc7e",
+ "kind": "talk",
+ "parent": "epoch/a0a10ef0666c4dc7e/0",
+ "stream": "a0a10ef0666c4dc7e",
+ "at": 1767225606100,
+ "ref": {
+ "seq": 2,
+ "row": 1
+ },
+ "attrs": {
+ "loops": 1,
+ "runs": 1,
+ "trigger": "unknown"
+ },
+ "reply": "Tests pass.",
+ "runs": 1,
+ "steps": 3,
+ "from": 1767225606100,
+ "to": 1767225607100,
+ "child": true,
+ "segment": "segment/at_1_2",
+ "children": [
+ {
+ "id": "run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle",
+ "kind": "run",
+ "parent": "talk/a0a10ef0666c4dc7e",
+ "stream": "a0a10ef0666c4dc7e",
+ "at": 1767225606100,
+ "ref": {
+ "seq": 2,
+ "row": 1
+ },
+ "attrs": {
+ "trigger": "external"
+ },
+ "children": [
+ {
+ "id": "call/checker-s1-call",
+ "kind": "llm.call",
+ "parent": "run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle",
+ "stream": "a0a10ef0666c4dc7e",
+ "at": 1767225607100,
+ "ref": {
+ "seq": 2,
+ "row": 2
+ },
+ "refs": [
+ {
+ "seq": 2,
+ "row": 2
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 2,
+ "seq": 2
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 2,
+ "out": 42,
+ "cache_read": 900,
+ "cache_write": 100
+ },
+ "children": [
+ {
+ "id": "msg/2/2:0",
+ "kind": "message.assistant",
+ "parent": "call/checker-s1-call",
+ "stream": "a0a10ef0666c4dc7e",
+ "at": 1767225607100,
+ "ref": {
+ "seq": 2,
+ "row": 2,
+ "block": 0
+ },
+ "text": "Tests pass.",
+ "state": "available",
+ "bytes": 11,
+ "flags": [
+ "finished"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "output/a0a10ef0666c4dc7e",
+ "kind": "agent.output",
+ "parent": "run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle",
+ "stream": "a0a10ef0666c4dc7e",
+ "at": 1767225607100,
+ "ref": {
+ "seq": 2,
+ "row": 2
+ },
+ "refs": [
+ {
+ "seq": 2,
+ "row": 2
+ }
+ ],
+ "attrs": {
+ "returned_value": "unavailable"
+ },
+ "text": "Tests pass.",
+ "state": "available",
+ "bytes": 11,
+ "flags": [
+ "finished"
+ ],
+ "edges": [
+ {
+ "type": "ends_with",
+ "other": "stream/a0a10ef0666c4dc7e",
+ "dir": "in",
+ "quality": "exact_unique",
+ "via": "the last response in the child stream, and what it returned"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "edges": [
+ {
+ "type": "in_segment",
+ "other": "segment/at_1_2",
+ "dir": "out",
+ "quality": "strong_inference",
+ "via": "inside the window of the talk that delegated it"
+ }
+ ]
+ }
+ ],
+ "loose": [
+ {
+ "id": "boundary/1/15",
+ "kind": "epoch.boundary",
+ "parent": "epoch/main/s8-boundary",
+ "stream": "main",
+ "at": 1767225611100,
+ "ref": {
+ "seq": 1,
+ "row": 15
+ },
+ "text": "{\"compactMetadata\":{\"preservedMessages\":{\"allUuids\":[\"s7-synthetic\"]},\"trigger\":\"auto\"},\"logicalParentUuid\":\"s7-synthetic\",\"subtype\":\"compact_boundary\",\"type\":\"system\"}",
+ "state": "available",
+ "bytes": 168,
+ "flags": [
+ "context_reset"
+ ],
+ "edges": [
+ {
+ "type": "summarizes",
+ "other": "summary/1/16",
+ "dir": "in",
+ "quality": "exact_unique",
+ "via": "containment parent"
+ }
+ ]
+ },
+ {
+ "id": "summary/1/16",
+ "kind": "epoch.summary",
+ "parent": "epoch/main/s8-boundary",
+ "stream": "main",
+ "at": 1767225610700,
+ "ref": {
+ "seq": 1,
+ "row": 16
+ },
+ "text": "Summary: the build was run and checked.",
+ "state": "available",
+ "bytes": 39,
+ "flags": [
+ "reset_summary"
+ ],
+ "edges": [
+ {
+ "type": "summarizes",
+ "other": "boundary/1/15",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "containment parent"
+ }
+ ]
+ }
+ ],
+ "relations": [
+ {
+ "id": "rel/ends_with/stream_a0a10ef0666c4dc7e/output_a0a10ef0666c4dc7e",
+ "type": "ends_with",
+ "from": "stream/a0a10ef0666c4dc7e",
+ "to": "output/a0a10ef0666c4dc7e",
+ "quality": "exact_unique",
+ "via": "the last response in the child stream, and what it returned",
+ "evidence": [
+ {
+ "seq": 2,
+ "row": 2
+ }
+ ]
+ },
+ {
+ "id": "rel/follows/epoch_main_s8-boundary/epoch_main_0",
+ "type": "follows",
+ "from": "epoch/main/s8-boundary",
+ "to": "epoch/main/0",
+ "quality": "exact_unique",
+ "via": "explicit context reset",
+ "evidence": [
+ {
+ "seq": 1,
+ "row": 15
+ }
+ ]
+ },
+ {
+ "id": "rel/in_segment/talk_a0a10ef0666c4dc7e/segment_at_1_2",
+ "type": "in_segment",
+ "from": "talk/a0a10ef0666c4dc7e",
+ "to": "segment/at_1_2",
+ "quality": "strong_inference",
+ "via": "inside the window of the talk that delegated it",
+ "evidence": [
+ {
+ "seq": 2,
+ "row": 1
+ }
+ ]
+ },
+ {
+ "id": "rel/in_segment/talk_main_s1-cycle/segment_at_1_2",
+ "type": "in_segment",
+ "from": "talk/main/s1-cycle",
+ "to": "segment/at_1_2",
+ "quality": "exact_unique",
+ "via": "activity window",
+ "evidence": [
+ {
+ "seq": 1,
+ "row": 2
+ }
+ ]
+ },
+ {
+ "id": "rel/in_segment/talk_main_s8-cycle-compact/segment_at_1_2",
+ "type": "in_segment",
+ "from": "talk/main/s8-cycle-compact",
+ "to": "segment/at_1_2",
+ "quality": "exact_unique",
+ "via": "activity window",
+ "evidence": [
+ {
+ "seq": 1,
+ "row": 16
+ }
+ ]
+ },
+ {
+ "id": "rel/reports/notify_1_12/stream_a0a10ef0666c4dc7e",
+ "type": "reports",
+ "from": "notify/1/12",
+ "to": "stream/a0a10ef0666c4dc7e",
+ "quality": "exact_unique",
+ "via": "task id on the notification",
+ "evidence": [
+ {
+ "seq": 1,
+ "row": 12
+ }
+ ]
+ },
+ {
+ "id": "rel/starts/tool_s5-tool/stream_a0a10ef0666c4dc7e",
+ "type": "starts",
+ "from": "tool/s5-tool",
+ "to": "stream/a0a10ef0666c4dc7e",
+ "quality": "exact_unique",
+ "via": "parent tool result",
+ "evidence": [
+ {
+ "seq": 1,
+ "row": 11
+ }
+ ]
+ },
+ {
+ "id": "rel/summarizes/summary_1_16/boundary_1_15",
+ "type": "summarizes",
+ "from": "summary/1/16",
+ "to": "boundary/1/15",
+ "quality": "exact_unique",
+ "via": "containment parent",
+ "evidence": [
+ {
+ "seq": 1,
+ "row": 16
+ }
+ ]
+ }
+ ],
+ "unresolved": []
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/asz-view-example.yaml b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/asz-view-example.yaml
new file mode 100644
index 0000000000..05c92a19a8
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/asz-view-example.yaml
@@ -0,0 +1,773 @@
+format: asz.view
+version: "1.0"
+conversation: 00000001-0000-4000-8000-000000000001
+sessions:
+ - 00000001-0000-4000-8000-000000000001
+head:
+ round: 1
+ digest: 3ad0dcd4cd53fa06c502a2649b8788bfbe0a382bbc72d7f1f0fe68d9a18e96e2
+parser: v1
+policy: v1+idle=10m0s
+summary:
+ title: build and check
+ state: verified
+ problems: []
+ talks: 3
+ steps: 21
+ streams: 2
+ segments: 1
+ rounds: 1
+ unresolved: 0
+ from: 1767225600000
+ to: 1767225611100
+ kinds:
+ agent.call: 1
+ agent.launch_ack: 1
+ agent.output: 1
+ context.injection: 1
+ epoch: 3
+ epoch.boundary: 1
+ epoch.summary: 1
+ llm.call: 6
+ message.assistant: 3
+ message.external: 1
+ message.synthetic: 1
+ run: 4
+ runtime.notification: 1
+ segment: 1
+ session: 1
+ stream: 2
+ talk: 3
+ thinking: 1
+ tool: 2
+ relation_types:
+ ends_with: 1
+ follows: 1
+ in_segment: 3
+ reports: 1
+ starts: 1
+ summarizes: 1
+ quality:
+ exact_unique: 7
+ strong_inference: 1
+rounds:
+ - round: 1
+ digest: 3ad0dcd4cd53fa06c502a2649b8788bfbe0a382bbc72d7f1f0fe68d9a18e96e2
+ previous: null
+ from_seq: 1
+ through_seq: 3
+ input_digest: 6872d48ef5d3e736d0bd9f5bc03844653fb304b4b98b9ef6a27342478d950c1b
+ from_time: 1767225600000
+ through_time: 1767225611100
+ verified: true
+files:
+ - file: 00000001-0000-4000-8000-000000000001/streams/main/transcript-20260101T000000.000000000Z-000001.sd
+ format: sd
+ kind: transcript
+ seq: 1
+ round: null
+ stream: main
+ run: null
+ lines: 18
+ bytes: 5868
+ digest: 944ed3d1209c76afb98f0622eb9239f95adb79fd0d0262d634c2f20bc041b1f7
+ from_time: 1767225600000
+ through_time: 1767225611100
+ - file: 00000001-0000-4000-8000-000000000001/streams/a0a10ef0666c4dc7e/transcript-20260101T000000.000000000Z-000002.sd
+ format: sd
+ kind: transcript
+ seq: 2
+ round: null
+ stream: a0a10ef0666c4dc7e
+ run: null
+ lines: 4
+ bytes: 995
+ digest: 39b8a446c3a87d2b8da5210bba7f66ee2fd04caccea1fa0a4efdc3345b7ff7ca
+ from_time: 1767225606100
+ through_time: 1767225607100
+ - file: 00000001-0000-4000-8000-000000000001/streams/a0a10ef0666c4dc7e/meta-20260101T000000.000000000Z-000003.sd
+ format: sd
+ kind: agent_meta
+ seq: 3
+ round: null
+ stream: a0a10ef0666c4dc7e
+ run: null
+ lines: 3
+ bytes: 676
+ digest: de085eda31cdf99231fb212cb8fefbbcf95bd4f88c007c70446ecc652db3b005
+ from_time: null
+ through_time: null
+ - file: _conversations/00000001-0000-4000-8000-000000000001/rounds/r000001-3ad0dcd4cd53.sf
+ format: sf
+ kind: round
+ seq: null
+ round: 1
+ stream: null
+ run: null
+ lines: 45
+ bytes: 10468
+ digest: 4f33cf8e85afcf104c823ea12be797059b4d3d10abbbb2ceb5916c2bd7e81d73
+ from_time: 1767225600000
+ through_time: 1767225611100
+streams:
+ - id: stream/main
+ name: main
+ role: main
+ label: ""
+ parent: ""
+ records: 16
+ steps: 16
+ talk: talk/main/s1-cycle
+ named_by: ""
+ opened_by: []
+ - id: stream/a0a10ef0666c4dc7e
+ name: a0a10ef0666c4dc7e
+ role: child
+ label: checker
+ parent: main
+ records: 3
+ steps: 3
+ talk: talk/a0a10ef0666c4dc7e
+ named_by: ""
+ opened_by:
+ - step: tool/s5-tool
+ stream: main
+ talk: talk/main/s1-cycle
+ quality: exact_unique
+segments:
+ - id: segment/at_1_2
+ state: open
+ committable: false
+ talks: 3
+ from: 1767225600000
+ to: 1767225610700
+talks:
+ - id: talk/main/s1-cycle
+ kind: talk
+ parent: epoch/main/0
+ stream: main
+ at: 1767225600000
+ ref:
+ seq: 1
+ row: 2
+ attrs:
+ loops: 2
+ runs: 2
+ trigger: external
+ label: run the build
+ reply: Build passed and tests are green.
+ runs: 2
+ steps: 16
+ tools: 3
+ from: 1767225600000
+ to: 1767225610100
+ segment: segment/at_1_2
+ children:
+ - id: run/talk_main_s1-cycle/s1-cycle
+ kind: run
+ parent: talk/main/s1-cycle
+ stream: main
+ at: 1767225600000
+ ref:
+ seq: 1
+ row: 2
+ attrs:
+ trigger: external
+ children:
+ - id: input/1/2
+ kind: message.external
+ parent: run/talk_main_s1-cycle/s1-cycle
+ stream: main
+ at: 1767225600000
+ ref:
+ seq: 1
+ row: 2
+ text: run the build
+ state: available
+ bytes: 13
+ flags:
+ - external_input
+ - id: inject/1/3
+ kind: context.injection
+ parent: run/talk_main_s1-cycle/s1-cycle
+ stream: main
+ at: 1767225600100
+ ref:
+ seq: 1
+ row: 3
+ text: 'skills: 1'
+ state: available
+ bytes: 9
+ flags:
+ - injected
+ - id: call/s3-call
+ kind: llm.call
+ parent: run/talk_main_s1-cycle/s1-cycle
+ stream: main
+ at: 1767225601000
+ ref:
+ seq: 1
+ row: 4
+ refs:
+ - seq: 1
+ row: 4
+ - seq: 1
+ row: 5
+ - seq: 1
+ row: 6
+ attrs:
+ fragments: 3
+ usage: observed_replayable
+ usage_at:
+ row: 6
+ seq: 1
+ usage_from: last_fragment_in_line_order
+ usage:
+ in: 2
+ out: 50
+ cache_read: 900
+ cache_write: 100
+ children:
+ - id: think/1/4:0
+ kind: thinking
+ parent: call/s3-call
+ stream: main
+ at: 1767225601000
+ ref:
+ seq: 1
+ row: 4
+ block: 0
+ state: unavailable
+ dropped:
+ - what: reasoning signature
+ bytes: 3
+ why: a provider verifies it; a reader cannot read it
+ - id: msg/1/5:0
+ kind: message.assistant
+ parent: call/s3-call
+ stream: main
+ at: 1767225601100
+ ref:
+ seq: 1
+ row: 5
+ block: 0
+ text: Building now.
+ state: available
+ bytes: 13
+ - id: tool/tool-run-make-build
+ kind: tool
+ parent: call/s3-call
+ stream: main
+ at: 1767225601200
+ ref:
+ seq: 1
+ row: 6
+ block: 0
+ refs:
+ - seq: 1
+ row: 6
+ block: 0
+ - seq: 1
+ row: 7
+ block: 0
+ attrs:
+ name: Bash
+ result: available
+ result_join: exact_unique
+ timing: unavailable
+ text: '{"command":"make build","description":"build the project"}'
+ state: available
+ bytes: 58
+ flags:
+ - finished
+ name: Bash
+ result: build succeeded
+ result_state: available
+ result_bytes: 15
+ request_to_result_ms: 800
+ request_to_result_join: exact_unique
+ - id: call/s4-call
+ kind: llm.call
+ parent: run/talk_main_s1-cycle/s1-cycle
+ stream: main
+ at: 1767225603000
+ ref:
+ seq: 1
+ row: 8
+ refs:
+ - seq: 1
+ row: 8
+ attrs:
+ fragments: 1
+ usage: observed_replayable
+ usage_at:
+ row: 8
+ seq: 1
+ usage_from: last_fragment_in_line_order
+ usage:
+ in: 2
+ out: 10
+ cache_read: 900
+ cache_write: 100
+ children:
+ - id: tool/srvtool-websearch
+ kind: tool
+ parent: call/s4-call
+ stream: main
+ at: 1767225603000
+ ref:
+ seq: 1
+ row: 8
+ block: 0
+ refs:
+ - seq: 1
+ row: 8
+ block: 0
+ - seq: 1
+ row: 9
+ block: 0
+ attrs:
+ name: WebSearch
+ result: available
+ result_join: exact_unique
+ timing: unavailable
+ text: '{"query":"go build cache"}'
+ state: available
+ bytes: 26
+ flags:
+ - finished
+ name: WebSearch
+ result: search results
+ result_state: available
+ result_bytes: 14
+ request_to_result_ms: 1000
+ request_to_result_join: exact_unique
+ - id: call/s5-call
+ kind: llm.call
+ parent: run/talk_main_s1-cycle/s1-cycle
+ stream: main
+ at: 1767225605000
+ ref:
+ seq: 1
+ row: 10
+ refs:
+ - seq: 1
+ row: 10
+ attrs:
+ fragments: 1
+ usage: observed_replayable
+ usage_at:
+ row: 10
+ seq: 1
+ usage_from: last_fragment_in_line_order
+ usage:
+ in: 2
+ out: 20
+ cache_read: 900
+ cache_write: 100
+ children:
+ - id: tool/s5-tool
+ kind: agent.call
+ parent: call/s5-call
+ stream: main
+ at: 1767225605000
+ ref:
+ seq: 1
+ row: 10
+ block: 0
+ refs:
+ - seq: 1
+ row: 10
+ block: 0
+ - seq: 1
+ row: 11
+ block: 0
+ attrs:
+ name: Agent
+ result: available
+ result_join: exact_unique
+ timing: unavailable
+ text: '{"description":"checker","prompt":"check the tests"}'
+ state: available
+ bytes: 52
+ flags:
+ - finished
+ name: Agent
+ result: launched
+ result_state: available
+ result_bytes: 8
+ request_to_result_ms: 100
+ request_to_result_join: exact_unique
+ edges:
+ - type: starts
+ other: stream/a0a10ef0666c4dc7e
+ dir: out
+ quality: exact_unique
+ via: parent tool result
+ - id: ack/1/11
+ kind: agent.launch_ack
+ parent: run/talk_main_s1-cycle/s1-cycle
+ stream: main
+ at: 1767225605100
+ ref:
+ seq: 1
+ row: 11
+ text: launched
+ state: available
+ bytes: 8
+ flags:
+ - launch_ack
+ - id: run/talk_main_s1-cycle/s5-cycle-notification
+ kind: run
+ parent: talk/main/s1-cycle
+ stream: main
+ at: 1767225608100
+ ref:
+ seq: 1
+ row: 12
+ attrs:
+ trigger: notification
+ children:
+ - id: notify/1/12
+ kind: runtime.notification
+ parent: run/talk_main_s1-cycle/s5-cycle-notification
+ stream: main
+ at: 1767225608100
+ ref:
+ seq: 1
+ row: 12
+ text: |-
+ <task-notification>
+ <task-id>a0a10ef0666c4dc7e</task-id>
+ <tool-use-id>s5-tool</tool-use-id>
+ <status>completed</status>
+ </task-notification>
+ state: available
+ bytes: 139
+ edges:
+ - type: reports
+ other: stream/a0a10ef0666c4dc7e
+ dir: out
+ quality: exact_unique
+ via: task id on the notification
+ - id: call/s6-call
+ kind: llm.call
+ parent: run/talk_main_s1-cycle/s5-cycle-notification
+ stream: main
+ at: 1767225609100
+ ref:
+ seq: 1
+ row: 13
+ refs:
+ - seq: 1
+ row: 13
+ attrs:
+ fragments: 1
+ usage: observed_replayable
+ usage_at:
+ row: 13
+ seq: 1
+ usage_from: last_fragment_in_line_order
+ usage:
+ in: 2
+ out: 30
+ cache_read: 900
+ cache_write: 100
+ children:
+ - id: msg/1/13:0
+ kind: message.assistant
+ parent: call/s6-call
+ stream: main
+ at: 1767225609100
+ ref:
+ seq: 1
+ row: 13
+ block: 0
+ text: Build passed and tests are green.
+ state: available
+ bytes: 33
+ flags:
+ - finished
+ - id: call/s7-synthetic-call
+ kind: llm.call
+ parent: run/talk_main_s1-cycle/s5-cycle-notification
+ stream: main
+ at: 1767225610100
+ ref:
+ seq: 1
+ row: 14
+ refs:
+ - seq: 1
+ row: 14
+ attrs:
+ fragments: 1
+ stop_reason: unavailable
+ usage: unavailable
+ usage_from: last_fragment_in_line_order
+ children:
+ - id: msg/1/14:0
+ kind: message.synthetic
+ parent: call/s7-synthetic-call
+ stream: main
+ at: 1767225610100
+ ref:
+ seq: 1
+ row: 14
+ block: 0
+ text: 'API Error: Connection lost mid-response.'
+ state: available
+ bytes: 40
+ flags:
+ - synthetic
+ edges:
+ - type: in_segment
+ other: segment/at_1_2
+ dir: out
+ quality: exact_unique
+ via: activity window
+ - id: talk/main/s8-cycle-compact
+ kind: talk
+ parent: epoch/main/s8-boundary
+ stream: main
+ at: 1767225610700
+ ref:
+ seq: 1
+ row: 16
+ attrs:
+ loops: 1
+ runs: 1
+ trigger: external
+ runs: 1
+ from: 1767225610700
+ to: 1767225610700
+ segment: segment/at_1_2
+ children:
+ - id: run/talk_main_s8-cycle-compact/s8-cycle-compact
+ kind: run
+ parent: talk/main/s8-cycle-compact
+ stream: main
+ at: 1767225610700
+ ref:
+ seq: 1
+ row: 16
+ attrs:
+ trigger: external
+ edges:
+ - type: in_segment
+ other: segment/at_1_2
+ dir: out
+ quality: exact_unique
+ via: activity window
+ - id: talk/a0a10ef0666c4dc7e
+ kind: talk
+ parent: epoch/a0a10ef0666c4dc7e/0
+ stream: a0a10ef0666c4dc7e
+ at: 1767225606100
+ ref:
+ seq: 2
+ row: 1
+ attrs:
+ loops: 1
+ runs: 1
+ trigger: unknown
+ reply: Tests pass.
+ runs: 1
+ steps: 3
+ from: 1767225606100
+ to: 1767225607100
+ child: true
+ segment: segment/at_1_2
+ children:
+ - id: run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle
+ kind: run
+ parent: talk/a0a10ef0666c4dc7e
+ stream: a0a10ef0666c4dc7e
+ at: 1767225606100
+ ref:
+ seq: 2
+ row: 1
+ attrs:
+ trigger: external
+ children:
+ - id: call/checker-s1-call
+ kind: llm.call
+ parent: run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle
+ stream: a0a10ef0666c4dc7e
+ at: 1767225607100
+ ref:
+ seq: 2
+ row: 2
+ refs:
+ - seq: 2
+ row: 2
+ attrs:
+ fragments: 1
+ usage: observed_replayable
+ usage_at:
+ row: 2
+ seq: 2
+ usage_from: last_fragment_in_line_order
+ usage:
+ in: 2
+ out: 42
+ cache_read: 900
+ cache_write: 100
+ children:
+ - id: msg/2/2:0
+ kind: message.assistant
+ parent: call/checker-s1-call
+ stream: a0a10ef0666c4dc7e
+ at: 1767225607100
+ ref:
+ seq: 2
+ row: 2
+ block: 0
+ text: Tests pass.
+ state: available
+ bytes: 11
+ flags:
+ - finished
+ - id: output/a0a10ef0666c4dc7e
+ kind: agent.output
+ parent: run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle
+ stream: a0a10ef0666c4dc7e
+ at: 1767225607100
+ ref:
+ seq: 2
+ row: 2
+ refs:
+ - seq: 2
+ row: 2
+ attrs:
+ returned_value: unavailable
+ text: Tests pass.
+ state: available
+ bytes: 11
+ flags:
+ - finished
+ edges:
+ - type: ends_with
+ other: stream/a0a10ef0666c4dc7e
+ dir: in
+ quality: exact_unique
+ via: the last response in the child stream, and what it returned
+ edges:
+ - type: in_segment
+ other: segment/at_1_2
+ dir: out
+ quality: strong_inference
+ via: inside the window of the talk that delegated it
+loose:
+ - id: boundary/1/15
+ kind: epoch.boundary
+ parent: epoch/main/s8-boundary
+ stream: main
+ at: 1767225611100
+ ref:
+ seq: 1
+ row: 15
+ text: '{"compactMetadata":{"preservedMessages":{"allUuids":["s7-synthetic"]},"trigger":"auto"},"logicalParentUuid":"s7-synthetic","subtype":"compact_boundary","type":"system"}'
+ state: available
+ bytes: 168
+ flags:
+ - context_reset
+ edges:
+ - type: summarizes
+ other: summary/1/16
+ dir: in
+ quality: exact_unique
+ via: containment parent
+ - id: summary/1/16
+ kind: epoch.summary
+ parent: epoch/main/s8-boundary
+ stream: main
+ at: 1767225610700
+ ref:
+ seq: 1
+ row: 16
+ text: 'Summary: the build was run and checked.'
+ state: available
+ bytes: 39
+ flags:
+ - reset_summary
+ edges:
+ - type: summarizes
+ other: boundary/1/15
+ dir: out
+ quality: exact_unique
+ via: containment parent
+relations:
+ - id: rel/ends_with/stream_a0a10ef0666c4dc7e/output_a0a10ef0666c4dc7e
+ type: ends_with
+ from: stream/a0a10ef0666c4dc7e
+ to: output/a0a10ef0666c4dc7e
+ quality: exact_unique
+ via: the last response in the child stream, and what it returned
+ evidence:
+ - seq: 2
+ row: 2
+ - id: rel/follows/epoch_main_s8-boundary/epoch_main_0
+ type: follows
+ from: epoch/main/s8-boundary
+ to: epoch/main/0
+ quality: exact_unique
+ via: explicit context reset
+ evidence:
+ - seq: 1
+ row: 15
+ - id: rel/in_segment/talk_a0a10ef0666c4dc7e/segment_at_1_2
+ type: in_segment
+ from: talk/a0a10ef0666c4dc7e
+ to: segment/at_1_2
+ quality: strong_inference
+ via: inside the window of the talk that delegated it
+ evidence:
+ - seq: 2
+ row: 1
+ - id: rel/in_segment/talk_main_s1-cycle/segment_at_1_2
+ type: in_segment
+ from: talk/main/s1-cycle
+ to: segment/at_1_2
+ quality: exact_unique
+ via: activity window
+ evidence:
+ - seq: 1
+ row: 2
+ - id: rel/in_segment/talk_main_s8-cycle-compact/segment_at_1_2
+ type: in_segment
+ from: talk/main/s8-cycle-compact
+ to: segment/at_1_2
+ quality: exact_unique
+ via: activity window
+ evidence:
+ - seq: 1
+ row: 16
+ - id: rel/reports/notify_1_12/stream_a0a10ef0666c4dc7e
+ type: reports
+ from: notify/1/12
+ to: stream/a0a10ef0666c4dc7e
+ quality: exact_unique
+ via: task id on the notification
+ evidence:
+ - seq: 1
+ row: 12
+ - id: rel/starts/tool_s5-tool/stream_a0a10ef0666c4dc7e
+ type: starts
+ from: tool/s5-tool
+ to: stream/a0a10ef0666c4dc7e
+ quality: exact_unique
+ via: parent tool result
+ evidence:
+ - seq: 1
+ row: 11
+ - id: rel/summarizes/summary_1_16/boundary_1_15
+ type: summarizes
+ from: summary/1/16
+ to: boundary/1/15
+ quality: exact_unique
+ via: containment parent
+ evidence:
+ - seq: 1
+ row: 16
+unresolved: []
+
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/meta-20260101T000000.000000000Z-000003.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/meta-20260101T000000.000000000Z-000003.sd
new file mode 100644
index 0000000000..f2e02a48fe
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/meta-20260101T000000.000000000Z-000003.sd
@@ -0,0 +1,3 @@
+{"h":1,"schema":"sd/1","seq":3,"at":"2026-01-01T00:00:00Z","kind":"agent_meta","adapter":"mock/0.2.0","dialect":"mock/1","src":"-Users-dev-01-full-conversation/00000001-0000-4000-8000-000000000001/streams/a0a10ef0666c4dc7e.meta","session":"00000001-0000-4000-8000-000000000001","stream":"a0a10ef0666c4dc7e"}
+{"ord":1,"off":0,"sha":"c0b42d2ebfe2","bytes":253,"child":"a0a10ef0666c4dc7e","label":"checker","from":"runtime","parts":[{"k":"data","data":{"agentType":"general-purpose","description":"checker","spawnDepth":1,"toolUseId":"s5-tool"},"state":"available","bytes":92}]}
+{"t":"end","records":1,"digest":"99ccbf85b7b267aa8ec0a052a969e9a43d4d51cdaf7a762beb52517eb001929a"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/r000001-3ad0dcd4cd53.sf b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/r000001-3ad0dcd4cd53.sf
new file mode 100644
index 0000000000..be0341107b
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/r000001-3ad0dcd4cd53.sf
@@ -0,0 +1,45 @@
+{"t":"header","schema":"sf/1","conversation":"00000001-0000-4000-8000-000000000001","session":"00000001-0000-4000-8000-000000000001","round":1,"from_seq":1,"through_seq":3,"input_digest":"6872d48ef5d3e736d0bd9f5bc03844653fb304b4b98b9ef6a27342478d950c1b","parser":"v1","policy":"v1+idle=10m0s","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:11.1Z","session_from_time":"2026-01-01T00:00:00Z","session_through_time":"2026-01-01T00:00:11.1Z","title":"build and check","talks" [...]
+{"t":"node","id":"ack/1/11","revision":1,"kind":"agent.launch_ack","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":11}}
+{"t":"node","id":"boundary/1/15","revision":1,"kind":"epoch.boundary","parent":"epoch/main/s8-boundary","stream":"main","ref":{"seq":1,"row":15}}
+{"t":"node","id":"call/checker-s1-call","revision":1,"kind":"llm.call","parent":"run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle","stream":"a0a10ef0666c4dc7e","ref":{"seq":2,"row":2},"refs":[{"seq":2,"row":2}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":2,"seq":2},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s3-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":4},"refs":[{"seq":1,"row":4},{"seq":1,"row":5},{"seq":1,"row":6}],"attrs":{"fragments":3,"usage":"observed_replayable","usage_at":{"row":6,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s4-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":8},"refs":[{"seq":1,"row":8}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":8,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s5-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":10},"refs":[{"seq":1,"row":10}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":10,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s6-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s5-cycle-notification","stream":"main","ref":{"seq":1,"row":13},"refs":[{"seq":1,"row":13}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":13,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s7-synthetic-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s5-cycle-notification","stream":"main","ref":{"seq":1,"row":14},"refs":[{"seq":1,"row":14}],"attrs":{"fragments":1,"stop_reason":"unavailable","usage":"unavailable","usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"epoch/a0a10ef0666c4dc7e/0","revision":1,"kind":"epoch","parent":"stream/a0a10ef0666c4dc7e","stream":"a0a10ef0666c4dc7e","ref":{"seq":2,"row":1},"attrs":{"records":3,"reset":"none"}}
+{"t":"node","id":"epoch/main/0","revision":1,"kind":"epoch","parent":"stream/main","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":14,"reset":"none"}}
+{"t":"node","id":"epoch/main/s8-boundary","revision":1,"kind":"epoch","parent":"stream/main","stream":"main","ref":{"seq":1,"row":15},"refs":[{"seq":1,"row":15}],"attrs":{"continues_from":"s7-synthetic","records":2,"reset":"observed_replayable"}}
+{"t":"node","id":"inject/1/3","revision":1,"kind":"context.injection","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":3}}
+{"t":"node","id":"input/1/2","revision":1,"kind":"message.external","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":2}}
+{"t":"node","id":"msg/1/13:0","revision":1,"kind":"message.assistant","parent":"call/s6-call","stream":"main","ref":{"seq":1,"row":13,"block":0}}
+{"t":"node","id":"msg/1/14:0","revision":1,"kind":"message.synthetic","parent":"call/s7-synthetic-call","stream":"main","ref":{"seq":1,"row":14,"block":0}}
+{"t":"node","id":"msg/1/5:0","revision":1,"kind":"message.assistant","parent":"call/s3-call","stream":"main","ref":{"seq":1,"row":5,"block":0}}
+{"t":"node","id":"msg/2/2:0","revision":1,"kind":"message.assistant","parent":"call/checker-s1-call","stream":"a0a10ef0666c4dc7e","ref":{"seq":2,"row":2,"block":0}}
+{"t":"node","id":"notify/1/12","revision":1,"kind":"runtime.notification","parent":"run/talk_main_s1-cycle/s5-cycle-notification","stream":"main","ref":{"seq":1,"row":12}}
+{"t":"node","id":"output/a0a10ef0666c4dc7e","revision":1,"kind":"agent.output","parent":"run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle","stream":"a0a10ef0666c4dc7e","ref":{"seq":2,"row":2},"refs":[{"seq":2,"row":2}],"attrs":{"returned_value":"unavailable"}}
+{"t":"node","id":"run/talk_a0a10ef0666c4dc7e/a0a10ef0666c4dc7e-cycle","revision":1,"kind":"run","parent":"talk/a0a10ef0666c4dc7e","stream":"a0a10ef0666c4dc7e","ref":{"seq":2,"row":1},"attrs":{"trigger":"external"}}
+{"t":"node","id":"run/talk_main_s1-cycle/s1-cycle","revision":1,"kind":"run","parent":"talk/main/s1-cycle","stream":"main","ref":{"seq":1,"row":2},"attrs":{"trigger":"external"}}
+{"t":"node","id":"run/talk_main_s1-cycle/s5-cycle-notification","revision":1,"kind":"run","parent":"talk/main/s1-cycle","stream":"main","ref":{"seq":1,"row":12},"attrs":{"trigger":"notification"}}
+{"t":"node","id":"run/talk_main_s8-cycle-compact/s8-cycle-compact","revision":1,"kind":"run","parent":"talk/main/s8-cycle-compact","stream":"main","ref":{"seq":1,"row":16},"attrs":{"trigger":"external"}}
+{"t":"node","id":"segment/at_1_2","revision":1,"kind":"segment","parent":"session/00000001-0000-4000-8000-000000000001","ref":{"seq":1,"row":2},"attrs":{"committable":false,"gates_unmet":["activity_boundary","lateness_watermark"],"state":"open","talks":2}}
+{"t":"node","id":"session/00000001-0000-4000-8000-000000000001","revision":1,"kind":"session","attrs":{"conversation":"00000001-0000-4000-8000-000000000001","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:11.1Z","title":"build and check","title_from":"observed_replayable"}}
+{"t":"node","id":"stream/a0a10ef0666c4dc7e","revision":1,"kind":"stream","parent":"session/00000001-0000-4000-8000-000000000001","stream":"a0a10ef0666c4dc7e","ref":{"seq":2,"row":1},"attrs":{"label":"checker","records":3,"role":"child"}}
+{"t":"node","id":"stream/main","revision":1,"kind":"stream","parent":"session/00000001-0000-4000-8000-000000000001","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":16,"role":"main"}}
+{"t":"node","id":"summary/1/16","revision":1,"kind":"epoch.summary","parent":"epoch/main/s8-boundary","stream":"main","ref":{"seq":1,"row":16}}
+{"t":"node","id":"talk/a0a10ef0666c4dc7e","revision":1,"kind":"talk","parent":"epoch/a0a10ef0666c4dc7e/0","stream":"a0a10ef0666c4dc7e","ref":{"seq":2,"row":1},"attrs":{"loops":1,"runs":1,"trigger":"unknown"}}
+{"t":"node","id":"talk/main/s1-cycle","revision":1,"kind":"talk","parent":"epoch/main/0","stream":"main","ref":{"seq":1,"row":2},"attrs":{"loops":2,"runs":2,"trigger":"external"}}
+{"t":"node","id":"talk/main/s8-cycle-compact","revision":1,"kind":"talk","parent":"epoch/main/s8-boundary","stream":"main","ref":{"seq":1,"row":16},"attrs":{"loops":1,"runs":1,"trigger":"external"}}
+{"t":"node","id":"think/1/4:0","revision":1,"kind":"thinking","parent":"call/s3-call","stream":"main","ref":{"seq":1,"row":4,"block":0}}
+{"t":"node","id":"tool/s5-tool","revision":1,"kind":"agent.call","parent":"call/s5-call","stream":"main","ref":{"seq":1,"row":10,"block":0},"refs":[{"seq":1,"row":10,"block":0},{"seq":1,"row":11,"block":0}],"attrs":{"name":"Agent","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/srvtool-websearch","revision":1,"kind":"tool","parent":"call/s4-call","stream":"main","ref":{"seq":1,"row":8,"block":0},"refs":[{"seq":1,"row":8,"block":0},{"seq":1,"row":9,"block":0}],"attrs":{"name":"WebSearch","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/tool-run-make-build","revision":1,"kind":"tool","parent":"call/s3-call","stream":"main","ref":{"seq":1,"row":6,"block":0},"refs":[{"seq":1,"row":6,"block":0},{"seq":1,"row":7,"block":0}],"attrs":{"name":"Bash","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"relation","id":"rel/ends_with/stream_a0a10ef0666c4dc7e/output_a0a10ef0666c4dc7e","revision":1,"type":"ends_with","from":"stream/a0a10ef0666c4dc7e","to":"output/a0a10ef0666c4dc7e","quality":"exact_unique","via":"the last response in the child stream, and what it returned","evidence":[{"seq":2,"row":2}]}
+{"t":"relation","id":"rel/follows/epoch_main_s8-boundary/epoch_main_0","revision":1,"type":"follows","from":"epoch/main/s8-boundary","to":"epoch/main/0","quality":"exact_unique","via":"explicit context reset","evidence":[{"seq":1,"row":15}]}
+{"t":"relation","id":"rel/in_segment/talk_a0a10ef0666c4dc7e/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/a0a10ef0666c4dc7e","to":"segment/at_1_2","quality":"strong_inference","via":"inside the window of the talk that delegated it","evidence":[{"seq":2,"row":1}]}
+{"t":"relation","id":"rel/in_segment/talk_main_s1-cycle/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/main/s1-cycle","to":"segment/at_1_2","quality":"exact_unique","via":"activity window","evidence":[{"seq":1,"row":2}]}
+{"t":"relation","id":"rel/in_segment/talk_main_s8-cycle-compact/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/main/s8-cycle-compact","to":"segment/at_1_2","quality":"exact_unique","via":"activity window","evidence":[{"seq":1,"row":16}]}
+{"t":"relation","id":"rel/reports/notify_1_12/stream_a0a10ef0666c4dc7e","revision":1,"type":"reports","from":"notify/1/12","to":"stream/a0a10ef0666c4dc7e","quality":"exact_unique","via":"task id on the notification","evidence":[{"seq":1,"row":12}]}
+{"t":"relation","id":"rel/starts/tool_s5-tool/stream_a0a10ef0666c4dc7e","revision":1,"type":"starts","from":"tool/s5-tool","to":"stream/a0a10ef0666c4dc7e","quality":"exact_unique","via":"parent tool result","evidence":[{"seq":1,"row":11}]}
+{"t":"relation","id":"rel/summarizes/summary_1_16/boundary_1_15","revision":1,"type":"summarizes","from":"summary/1/16","to":"boundary/1/15","quality":"exact_unique","via":"containment parent","evidence":[{"seq":1,"row":16}]}
+{"t":"commit","digest":"3ad0dcd4cd53fa06c502a2649b8788bfbe0a382bbc72d7f1f0fe68d9a18e96e2","counts":{"nodes":35,"relations":8,"unresolved":0}}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/transcript-20260101T000000.000000000Z-000001.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/transcript-20260101T000000.000000000Z-000001.sd
new file mode 100644
index 0000000000..dde53dce25
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/transcript-20260101T000000.000000000Z-000001.sd
@@ -0,0 +1,18 @@
+{"h":1,"schema":"sd/1","seq":1,"at":"2026-01-01T00:00:00Z","kind":"transcript","adapter":"mock/0.2.0","dialect":"mock/1","src":"-Users-dev-01-full-conversation/00000001-0000-4000-8000-000000000001/streams/main","session":"00000001-0000-4000-8000-000000000001","stream":"main"}
+{"ord":1,"off":0,"sha":"b1ad2a1f1ea1","bytes":188,"label":"build and check","from":"runtime","parts":[{"k":"data","data":{"aiTitle":"build and check","type":"ai-title"},"state":"available","bytes":47}]}
+{"ord":2,"off":189,"sha":"b4ec9ae53c68","bytes":248,"id":"s1-input","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:00.000Z","trigger":"external","flags":["external_input"],"parts":[{"k":"text","text":"run the build","state":"available","bytes":13}]}
+{"ord":3,"off":438,"sha":"e29c543b2bbb","bytes":219,"id":"s2-inject","parent":"s1-input","from":"runtime","time":"2026-01-01T00:00:00.100Z","flags":["injected"],"parts":[{"k":"text","text":"skills: 1","state":"available","bytes":9}]}
+{"ord":4,"off":658,"sha":"803225b155fb","bytes":363,"id":"s3-call-f1","parent":"s2-inject","call":"s3-call","from":"agent","time":"2026-01-01T00:00:01.000Z","usage":{"in":2,"out":50,"cache_read":900,"cache_write":100},"parts":[{"k":"reasoning","state":"unavailable"}],"dropped":[{"what":"reasoning signature","bytes":3,"why":"a provider verifies it; a reader cannot read it"}]}
+{"ord":5,"off":1022,"sha":"7688dad3dff5","bytes":283,"id":"s3-call-f2","parent":"s3-call-f1","call":"s3-call","from":"agent","time":"2026-01-01T00:00:01.100Z","usage":{"in":2,"out":50,"cache_read":900,"cache_write":100},"parts":[{"k":"text","text":"Building now.","state":"available","bytes":13}]}
+{"ord":6,"off":1306,"sha":"92fa4be06310","bytes":388,"id":"s3-call-f3","parent":"s3-call-f2","call":"s3-call","from":"agent","time":"2026-01-01T00:00:01.200Z","flags":["finished"],"usage":{"in":2,"out":50,"cache_read":900,"cache_write":100},"parts":[{"k":"call","data":{"command":"make build","description":"build the project"},"id":"tool-run-make-build","name":"Bash","state":"available","bytes":58}]}
+{"ord":7,"off":1695,"sha":"e912d793c30f","bytes":303,"id":"s3-result","parent":"s3-call-f3","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:02.000Z","parts":[{"k":"result","text":"build succeeded","data":{"stderr":"","stdout":"build succeeded"},"of":"tool-run-make-build","state":"available","bytes":15}]}
+{"ord":8,"off":1999,"sha":"df8cdfede2d1","bytes":358,"id":"s4-call-f1","parent":"s3-result","call":"s4-call","from":"agent","time":"2026-01-01T00:00:03.000Z","flags":["finished"],"usage":{"in":2,"out":10,"cache_read":900,"cache_write":100},"parts":[{"k":"call","data":{"query":"go build cache"},"id":"srvtool-websearch","name":"WebSearch","state":"available","bytes":26}]}
+{"ord":9,"off":2358,"sha":"061276d6dafc","bytes":299,"id":"s4-result","parent":"s4-call-f1","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:04.000Z","parts":[{"k":"result","text":"search results","data":{"stderr":"","stdout":"search results"},"of":"srvtool-websearch","state":"available","bytes":14}]}
+{"ord":10,"off":2658,"sha":"f952505dd8a8","bytes":371,"id":"s5-call-f1","parent":"s4-result","call":"s5-call","from":"agent","time":"2026-01-01T00:00:05.000Z","flags":["finished"],"usage":{"in":2,"out":20,"cache_read":900,"cache_write":100},"parts":[{"k":"call","data":{"description":"checker","prompt":"check the tests"},"id":"s5-tool","name":"Agent","state":"available","bytes":52}]}
+{"ord":11,"off":3030,"sha":"a3432e2622a0","bytes":415,"id":"s5-ack","parent":"s5-call-f1","run":"s1-cycle","child":"a0a10ef0666c4dc7e","from":"external","time":"2026-01-01T00:00:05.100Z","flags":["launch_ack"],"parts":[{"k":"result","text":"launched","data":{"agentId":"a0a10ef0666c4dc7e","description":"checker","isAsync":true,"prompt":"check the tests","status":"async_launched"},"of":"s5-tool","state":"available","bytes":8}]}
+{"ord":12,"off":3446,"sha":"52d9e0f15321","bytes":515,"id":"s5-notice","parent":"s5-ack","run":"s5-cycle-notification","tool":"s5-tool","child":"a0a10ef0666c4dc7e","from":"external","time":"2026-01-01T00:00:08.100Z","trigger":"notification","parts":[{"k":"text","text":"\u003ctask-notification\u003e\n\u003ctask-id\u003ea0a10ef0666c4dc7e\u003c/task-id\u003e\n\u003ctool-use-id\u003es5-tool\u003c/tool-use-id\u003e\n\u003cstatus\u003ecompleted\u003c/status\u003e\n\u003c/task-notification\u003 [...]
+{"ord":13,"off":3962,"sha":"37c80bf062f2","bytes":324,"id":"s6-call-f1","parent":"s5-notice","call":"s6-call","from":"agent","time":"2026-01-01T00:00:09.100Z","flags":["finished"],"usage":{"in":2,"out":30,"cache_read":900,"cache_write":100},"parts":[{"k":"text","text":"Build passed and tests are green.","state":"available","bytes":33}]}
+{"ord":14,"off":4287,"sha":"2a4d3900c99a","bytes":295,"id":"s7-synthetic","parent":"s6-call-f1","call":"s7-synthetic-call","from":"agent","time":"2026-01-01T00:00:10.100Z","flags":["synthetic"],"usage":{},"parts":[{"k":"text","text":"API Error: Connection lost mid-response.","state":"available","bytes":40}]}
+{"ord":15,"off":4583,"sha":"0b188e3d602b","bytes":394,"id":"s8-boundary","continues":"s7-synthetic","from":"runtime","time":"2026-01-01T00:00:11.100Z","flags":["context_reset"],"parts":[{"k":"data","data":{"compactMetadata":{"preservedMessages":{"allUuids":["s7-synthetic"]},"trigger":"auto"},"logicalParentUuid":"s7-synthetic","subtype":"compact_boundary","type":"system"},"state":"available","bytes":168}]}
+{"ord":16,"off":4978,"sha":"19ae6ce3cfab","bytes":287,"id":"s8-summary","parent":"s8-boundary","run":"s8-cycle-compact","from":"external","time":"2026-01-01T00:00:10.700Z","flags":["reset_summary"],"parts":[{"k":"text","text":"Summary: the build was run and checked.","state":"available","bytes":39}]}
+{"t":"end","records":16,"digest":"a158e0ee932711b231d35933cb8d7cb9f552c62212a546827556e83057853109"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/transcript-20260101T000000.000000000Z-000002.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/transcript-20260101T000000.000000000Z-000002.sd
new file mode 100644
index 0000000000..ba70485e95
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/transcript-20260101T000000.000000000Z-000002.sd
@@ -0,0 +1,4 @@
+{"h":1,"schema":"sd/1","seq":2,"at":"2026-01-01T00:00:00Z","kind":"transcript","adapter":"mock/0.2.0","dialect":"mock/1","src":"-Users-dev-01-full-conversation/00000001-0000-4000-8000-000000000001/streams/a0a10ef0666c4dc7e","session":"00000001-0000-4000-8000-000000000001","stream":"a0a10ef0666c4dc7e"}
+{"ord":1,"off":0,"sha":"59094aaae474","bytes":231,"id":"a0a10ef0666c4dc7e-prompt","run":"a0a10ef0666c4dc7e-cycle","from":"external","time":"2026-01-01T00:00:06.100Z","parts":[{"k":"text","text":"check the tests","state":"available","bytes":15}]}
+{"ord":2,"off":232,"sha":"3e3520948eac","bytes":331,"id":"checker-s1-call-f1","parent":"a0a10ef0666c4dc7e-prompt","call":"checker-s1-call","from":"agent","time":"2026-01-01T00:00:07.100Z","flags":["finished"],"usage":{"in":2,"out":42,"cache_read":900,"cache_write":100},"parts":[{"k":"text","text":"Tests pass.","state":"available","bytes":11}]}
+{"t":"end","records":2,"digest":"53cfa99603eef5f2ee6a007bfa9642a0386744bd6267f8484d53a855884ab10d"}
diff --git a/oap-server/analyzer/pom.xml b/oap-server/analyzer/pom.xml
index 8bc703e08f..9ba60b726c 100644
--- a/oap-server/analyzer/pom.xml
+++ b/oap-server/analyzer/pom.xml
@@ -36,6 +36,7 @@
<module>hierarchy</module>
<module>gen-ai-analyzer</module>
<module>ai-evaluation</module>
+ <module>ai-agent-conversation</module>
<module>ios-analyzer</module>
<module>meter-analyzer-scripts-test</module>
</modules>
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/Layer.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/Layer.java
index eba23be781..5cec73183a 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/Layer.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/Layer.java
@@ -301,6 +301,12 @@ public final class Layer {
/** Apache Airflow workflow orchestration (native OpenTelemetry metrics via OTel Collector). */
public static final Layer AIRFLOW = register("AIRFLOW", 50, true);
+ /**
+ * Conversations of long-lived AI agents, landed by the AI Sessionizer as Session Data and Session Flow files
+ * over OTLP logs. The service is the agent runtime; the instance is the sender.
+ */
+ public static final Layer AI_AGENT = register("AI_AGENT", 51, true);
+
private final String name;
private final int value;
/**
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionDataRecord.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionDataRecord.java
new file mode 100644
index 0000000000..c5b9b22df5
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionDataRecord.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.analysis.manual.aiagent;
+
+import com.google.common.hash.Hashing;
+import java.nio.charset.StandardCharsets;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.Const;
+import org.apache.skywalking.oap.server.core.analysis.Stream;
+import org.apache.skywalking.oap.server.core.analysis.record.Record;
+import org.apache.skywalking.oap.server.core.analysis.worker.RecordStreamProcessor;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.source.ScopeDeclaration;
+import org.apache.skywalking.oap.server.core.storage.StorageID;
+import org.apache.skywalking.oap.server.core.storage.annotation.BanyanDB;
+import org.apache.skywalking.oap.server.core.storage.annotation.Column;
+import org.apache.skywalking.oap.server.core.storage.annotation.ElasticSearch;
+import org.apache.skywalking.oap.server.core.storage.annotation.SuperDataset;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Entity;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Storage;
+import org.apache.skywalking.oap.server.core.storage.type.StorageBuilder;
+
+/**
+ * One Session Data (<code>.sd</code>) file of an AI agent conversation, stored verbatim.
+ *
+ * <p>A file belongs to one session and is named within it by <code>seq</code>. The row keeps only what a
+ * read filters on: the series id <code>(service, instance, session)</code> and <code>seq</code>. Everything else
+ * about the file, its kind, stream or run, time range and schema, is on the body's header line, and the
+ * closing line carries the digest; readers decode those lines rather than duplicating them as columns.
+ *
+ * <p>The timestamp is the record time range's end of the file, or the session's latest record time known to
+ * the sender when the file carries no timed record, so a read bounded by the conversation's range finds every
+ * file a round consumed.
+ */
+@Getter
+@Setter
+@SuperDataset
+@ScopeDeclaration(id = DefaultScopeDefine.AI_AGENT_SESSION_DATA, name = "AIAgentSessionData")
+@Stream(name = AIAgentSessionDataRecord.INDEX_NAME, scopeId = DefaultScopeDefine.AI_AGENT_SESSION_DATA,
+ builder = AIAgentSessionDataRecord.Builder.class, processor = RecordStreamProcessor.class)
+@BanyanDB.TimestampColumn(AIAgentSessionDataRecord.TIMESTAMP)
+@BanyanDB.Group(streamGroup = BanyanDB.StreamGroup.RECORDS_AI_AGENT)
+public class AIAgentSessionDataRecord extends Record {
+ public static final String INDEX_NAME = "ai_agent_session_data";
+ public static final String SERVICE_ID = "service_id";
+ public static final String SERVICE_INSTANCE_ID = "service_instance_id";
+ public static final String SESSION = "session";
+ public static final String SEQ = "seq";
+ public static final String DIGEST = "digest";
+ /** The name of the id fragment that hashes the owner; not a column. */
+ public static final String OWNER = "owner";
+ public static final String TIMESTAMP = "timestamp";
+ public static final String BODY = "body";
+ public static final int BODY_MAX_LENGTH = 16 * 1024 * 1024;
+
+ @Column(name = SERVICE_ID)
+ @BanyanDB.SeriesID(index = 0)
+ private String serviceId;
+ @Column(name = SERVICE_INSTANCE_ID, length = 512)
+ @BanyanDB.SeriesID(index = 1)
+ private String serviceInstanceId;
+ @Column(name = SESSION, length = 256)
+ @BanyanDB.SeriesID(index = 2)
+ private String session;
+ @ElasticSearch.EnableDocValues
+ @Column(name = SEQ)
+ private long seq;
+ @Column(name = DIGEST, length = 64, storageOnly = true)
+ private String digest;
+ @ElasticSearch.EnableDocValues
+ @Column(name = TIMESTAMP)
+ private long timestamp;
+ /**
+ * The file as landed. The length is the most raw bytes a body may hold, BanyanDB's gRPC message limit; the
+ * ai-agent-conversation module admits less, and a JDBC storage sizes the column by it, as Base64.
+ */
+ @Column(name = BODY, storageOnly = true, length = BODY_MAX_LENGTH)
+ private byte[] body;
+
+ /**
+ * The sender owns the row: the same file pushed under another service or another sender is another row,
+ * while the same sender pushing it again lands on the same one. The owner is hashed, because a service and
+ * an instance name can each be long enough for the two ids to overrun a storage's id length.
+ */
+ @Override
+ public StorageID id() {
+ return new StorageID().append(OWNER, ownerHash(serviceId, serviceInstanceId)).append(DIGEST, digest);
+ }
+
+ /**
+ * @param serviceId the service
+ * @param serviceInstanceId the sender
+ * @return the sha256 hex of the two, the owner half of a row's id
+ */
+ public static String ownerHash(final String serviceId, final String serviceInstanceId) {
+ return Hashing.sha256().hashString(serviceId + Const.ID_CONNECTOR + serviceInstanceId, StandardCharsets.UTF_8).toString();
+ }
+
+ public static class Builder implements StorageBuilder<AIAgentSessionDataRecord> {
+ @Override
+ public AIAgentSessionDataRecord storage2Entity(final Convert2Entity converter) {
+ final AIAgentSessionDataRecord record = new AIAgentSessionDataRecord();
+ record.setServiceId((String) converter.get(SERVICE_ID));
+ record.setServiceInstanceId((String) converter.get(SERVICE_INSTANCE_ID));
+ record.setSession((String) converter.get(SESSION));
+ record.setSeq(((Number) converter.get(SEQ)).longValue());
+ record.setDigest((String) converter.get(DIGEST));
+ record.setTimestamp(((Number) converter.get(TIMESTAMP)).longValue());
+ record.setBody(converter.getBytes(BODY));
+ record.setTimeBucket(((Number) converter.get(TIME_BUCKET)).longValue());
+ return record;
+ }
+
+ @Override
+ public void entity2Storage(final AIAgentSessionDataRecord record, final Convert2Storage converter) {
+ converter.accept(SERVICE_ID, record.getServiceId());
+ converter.accept(SERVICE_INSTANCE_ID, record.getServiceInstanceId());
+ converter.accept(SESSION, record.getSession());
+ converter.accept(SEQ, record.getSeq());
+ converter.accept(DIGEST, record.getDigest());
+ converter.accept(TIMESTAMP, record.getTimestamp());
+ converter.accept(BODY, record.getBody());
+ converter.accept(TIME_BUCKET, record.getTimeBucket());
+ }
+ }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionFlowRecord.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionFlowRecord.java
new file mode 100644
index 0000000000..837a6ede32
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionFlowRecord.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.analysis.manual.aiagent;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.skywalking.oap.server.core.analysis.Stream;
+import org.apache.skywalking.oap.server.core.analysis.record.Record;
+import org.apache.skywalking.oap.server.core.analysis.worker.RecordStreamProcessor;
+import org.apache.skywalking.oap.server.core.source.DefaultScopeDefine;
+import org.apache.skywalking.oap.server.core.source.ScopeDeclaration;
+import org.apache.skywalking.oap.server.core.storage.StorageID;
+import org.apache.skywalking.oap.server.core.storage.annotation.BanyanDB;
+import org.apache.skywalking.oap.server.core.storage.annotation.Column;
+import org.apache.skywalking.oap.server.core.storage.annotation.ElasticSearch;
+import org.apache.skywalking.oap.server.core.storage.annotation.SuperDataset;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Entity;
+import org.apache.skywalking.oap.server.core.storage.type.Convert2Storage;
+import org.apache.skywalking.oap.server.core.storage.type.StorageBuilder;
+
+/**
+ * One Session Flow (<code>.sf</code>) round of an AI agent conversation, stored verbatim.
+ *
+ * <p>A conversation is an append-only chain of rounds; this row is one of them. The series id is the sender,
+ * <code>(service, instance)</code>, and <code>conversation</code> is indexed, so the list page reads rounds by
+ * sender and time and a conversation read selects its chain. The stored-only columns exist for one reason: the
+ * list page shows them without opening a body. They are the conversation's title and counts as of this round,
+ * stamped by the sender, which holds the fold. The chain fields, previous digest, seq window and input digest,
+ * are on the body's header line and are read from there.
+ *
+ * <p>The timestamp is the conversation's last activity as of this round, so the newest row per conversation is
+ * the head and a time window on the list means "active in this window".
+ */
+@Getter
+@Setter
+@SuperDataset
+@ScopeDeclaration(id = DefaultScopeDefine.AI_AGENT_SESSION_FLOW, name = "AIAgentSessionFlow")
+@Stream(name = AIAgentSessionFlowRecord.INDEX_NAME, scopeId = DefaultScopeDefine.AI_AGENT_SESSION_FLOW,
+ builder = AIAgentSessionFlowRecord.Builder.class, processor = RecordStreamProcessor.class)
+@BanyanDB.TimestampColumn(AIAgentSessionFlowRecord.TIMESTAMP)
+@BanyanDB.Group(streamGroup = BanyanDB.StreamGroup.RECORDS_AI_AGENT)
+public class AIAgentSessionFlowRecord extends Record {
+ public static final String INDEX_NAME = "ai_agent_session_flow";
+ public static final String SERVICE_ID = "service_id";
+ public static final String SERVICE_INSTANCE_ID = "service_instance_id";
+ public static final String CONVERSATION = "conversation";
+ public static final String ROUND = "round";
+ public static final String SESSION_FROM_TIME = "session_from_time";
+ public static final String TITLE = "title";
+ public static final int TITLE_MAX_LENGTH = 1024;
+ public static final String TALKS = "talks";
+ public static final String STEPS = "steps";
+ public static final String STREAMS = "streams";
+ public static final String SEGMENTS = "segments";
+ public static final String UNRESOLVED = "unresolved";
+ public static final String DIGEST = "digest";
+ /** The name of the id fragment that hashes the owner; not a column. */
+ public static final String OWNER = "owner";
+ public static final String TIMESTAMP = "timestamp";
+ public static final String BODY = "body";
+ public static final int BODY_MAX_LENGTH = 16 * 1024 * 1024;
+
+ @Column(name = SERVICE_ID)
+ @BanyanDB.SeriesID(index = 0)
+ private String serviceId;
+ @Column(name = SERVICE_INSTANCE_ID, length = 512)
+ @BanyanDB.SeriesID(index = 1)
+ private String serviceInstanceId;
+ @Column(name = CONVERSATION, length = 256)
+ private String conversation;
+ /** Sortable, so the head of a chain is one read of the highest round rather than a page of rows. */
+ @ElasticSearch.EnableDocValues
+ @BanyanDB.EnableSort
+ @Column(name = ROUND)
+ private long round;
+ @Column(name = SESSION_FROM_TIME, storageOnly = true)
+ private long sessionFromTime;
+ @Column(name = TITLE, length = TITLE_MAX_LENGTH, storageOnly = true)
+ private String title;
+ @Column(name = TALKS, storageOnly = true)
+ private long talks;
+ @Column(name = STEPS, storageOnly = true)
+ private long steps;
+ @Column(name = STREAMS, storageOnly = true)
+ private long streams;
+ @Column(name = SEGMENTS, storageOnly = true)
+ private long segments;
+ @Column(name = UNRESOLVED, storageOnly = true)
+ private long unresolved;
+ @Column(name = DIGEST, length = 64, storageOnly = true)
+ private String digest;
+ @ElasticSearch.EnableDocValues
+ @Column(name = TIMESTAMP)
+ private long timestamp;
+ /**
+ * The file as landed. The length is the most raw bytes a body may hold, BanyanDB's gRPC message limit; the
+ * ai-agent-conversation module admits less, and a JDBC storage sizes the column by it, as Base64.
+ */
+ @Column(name = BODY, storageOnly = true, length = BODY_MAX_LENGTH)
+ private byte[] body;
+
+ /**
+ * The sender owns the row: the same round pushed under another service or another sender is another row,
+ * while the same sender pushing it again lands on the same one. The owner is hashed, because a service and
+ * an instance name can each be long enough for the two ids to overrun a storage's id length.
+ */
+ @Override
+ public StorageID id() {
+ return new StorageID().append(OWNER, AIAgentSessionDataRecord.ownerHash(serviceId, serviceInstanceId))
+ .append(DIGEST, digest);
+ }
+
+ public static class Builder implements StorageBuilder<AIAgentSessionFlowRecord> {
+ @Override
+ public AIAgentSessionFlowRecord storage2Entity(final Convert2Entity converter) {
+ final AIAgentSessionFlowRecord record = new AIAgentSessionFlowRecord();
+ record.setServiceId((String) converter.get(SERVICE_ID));
+ record.setServiceInstanceId((String) converter.get(SERVICE_INSTANCE_ID));
+ record.setConversation((String) converter.get(CONVERSATION));
+ record.setRound(((Number) converter.get(ROUND)).longValue());
+ record.setSessionFromTime(((Number) converter.get(SESSION_FROM_TIME)).longValue());
+ record.setTitle((String) converter.get(TITLE));
+ record.setTalks(((Number) converter.get(TALKS)).longValue());
+ record.setSteps(((Number) converter.get(STEPS)).longValue());
+ record.setStreams(((Number) converter.get(STREAMS)).longValue());
+ record.setSegments(((Number) converter.get(SEGMENTS)).longValue());
+ record.setUnresolved(((Number) converter.get(UNRESOLVED)).longValue());
+ record.setDigest((String) converter.get(DIGEST));
+ record.setTimestamp(((Number) converter.get(TIMESTAMP)).longValue());
+ record.setBody(converter.getBytes(BODY));
+ record.setTimeBucket(((Number) converter.get(TIME_BUCKET)).longValue());
+ return record;
+ }
+
+ @Override
+ public void entity2Storage(final AIAgentSessionFlowRecord record, final Convert2Storage converter) {
+ converter.accept(SERVICE_ID, record.getServiceId());
+ converter.accept(SERVICE_INSTANCE_ID, record.getServiceInstanceId());
+ converter.accept(CONVERSATION, record.getConversation());
+ converter.accept(ROUND, record.getRound());
+ converter.accept(SESSION_FROM_TIME, record.getSessionFromTime());
+ converter.accept(TITLE, record.getTitle());
+ converter.accept(TALKS, record.getTalks());
+ converter.accept(STEPS, record.getSteps());
+ converter.accept(STREAMS, record.getStreams());
+ converter.accept(SEGMENTS, record.getSegments());
+ converter.accept(UNRESOLVED, record.getUnresolved());
+ converter.accept(DIGEST, record.getDigest());
+ converter.accept(TIMESTAMP, record.getTimestamp());
+ converter.accept(BODY, record.getBody());
+ converter.accept(TIME_BUCKET, record.getTimeBucket());
+ }
+ }
+}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/source/DefaultScopeDefine.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/source/DefaultScopeDefine.java
index 3e1ddbe24f..2cba81c864 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/source/DefaultScopeDefine.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/source/DefaultScopeDefine.java
@@ -160,6 +160,8 @@ public class DefaultScopeDefine {
public static final int GEN_AI_MODEL_ACCESS = 97;
public static final int RUNTIME_RULE = 98;
public static final int GEN_AI_EVALUATION_RECORD = 99;
+ public static final int AI_AGENT_SESSION_DATA = 100;
+ public static final int AI_AGENT_SESSION_FLOW = 101;
/**
* Catalog of scope, the metrics processor could use this to group all generated metrics by oal rt.
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/StorageModule.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/StorageModule.java
index dd0a98d4dd..423fe033da 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/StorageModule.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/StorageModule.java
@@ -40,6 +40,7 @@ import org.apache.skywalking.oap.server.core.storage.profiling.ebpf.IEBPFProfili
import org.apache.skywalking.oap.server.core.storage.profiling.ebpf.IEBPFProfilingScheduleDAO;
import org.apache.skywalking.oap.server.core.storage.profiling.ebpf.IEBPFProfilingTaskDAO;
import org.apache.skywalking.oap.server.core.storage.query.IEventQueryDAO;
+import org.apache.skywalking.oap.server.core.storage.query.IAIAgentConversationQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.IGenAIEvaluationRecordQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.IHierarchyQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.ILogQueryDAO;
@@ -83,6 +84,7 @@ public class StorageModule extends ModuleDefine {
IRecordsQueryDAO.class,
ILogQueryDAO.class,
IGenAIEvaluationRecordQueryDAO.class,
+ IAIAgentConversationQueryDAO.class,
IProfileTaskQueryDAO.class,
IProfileTaskLogQueryDAO.class,
IProfileThreadSnapshotQueryDAO.class,
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/BanyanDB.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/BanyanDB.java
index d256c40e91..ad08455aa4 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/BanyanDB.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/annotation/BanyanDB.java
@@ -312,6 +312,11 @@ public @interface BanyanDB {
RECORDS("records"),
RECORDS_LOG("recordsLog"),
RECORDS_BROWSER_ERROR_LOG("recordsBrowserErrorLog"),
+ /**
+ * AI agent conversation files: Session Data and Session Flow rows of up to 2 MiB each, kept for weeks,
+ * so they carry their own retention and shard settings apart from the log group.
+ */
+ RECORDS_AI_AGENT("recordsAIAgent"),
NONE("none");
@Getter
private final String name;
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/IAIAgentConversationQueryDAO.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/IAIAgentConversationQueryDAO.java
new file mode 100644
index 0000000000..187e426f3d
--- /dev/null
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/IAIAgentConversationQueryDAO.java
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.storage.query;
+
+import java.io.IOException;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionDataRecord;
+import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionFlowRecord;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingSpan;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext;
+import org.apache.skywalking.oap.server.library.module.Service;
+
+import static org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext.TRACE_CONTEXT;
+
+/**
+ * The three reads of an AI agent conversation: the rounds of a sender or of one conversation, and the files of
+ * one session in a seq window. Every storage implements it once against what it does natively.
+ */
+public interface IAIAgentConversationQueryDAO extends Service {
+ default List<AIAgentSessionFlowRecord> queryRoundsDebuggable(String serviceId,
+ @Nullable String serviceInstanceId,
+ @Nullable String conversation,
+ @Nullable Duration duration,
+ int limit,
+ boolean includeBody) throws IOException {
+ final DebuggingTraceContext traceContext = TRACE_CONTEXT.get();
+ DebuggingSpan span = null;
+ try {
+ if (traceContext != null) {
+ span = traceContext.createSpan("Query Dao: queryRounds");
+ span.setMsg("ServiceId: " + serviceId + ", ServiceInstanceId: " + serviceInstanceId
+ + ", Conversation: " + conversation + ", Duration: " + duration
+ + ", Limit: " + limit + ", IncludeBody: " + includeBody);
+ }
+ return queryRounds(serviceId, serviceInstanceId, conversation, duration, limit, includeBody);
+ } finally {
+ if (traceContext != null && span != null) {
+ traceContext.stopSpan(span);
+ }
+ }
+ }
+
+ default long queryHeadRoundDebuggable(String serviceId,
+ @Nullable String serviceInstanceId,
+ String conversation) throws IOException {
+ final DebuggingTraceContext traceContext = TRACE_CONTEXT.get();
+ DebuggingSpan span = null;
+ try {
+ if (traceContext != null) {
+ span = traceContext.createSpan("Query Dao: queryHeadRound");
+ span.setMsg("ServiceId: " + serviceId + ", ServiceInstanceId: " + serviceInstanceId
+ + ", Conversation: " + conversation);
+ }
+ return queryHeadRound(serviceId, serviceInstanceId, conversation);
+ } finally {
+ if (traceContext != null && span != null) {
+ traceContext.stopSpan(span);
+ }
+ }
+ }
+
+ default List<AIAgentSessionFlowRecord> queryRoundsByNumberDebuggable(String serviceId,
+ @Nullable String serviceInstanceId,
+ String conversation,
+ long fromRound,
+ long throughRound,
+ int maxResponseBytes) throws IOException {
+ final DebuggingTraceContext traceContext = TRACE_CONTEXT.get();
+ DebuggingSpan span = null;
+ try {
+ if (traceContext != null) {
+ span = traceContext.createSpan("Query Dao: queryRoundsByNumber");
+ span.setMsg("ServiceId: " + serviceId + ", ServiceInstanceId: " + serviceInstanceId
+ + ", Conversation: " + conversation + ", Round: " + fromRound + ".." + throughRound);
+ }
+ return queryRoundsByNumber(serviceId, serviceInstanceId, conversation, fromRound, throughRound, maxResponseBytes);
+ } finally {
+ if (traceContext != null && span != null) {
+ traceContext.stopSpan(span);
+ }
+ }
+ }
+
+ default List<AIAgentSessionDataRecord> queryFilesDebuggable(String serviceId,
+ @Nullable String serviceInstanceId,
+ String session,
+ long fromTimestamp,
+ long toTimestamp,
+ long fromSeq,
+ long throughSeq,
+ int maxResponseBytes) throws IOException {
+ final DebuggingTraceContext traceContext = TRACE_CONTEXT.get();
+ DebuggingSpan span = null;
+ try {
+ if (traceContext != null) {
+ span = traceContext.createSpan("Query Dao: queryFiles");
+ span.setMsg("ServiceId: " + serviceId + ", ServiceInstanceId: " + serviceInstanceId
+ + ", Session: " + session + ", From: " + fromTimestamp + ", To: " + toTimestamp
+ + ", Seq: " + fromSeq + ".." + throughSeq);
+ }
+ return queryFiles(
+ serviceId, serviceInstanceId, session, fromTimestamp, toTimestamp, fromSeq, throughSeq, maxResponseBytes);
+ } finally {
+ if (traceContext != null && span != null) {
+ traceContext.stopSpan(span);
+ }
+ }
+ }
+
+ /**
+ * Rounds newest first.
+ *
+ * @param serviceId the service
+ * @param serviceInstanceId the sender, or null for every sender of the service
+ * @param conversation one conversation, or null for every conversation
+ * @param duration the time window, or null for the whole retention window
+ * @param limit at most this many rows
+ * @param includeBody whether to read the body column; the list page does not
+ * @return the rounds, newest first
+ * @throws IOException on a storage failure
+ */
+ List<AIAgentSessionFlowRecord> queryRounds(String serviceId,
+ @Nullable String serviceInstanceId,
+ @Nullable String conversation,
+ @Nullable Duration duration,
+ int limit,
+ boolean includeBody) throws IOException;
+
+ /**
+ * The highest round number stored for a conversation, over every retained stage, or 0 when none is. That is
+ * the chain's head; it is not the newest row by time, because the Sessionizer can write a later round that
+ * carries no later activity, and it is read directly rather than off a page of rows.
+ *
+ * @param serviceId the service
+ * @param serviceInstanceId the sender, or null for every sender of the service
+ * @param conversation the conversation
+ * @return the highest round number, or 0
+ * @throws IOException on a storage failure
+ */
+ long queryHeadRound(String serviceId, @Nullable String serviceInstanceId, String conversation) throws IOException;
+
+ /**
+ * The rounds of one conversation whose numbers lie in a window, bodies included, in round order, over every
+ * retained stage. A round is up to 2 MiB, so the caller reads a long chain window by window.
+ *
+ * @param serviceId the service
+ * @param serviceInstanceId the sender, or null for every sender of the service
+ * @param conversation the conversation
+ * @param fromRound the first round number of the window
+ * @param throughRound the last round number of the window
+ * @param maxResponseBytes the most bytes one storage response may carry; a storage that caps a response per
+ * call applies it to this read, another ignores it
+ * @return the rounds of the window, in round order
+ * @throws IOException on a storage failure
+ */
+ List<AIAgentSessionFlowRecord> queryRoundsByNumber(String serviceId, @Nullable String serviceInstanceId,
+ String conversation, long fromRound,
+ long throughRound, int maxResponseBytes) throws IOException;
+
+ /**
+ * The files of one session whose seq is within the window and whose timestamp is within the range, with
+ * their bodies, seq ascending.
+ *
+ * @param serviceId the service
+ * @param serviceInstanceId the sender, or null for every sender of the service
+ * @param session the session the files belong to
+ * @param fromTimestamp inclusive start of the timestamp range, milliseconds
+ * @param toTimestamp inclusive end of the timestamp range, milliseconds
+ * @param fromSeq inclusive first seq
+ * @param throughSeq inclusive last seq
+ * @param maxResponseBytes the most bytes one storage response may carry; a storage that caps a response per
+ * call applies it to this read, another ignores it
+ * @return the files, seq ascending
+ * @throws IOException on a storage failure
+ */
+ List<AIAgentSessionDataRecord> queryFiles(String serviceId,
+ @Nullable String serviceInstanceId,
+ String session,
+ long fromTimestamp,
+ long toTimestamp,
+ long fromSeq,
+ long throughSeq,
+ int maxResponseBytes) throws IOException;
+}
diff --git a/oap-server/server-library/library-banyandb-client/src/main/java/org/apache/skywalking/library/banyandb/v1/client/BanyanDBClient.java b/oap-server/server-library/library-banyandb-client/src/main/java/org/apache/skywalking/library/banyandb/v1/client/BanyanDBClient.java
index 585450c2a8..223e836807 100644
--- a/oap-server/server-library/library-banyandb-client/src/main/java/org/apache/skywalking/library/banyandb/v1/client/BanyanDBClient.java
+++ b/oap-server/server-library/library-banyandb-client/src/main/java/org/apache/skywalking/library/banyandb/v1/client/BanyanDBClient.java
@@ -415,7 +415,8 @@ public class BanyanDBClient implements Closeable {
@SafeVarargs
private BanyandbBydbql.QueryResponse queryBydbQL(
- String bydbql, Serializable<BanyandbModel.TagValue>... params) throws BanyanDBException {
+ int maxInboundMessageSize, String bydbql, Serializable<BanyandbModel.TagValue>... params)
+ throws BanyanDBException {
checkState(this.bydbQLServiceBlockingStub != null, "bydbql service is null");
final List<BanyandbModel.TagValue> tagValues = new ArrayList<>(params.length);
for (final Serializable<BanyandbModel.TagValue> param : params) {
@@ -425,10 +426,13 @@ public class BanyanDBClient implements Closeable {
.setQuery(bydbql)
.addAllParams(tagValues)
.build();
- return HandleExceptionsWith.callAndTranslateApiException(() ->
- this.bydbQLServiceBlockingStub
- .withDeadlineAfter(this.getOptions().getDeadline(), TimeUnit.SECONDS)
- .query(request));
+ BydbQLServiceGrpc.BydbQLServiceBlockingStub stub =
+ this.bydbQLServiceBlockingStub.withDeadlineAfter(this.getOptions().getDeadline(), TimeUnit.SECONDS);
+ if (maxInboundMessageSize > 0) {
+ stub = stub.withMaxInboundMessageSize(maxInboundMessageSize);
+ }
+ final BydbQLServiceGrpc.BydbQLServiceBlockingStub bound = stub;
+ return HandleExceptionsWith.callAndTranslateApiException(() -> bound.query(request));
}
/**
@@ -442,7 +446,7 @@ public class BanyanDBClient implements Closeable {
@SafeVarargs
public final MeasureQueryResponse queryMeasure(
String bydbql, Serializable<BanyandbModel.TagValue>... params) throws BanyanDBException {
- final BanyandbBydbql.QueryResponse resp = queryBydbQL(bydbql, params);
+ final BanyandbBydbql.QueryResponse resp = queryBydbQL(0, bydbql, params);
if (resp.getResultCase() != BanyandbBydbql.QueryResponse.ResultCase.MEASURE_RESULT) {
throw new IllegalStateException("expected measure_result but got " + resp.getResultCase());
}
@@ -460,7 +464,25 @@ public class BanyanDBClient implements Closeable {
@SafeVarargs
public final StreamQueryResponse queryStream(
String bydbql, Serializable<BanyandbModel.TagValue>... params) throws BanyanDBException {
- final BanyandbBydbql.QueryResponse resp = queryBydbQL(bydbql, params);
+ return queryStream(0, bydbql, params);
+ }
+
+ /**
+ * Query a stream with a BydbQL statement, with a cap on the response of this call alone. The cap is a call
+ * option on the shared channel, not a channel option: the next call sees the channel's default again.
+ *
+ * @param maxInboundMessageSize the most bytes the response may carry, in place of the channel's default,
+ * {@link Options#getMaxInboundMessageSize()}; 0 keeps the default
+ * @param bydbql a BydbQL query whose FROM clause targets a STREAM
+ * @param params values bound to the {@code ?} placeholders, in order of appearance
+ * @return the stream query response
+ * @throws BanyanDBException if the query fails or the server returns a non-stream result
+ */
+ @SafeVarargs
+ public final StreamQueryResponse queryStream(
+ int maxInboundMessageSize, String bydbql, Serializable<BanyandbModel.TagValue>... params)
+ throws BanyanDBException {
+ final BanyandbBydbql.QueryResponse resp = queryBydbQL(maxInboundMessageSize, bydbql, params);
if (resp.getResultCase() != BanyandbBydbql.QueryResponse.ResultCase.STREAM_RESULT) {
throw new IllegalStateException("expected stream_result but got " + resp.getResultCase());
}
@@ -478,7 +500,7 @@ public class BanyanDBClient implements Closeable {
@SafeVarargs
public final TraceQueryResponse queryTrace(
String bydbql, Serializable<BanyandbModel.TagValue>... params) throws BanyanDBException {
- final BanyandbBydbql.QueryResponse resp = queryBydbQL(bydbql, params);
+ final BanyandbBydbql.QueryResponse resp = queryBydbQL(0, bydbql, params);
if (resp.getResultCase() != BanyandbBydbql.QueryResponse.ResultCase.TRACE_RESULT) {
throw new IllegalStateException("expected trace_result but got " + resp.getResultCase());
}
@@ -496,7 +518,7 @@ public class BanyanDBClient implements Closeable {
@SafeVarargs
public final TopNQueryResponse queryTopN(
String bydbql, Serializable<BanyandbModel.TagValue>... params) throws BanyanDBException {
- final BanyandbBydbql.QueryResponse resp = queryBydbQL(bydbql, params);
+ final BanyandbBydbql.QueryResponse resp = queryBydbQL(0, bydbql, params);
if (resp.getResultCase() != BanyandbBydbql.QueryResponse.ResultCase.TOPN_RESULT) {
throw new IllegalStateException("expected topn_result but got " + resp.getResultCase());
}
@@ -515,7 +537,7 @@ public class BanyanDBClient implements Closeable {
@SafeVarargs
public final BanyandbProperty.QueryResponse queryProperty(
String bydbql, Serializable<BanyandbModel.TagValue>... params) throws BanyanDBException {
- final BanyandbBydbql.QueryResponse resp = queryBydbQL(bydbql, params);
+ final BanyandbBydbql.QueryResponse resp = queryBydbQL(0, bydbql, params);
if (resp.getResultCase() != BanyandbBydbql.QueryResponse.ResultCase.PROPERTY_RESULT) {
throw new IllegalStateException("expected property_result but got " + resp.getResultCase());
}
diff --git a/oap-server/server-query-plugin/query-graphql-plugin/pom.xml b/oap-server/server-query-plugin/query-graphql-plugin/pom.xml
index 82ed6c7949..a1938d80c7 100644
--- a/oap-server/server-query-plugin/query-graphql-plugin/pom.xml
+++ b/oap-server/server-query-plugin/query-graphql-plugin/pom.xml
@@ -38,6 +38,11 @@
<artifactId>server-health-checker</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.skywalking</groupId>
+ <artifactId>ai-agent-conversation</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>log-analyzer</artifactId>
diff --git a/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/GraphQLQueryProvider.java b/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/GraphQLQueryProvider.java
index 7e95f09c5b..eb7d8f9e37 100644
--- a/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/GraphQLQueryProvider.java
+++ b/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/GraphQLQueryProvider.java
@@ -34,6 +34,8 @@ import org.apache.skywalking.oap.query.graphql.resolver.ContinuousProfilingQuery
import org.apache.skywalking.oap.query.graphql.resolver.EBPFProcessProfilingMutation;
import org.apache.skywalking.oap.query.graphql.resolver.EBPFProcessProfilingQuery;
import org.apache.skywalking.oap.query.graphql.resolver.EventQuery;
+import org.apache.skywalking.oap.query.graphql.resolver.AIAgentConversationQuery;
+import org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationModule;
import org.apache.skywalking.oap.query.graphql.resolver.GenAIEvaluationRecordQuery;
import org.apache.skywalking.oap.query.graphql.resolver.HealthQuery;
import org.apache.skywalking.oap.query.graphql.resolver.HierarchyQuery;
@@ -139,6 +141,8 @@ public class GraphQLQueryProvider extends ModuleProvider {
)
.file("query-protocol/gen-ai-evaluation-record.graphqls")
.resolvers(new GenAIEvaluationRecordQuery(getManager()))
+ .file("query-protocol/ai-agent-conversation.graphqls")
+ .resolvers(new AIAgentConversationQuery(getManager()))
.file("query-protocol/profile.graphqls")
.resolvers(new ProfileQuery(getManager()), new ProfileMutation(getManager()))
.file("query-protocol/browser-log.graphqls")
@@ -186,6 +190,8 @@ public class GraphQLQueryProvider extends ModuleProvider {
@Override
public String[] requiredModules() {
- return new String[0];
+ return new String[] {
+ AIAgentConversationModule.NAME
+ };
}
}
diff --git a/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/resolver/AIAgentConversationQuery.java b/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/resolver/AIAgentConversationQuery.java
new file mode 100644
index 0000000000..8d6def069f
--- /dev/null
+++ b/oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/resolver/AIAgentConversationQuery.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.query.graphql.resolver;
+
+import graphql.kickstart.tools.GraphQLQueryResolver;
+import graphql.schema.DataFetchingEnvironment;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationModule;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.IConversationQueryService;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.input.ConversationCondition;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.input.ConversationListCondition;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationList;
+import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFiles;
+import org.apache.skywalking.oap.server.core.query.input.Duration;
+import org.apache.skywalking.oap.server.core.query.input.InstanceCondition;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingSpan;
+import org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext;
+import org.apache.skywalking.oap.server.library.module.ModuleManager;
+
+import static org.apache.skywalking.oap.query.graphql.AsyncQueryUtils.queryAsync;
+import static org.apache.skywalking.oap.server.core.query.type.debugging.DebuggingTraceContext.TRACE_CONTEXT;
+
+/**
+ * Resolvers of <code>ai-agent-conversation.graphqls</code>. The conversation view is not a GraphQL query; it is
+ * the module's own HTTP route on the same server, see <code>ConversationViewHandler</code>.
+ */
+public class AIAgentConversationQuery implements GraphQLQueryResolver {
+ private final ModuleManager moduleManager;
+ private IConversationQueryService queryService;
+
+ public AIAgentConversationQuery(final ModuleManager moduleManager) {
+ this.moduleManager = moduleManager;
+ }
+
+ private IConversationQueryService getQueryService() {
+ if (queryService == null) {
+ queryService = moduleManager.find(AIAgentConversationModule.NAME)
+ .provider()
+ .getService(IConversationQueryService.class);
+ }
+ return queryService;
+ }
+
+ public CompletableFuture<ConversationList> listConversations(final ConversationListCondition condition,
+ final Duration duration,
+ final boolean debug) {
+ return queryAsync(() -> {
+ final DebuggingTraceContext traceContext = new DebuggingTraceContext(
+ "ConversationListCondition: " + condition + ", Duration: " + duration, debug, false);
+ TRACE_CONTEXT.set(traceContext);
+ final DebuggingSpan span = traceContext.createSpan("Query AI agent conversations");
+ try {
+ final ConversationList list = getQueryService().listConversations(
+ condition.getService().getServiceId(),
+ instanceId(condition.getInstance()),
+ condition.getConversation(),
+ condition.getTitle(),
+ duration,
+ condition.getLimit()
+ );
+ if (debug) {
+ list.setDebuggingTrace(traceContext.getExecTrace());
+ }
+ return list;
+ } finally {
+ traceContext.stopSpan(span);
+ traceContext.stopTrace();
+ TRACE_CONTEXT.remove();
+ }
+ });
+ }
+
+ public CompletableFuture<ConversationRawFiles> getConversationRawFiles(final ConversationCondition condition,
+ final List<String> files,
+ final boolean debug,
+ final DataFetchingEnvironment env) {
+ // The body is read from storage only when the client selected it; selecting it on every file is the
+ // export path.
+ final boolean includeBody = env != null && env.getSelectionSet() != null
+ && env.getSelectionSet().contains("files/body");
+ return queryAsync(() -> {
+ final DebuggingTraceContext traceContext = new DebuggingTraceContext(
+ "ConversationCondition: " + condition + ", Files: " + files, debug, false);
+ TRACE_CONTEXT.set(traceContext);
+ final DebuggingSpan span = traceContext.createSpan("Query AI agent conversation raw files");
+ try {
+ final ConversationRawFiles raw = getQueryService().getConversationRawFiles(
+ condition.getService().getServiceId(),
+ instanceId(condition.getInstance()),
+ condition.getConversation(),
+ files,
+ includeBody
+ );
+ if (debug) {
+ raw.setDebuggingTrace(traceContext.getExecTrace());
+ }
+ return raw;
+ } finally {
+ traceContext.stopSpan(span);
+ traceContext.stopTrace();
+ TRACE_CONTEXT.remove();
+ }
+ });
+ }
+
+ private static String instanceId(final InstanceCondition instance) {
+ return instance == null ? null : instance.getInstanceId();
+ }
+}
diff --git a/oap-server/server-query-plugin/query-graphql-plugin/src/main/resources/query-protocol b/oap-server/server-query-plugin/query-graphql-plugin/src/main/resources/query-protocol
index 052072c31e..1d937ea725 160000
--- a/oap-server/server-query-plugin/query-graphql-plugin/src/main/resources/query-protocol
+++ b/oap-server/server-query-plugin/query-graphql-plugin/src/main/resources/query-protocol
@@ -1 +1 @@
-Subproject commit 052072c31eb41547636f50fa0cfdaba776293874
+Subproject commit 1d937ea7258eeca24fbaf092411e76c72437947f
diff --git a/oap-server/server-starter/pom.xml b/oap-server/server-starter/pom.xml
index d14b6effa6..927b230d36 100644
--- a/oap-server/server-starter/pom.xml
+++ b/oap-server/server-starter/pom.xml
@@ -65,6 +65,12 @@
<artifactId>ai-evaluation</artifactId>
<version>${project.version}</version>
</dependency>
+ <!-- AI agent conversations landed by the AI Sessionizer. -->
+ <dependency>
+ <groupId>org.apache.skywalking</groupId>
+ <artifactId>ai-agent-conversation</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<!-- cluster module -->
<dependency>
diff --git a/oap-server/server-starter/src/main/resources/application.yml b/oap-server/server-starter/src/main/resources/application.yml
index cb8a48c962..d85a83626b 100644
--- a/oap-server/server-starter/src/main/resources/application.yml
+++ b/oap-server/server-starter/src/main/resources/application.yml
@@ -246,7 +246,7 @@ agent-analyzer:
log-analyzer:
selector: ${SW_LOG_ANALYZER:default}
default:
- lalFiles: ${SW_LOG_LAL_FILES:envoy-als,mesh-dp,mysql-slowsql,pgsql-slowsql,redis-slowsql,k8s-service,nginx,envoy-ai-gateway,miniprogram,default}
+ lalFiles: ${SW_LOG_LAL_FILES:envoy-als,mesh-dp,mysql-slowsql,pgsql-slowsql,redis-slowsql,k8s-service,nginx,envoy-ai-gateway,miniprogram,ai-agent,default}
malFiles: ${SW_LOG_MAL_FILES:"nginx,miniprogram-wechat,miniprogram-alipay"}
event-analyzer:
@@ -266,6 +266,23 @@ ai-evaluation:
# Maximum escaped characters included from each input/output message field.
maxContentLength: ${SW_AI_EVALUATION_MAX_CONTENT_LENGTH:16384}
+# AI agent conversations landed by the AI Sessionizer (Session Data and Session Flow files over OTLP logs under the
+# AI_AGENT layer).
+ai-agent-conversation:
+ selector: ${SW_AI_AGENT_CONVERSATION:default}
+ default:
+ # How many Session Data files one storage read fetches; keeps a single BanyanDB response under its inbound cap.
+ fileReadWindow: ${SW_AI_AGENT_CONVERSATION_FILE_READ_WINDOW:16}
+ roundReadWindow: ${SW_AI_AGENT_CONVERSATION_ROUND_READ_WINDOW:16}
+ # How many rebuilt conversation views stay in memory, keyed by the head round's digest. A large view is ~20 MB.
+ # The most rounds one list query reads before folding to one row per conversation.
+ maxListLimit: ${SW_AI_AGENT_CONVERSATION_MAX_LIST_LIMIT:10000}
+ viewRequestTimeout: ${SW_AI_AGENT_CONVERSATION_VIEW_REQUEST_TIMEOUT:120}
+ # The largest file stored, in bytes; a larger one is rejected at ingest. Under BanyanDB's 16 MiB gRPC message limit.
+ maxFileBytes: ${SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES:15728640}
+ # The most bytes one window read may answer with, applied to that read alone where the storage caps a response per call.
+ maxResponseBytes: ${SW_AI_AGENT_CONVERSATION_MAX_RESPONSE_BYTES:104857600}
+
receiver-sharing-server:
selector: ${SW_RECEIVER_SHARING_SERVER:default}
default:
diff --git a/oap-server/server-starter/src/main/resources/bydb.yml b/oap-server/server-starter/src/main/resources/bydb.yml
index f7d55ee19e..26a2d64276 100644
--- a/oap-server/server-starter/src/main/resources/bydb.yml
+++ b/oap-server/server-starter/src/main/resources/bydb.yml
@@ -228,6 +228,28 @@ groups:
ttl: ${SW_STORAGE_BANYANDB_LOG_COLD_TTL_DAYS:30}
replicas: ${SW_STORAGE_BANYANDB_LOG_COLD_REPLICAS:0}
nodeSelector: ${SW_STORAGE_BANYANDB_LOG_COLD_NODE_SELECTOR:"type=cold"}
+ # The "recordsAIAgent" group holds AI agent conversation files landed by the AI Sessionizer: Session Data (.sd)
+ # and Session Flow (.sf) rows of up to 2 MiB each. It mirrors the log group's keys, hot, warm and cold, but has its
+ # own retention because conversations are kept for weeks and a round whose files have expired is a broken chain.
+ recordsAIAgent:
+ shardNum: ${SW_STORAGE_BANYANDB_AI_AGENT_SHARD_NUM:2}
+ segmentInterval: ${SW_STORAGE_BANYANDB_AI_AGENT_SI_DAYS:1}
+ ttl: ${SW_STORAGE_BANYANDB_AI_AGENT_TTL_DAYS:30}
+ replicas: ${SW_STORAGE_BANYANDB_AI_AGENT_REPLICAS:0}
+ enableWarmStage: ${SW_STORAGE_BANYANDB_AI_AGENT_ENABLE_WARM_STAGE:false}
+ enableColdStage: ${SW_STORAGE_BANYANDB_AI_AGENT_ENABLE_COLD_STAGE:false}
+ warm:
+ shardNum: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_SHARD_NUM:2}
+ segmentInterval: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_SI_DAYS:1}
+ ttl: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_TTL_DAYS:60}
+ replicas: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_REPLICAS:0}
+ nodeSelector: ${SW_STORAGE_BANYANDB_AI_AGENT_WARM_NODE_SELECTOR:"type=warm"}
+ cold:
+ shardNum: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_SHARD_NUM:2}
+ segmentInterval: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_SI_DAYS:1}
+ ttl: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_TTL_DAYS:180}
+ replicas: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_REPLICAS:0}
+ nodeSelector: ${SW_STORAGE_BANYANDB_AI_AGENT_COLD_NODE_SELECTOR:"type=cold"}
recordsBrowserErrorLog:
shardNum: ${SW_STORAGE_BANYANDB_BROWSER_ERROR_LOG_SHARD_NUM:2}
segmentInterval: ${SW_STORAGE_BANYANDB_BROWSER_ERROR_LOG_SI_DAYS:1}
diff --git a/oap-server/server-starter/src/main/resources/lal/ai-agent.yaml b/oap-server/server-starter/src/main/resources/lal/ai-agent.yaml
new file mode 100644
index 0000000000..9b23ef1322
--- /dev/null
+++ b/oap-server/server-starter/src/main/resources/lal/ai-agent.yaml
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# AI agent conversations landed by the AI Sessionizer (apache/skywalking-ai-sessionizer).
+#
+# Every OTLP log record under the AI_AGENT layer is one file: a Session Data (.sd) file of one session, or one
+# Session Flow (.sf) round of a conversation. The body is the file verbatim. The extractor takes only what a query
+# filters on, the list page shows, or verification needs; everything else about a file stays on its own first
+# line. The output builder verifies the digest and the line count, then stores the file in the table its format
+# names. One rule, one builder: the branch on the format lives in the builder.
+# The list attributes, the title and the five counts, came with a later round header. A round from before them
+# travels without them, and an absent tag reads as an empty string, which is not a number: they are read only
+# when the round carries them, and such a round lists with zero counts.
+rules:
+ - name: ai-agent-conversation
+ layer: AI_AGENT
+ outputType: ConversationFile
+ dsl: |
+ filter {
+ extractor {
+ format tag("asz.format") as String
+ digest tag("asz.file.digest") as String
+ lines tag("asz.lines") as Long
+ throughTime tag("asz.through_time") as String
+ if (tag("asz.format") == "sd") {
+ session tag("asz.session") as String
+ seq tag("asz.seq") as Long
+ }
+ if (tag("asz.format") == "sf") {
+ conversation tag("asz.conversation") as String
+ round tag("asz.round") as Long
+ sessionFromTime tag("asz.session.from_time") as String
+ sessionThroughTime tag("asz.session.through_time") as String
+ title tag("asz.conversation.title") as String
+ if (tag("asz.conversation.talks") != "") {
+ talks tag("asz.conversation.talks") as Long
+ steps tag("asz.conversation.steps") as Long
+ streams tag("asz.conversation.streams") as Long
+ segments tag("asz.conversation.segments") as Long
+ unresolved tag("asz.conversation.unresolved") as Long
+ }
+ }
+ }
+ sink {
+ }
+ }
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigDumpExtension.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigDumpExtension.java
index 8a8acd06a4..f08a1e962c 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigDumpExtension.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigDumpExtension.java
@@ -63,6 +63,7 @@ public class BanyanDBConfigDumpExtension implements ConfigDumpExtension {
groups.put("trace", config.getTrace());
groups.put("zipkinTrace", config.getZipkinTrace());
groups.put("recordsBrowserErrorLog", config.getRecordsBrowserErrorLog());
+ groups.put("recordsAIAgent", config.getRecordsAIAgent());
groups.put("metricsMinute", config.getMetricsMin());
groups.put("metricsHour", config.getMetricsHour());
groups.put("metricsDay", config.getMetricsDay());
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigLoader.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigLoader.java
index 4d576b7710..ba97928576 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigLoader.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBConfigLoader.java
@@ -121,6 +121,13 @@ public class BanyanDBConfigLoader {
(Map<String, Object>) rawGroups.get(BanyanDB.TraceGroup.ZIPKIN_TRACE.getName()), config.getZipkinTrace());
}
+ Properties aiAgent = (Properties) groups.get(BanyanDB.StreamGroup.RECORDS_AI_AGENT.getName());
+ copyProperties(
+ config.getRecordsAIAgent(), aiAgent,
+ moduleProvider.getModule().name(), moduleProvider.name()
+ );
+ copyStages(aiAgent, config.getRecordsAIAgent());
+
Properties browserErrorLog = (Properties) groups.get(BanyanDB.StreamGroup.RECORDS_BROWSER_ERROR_LOG.getName());
copyProperties(
config.getRecordsBrowserErrorLog(), browserErrorLog,
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageClient.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageClient.java
index 8add7afd67..d08e72c743 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageClient.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageClient.java
@@ -242,8 +242,24 @@ public class BanyanDBStorageClient implements Client, HealthCheckable {
@SafeVarargs
public final StreamQueryResponse queryStream(String bydbql, Serializable<BanyandbModel.TagValue>... params) throws IOException {
+ return queryStream(0, bydbql, params);
+ }
+
+ /**
+ * Query a stream with a BydbQL statement, with a cap on the response of this call alone.
+ *
+ * @param maxResponseBytes the most bytes the response may carry, in place of the client's default; 0 keeps
+ * the default
+ * @param bydbql a BydbQL query whose FROM clause targets a STREAM
+ * @param params values bound to the {@code ?} placeholders, in order of appearance
+ * @return the stream query response
+ * @throws IOException if the query fails
+ */
+ @SafeVarargs
+ public final StreamQueryResponse queryStream(int maxResponseBytes, String bydbql,
+ Serializable<BanyandbModel.TagValue>... params) throws IOException {
try {
- StreamQueryResponse response = this.client.queryStream(bydbql, params);
+ StreamQueryResponse response = this.client.queryStream(maxResponseBytes, bydbql, params);
this.healthChecker.health();
return response;
} catch (BanyanDBException ex) {
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageConfig.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageConfig.java
index 92a4d95df2..6329fb5164 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageConfig.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageConfig.java
@@ -44,6 +44,7 @@ public class BanyanDBStorageConfig extends ModuleConfig {
private RecordsZipkinTrace recordsZipkinTrace = new RecordsZipkinTrace();
private RecordsLog recordsLog = new RecordsLog();
private RecordsBrowserErrorLog recordsBrowserErrorLog = new RecordsBrowserErrorLog();
+ private RecordsAIAgent recordsAIAgent = new RecordsAIAgent();
private MetricsMin metricsMin = new MetricsMin();
private MetricsHour metricsHour = new MetricsHour();
@@ -255,6 +256,15 @@ public class BanyanDBStorageConfig extends ModuleConfig {
public static class RecordsBrowserErrorLog extends BanyanDBStorageConfig.GroupResource {
}
+ /**
+ * The group of AI agent conversation files (Session Data and Session Flow), configured like the log group but
+ * kept apart from it: elements are files of up to 2 MiB and conversations are wanted for weeks.
+ */
+ @Getter
+ @Setter
+ public static class RecordsAIAgent extends BanyanDBStorageConfig.GroupResource {
+ }
+
// The group settings of metrics.
//
// OAP stores metrics based its granularity.
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageProvider.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageProvider.java
index b123583d8e..a0ff30d9f6 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageProvider.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBStorageProvider.java
@@ -61,6 +61,7 @@ import org.apache.skywalking.oap.server.core.storage.query.IAggregationQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.IAlarmQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.IBrowserLogQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.IEventQueryDAO;
+import org.apache.skywalking.oap.server.core.storage.query.IAIAgentConversationQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.IGenAIEvaluationRecordQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.IHierarchyQueryDAO;
import org.apache.skywalking.oap.server.core.storage.query.ILogQueryDAO;
@@ -82,6 +83,7 @@ import org.apache.skywalking.oap.server.library.util.MultipleFilesChangeMonitor;
import org.apache.skywalking.oap.server.library.util.StringUtil;
import org.apache.skywalking.oap.server.storage.plugin.banyandb.measure.BanyanDBEBPFProfilingScheduleQueryDAO;
import org.apache.skywalking.oap.server.storage.plugin.banyandb.stream.BanyanDBEventQueryDAO;
+import org.apache.skywalking.oap.server.storage.plugin.banyandb.stream.BanyanDBAIAgentConversationQueryDAO;
import org.apache.skywalking.oap.server.storage.plugin.banyandb.stream.BanyanDBGenAIEvaluationRecordQueryDAO;
import org.apache.skywalking.oap.server.storage.plugin.banyandb.measure.BanyanDBHierarchyQueryDAO;
import org.apache.skywalking.oap.server.storage.plugin.banyandb.measure.BanyanDBMetadataQueryDAO;
@@ -192,6 +194,8 @@ public class BanyanDBStorageProvider extends ModuleProvider {
this.registerServiceImplementation(ILogQueryDAO.class, new BanyanDBLogQueryDAO(client));
this.registerServiceImplementation(
IGenAIEvaluationRecordQueryDAO.class, new BanyanDBGenAIEvaluationRecordQueryDAO(client));
+ this.registerServiceImplementation(
+ IAIAgentConversationQueryDAO.class, new BanyanDBAIAgentConversationQueryDAO(client));
this.registerServiceImplementation(
IProfileTaskQueryDAO.class, new BanyanDBProfileTaskQueryDAO(client,
this.config.getGlobal().getProfileTaskQueryMaxSize()
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
index d14fb4055d..fa9221fffd 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/MetadataRegistry.java
@@ -865,6 +865,15 @@ public enum MetadataRegistry {
... 3366 lines suppressed ...