From 0beff520e559edc9c204a3f33b2195ebc7b9bdbb Mon Sep 17 00:00:00 2001 From: Wu Sheng Date: Sat, 5 Sep 2026 09:36:46 +0800 Subject: [PATCH] Add AI agent conversations landed by the AI Sessionizer 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/api/query-protocol.md | 20 + docs/en/changes/changes.md | 1 + .../en/setup/backend/ai-agent-conversation.md | 155 ++ .../setup/backend/configuration-vocabulary.md | 5 + docs/en/setup/backend/storages/banyandb.md | 23 + .../setup/backend/storages/elasticsearch.md | 2 + docs/menu.yml | 2 + .../analyzer/ai-agent-conversation/pom.xml | 74 + .../AIAgentConversationConfig.java | 49 + .../AIAgentConversationModule.java | 45 + .../AIAgentConversationProvider.java | 129 ++ .../conversation/fold/ConversationFold.java | 251 +++ .../ai/agent/conversation/format/Digests.java | 103 ++ .../agent/conversation/format/FileNames.java | 136 ++ .../ai/agent/conversation/format/RawJson.java | 195 +++ .../ai/agent/conversation/format/Ref.java | 66 + .../conversation/format/SessionDataFile.java | 332 ++++ .../conversation/format/SessionFlowRound.java | 419 +++++ .../ai/agent/conversation/format/Times.java | 87 ++ .../ingest/ConversationFileBuilder.java | 315 ++++ .../query/ConversationQueryService.java | 395 +++++ .../query/IConversationQueryService.java | 75 + .../query/http/CompressResponse.java | 43 + .../query/http/ConversationViewHandler.java | 224 +++ .../query/input/ConversationCondition.java | 30 + .../input/ConversationListCondition.java | 30 + .../query/type/ConversationFileFormat.java | 29 + .../query/type/ConversationList.java | 31 + .../query/type/ConversationRawFile.java | 37 + .../query/type/ConversationRawFiles.java | 31 + .../query/type/ConversationRow.java | 40 + .../view/ConversationViewBuilder.java | 1389 +++++++++++++++++ .../ai/agent/conversation/view/ViewJson.java | 45 + .../ai/agent/conversation/view/ViewYaml.java | 71 + ...ng.oap.server.core.source.LALOutputBuilder | 18 + ...ing.oap.server.library.module.ModuleDefine | 18 + ...g.oap.server.library.module.ModuleProvider | 18 + .../conversation/AIAgentLalRuleTest.java | 61 + .../ConversationFileBuilderTest.java | 193 +++ .../ConversationViewBuilderTest.java | 116 ++ .../ai/agent/conversation/Fixtures.java | 69 + .../conversation/SessionFormatsTest.java | 165 ++ .../http/ConversationViewHandlerTest.java | 250 +++ .../test/resources/fixtures/.gitattributes | 3 + .../resources/fixtures/asz-view-example.json | 1043 +++++++++++++ .../resources/fixtures/asz-view-example.yaml | 773 +++++++++ .../meta-20260101T000000.000000000Z-000003.sd | 3 + .../fixtures/r000001-3ad0dcd4cd53.sf | 45 + ...cript-20260101T000000.000000000Z-000001.sd | 18 + ...cript-20260101T000000.000000000Z-000002.sd | 4 + oap-server/analyzer/pom.xml | 1 + .../oap/server/core/analysis/Layer.java | 6 + .../aiagent/AIAgentSessionDataRecord.java | 138 ++ .../aiagent/AIAgentSessionFlowRecord.java | 163 ++ .../core/source/DefaultScopeDefine.java | 2 + .../server/core/storage/StorageModule.java | 2 + .../core/storage/annotation/BanyanDB.java | 5 + .../query/IAIAgentConversationQueryDAO.java | 162 ++ .../query-graphql-plugin/pom.xml | 5 + .../query/graphql/GraphQLQueryProvider.java | 8 +- .../resolver/AIAgentConversationQuery.java | 124 ++ .../src/main/resources/query-protocol | 2 +- oap-server/server-starter/pom.xml | 6 + .../src/main/resources/application.yml | 15 +- .../src/main/resources/bydb.yml | 22 + .../src/main/resources/lal/ai-agent.yaml | 53 + .../banyandb/BanyanDBConfigDumpExtension.java | 1 + .../plugin/banyandb/BanyanDBConfigLoader.java | 7 + .../banyandb/BanyanDBStorageConfig.java | 10 + .../banyandb/BanyanDBStorageProvider.java | 4 + .../plugin/banyandb/MetadataRegistry.java | 9 + .../BanyanDBAIAgentConversationQueryDAO.java | 223 +++ .../src/test/resources/bydb.yml | 19 + .../StorageModuleElasticsearchProvider.java | 4 + .../query/AIAgentConversationQueryEsDAO.java | 242 +++ .../jdbc/common/JDBCStorageProvider.java | 5 + .../dao/JDBCAIAgentConversationQueryDAO.java | 298 ++++ .../src/main/resources/application.yml | 9 + .../e2e-v2/cases/ai-agent/ai-agent-cases.yaml | 46 + .../ai-agent/banyandb/docker-compose.yml | 186 +++ test/e2e-v2/cases/ai-agent/banyandb/e2e.yaml | 52 + .../cases/ai-agent/es/docker-compose.yml | 195 +++ test/e2e-v2/cases/ai-agent/es/e2e.yaml | 42 + .../expected/conversations-instance.yml | 17 + .../ai-agent/expected/conversations-limit.yml | 17 + .../expected/conversations-window.yml | 17 + .../cases/ai-agent/expected/conversations.yml | 55 + .../cases/ai-agent/expected/instance.yml | 22 + .../cases/ai-agent/expected/multi-round.yml | 52 + .../cases/ai-agent/expected/raw-files.yml | 18 + .../e2e-v2/cases/ai-agent/expected/reject.yml | 17 + .../cases/ai-agent/expected/service.yml | 24 + test/e2e-v2/cases/ai-agent/expected/views.yml | 32 + test/e2e-v2/cases/ai-agent/fixture.yaml | 54 + .../cases/ai-agent/mysql/docker-compose.yml | 197 +++ test/e2e-v2/cases/ai-agent/mysql/e2e.yaml | 42 + .../ai-agent/postgres/docker-compose.yml | 195 +++ 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 | 189 +++ .../silence-after-graphql-critical.yml | 1 + .../expected/silence-after-graphql-warn.yml | 2 + .../silence-before-graphql-critical.yml | 1 + .../expected/silence-before-graphql-warn.yml | 2 + .../cases/baseline/expected/critical.yml | 1 + test/e2e-v2/cases/php/Dockerfile.php | 9 +- .../cases/storage/expected/config-dump.yml | 7 +- test/e2e-v2/script/env | 3 +- 110 files changed, 10801 insertions(+), 9 deletions(-) create mode 100644 docs/en/setup/backend/ai-agent-conversation.md create mode 100644 oap-server/analyzer/ai-agent-conversation/pom.xml create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationConfig.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationModule.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationProvider.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/fold/ConversationFold.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Digests.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/FileNames.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJson.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/ingest/ConversationFileBuilder.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryService.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/IConversationQueryService.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/CompressResponse.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationViewHandler.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationCondition.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationListCondition.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationFileFormat.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationList.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFile.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFiles.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRow.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ConversationViewBuilder.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ViewJson.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ViewYaml.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.core.source.LALOutputBuilder create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.library.module.ModuleDefine create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/resources/META-INF/services/org.apache.skywalking.oap.server.library.module.ModuleProvider create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentLalRuleTest.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ConversationFileBuilderTest.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ConversationViewBuilderTest.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/Fixtures.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationViewHandlerTest.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/.gitattributes create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/asz-view-example.json create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/asz-view-example.yaml create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/meta-20260101T000000.000000000Z-000003.sd create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/r000001-3ad0dcd4cd53.sf create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/transcript-20260101T000000.000000000Z-000001.sd create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/transcript-20260101T000000.000000000Z-000002.sd create mode 100644 oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionDataRecord.java create mode 100644 oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionFlowRecord.java create mode 100644 oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/IAIAgentConversationQueryDAO.java create mode 100644 oap-server/server-query-plugin/query-graphql-plugin/src/main/java/org/apache/skywalking/oap/query/graphql/resolver/AIAgentConversationQuery.java create mode 100644 oap-server/server-starter/src/main/resources/lal/ai-agent.yaml create mode 100644 oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/stream/BanyanDBAIAgentConversationQueryDAO.java create mode 100644 oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/AIAgentConversationQueryEsDAO.java create mode 100644 oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/dao/JDBCAIAgentConversationQueryDAO.java create mode 100644 test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml create mode 100644 test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml create mode 100644 test/e2e-v2/cases/ai-agent/banyandb/e2e.yaml create mode 100644 test/e2e-v2/cases/ai-agent/es/docker-compose.yml create mode 100644 test/e2e-v2/cases/ai-agent/es/e2e.yaml create mode 100644 test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/conversations-limit.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/conversations-window.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/conversations.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/instance.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/multi-round.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/raw-files.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/reject.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/service.yml create mode 100644 test/e2e-v2/cases/ai-agent/expected/views.yml create mode 100644 test/e2e-v2/cases/ai-agent/fixture.yaml create mode 100644 test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml create mode 100644 test/e2e-v2/cases/ai-agent/mysql/e2e.yaml create mode 100644 test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml create mode 100644 test/e2e-v2/cases/ai-agent/postgres/e2e.yaml create mode 100644 test/e2e-v2/cases/ai-agent/three-rounds.yaml create mode 100755 test/e2e-v2/cases/ai-agent/verify.sh diff --git a/.github/workflows/skywalking.yaml b/.github/workflows/skywalking.yaml index c177cc086e3d..8d17d3124e29 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 556b7965154b..1f50cd5cb4a4 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/api/query-protocol.md b/docs/en/api/query-protocol.md index 90c9fb818844..efca61e97a7a 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 f094b4c0b898..cb81d5527cc8 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-set capacity and finalization state, the plugin telemetry-host safety bounds, and the first-party `sw-trace-sampler` / `zipkin-trace-sampler` decision and row metrics. The plugin chain is optional, and the metrics follow it: on a cluster with no sampler configured the wire families are never registered, so every metric here stays absent rather than reading zero. Modeled at Service scope with `group` kept as a metric label rather than at Endpoint scope, so one cluster-wide page can render per-group series and cluster totals alike — OAP does no cross-scope rollup, so an Endpoint-scope metric could not have been aggregated back up to the cluster. * 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 difference against each other's values. The queue is ordered by (timestamp, value), so the smaller counter wins the lower-bound lookup and still reads correctly while its partner is inflated by the gap between them — which is why this went unnoticed. A collision needs the discriminating label to be DROPPED by the `.sum(...)`: where it survives, the rules' label values differ and the window keeps them apart. Auditing the shipped rules on that basis gives 10 colliding keys over ~25 rules — `meter_activemq_cluster_gc_parallel_young_collection_count` reported ~9000/min of young-gen collections from a completely idle broker (differencing against the old-gen counter); MySQL `commands_*` / `tps` rate against each other; so do the GenAI gateway input/output token rates, four Envoy `cluster_*` counters, APISIX matched/unmatched instance bandwidth, and BanyanDB's own `network_recv` / `network_sent`, which drop the `kind` label that separates bytes-received from bytes-sent on one interface. Measured against two live scrapes of the demo cluster's FODC proxy, that last pair was wrong on every interface: `network_sent` read a flat 0 B/s and `network_recv` read large negative values (down to -778 MB/s) from differencing against the sent counter, where both now match the byte delta exactly. No rule changes were needed for any of these -- each rule already reduces to the labels it should; only the window key was wrong. The window is now keyed by (owning rule, counter name, labels). This is the complement of the within-rule collision fixed earlier by keying on the counter's own name: neither name alone is sufficient, because the two collisions are independent. `RunningContext.metricName` — written on every rule evaluation and read by nobody since that earlier fix — is what supplies the rule identity, so no code generation or MAL syntax changes. Note the whole-rule-set comparison suite could not have caught this: it resets the shared window before every rule, the one condition under which the collision cannot appear. * 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/manual ack). +* 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` / `getConversationRawFiles` GraphQL queries and the streamed `GET /ai-agent/conversations/{conversation}/v1/view` route that serves the document. #### 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 000000000000..aa6808bb18b3 --- /dev/null +++ b/docs/en/setup/backend/ai-agent-conversation.md @@ -0,0 +1,155 @@ +# 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; a stored file is a verified file. 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 `MEDIUMTEXT` on MySQL and H2 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. +- `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 stops before a round that is missing, that does not read, or that belongs to another conversation, +session, parser or policy; `head` names the last round it reached, and the rounds after it are listed and not +verified, exactly as the Sessionizer's own viewer does. 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} +``` + +| 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, and the BanyanDB client caps one response at 50 MB. | +| `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. | +| `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. | + +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 BanyanDB client caps one query response at 50 MB, and Elasticsearch answers at most 10,000 hits to one search. + 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. +- 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 f1235a27e1a9..4628d1af8fef 100644 --- a/docs/en/setup/backend/configuration-vocabulary.md +++ b/docs/en/setup/backend/configuration-vocabulary.md @@ -172,6 +172,11 @@ It divided into several modules, each of which has its own settings. The followi | log-analyzer | default | Log Analyzer. | SW_LOG_ANALYZER | default | | | - | - | lalFiles | The LAL configuration file names (without file extension) to be activated. Read [LAL](../../concepts-and-designs/lal.md) for more details. | SW_LOG_LAL_FILES | default | | - | - | malFiles | The MAL configuration file names (without file extension) to be activated. Read [LAL](../../concepts-and-designs/lal.md) for more details. | SW_LOG_MAL_FILES | "" | +| 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 | | event-analyzer | default | Event Analyzer. | SW_EVENT_ANALYZER | default | | | 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 5ddf4188181c..662ca29b535d 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 e83892fe7d73..d78abcb3ee7f 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 d0e4cc3fe57b..d9a069c9db89 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/analyzer/ai-agent-conversation/pom.xml b/oap-server/analyzer/ai-agent-conversation/pom.xml new file mode 100644 index 000000000000..07c2b5239560 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/pom.xml @@ -0,0 +1,74 @@ + + + + + 4.0.0 + + + org.apache.skywalking + analyzer + ${revision} + + + org.apache.skywalking + ai-agent-conversation + + + 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. + + + + + org.apache.skywalking + library-module + ${project.version} + + + org.apache.skywalking + telemetry-api + ${project.version} + + + org.apache.skywalking + server-core + ${project.version} + + + org.apache.skywalking + log-analyzer + ${project.version} + test + + + com.google.code.gson + gson + + + com.linecorp.armeria + armeria-junit5 + test + + + org.yaml + snakeyaml + + + 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 000000000000..e958b9345625 --- /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,49 @@ +/* + * 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, and the BanyanDB client caps + * one response at 50 MB, so the window keeps a single response well under the cap. + */ + private int fileReadWindow = 16; + /** + * How many Session Flow rounds one storage read fetches. A round is cut at 2 MiB by the Sessionizer, so the + * window keeps a single response well under the BanyanDB client's cap and Elasticsearch's result window. + */ + private int roundReadWindow = 16; + /** + * 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; +} 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 000000000000..13163784d866 --- /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 (.sd) and + * Session Flow (.sf) files over OTLP logs under the AI_AGENT layer. + * + *

Ingest is a LAL output builder, {@code ConversationFile}, that the bundled lal/ai-agent.yaml + * 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 asz.view 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 000000000000..09ef66c3227a --- /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,129 @@ +/* + * 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 module() { + return AIAgentConversationModule.class; + } + + @Override + public ConfigCreator newConfigCreator() { + return new ConfigCreator() { + @Override + public Class 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"); + } + 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.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")) + ); + } + + @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 000000000000..f76e0712ec6d --- /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,251 @@ +/* + * 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 sessionflow.View folds them: entities + * keyed by id, a higher revision replaces a lower one, a tombstone removes, absence means unchanged. + * + *

Rounds must fold in order and each must name the previous round's commit digest. A round that breaks the + * chain is still folded, so a viewer sees what could be folded, and the break is recorded as a problem. + */ +@Getter +public final class ConversationFold { + private final Map nodes = new LinkedHashMap<>(); + private final Map relations = new LinkedHashMap<>(); + private final Map 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> kids; + private Map> from; + private Map> to; + + /** + * Fold one round on top of the current head, or refuse it the way the Sessionizer's Apply + * 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(); + sessionFromTime = h.getSessionFromTime(); + sessionThroughTime = h.getSessionThroughTime(); + return null; + } + + 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 children(final String id) { + index(); + return kids.getOrDefault(id, Collections.emptyList()); + } + + public List relationsFrom(final String id) { + index(); + return from.getOrDefault(id, Collections.emptyList()); + } + + public List 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 nodesOfKind(final String kind) { + final List 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 openUnresolved() { + final List 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 inOrder(final List nodes) { + final List 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> 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 list : k.values()) { + list.sort(ConversationFold::compare); + } + final Map> f = new HashMap<>(); + final Map> 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 000000000000..29a70bb1930f --- /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 input_digest: 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 added) { + final List 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 asz.lines 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 000000000000..d2c795a8f146 --- /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. + * + *

+ * <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
+ * 
+ */ +public final class FileNames { + private static final Pattern DATA_FILE = + Pattern.compile("^(?[^/]+)/(streams|runs)/[^/]+/[a-z]+-[^/-]+-(?\\d{6,})\\.sd$"); + private static final Pattern ROUND_FILE = + Pattern.compile("^_conversations/(?[^/]+)/rounds/r(?\\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 000000000000..2fe9e9d0d61e --- /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 data value in one record line, so a part's data is rendered + * as the Sessionizer wrote it. Go keeps that value as json.RawMessage 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. + * + *

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 data text of each element of the line's parts 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 partData(final String line) { + try { + return new RawJson(line).parts(); + } catch (final IllegalStateException | StringIndexOutOfBoundsException e) { + return new ArrayList<>(); + } + } + + private List parts() { + final List 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 data 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 000000000000..df8676cf47e2 --- /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 seq, the line by row, and, when the + * node stands on one part of a record, the part by block. + */ +@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 toMap() { + final Map 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 000000000000..41a4a68f8db2 --- /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 (.sd) 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. + * + *

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 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 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 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 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 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 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 text part joined by a newline, as the + * Sessionizer's Record.Text() 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 flags, empty when none + */ + public List flags() { + return strings("flags"); + } + + /** + * @return the record's usage 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 dropped 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 child, or null + */ + @Nullable + public String child() { + return string(json, "child"); + } + + private List strings(final String key) { + final JsonElement e = json.get(key); + if (e == null || !e.isJsonArray()) { + return Collections.emptyList(); + } + final List 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 data 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 000000000000..be245619c066 --- /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 (.sf) 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 nodes; + private final List relations; + private final List 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 nodes, final List relations, + final List 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 nodes = new ArrayList<>(); + final List relations = new ArrayList<>(); + final List 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 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 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 many, final Header header) { + final List 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 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 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 attrString; 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 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 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 000000000000..b057f68c669d --- /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: 20260904T033423.840913000Z, from the header's at. + */ + 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 time.RFC3339Nano 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 at + * @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 000000000000..2d6145ffe3a4 --- /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,315 @@ +/* + * 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 outputType: ConversationFile in lal/ai-agent.yaml. + * + *

The rule's extractor sets the fields below from the record's asz.* 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 format lives here. + * + *

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"; + 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 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 + */ + public static void setMetrics(final CounterMetrics dataAccepted, + final CounterMetrics flowAccepted, + final CounterMetrics digest, + final CounterMetrics lines, + final CounterMetrics attributes) { + ACCEPTED_DATA = dataAccepted; + ACCEPTED_FLOW = flowAccepted; + REJECTED_DIGEST = digest; + REJECTED_LINES = lines; + REJECTED_ATTRIBUTES = attributes; + } + + /** + * 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 (!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 000000000000..19cf3c96db10 --- /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,395 @@ +/* + * 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.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 + * conversation 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, + 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 newestFirst = + dao().queryRoundsDebuggable(serviceId, serviceInstanceId, null, duration, rounds, false); + final Map 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)); + } + final ConversationList list = new ConversationList(); + list.setConversations(new ArrayList<>(rows.values())); + 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 buildConversationView(final String serviceId, + @Nullable final String serviceInstanceId, + final String conversation) throws IOException { + final List 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 files, + final boolean includeBody) throws IOException { + final Set 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 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); + final SessionFlowRound head = SessionFlowRound.parse(headRow.getBody()); + // 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 = Times.millis(head.getHeader().getSessionFromTime()); + final long to = rangeEnd(head.getHeader().getSessionThroughTime(), headRow.getTimestamp()); + + // Session Data files first, then the rounds, each list in its own order. + final Set sessions = new LinkedHashSet<>(); + if (StringUtil.isNotEmpty(head.getHeader().getSession())) { + sessions.add(head.getHeader().getSession()); + } + for (final FileNames.Parsed p : wanted) { + if (p.isDataFile()) { + sessions.add(p.getSession()); + } + } + for (final String session : sessions) { + final Set 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.getHeader().getThroughSeq() : seqs.stream().mapToLong(Long::longValue).max().orElse(0); + final long fromSeq = all ? 1 : seqs.stream().mapToLong(Long::longValue).min().orElse(1); + final Set 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; + } + } + final SessionFlowRound parsed = SessionFlowRound.parse(r.getBody()); + final ConversationRawFile raw = new ConversationRawFile(); + raw.setId(FileNames.roundFile(conversation, r.getRound(), parsed.getCommitDigest())); + 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 roundInputs = new ArrayList<>(); + final Map files = new TreeMap<>(); + /** What stopped the fold short of the chain's last round, in words, as the Sessionizer's FoldPartial. */ + final List 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, 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 readRounds(final String serviceId, @Nullable final String instance, + final String conversation) throws IOException { + long headRound = 0; + for (final AIAgentSessionFlowRecord r : dao().queryRoundsDebuggable( + serviceId, instance, conversation, null, config.getMaxListLimit(), false)) { + headRound = Math.max(headRound, r.getRound()); + } + if (headRound == 0) { + return new ArrayList<>(); + } + final Map 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)) { + byRound.putIfAbsent(r.getRound(), r); + } + } + return new ArrayList<>(byRound.values()); + } + + /** + * The fold goes as far as the chain holds, as the Sessionizer's FoldPartial: a round that is + * missing, that does not read, or that the fold refuses stops it, and what was wrong is carried in words. + * Every stored round is still listed, so the document shows the rounds after the gap, unverified. + */ + private Chain readChain(final String serviceId, @Nullable final String serviceInstanceId, + final String conversation) throws IOException { + final Chain chain = new Chain(); + final List rounds = readRounds(serviceId, serviceInstanceId, conversation); + final String instance = StringUtil.isNotEmpty(serviceInstanceId) ? serviceInstanceId : null; + boolean folding = true; + long throughSeq = 0; + AIAgentSessionFlowRecord headRow = null; + for (final AIAgentSessionFlowRecord r : rounds) { + if (folding && r.getRound() != chain.fold.getRound() + 1) { + chain.problems.add("round " + (chain.fold.getRound() + 1) + " is missing; the chain stops at round " + + chain.fold.getRound()); + folding = false; + } + 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)); + if (folding) { + chain.problems.add("round " + r.getRound() + " does not read: " + unreadable); + folding = false; + } + continue; + } + chain.roundInputs.add(new ConversationViewBuilder.RoundInput(parsed, r.getDigest())); + throughSeq = Math.max(throughSeq, parsed.getHeader().getThroughSeq()); + if (!folding) { + continue; + } + final String refused = chain.fold.apply(parsed); + if (refused != null) { + chain.problems.add("round " + r.getRound() + " does not fold: " + refused); + folding = false; + 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()); + final Set 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 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 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)); + } + 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 000000000000..007867c0952a --- /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,75 @@ +/* + * 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 ai-agent-conversation.graphqls 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 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, Duration duration, + @Nullable Integer limit) throws IOException; + + /** + * The whole conversation, once, as one asz.view 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 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 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 000000000000..a437745edfbd --- /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 Accept-Encoding 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 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 000000000000..d695ea4e08dc --- /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,224 @@ +/* + * 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; + +/** + * GET /ai-agent/conversations/{conversation}/v1/view: the whole conversation as one + * asz.view 1.0 document, streamed. It lives on the core HTTP server beside /graphql, + * 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. + * + *

Query parameters: service, the service name, or serviceId; optionally + * instance, the sender's instance name. What the body is, the HTTP layer says: the media type + * names the document format and its version, application/vnd.skywalking.asz.view+json; version=1.0, or the + * +yaml twin when Accept asks for YAML. The document's own first two keys repeat it. + * + *

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): + * {"type": "about:blank", "title": "...", "status": 404, "detail": "..."}. + */ +@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 doc; + try { + doc = service.buildConversationView(serviceId, instanceId, conversation); + } catch (final IOException | RuntimeException e) { + 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 000000000000..5804d5caf660 --- /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 000000000000..7f43827d14ba --- /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,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 ConversationListCondition { + private ServiceCondition service; + private InstanceCondition instance; + 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 000000000000..57973ead739e --- /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 000000000000..9af0abb3d2f2 --- /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 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 000000000000..a0fa97efe11e --- /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 000000000000..341179e50d1d --- /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 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 000000000000..8f166a76a3b1 --- /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 000000000000..5437dfaa24ac --- /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,1389 @@ +/* + * 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 asz.view 1.0 document of one conversation, key for key as the Sessionizer's + * pkg/sessionview defines it and its internal/view builds it, so the document the OAP + * answers with equals the one asz conversation -json prints for the same files. + * + *

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 bytes. */ + 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 rounds; + private final Map files; + private final List problems; + /** Every timed record's moment in nanoseconds, the precision the Sessionizer computes intervals with. */ + private final Map 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 rounds, + final Map files, + final List 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 build() { + final Overview o = overview(); + final Chain chain = chain(); + + final Map 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 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 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> 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 sessions() { + final List out = new ArrayList<>(); + out.add(nullToEmpty(fold.getSession())); + final List 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> rounds = new ArrayList<>(); + final List> roundFiles = new ArrayList<>(); + final List 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 stopped short is incomplete at best. + */ + private Chain chain() { + final Chain chain = new Chain(); + String prevDigest = ""; + String prevInput = ""; + long prevThrough = 0; + long prevRound = 0; + 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) { + // a missing round: this one cannot link to what is not there, and nothing after it folds + chain.incomplete("round " + (prevRound + 1) + " is missing before round " + h.getRound()); + ok = false; + } 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 added = new ArrayList<>(); + for (long seq = h.getFromSeq(); seq <= h.getThroughSeq(); seq++) { + final SessionDataFile f = files.get(seq); + if (f == null) { + chain.incomplete("round " + h.getRound() + ": landed file seq " + seq + " is missing"); + 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 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 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(); + } + if (!problems.isEmpty()) { + chain.problems.addAll(0, problems); + if (STATE_VERIFIED.equals(chain.state)) { + chain.state = STATE_INCOMPLETE; + } + } + return chain; + } + + private List> files() { + final List> out = new ArrayList<>(); + final List 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 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 labelAt = Collections.emptyList(); + Ref replyAt; + SessionFlowRound.Node node; + } + + private static final class Overview { + String title = ""; + Map kinds; + Map relationTypes; + Map quality; + List talks; + List> streams; + List> segments; + } + + private Overview overview() { + final Overview o = new Overview(); + final TreeMap 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 quality = new TreeMap<>(); + final TreeMap 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 childStream = new HashMap<>(); + for (final SessionFlowRound.Node st : streamNodes()) { + if (!"main".equals(st.attr("role"))) { + childStream.put(st.getStream(), true); + } + } + final Map 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 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 labelRefs(final SessionFlowRound.Node t) { + final List 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 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> segmentRows(final List talks) { + final Map span = new HashMap<>(); + final Map 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> out = new ArrayList<>(); + for (final SessionFlowRound.Node n : fold.nodesOfKind("segment")) { + final long[] s = span.getOrDefault(n.getId(), new long[2]); + final Map 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 streamNodes() { + final List 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> streamRows(final List talks) { + final Map firstTalk = new HashMap<>(); + final Map 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 parent = new HashMap<>(); + final Map>> 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 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 journalNames = journalNames(); + final List> 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 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 journalNames() { + final Map names = new HashMap<>(); + final List 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 refuted_claims is absent or a list of objects whose claim 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 strings.Fields, so a no-break space splits too. + */ + private static List fields(final String s) { + final List 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 unicode.IsSpace: 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 strings.TrimSpace. + */ + 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> talks(final Overview o) { + final List> 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 sessionview.Node, empty values absent. A talk carries its row's + * summary keys between the record keys and the tool keys. + */ + private Map step(final SessionFlowRound.Node n, final int depth, @Nullable final TalkRow talk) { + final Map 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> 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 content = new LinkedHashMap<>(); + final Map 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 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> 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> 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 e) -> (String) e.get("id")) + .thenComparing(e -> (String) e.get("dir"))); + for (final Map 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 talks this holds every run and step of the fold. + */ + private List> loose() { + final Map 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 ordered = new ArrayList<>(roots.values()); + ordered.sort(ConversationFold::compare); + final List> out = new ArrayList<>(); + for (final SessionFlowRound.Node n : ordered) { + out.add(step(n, 0, null)); + } + return out; + } + + private static Map edge(final SessionFlowRound.Relation r, final String other, final String dir) { + final Map 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 content, final Map 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 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 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 content, final Map 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> relations() { + final List sorted = new ArrayList<>(fold.getRelations().values()); + sorted.sort(Comparator.comparing(SessionFlowRound.Relation::getId)); + final List> out = new ArrayList<>(); + for (final SessionFlowRound.Relation r : sorted) { + final Map 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> evidence = new ArrayList<>(); + for (final Ref ref : r.getEvidence()) { + evidence.add(ref.toMap()); + } + m.put("evidence", evidence); + } + out.add(m); + } + return out; + } + + private List> unresolved() { + final List sorted = new ArrayList<>(fold.getUnresolved().values()); + sorted.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId)); + final List> out = new ArrayList<>(); + for (final SessionFlowRound.Unresolved u : sorted) { + final Map 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 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 candidates(final SessionDataFile.Record rec) { + final List 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 jsonToMap(final JsonObject json) { + final Map out = new LinkedHashMap<>(); + for (final Map.Entry 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 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 000000000000..8668bd3c690f --- /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 asz.view document as JSON, keys in insertion order and absent values as null, the way + * asz conversation -json 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 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 000000000000..523208a4d914 --- /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 asz.view 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 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 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 000000000000..7538d1742512 --- /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 000000000000..202ce8d15474 --- /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 000000000000..8efb6f92bcbf --- /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 000000000000..db1fab8ab9a4 --- /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,61 @@ +/* + * 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.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import javassist.ClassPool; +import org.apache.skywalking.oap.log.analyzer.v2.compiler.LALClassGenerator; +import org.apache.skywalking.oap.server.ai.agent.conversation.ingest.ConversationFileBuilder; +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; + +/** + * 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. + */ +public class AIAgentLalRuleTest { + private static final Path RULE = Paths.get("..", "..", "server-starter", "src", "main", "resources", "lal", "ai-agent.yaml"); + + @Test + @SuppressWarnings("unchecked") + public void theBundledRuleCompilesAgainstTheBuilder() throws Exception { + assertTrue(Files.exists(RULE), "the bundled rule is at " + RULE.toAbsolutePath()); + final Map yaml = new Yaml().load(new String(Files.readAllBytes(RULE), StandardCharsets.UTF_8)); + final List> rules = (List>) yaml.get("rules"); + assertEquals(1, rules.size()); + final Map 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"); + assertNotNull(generator.compile((String) rule.get("dsl"))); + } +} 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 000000000000..2a7c8eb68b32 --- /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,193 @@ +/* + * 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 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 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 000000000000..de57b580d8c8 --- /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,116 @@ +/* + * 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.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.assertTrue; + +public class ConversationViewBuilderTest { + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + + private static Map view(final byte[] roundBytes, final Map files, + final List problems) { + final SessionFlowRound round = SessionFlowRound.parse(roundBytes); + final ConversationFold fold = new ConversationFold(); + final List rounds = new ArrayList<>(); + final List 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 asz conversation -json + * printed for the same three files and one round. + */ + @Test + public void buildsTheSameDocumentAsTheSessionizer() throws Exception { + final Map 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 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 files = Fixtures.dataFiles(); + files.remove(2L); + final Map incomplete = view(Fixtures.bytes(Fixtures.ROUND_FILE), files, Collections.emptyList()); + final Map summary = (Map) incomplete.get("summary"); + assertEquals("incomplete", summary.get("state")); + assertEquals(Collections.singletonList("round 1: landed file seq 2 is missing"), summary.get("problems")); + final List> rounds = (List>) 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 mismatch = view( + tampered.getBytes(StandardCharsets.UTF_8), Fixtures.dataFiles(), Collections.emptyList()); + assertEquals("mismatch", ((Map) mismatch.get("summary")).get("state")); + assertEquals(0, ((List) mismatch.get("rounds")).size()); + } +} 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 000000000000..2777d402b84d --- /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,69 @@ +/* + * 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.util.Map; +import java.util.TreeMap; +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 tests/scenarios/fixture.yaml, built with + * asz scenario build --format sd --at 2026-01-01T00:00:00Z and parsed: three Session Data files and one + * round, byte for byte as the Sessionizer wrote them, so every digest is real, and the asz.view + * document asz conversation -json 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 dataFiles() throws IOException { + final Map 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)); + } +} 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 000000000000..550754a32f25 --- /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 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 files = Fixtures.dataFiles(); + final List 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 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 \\/ \", \"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 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/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 000000000000..fe8f9518e1f0 --- /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,250 @@ +/* + * 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.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 DOC = fixtureDocument(); + private static final AtomicReference LAST_INSTANCE_ID = new AtomicReference<>(); + + private static final IConversationQueryService STUB = new IConversationQueryService() { + @Override + public ConversationList listConversations(final String serviceId, @Nullable final String serviceInstanceId, + final Duration duration, @Nullable final Integer limit) { + throw new UnsupportedOperationException(); + } + + @Override + @Nullable + public Map 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 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 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 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> 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> 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 java.io.ByteArrayOutputStream bytes = new java.io.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 000000000000..3b53af8fedad --- /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 000000000000..cc1ea3a50f7c --- /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 000000000000..05c92a19a8ad --- /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: |- + + a0a10ef0666c4dc7e + s5-tool + completed + + 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 000000000000..f2e02a48fe83 --- /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 000000000000..be0341107b32 --- /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":3,"steps":21,"streams":2,"segments":1,"unresolved":0} +{"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 000000000000..dde53dce25a3 --- /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\u003e","state":"available","bytes":139}]} +{"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 000000000000..ba70485e95d3 --- /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 8bc703e08f5d..9ba60b726c26 100644 --- a/oap-server/analyzer/pom.xml +++ b/oap-server/analyzer/pom.xml @@ -36,6 +36,7 @@ hierarchy gen-ai-analyzer ai-evaluation + ai-agent-conversation ios-analyzer meter-analyzer-scripts-test 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 eba23be7813d..5cec73183a96 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 000000000000..cea3fb2e6d94 --- /dev/null +++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionDataRecord.java @@ -0,0 +1,138 @@ +/* + * 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 (.sd) file of an AI agent conversation, stored verbatim. + * + *

A file belongs to one session and is named within it by seq. The row keeps only what a + * read filters on: the series id (service, instance, session) and seq. 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. + * + *

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"; + + @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; + @Column(name = BODY, storageOnly = true) + 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 { + @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 000000000000..b93c8b1a1551 --- /dev/null +++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/analysis/manual/aiagent/AIAgentSessionFlowRecord.java @@ -0,0 +1,163 @@ +/* + * 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 (.sf) round of an AI agent conversation, stored verbatim. + * + *

A conversation is an append-only chain of rounds; this row is one of them. The series id is the sender, + * (service, instance), and conversation 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. + * + *

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"; + + @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; + @ElasticSearch.EnableDocValues + @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; + @Column(name = BODY, storageOnly = true) + 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 { + @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 3e1ddbe24f80..2cba81c86450 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 dd0a98d4dddf..423fe033dada 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.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[] services() { 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 d256c40e9158..ad08455aa490 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 @@ enum StreamGroup { 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 000000000000..be03c34253fc --- /dev/null +++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/query/IAIAgentConversationQueryDAO.java @@ -0,0 +1,162 @@ +/* + * 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 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 List queryRoundsByNumberDebuggable(String serviceId, + @Nullable String serviceInstanceId, + String conversation, + long fromRound, + long throughRound) 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); + } finally { + if (traceContext != null && span != null) { + traceContext.stopSpan(span); + } + } + } + + default List queryFilesDebuggable(String serviceId, + @Nullable String serviceInstanceId, + String session, + long fromTimestamp, + long toTimestamp, + long fromSeq, + long throughSeq) 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); + } 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 queryRounds(String serviceId, + @Nullable String serviceInstanceId, + @Nullable String conversation, + @Nullable Duration duration, + int limit, + boolean includeBody) 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 + * @return the rounds of the window, in round order + * @throws IOException on a storage failure + */ + List queryRoundsByNumber(String serviceId, @Nullable String serviceInstanceId, + String conversation, long fromRound, + long throughRound) 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 + * @return the files, seq ascending + * @throws IOException on a storage failure + */ + List queryFiles(String serviceId, + @Nullable String serviceInstanceId, + String session, + long fromTimestamp, + long toTimestamp, + long fromSeq, + long throughSeq) throws IOException; +} 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 82ed6c794949..a1938d80c7b8 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 @@ server-health-checker ${project.version} + + org.apache.skywalking + ai-agent-conversation + ${project.version} + org.apache.skywalking log-analyzer 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 7e95f09c5baa..eb7d8f9e372f 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.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 void prepare() throws ServiceNotProvidedException { ) .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 void notifyAfterCompleted() throws ServiceNotProvidedException { @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 000000000000..db1ebc7d9069 --- /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,124 @@ +/* + * 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 ai-agent-conversation.graphqls. The conversation view is not a GraphQL query; it is + * the module's own HTTP route on the same server, see ConversationViewHandler. + */ +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 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()), + duration, + condition.getLimit() + ); + if (debug) { + list.setDebuggingTrace(traceContext.getExecTrace()); + } + return list; + } finally { + traceContext.stopSpan(span); + traceContext.stopTrace(); + TRACE_CONTEXT.remove(); + } + }); + } + + public CompletableFuture getConversationRawFiles(final ConversationCondition condition, + final List 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 052072c31eb4..38025232831e 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 38025232831e4fd19473b5742832d471a58780d6 diff --git a/oap-server/server-starter/pom.xml b/oap-server/server-starter/pom.xml index d14b6effa6e5..927b230d3661 100644 --- a/oap-server/server-starter/pom.xml +++ b/oap-server/server-starter/pom.xml @@ -65,6 +65,12 @@ ai-evaluation ${project.version} + + + org.apache.skywalking + ai-agent-conversation + ${project.version} + diff --git a/oap-server/server-starter/src/main/resources/application.yml b/oap-server/server-starter/src/main/resources/application.yml index cb8a48c96216..590e3dc6a4ad 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,19 @@ 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} + 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 f7d55ee19e76..26a2d642764a 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 000000000000..3ee6d5cfc8c4 --- /dev/null +++ b/oap-server/server-starter/src/main/resources/lal/ai-agent.yaml @@ -0,0 +1,53 @@ +# 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. +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 + 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 8a8acd06a42e..f08a1e962c05 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 Map dumpConfigurations() { 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 4d576b77100c..ba97928576f5 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 @@ private void loadBaseConfig() throws ModuleStartException { (Map) 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/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 92a4d95df23a..6329fb5164eb 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 static class RecordsZipkinTrace extends BanyanDBStorageConfig.GroupResour 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 b123583d8ea3..a0ff30d9f651 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.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.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 @@ IBatchDAO.class, new BanyanDBBatchDAO(client, config.getGlobal().getMaxBulkSize( 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 d14fb4055d27..fa9221fffdb4 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 SchemaMetadata parseMetadata(Model model, BanyanDBStorageConfig config, D model.getDownsampling(), config.getRecordsBrowserErrorLog() ); + case RECORDS_AI_AGENT: + return new SchemaMetadata( + namespace, + BanyanDB.StreamGroup.RECORDS_AI_AGENT.getName(), + model.getName(), + Kind.STREAM, + model.getDownsampling(), + config.getRecordsAIAgent() + ); case RECORDS: return new SchemaMetadata( namespace, diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/stream/BanyanDBAIAgentConversationQueryDAO.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/stream/BanyanDBAIAgentConversationQueryDAO.java new file mode 100644 index 000000000000..cf9d2b3d5062 --- /dev/null +++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/stream/BanyanDBAIAgentConversationQueryDAO.java @@ -0,0 +1,223 @@ +/* + * 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.storage.plugin.banyandb.stream; + +import com.google.common.collect.ImmutableSet; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import javax.annotation.Nullable; +import org.apache.skywalking.library.banyandb.v1.client.RowEntity; +import org.apache.skywalking.library.banyandb.v1.client.TimestampRange; +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.query.input.Duration; +import org.apache.skywalking.oap.server.core.storage.query.IAIAgentConversationQueryDAO; +import org.apache.skywalking.oap.server.library.util.StringUtil; +import org.apache.skywalking.oap.server.storage.plugin.banyandb.BanyanDBStorageClient; +import org.apache.skywalking.oap.server.storage.plugin.banyandb.MetadataRegistry; + +/** + * Both reads are series lookups: the rounds by (service, instance) with conversation as an + * indexed tag, the files by (service, instance, session) with a range on the indexed seq. + * A missing instance is a partial series match, the way the log query works by service alone. + * + *

A read that is not bound to a duration covers every retained stage: the default stages and, when the group + * has one, the cold stage, in two queries merged here. The list page alone follows its duration's stage. + */ +public class BanyanDBAIAgentConversationQueryDAO extends AbstractBanyanDBDAO implements IAIAgentConversationQueryDAO { + private static final Set ROUND_TAGS = ImmutableSet.of( + AIAgentSessionFlowRecord.SERVICE_ID, + AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID, + AIAgentSessionFlowRecord.CONVERSATION, + AIAgentSessionFlowRecord.ROUND, + AIAgentSessionFlowRecord.SESSION_FROM_TIME, + AIAgentSessionFlowRecord.TITLE, + AIAgentSessionFlowRecord.TALKS, + AIAgentSessionFlowRecord.STEPS, + AIAgentSessionFlowRecord.STREAMS, + AIAgentSessionFlowRecord.SEGMENTS, + AIAgentSessionFlowRecord.UNRESOLVED, + AIAgentSessionFlowRecord.DIGEST, + AIAgentSessionFlowRecord.TIMESTAMP + ); + private static final Set ROUND_TAGS_WITH_BODY = ImmutableSet.builder() + .addAll(ROUND_TAGS) + .add(AIAgentSessionFlowRecord.BODY) + .build(); + private static final Set FILE_TAGS = ImmutableSet.of( + AIAgentSessionDataRecord.SERVICE_ID, + AIAgentSessionDataRecord.SERVICE_INSTANCE_ID, + AIAgentSessionDataRecord.SESSION, + AIAgentSessionDataRecord.SEQ, + AIAgentSessionDataRecord.DIGEST, + AIAgentSessionDataRecord.TIMESTAMP, + AIAgentSessionDataRecord.BODY + ); + /** + * A file is stamped with a record time, which is at or before the moment it lands; a round with the + * conversation's last activity. A read over "everything retained" therefore ends a little after now. + */ + private static final long CLOCK_SKEW_MILLIS = 60_000L; + + public BanyanDBAIAgentConversationQueryDAO(final BanyanDBStorageClient client) { + super(client); + } + + @Override + public List queryRounds(final String serviceId, + @Nullable final String serviceInstanceId, + @Nullable final String conversation, + @Nullable final Duration duration, + final int limit, + final boolean includeBody) throws IOException { + final Conditions where = Conditions.create(); + where.eq(AIAgentSessionFlowRecord.SERVICE_ID, serviceId); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + where.eq(AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID, serviceInstanceId); + } + if (StringUtil.isNotEmpty(conversation)) { + where.eq(AIAgentSessionFlowRecord.CONVERSATION, conversation); + } + where.orderByDesc().limit(limit); + final Set tags = includeBody ? ROUND_TAGS_WITH_BODY : ROUND_TAGS; + final List rows; + if (duration == null) { + rows = everyStage(AIAgentSessionFlowRecord.INDEX_NAME, tags, everythingRetained(), where); + } else { + rows = queryDebuggable( + duration.isColdStage(), AIAgentSessionFlowRecord.INDEX_NAME, tags, getTimestampRange(duration), where + ).getElements(); + } + final List rounds = rounds(rows, includeBody); + rounds.sort((a, b) -> Long.compare(b.getTimestamp(), a.getTimestamp())); + return rounds.size() > limit ? new ArrayList<>(rounds.subList(0, limit)) : rounds; + } + + @Override + public List queryRoundsByNumber(final String serviceId, + @Nullable final String serviceInstanceId, + final String conversation, + final long fromRound, + final long throughRound) throws IOException { + final Conditions where = Conditions.create(); + where.eq(AIAgentSessionFlowRecord.SERVICE_ID, serviceId); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + where.eq(AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID, serviceInstanceId); + } + where.eq(AIAgentSessionFlowRecord.CONVERSATION, conversation); + where.gte(AIAgentSessionFlowRecord.ROUND, fromRound); + where.lte(AIAgentSessionFlowRecord.ROUND, throughRound); + // every row of the window, up to the client's result window: a round two senders both pushed is there twice + where.orderByAsc(); + final List rounds = rounds( + everyStage(AIAgentSessionFlowRecord.INDEX_NAME, ROUND_TAGS_WITH_BODY, everythingRetained(), where), true); + rounds.sort((a, b) -> Long.compare(a.getRound(), b.getRound())); + return rounds; + } + + private static List rounds(final List rows, final boolean includeBody) { + final List rounds = new ArrayList<>(rows.size()); + for (final RowEntity row : rows) { + final AIAgentSessionFlowRecord record = new AIAgentSessionFlowRecord(); + record.setServiceId(row.getTagValue(AIAgentSessionFlowRecord.SERVICE_ID)); + record.setServiceInstanceId(row.getTagValue(AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID)); + record.setConversation(row.getTagValue(AIAgentSessionFlowRecord.CONVERSATION)); + record.setRound(longOf(row.getTagValue(AIAgentSessionFlowRecord.ROUND))); + record.setSessionFromTime(longOf(row.getTagValue(AIAgentSessionFlowRecord.SESSION_FROM_TIME))); + record.setTitle(row.getTagValue(AIAgentSessionFlowRecord.TITLE)); + record.setTalks(longOf(row.getTagValue(AIAgentSessionFlowRecord.TALKS))); + record.setSteps(longOf(row.getTagValue(AIAgentSessionFlowRecord.STEPS))); + record.setStreams(longOf(row.getTagValue(AIAgentSessionFlowRecord.STREAMS))); + record.setSegments(longOf(row.getTagValue(AIAgentSessionFlowRecord.SEGMENTS))); + record.setUnresolved(longOf(row.getTagValue(AIAgentSessionFlowRecord.UNRESOLVED))); + record.setDigest(row.getTagValue(AIAgentSessionFlowRecord.DIGEST)); + final long timestamp = longOf(row.getTagValue(AIAgentSessionFlowRecord.TIMESTAMP)); + record.setTimestamp(timestamp); + record.setTimeBucket(TimeBucket.getRecordTimeBucket(timestamp)); + if (includeBody) { + record.setBody(row.getTagValue(AIAgentSessionFlowRecord.BODY)); + } + rounds.add(record); + } + return rounds; + } + + @Override + public List queryFiles(final String serviceId, + @Nullable final String serviceInstanceId, + final String session, + final long fromTimestamp, + final long toTimestamp, + final long fromSeq, + final long throughSeq) throws IOException { + final Conditions where = Conditions.create(); + where.eq(AIAgentSessionDataRecord.SERVICE_ID, serviceId); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + where.eq(AIAgentSessionDataRecord.SERVICE_INSTANCE_ID, serviceInstanceId); + } + where.eq(AIAgentSessionDataRecord.SESSION, session); + where.gte(AIAgentSessionDataRecord.SEQ, fromSeq); + where.lte(AIAgentSessionDataRecord.SEQ, throughSeq); + where.orderByAsc(); + final List rows = everyStage( + AIAgentSessionDataRecord.INDEX_NAME, FILE_TAGS, + new TimestampRange(Math.max(0, fromTimestamp - 1), toTimestamp + 1), where); + final List files = new ArrayList<>(rows.size()); + for (final RowEntity row : rows) { + final AIAgentSessionDataRecord record = new AIAgentSessionDataRecord(); + record.setServiceId(row.getTagValue(AIAgentSessionDataRecord.SERVICE_ID)); + record.setServiceInstanceId(row.getTagValue(AIAgentSessionDataRecord.SERVICE_INSTANCE_ID)); + record.setSession(row.getTagValue(AIAgentSessionDataRecord.SESSION)); + record.setSeq(longOf(row.getTagValue(AIAgentSessionDataRecord.SEQ))); + record.setDigest(row.getTagValue(AIAgentSessionDataRecord.DIGEST)); + final long timestamp = longOf(row.getTagValue(AIAgentSessionDataRecord.TIMESTAMP)); + record.setTimestamp(timestamp); + record.setTimeBucket(TimeBucket.getRecordTimeBucket(timestamp)); + record.setBody(row.getTagValue(AIAgentSessionDataRecord.BODY)); + files.add(record); + } + files.sort((a, b) -> Long.compare(a.getSeq(), b.getSeq())); + return files; + } + + private static long longOf(final Object value) { + return value == null ? 0L : ((Number) value).longValue(); + } + + private static TimestampRange everythingRetained() { + return new TimestampRange(0, System.currentTimeMillis() + CLOCK_SKEW_MILLIS); + } + + /** + * The rows of the default stages and, when the group keeps one, of the cold stage: a conversation can span + * the two, and its list row may come from either. + */ + private List everyStage(final String model, final Set tags, final TimestampRange range, + final Conditions where) throws IOException { + final List rows = new ArrayList<>(queryDebuggable(false, model, tags, range, where).getElements()); + final MetadataRegistry.Schema schema = MetadataRegistry.INSTANCE.findRecordMetadata(model); + if (schema != null && schema.getMetadata().getResource().isEnableColdStage()) { + rows.addAll(queryDebuggable(true, model, tags, range, where).getElements()); + } + return rows; + } +} diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/test/resources/bydb.yml b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/test/resources/bydb.yml index 75fe379e28e3..7032cb63f159 100644 --- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/test/resources/bydb.yml +++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/test/resources/bydb.yml @@ -194,6 +194,25 @@ 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"} + 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-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/StorageModuleElasticsearchProvider.java b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/StorageModuleElasticsearchProvider.java index e6856e2d4c26..dffd37211806 100644 --- a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/StorageModuleElasticsearchProvider.java +++ b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/StorageModuleElasticsearchProvider.java @@ -55,6 +55,7 @@ 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; @@ -94,6 +95,7 @@ import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.EBPFProfilingTaskEsDAO; import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.ESEventQueryDAO; import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.HierarchyQueryEsDAO; +import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.AIAgentConversationQueryEsDAO; import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.GenAIEvaluationRecordQueryEsDAO; import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.JFRDataQueryEsDAO; import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.query.PprofDataQueryEsDAO; @@ -245,6 +247,8 @@ public void prepare() throws ServiceNotProvidedException { this.registerServiceImplementation(ILogQueryDAO.class, new LogQueryEsDAO(elasticSearchClient)); this.registerServiceImplementation( IGenAIEvaluationRecordQueryDAO.class, new GenAIEvaluationRecordQueryEsDAO(elasticSearchClient)); + this.registerServiceImplementation( + IAIAgentConversationQueryDAO.class, new AIAgentConversationQueryEsDAO(elasticSearchClient)); this.registerServiceImplementation( IProfileTaskQueryDAO.class, new ProfileTaskQueryEsDAO(elasticSearchClient, config .getProfileTaskQueryMaxSize())); diff --git a/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/AIAgentConversationQueryEsDAO.java b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/AIAgentConversationQueryEsDAO.java new file mode 100644 index 000000000000..b9380083ef56 --- /dev/null +++ b/oap-server/server-storage-plugin/storage-elasticsearch-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/elasticsearch/query/AIAgentConversationQueryEsDAO.java @@ -0,0 +1,242 @@ +/* + * 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.storage.plugin.elasticsearch.query; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.apache.skywalking.library.elasticsearch.requests.search.BoolQueryBuilder; +import org.apache.skywalking.library.elasticsearch.requests.search.Query; +import org.apache.skywalking.library.elasticsearch.requests.search.Search; +import org.apache.skywalking.library.elasticsearch.requests.search.SearchBuilder; +import org.apache.skywalking.library.elasticsearch.requests.search.Sort; +import org.apache.skywalking.library.elasticsearch.response.search.SearchHit; +import org.apache.skywalking.library.elasticsearch.response.search.SearchResponse; +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.query.input.Duration; +import org.apache.skywalking.oap.server.core.storage.query.IAIAgentConversationQueryDAO; +import org.apache.skywalking.oap.server.library.client.elasticsearch.ElasticSearchClient; +import org.apache.skywalking.oap.server.library.util.StringUtil; +import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.base.EsDAO; +import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.base.IndexController; +import org.apache.skywalking.oap.server.storage.plugin.elasticsearch.base.TimeRangeIndexNameGenerator; + +/** + * Both models are super datasets, so each has its own index family; the queries are term filters on the + * series-id columns plus a range on seq or on the timestamp. The list read leaves the body out of the + * returned source. + */ +public class AIAgentConversationQueryEsDAO extends EsDAO implements IAIAgentConversationQueryDAO { + /** Elasticsearch's default result window; a seq or round window of 16 is far below it, copies included. */ + private static final int RESULT_WINDOW = 10_000; + private static final String[] ROUND_FIELDS = { + AIAgentSessionFlowRecord.SERVICE_ID, + AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID, + AIAgentSessionFlowRecord.CONVERSATION, + AIAgentSessionFlowRecord.ROUND, + AIAgentSessionFlowRecord.SESSION_FROM_TIME, + AIAgentSessionFlowRecord.TITLE, + AIAgentSessionFlowRecord.TALKS, + AIAgentSessionFlowRecord.STEPS, + AIAgentSessionFlowRecord.STREAMS, + AIAgentSessionFlowRecord.SEGMENTS, + AIAgentSessionFlowRecord.UNRESOLVED, + AIAgentSessionFlowRecord.DIGEST, + AIAgentSessionFlowRecord.TIMESTAMP + }; + + public AIAgentConversationQueryEsDAO(final ElasticSearchClient client) { + super(client); + } + + @Override + public List queryRounds(final String serviceId, + @Nullable final String serviceInstanceId, + @Nullable final String conversation, + @Nullable final Duration duration, + final int limit, + final boolean includeBody) throws IOException { + final BoolQueryBuilder query = Query.bool(); + if (IndexController.LogicIndicesRegister.isMergedTable(AIAgentSessionFlowRecord.INDEX_NAME)) { + query.must(Query.term( + IndexController.LogicIndicesRegister.RECORD_TABLE_NAME, AIAgentSessionFlowRecord.INDEX_NAME)); + } + query.must(Query.term(AIAgentSessionFlowRecord.SERVICE_ID, serviceId)); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + query.must(Query.term(AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID, serviceInstanceId)); + } + if (StringUtil.isNotEmpty(conversation)) { + query.must(Query.term(AIAgentSessionFlowRecord.CONVERSATION, conversation)); + } + long startSecondTB = 0; + long endSecondTB = 0; + if (duration != null) { + startSecondTB = duration.getStartTimeBucketInSec(); + endSecondTB = duration.getEndTimeBucketInSec(); + query.must(Query.range(Record.TIME_BUCKET).gte(startSecondTB).lte(endSecondTB)); + } + final SearchBuilder search = Search.builder() + .query(query) + .sort(AIAgentSessionFlowRecord.TIMESTAMP, Sort.Order.DESC) + .size(limit); + if (!includeBody) { + for (final String field : ROUND_FIELDS) { + search.source(field); + } + } + final SearchResponse response = searchDebuggable( + new TimeRangeIndexNameGenerator( + IndexController.LogicIndicesRegister.getPhysicalTableName(AIAgentSessionFlowRecord.INDEX_NAME), + startSecondTB, endSecondTB + ), + search.build() + ); + return rounds(response, includeBody); + } + + @Override + public List queryRoundsByNumber(final String serviceId, + @Nullable final String serviceInstanceId, + final String conversation, + final long fromRound, + final long throughRound) throws IOException { + final BoolQueryBuilder query = Query.bool(); + if (IndexController.LogicIndicesRegister.isMergedTable(AIAgentSessionFlowRecord.INDEX_NAME)) { + query.must(Query.term( + IndexController.LogicIndicesRegister.RECORD_TABLE_NAME, AIAgentSessionFlowRecord.INDEX_NAME)); + } + query.must(Query.term(AIAgentSessionFlowRecord.SERVICE_ID, serviceId)); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + query.must(Query.term(AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID, serviceInstanceId)); + } + query.must(Query.term(AIAgentSessionFlowRecord.CONVERSATION, conversation)); + query.must(Query.range(AIAgentSessionFlowRecord.ROUND).gte(fromRound).lte(throughRound)); + final SearchBuilder search = Search.builder() + .query(query) + .sort(AIAgentSessionFlowRecord.ROUND, Sort.Order.ASC) + // every row of the window: a round two senders both pushed is there twice + .size(RESULT_WINDOW); + // every index of the family: the rounds of a long conversation span days + final SearchResponse response = searchDebuggable( + new TimeRangeIndexNameGenerator( + IndexController.LogicIndicesRegister.getPhysicalTableName(AIAgentSessionFlowRecord.INDEX_NAME), 0, 0), + search.build() + ); + return rounds(response, true); + } + + private static List rounds(final SearchResponse response, final boolean includeBody) { + final List rounds = new ArrayList<>(); + for (final SearchHit hit : response.getHits().getHits()) { + final Map source = hit.getSource(); + final AIAgentSessionFlowRecord record = new AIAgentSessionFlowRecord(); + record.setServiceId((String) source.get(AIAgentSessionFlowRecord.SERVICE_ID)); + record.setServiceInstanceId((String) source.get(AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID)); + record.setConversation((String) source.get(AIAgentSessionFlowRecord.CONVERSATION)); + record.setRound(longOf(source.get(AIAgentSessionFlowRecord.ROUND))); + record.setSessionFromTime(longOf(source.get(AIAgentSessionFlowRecord.SESSION_FROM_TIME))); + record.setTitle((String) source.get(AIAgentSessionFlowRecord.TITLE)); + record.setTalks(longOf(source.get(AIAgentSessionFlowRecord.TALKS))); + record.setSteps(longOf(source.get(AIAgentSessionFlowRecord.STEPS))); + record.setStreams(longOf(source.get(AIAgentSessionFlowRecord.STREAMS))); + record.setSegments(longOf(source.get(AIAgentSessionFlowRecord.SEGMENTS))); + record.setUnresolved(longOf(source.get(AIAgentSessionFlowRecord.UNRESOLVED))); + record.setDigest((String) source.get(AIAgentSessionFlowRecord.DIGEST)); + final long timestamp = longOf(source.get(AIAgentSessionFlowRecord.TIMESTAMP)); + record.setTimestamp(timestamp); + record.setTimeBucket(TimeBucket.getRecordTimeBucket(timestamp)); + if (includeBody) { + record.setBody(bytesOf(source.get(AIAgentSessionFlowRecord.BODY))); + } + rounds.add(record); + } + return rounds; + } + + @Override + public List queryFiles(final String serviceId, + @Nullable final String serviceInstanceId, + final String session, + final long fromTimestamp, + final long toTimestamp, + final long fromSeq, + final long throughSeq) throws IOException { + final BoolQueryBuilder query = Query.bool(); + if (IndexController.LogicIndicesRegister.isMergedTable(AIAgentSessionDataRecord.INDEX_NAME)) { + query.must(Query.term( + IndexController.LogicIndicesRegister.RECORD_TABLE_NAME, AIAgentSessionDataRecord.INDEX_NAME)); + } + query.must(Query.term(AIAgentSessionDataRecord.SERVICE_ID, serviceId)); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + query.must(Query.term(AIAgentSessionDataRecord.SERVICE_INSTANCE_ID, serviceInstanceId)); + } + query.must(Query.term(AIAgentSessionDataRecord.SESSION, session)); + query.must(Query.range(AIAgentSessionDataRecord.SEQ).gte(fromSeq).lte(throughSeq)); + query.must(Query.range(AIAgentSessionDataRecord.TIMESTAMP).gte(fromTimestamp).lte(toTimestamp)); + final long startSecondTB = TimeBucket.getRecordTimeBucket(fromTimestamp); + final long endSecondTB = TimeBucket.getRecordTimeBucket(toTimestamp); + final SearchBuilder search = Search.builder() + .query(query) + .sort(AIAgentSessionDataRecord.SEQ, Sort.Order.ASC) + .size(RESULT_WINDOW); + final SearchResponse response = searchDebuggable( + new TimeRangeIndexNameGenerator( + IndexController.LogicIndicesRegister.getPhysicalTableName(AIAgentSessionDataRecord.INDEX_NAME), + startSecondTB, endSecondTB + ), + search.build() + ); + final List files = new ArrayList<>(); + for (final SearchHit hit : response.getHits().getHits()) { + final Map source = hit.getSource(); + final AIAgentSessionDataRecord record = new AIAgentSessionDataRecord(); + record.setServiceId((String) source.get(AIAgentSessionDataRecord.SERVICE_ID)); + record.setServiceInstanceId((String) source.get(AIAgentSessionDataRecord.SERVICE_INSTANCE_ID)); + record.setSession((String) source.get(AIAgentSessionDataRecord.SESSION)); + record.setSeq(longOf(source.get(AIAgentSessionDataRecord.SEQ))); + record.setDigest((String) source.get(AIAgentSessionDataRecord.DIGEST)); + final long timestamp = longOf(source.get(AIAgentSessionDataRecord.TIMESTAMP)); + record.setTimestamp(timestamp); + record.setTimeBucket(TimeBucket.getRecordTimeBucket(timestamp)); + record.setBody(bytesOf(source.get(AIAgentSessionDataRecord.BODY))); + files.add(record); + } + return files; + } + + private static long longOf(final Object value) { + return value == null ? 0L : ((Number) value).longValue(); + } + + /** + * A byte[] column is written as base64 text, the way the segment and browser log bodies are. + */ + private static byte[] bytesOf(final Object value) { + if (value == null) { + return null; + } + return Base64.getDecoder().decode((String) value); + } +} diff --git a/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/JDBCStorageProvider.java b/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/JDBCStorageProvider.java index 43a80b1d558e..130e63857526 100644 --- a/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/JDBCStorageProvider.java +++ b/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/JDBCStorageProvider.java @@ -48,6 +48,7 @@ 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; @@ -80,6 +81,7 @@ import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.dao.JDBCEBPFProfilingScheduleDAO; import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.dao.JDBCEBPFProfilingTaskDAO; import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.dao.JDBCEventQueryDAO; +import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.dao.JDBCAIAgentConversationQueryDAO; import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.dao.JDBCGenAIEvaluationRecordQueryDAO; import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.dao.JDBCHierarchyQueryDAO; import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.dao.JDBCHistoryDeleteDAO; @@ -203,6 +205,9 @@ public void prepare() throws ServiceNotProvidedException, ModuleStartException { this.registerServiceImplementation( IGenAIEvaluationRecordQueryDAO.class, new JDBCGenAIEvaluationRecordQueryDAO(jdbcClient, tableHelper)); + this.registerServiceImplementation( + IAIAgentConversationQueryDAO.class, + new JDBCAIAgentConversationQueryDAO(jdbcClient, tableHelper)); this.registerServiceImplementation( IProfileTaskQueryDAO.class, diff --git a/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/dao/JDBCAIAgentConversationQueryDAO.java b/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/dao/JDBCAIAgentConversationQueryDAO.java new file mode 100644 index 000000000000..55a8c38319af --- /dev/null +++ b/oap-server/server-storage-plugin/storage-jdbc-hikaricp-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/jdbc/common/dao/JDBCAIAgentConversationQueryDAO.java @@ -0,0 +1,298 @@ +/* + * 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.storage.plugin.jdbc.common.dao; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.apache.skywalking.oap.server.core.analysis.DownSampling; +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.query.input.Duration; +import org.apache.skywalking.oap.server.core.storage.model.ColumnName; +import org.apache.skywalking.oap.server.core.storage.model.ModelColumn; +import org.apache.skywalking.oap.server.core.storage.query.IAIAgentConversationQueryDAO; +import org.apache.skywalking.oap.server.library.client.jdbc.hikaricp.JDBCClient; +import org.apache.skywalking.oap.server.library.util.StringUtil; +import org.apache.skywalking.oap.server.storage.plugin.jdbc.TableMetaInfo; +import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.JDBCTableInstaller; +import org.apache.skywalking.oap.server.storage.plugin.jdbc.common.TableHelper; + +import static java.util.stream.Collectors.joining; + +/** + * Every query condition is a WHERE clause on a plain column. Both models are super datasets, so each + * has its own table per day; a read spans the tables of the range and merges in memory. + */ +@RequiredArgsConstructor +public class JDBCAIAgentConversationQueryDAO implements IAIAgentConversationQueryDAO { + private static final List ROUND_COLUMNS = List.of( + AIAgentSessionFlowRecord.SERVICE_ID, + AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID, + AIAgentSessionFlowRecord.CONVERSATION, + AIAgentSessionFlowRecord.ROUND, + AIAgentSessionFlowRecord.SESSION_FROM_TIME, + AIAgentSessionFlowRecord.TITLE, + AIAgentSessionFlowRecord.TALKS, + AIAgentSessionFlowRecord.STEPS, + AIAgentSessionFlowRecord.STREAMS, + AIAgentSessionFlowRecord.SEGMENTS, + AIAgentSessionFlowRecord.UNRESOLVED, + AIAgentSessionFlowRecord.DIGEST, + AIAgentSessionFlowRecord.TIMESTAMP + ); + private static final List FILE_COLUMNS = List.of( + AIAgentSessionDataRecord.SERVICE_ID, + AIAgentSessionDataRecord.SERVICE_INSTANCE_ID, + AIAgentSessionDataRecord.SESSION, + AIAgentSessionDataRecord.SEQ, + AIAgentSessionDataRecord.DIGEST, + AIAgentSessionDataRecord.TIMESTAMP, + AIAgentSessionDataRecord.BODY + ); + + private final JDBCClient jdbcClient; + private final TableHelper tableHelper; + + @Override + @SneakyThrows + public List queryRounds(final String serviceId, + @Nullable final String serviceInstanceId, + @Nullable final String conversation, + @Nullable final Duration duration, + final int limit, + final boolean includeBody) { + final List tables = duration == null + ? tableHelper.getTablesWithinTTL(AIAgentSessionFlowRecord.INDEX_NAME) + : tableHelper.getTablesForRead( + AIAgentSessionFlowRecord.INDEX_NAME, duration.getStartTimeBucket(), duration.getEndTimeBucket()); + final List columns = new ArrayList<>(ROUND_COLUMNS); + if (includeBody) { + columns.add(AIAgentSessionFlowRecord.BODY); + } + final List rounds = new ArrayList<>(); + for (final String table : tables) { + final StringBuilder sql = new StringBuilder("select "); + final List parameters = new ArrayList<>(); + sql.append(select(AIAgentSessionFlowRecord.INDEX_NAME, columns)) + .append(" from ").append(table) + .append(" where ").append(JDBCTableInstaller.TABLE_COLUMN).append(" = ?"); + parameters.add(AIAgentSessionFlowRecord.INDEX_NAME); + if (duration != null) { + sql.append(" and ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, Record.TIME_BUCKET)) + .append(" >= ?"); + parameters.add(duration.getStartTimeBucketInSec()); + sql.append(" and ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, Record.TIME_BUCKET)) + .append(" <= ?"); + parameters.add(duration.getEndTimeBucketInSec()); + } + sql.append(" and ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.SERVICE_ID)) + .append(" = ?"); + parameters.add(serviceId); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + sql.append(" and ") + .append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID)) + .append(" = ?"); + parameters.add(serviceInstanceId); + } + if (StringUtil.isNotEmpty(conversation)) { + sql.append(" and ") + .append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.CONVERSATION)) + .append(" = ?"); + parameters.add(conversation); + } + sql.append(" order by ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.TIMESTAMP)) + .append(" desc limit ").append(limit); + rounds.addAll(jdbcClient.executeQuery(sql.toString(), rs -> parseRounds(rs, includeBody), parameters.toArray())); + } + rounds.sort(Comparator.comparingLong(AIAgentSessionFlowRecord::getTimestamp).reversed()); + return rounds.size() > limit ? new ArrayList<>(rounds.subList(0, limit)) : rounds; + } + + @Override + @SneakyThrows + public List queryRoundsByNumber(final String serviceId, + @Nullable final String serviceInstanceId, + final String conversation, + final long fromRound, + final long throughRound) { + final List columns = new ArrayList<>(ROUND_COLUMNS); + columns.add(AIAgentSessionFlowRecord.BODY); + final List rounds = new ArrayList<>(); + // every table within the TTL: the rounds of a long conversation span days + for (final String table : tableHelper.getTablesWithinTTL(AIAgentSessionFlowRecord.INDEX_NAME)) { + final StringBuilder sql = new StringBuilder("select "); + final List parameters = new ArrayList<>(); + sql.append(select(AIAgentSessionFlowRecord.INDEX_NAME, columns)) + .append(" from ").append(table) + .append(" where ").append(JDBCTableInstaller.TABLE_COLUMN).append(" = ?"); + parameters.add(AIAgentSessionFlowRecord.INDEX_NAME); + sql.append(" and ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.SERVICE_ID)) + .append(" = ?"); + parameters.add(serviceId); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + sql.append(" and ") + .append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID)) + .append(" = ?"); + parameters.add(serviceInstanceId); + } + sql.append(" and ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.CONVERSATION)) + .append(" = ?"); + parameters.add(conversation); + sql.append(" and ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.ROUND)) + .append(" >= ? and ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.ROUND)) + .append(" <= ?"); + parameters.add(fromRound); + parameters.add(throughRound); + sql.append(" order by ").append(column(AIAgentSessionFlowRecord.INDEX_NAME, AIAgentSessionFlowRecord.ROUND)) + .append(" asc"); + rounds.addAll(jdbcClient.executeQuery(sql.toString(), rs -> parseRounds(rs, true), parameters.toArray())); + } + rounds.sort(Comparator.comparingLong(AIAgentSessionFlowRecord::getRound)); + return rounds; + } + + @Override + @SneakyThrows + public List queryFiles(final String serviceId, + @Nullable final String serviceInstanceId, + final String session, + final long fromTimestamp, + final long toTimestamp, + final long fromSeq, + final long throughSeq) { + final List tables = tableHelper.getTablesForRead( + AIAgentSessionDataRecord.INDEX_NAME, + TimeBucket.getTimeBucket(fromTimestamp, DownSampling.Day), + TimeBucket.getTimeBucket(toTimestamp, DownSampling.Day) + ); + final List files = new ArrayList<>(); + for (final String table : tables) { + final StringBuilder sql = new StringBuilder("select "); + final List parameters = new ArrayList<>(); + sql.append(select(AIAgentSessionDataRecord.INDEX_NAME, FILE_COLUMNS)) + .append(" from ").append(table) + .append(" where ").append(JDBCTableInstaller.TABLE_COLUMN).append(" = ?"); + parameters.add(AIAgentSessionDataRecord.INDEX_NAME); + sql.append(" and ").append(column(AIAgentSessionDataRecord.INDEX_NAME, AIAgentSessionDataRecord.SERVICE_ID)) + .append(" = ?"); + parameters.add(serviceId); + if (StringUtil.isNotEmpty(serviceInstanceId)) { + sql.append(" and ") + .append(column(AIAgentSessionDataRecord.INDEX_NAME, AIAgentSessionDataRecord.SERVICE_INSTANCE_ID)) + .append(" = ?"); + parameters.add(serviceInstanceId); + } + sql.append(" and ").append(column(AIAgentSessionDataRecord.INDEX_NAME, AIAgentSessionDataRecord.SESSION)) + .append(" = ?"); + parameters.add(session); + sql.append(" and ").append(column(AIAgentSessionDataRecord.INDEX_NAME, AIAgentSessionDataRecord.SEQ)) + .append(" >= ? and ").append(column(AIAgentSessionDataRecord.INDEX_NAME, AIAgentSessionDataRecord.SEQ)) + .append(" <= ?"); + parameters.add(fromSeq); + parameters.add(throughSeq); + sql.append(" and ").append(column(AIAgentSessionDataRecord.INDEX_NAME, AIAgentSessionDataRecord.TIMESTAMP)) + .append(" >= ? and ").append(column(AIAgentSessionDataRecord.INDEX_NAME, AIAgentSessionDataRecord.TIMESTAMP)) + .append(" <= ?"); + parameters.add(fromTimestamp); + parameters.add(toTimestamp); + sql.append(" order by ").append(column(AIAgentSessionDataRecord.INDEX_NAME, AIAgentSessionDataRecord.SEQ)) + .append(" asc"); + files.addAll(jdbcClient.executeQuery(sql.toString(), this::parseFiles, parameters.toArray())); + } + files.sort(Comparator.comparingLong(AIAgentSessionDataRecord::getSeq)); + return files; + } + + private List parseRounds(final ResultSet resultSet, final boolean includeBody) + throws SQLException { + final List rounds = new ArrayList<>(); + while (resultSet.next()) { + final AIAgentSessionFlowRecord record = new AIAgentSessionFlowRecord(); + record.setServiceId(resultSet.getString(AIAgentSessionFlowRecord.SERVICE_ID)); + record.setServiceInstanceId(resultSet.getString(AIAgentSessionFlowRecord.SERVICE_INSTANCE_ID)); + record.setConversation(resultSet.getString(AIAgentSessionFlowRecord.CONVERSATION)); + record.setRound(resultSet.getLong(AIAgentSessionFlowRecord.ROUND)); + record.setSessionFromTime(resultSet.getLong(AIAgentSessionFlowRecord.SESSION_FROM_TIME)); + record.setTitle(resultSet.getString(AIAgentSessionFlowRecord.TITLE)); + record.setTalks(resultSet.getLong(AIAgentSessionFlowRecord.TALKS)); + record.setSteps(resultSet.getLong(AIAgentSessionFlowRecord.STEPS)); + record.setStreams(resultSet.getLong(AIAgentSessionFlowRecord.STREAMS)); + record.setSegments(resultSet.getLong(AIAgentSessionFlowRecord.SEGMENTS)); + record.setUnresolved(resultSet.getLong(AIAgentSessionFlowRecord.UNRESOLVED)); + record.setDigest(resultSet.getString(AIAgentSessionFlowRecord.DIGEST)); + final long timestamp = resultSet.getLong(AIAgentSessionFlowRecord.TIMESTAMP); + record.setTimestamp(timestamp); + record.setTimeBucket(TimeBucket.getRecordTimeBucket(timestamp)); + if (includeBody) { + record.setBody(bytesOf(resultSet.getString(AIAgentSessionFlowRecord.BODY))); + } + rounds.add(record); + } + return rounds; + } + + private List parseFiles(final ResultSet resultSet) throws SQLException { + final List files = new ArrayList<>(); + while (resultSet.next()) { + final AIAgentSessionDataRecord record = new AIAgentSessionDataRecord(); + record.setServiceId(resultSet.getString(AIAgentSessionDataRecord.SERVICE_ID)); + record.setServiceInstanceId(resultSet.getString(AIAgentSessionDataRecord.SERVICE_INSTANCE_ID)); + record.setSession(resultSet.getString(AIAgentSessionDataRecord.SESSION)); + record.setSeq(resultSet.getLong(AIAgentSessionDataRecord.SEQ)); + record.setDigest(resultSet.getString(AIAgentSessionDataRecord.DIGEST)); + final long timestamp = resultSet.getLong(AIAgentSessionDataRecord.TIMESTAMP); + record.setTimestamp(timestamp); + record.setTimeBucket(TimeBucket.getRecordTimeBucket(timestamp)); + record.setBody(bytesOf(resultSet.getString(AIAgentSessionDataRecord.BODY))); + files.add(record); + } + return files; + } + + /** + * A byte[] column is written as base64 text, the way the segment body is. + */ + private static byte[] bytesOf(final String value) { + return StringUtil.isEmpty(value) ? null : Base64.getDecoder().decode(value); + } + + private static String select(final String model, final List columns) { + return columns.stream().map(c -> column(model, c)).collect(joining(", ")); + } + + private static String column(final String model, final String logicalColumn) { + return TableMetaInfo.get(model) + .getColumns() + .stream() + .map(ModelColumn::getColumnName) + .filter(it -> logicalColumn.equals(it.getName())) + .findFirst() + .map(ColumnName::getStorageName) + .orElse(logicalColumn); + } +} diff --git a/oap-server/server-tools/data-generator/src/main/resources/application.yml b/oap-server/server-tools/data-generator/src/main/resources/application.yml index d4c2861e7edc..99a89c296889 100755 --- a/oap-server/server-tools/data-generator/src/main/resources/application.yml +++ b/oap-server/server-tools/data-generator/src/main/resources/application.yml @@ -184,6 +184,15 @@ event-analyzer: selector: ${SW_EVENT_ANALYZER:default} default: +# The GraphQL query module requires the AI agent conversation module. +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} + query: selector: ${SW_QUERY:graphql} graphql: diff --git a/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml b/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml new file mode 100644 index 000000000000..aee96b2ecc5f --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml @@ -0,0 +1,46 @@ +# 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. + +# The verify cases of every storage's ai-agent e2e. The reads go through swctl's ai-agent commands, so the +# CLI pinned in script/env is proven against this OAP too; the paths are from the repository root, the +# expected files beside this file. +cases: + # the sender's service and instance, under the AI_AGENT layer + - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql service layer AI_AGENT + expected: expected/service.yml + - query: swctl --display yaml --base-url=http://${oap_host}:${oap_12800}/graphql instance list --service-name="e2e-ai-agent" + expected: expected/instance.yml + # the list page: one row per conversation, from the newest round's attributes + - query: bash test/e2e-v2/cases/ai-agent/verify.sh list http://${oap_host}:${oap_12800} + expected: expected/conversations.yml + # query filters: limit, sender instance, a time window that holds only the first conversation + - query: bash test/e2e-v2/cases/ai-agent/verify.sh list-limit http://${oap_host}:${oap_12800} + expected: expected/conversations-limit.yml + - query: bash test/e2e-v2/cases/ai-agent/verify.sh list-instance http://${oap_host}:${oap_12800} + expected: expected/conversations-instance.yml + - query: bash test/e2e-v2/cases/ai-agent/verify.sh list-window http://${oap_host}:${oap_12800} + expected: expected/conversations-window.yml + # every conversation's asz.view document equals the one the Sessionizer serves for the same files + - query: bash test/e2e-v2/cases/ai-agent/verify.sh views http://${oap_host}:${oap_12800} http://${aszview_host}:${aszview_8787} + expected: expected/views.yml + # the raw files of a conversation are the files the document names, digest for digest + - query: bash test/e2e-v2/cases/ai-agent/verify.sh raw-files http://${oap_host}:${oap_12800} + expected: expected/raw-files.yml + # a file whose digest does not match its body is never stored + - query: bash test/e2e-v2/cases/ai-agent/verify.sh reject http://${oap_host}:${oap_12800} + expected: expected/reject.yml + # one session landed, parsed and pushed in three stages folds to one verified document over three rounds + - query: bash test/e2e-v2/cases/ai-agent/verify.sh multi-round http://${oap_host}:${oap_12800} + expected: expected/multi-round.yml diff --git a/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml b/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml new file mode 100644 index 000000000000..4005bedefd92 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml @@ -0,0 +1,186 @@ +# 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, end to end with the AI Sessionizer (asz), in one storage root: +# fixture.yaml, three sessions build -> parse -> push, once +# three-rounds.yaml, one session build through the first checkpoint -> parse -> push, +# then through the second, then to the end: three rounds over +# files cut at each stage, and the final document must cover it all +# asz view the Sessionizer's own asz.view documents, the reference the OAP's must equal +# The asz image is distroless and has no shell; the one-shot steps chain through +# service_completed_successfully. `scenario build` keeps what a person appends to its asz.yaml, so the receiver +# is appended once and every later build, parse and push reads the one file. +x-asz: &asz + image: ghcr.io/apache/skywalking-ai-sessionizer:${SW_AI_SESSIONIZER_COMMIT} + volumes: + - aszdata:/asz/data + - ../fixture.yaml:/asz/fixture.yaml:ro + - ../three-rounds.yaml:/asz/three-rounds.yaml:ro + networks: + - e2e + +services: + banyandb: + extends: + file: ../../../script/docker-compose/base-compose.yml + service: banyandb + networks: + - e2e + + oap: + extends: + file: ../../../script/docker-compose/base-compose.yml + service: oap + environment: + SW_STORAGE: banyandb + ports: + - 12800 + depends_on: + banyandb: + condition: service_healthy + networks: + - e2e + + # The asz image runs as a non-root user; a fresh named volume is root-owned. + asz-volume: + image: busybox:1.36 + command: ["sh", "-c", "chown -R 65532:65532 /asz/data"] + volumes: + - aszdata:/asz/data + networks: + - e2e + + # ---- fixture.yaml: three sessions, one stage + asz-build: + <<: *asz + command: ["scenario", "build", "/asz/fixture.yaml", "--format", "sd", "--out", "/asz/data", "--repeat", "3"] + depends_on: + asz-volume: + condition: service_completed_successfully + + # The receiver and this sender's identity, appended to the asz.yaml `build` wrote. + asz-config: + image: busybox:1.36 + command: + - sh + - -c + - "printf 'export:\\n otlp:\\n endpoint: http://oap:12800\\n service_name: e2e-ai-agent\\n instance_id: e2e-sender\\n' >> /asz/data/asz.yaml" + volumes: + - aszdata:/asz/data + depends_on: + asz-build: + condition: service_completed_successfully + networks: + - e2e + + asz-parse: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + asz-config: + condition: service_completed_successfully + + asz-push: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + asz-parse: + condition: service_completed_successfully + oap: + condition: service_healthy + + # ---- three-rounds.yaml: one session in three stages, each landed, parsed and pushed before the next + mr-build-1: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] + depends_on: + asz-push: + condition: service_completed_successfully + mr-parse-1: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-1: + condition: service_completed_successfully + mr-push-1: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-1: + condition: service_completed_successfully + + mr-build-2: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "second"] + depends_on: + mr-push-1: + condition: service_completed_successfully + mr-parse-2: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-2: + condition: service_completed_successfully + mr-push-2: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-2: + condition: service_completed_successfully + + mr-build-3: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + mr-push-2: + condition: service_completed_successfully + mr-parse-3: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-3: + condition: service_completed_successfully + mr-push-3: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-3: + condition: service_completed_successfully + + # The Sessionizer's own view of the same storage root, at /api/c/{id}/view, for the comparison. + asz: + <<: *asz + command: ["view", "-config", "/asz/data/asz.yaml", "0.0.0.0:8787"] + depends_on: + mr-parse-3: + condition: service_completed_successfully + + # The e2e runner probes a published port from inside its container with /bin/sh, which the distroless asz image + # does not have, so the port is published by a forwarder that has one. + aszview: + image: alpine/socat:1.8.0.0 + command: ["TCP-LISTEN:8787,fork,reuseaddr", "TCP:asz:8787"] + ports: + - 8787 + depends_on: + - asz + networks: + - e2e + +volumes: + aszdata: + +networks: + e2e: diff --git a/test/e2e-v2/cases/ai-agent/banyandb/e2e.yaml b/test/e2e-v2/cases/ai-agent/banyandb/e2e.yaml new file mode 100644 index 000000000000..a210884e5aa9 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/banyandb/e2e.yaml @@ -0,0 +1,52 @@ +# 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, on BanyanDB: three sessions generated from one +# scenario and one session generated in three stages, parsed, pushed over OTLP, then read back through swctl +# and the view route and compared, document for document, with what the Sessionizer's own viewer serves. + +setup: + env: compose + file: docker-compose.yml + timeout: 20m + init-system-environment: ../../../script/env + steps: + - name: set PATH + command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH + - name: install yq + command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq + - name: install swctl + command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl + +verify: + retry: + count: 30 + interval: 10s + cases: + - includes: + - ../ai-agent-cases.yaml + +cleanup: + on: always + collect: + on: failure + output-dir: $SW_INFRA_E2E_LOG_DIR/banyandb-data + items: + - service: banyandb + paths: + - /tmp/stream/ + - /tmp/measure/ + - /tmp/property/ + - /tmp/schema-property/ diff --git a/test/e2e-v2/cases/ai-agent/es/docker-compose.yml b/test/e2e-v2/cases/ai-agent/es/docker-compose.yml new file mode 100644 index 000000000000..282ed84a90bc --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/es/docker-compose.yml @@ -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. + +# AI agent conversations, end to end with the AI Sessionizer (asz), in one storage root: +# fixture.yaml, three sessions build -> parse -> push, once +# three-rounds.yaml, one session build through the first checkpoint -> parse -> push, +# then through the second, then to the end: three rounds over +# files cut at each stage, and the final document must cover it all +# asz view the Sessionizer's own asz.view documents, the reference the OAP's must equal +# The asz image is distroless and has no shell; the one-shot steps chain through +# service_completed_successfully. `scenario build` keeps what a person appends to its asz.yaml, so the receiver +# is appended once and every later build, parse and push reads the one file. +x-asz: &asz + image: ghcr.io/apache/skywalking-ai-sessionizer:${SW_AI_SESSIONIZER_COMMIT} + volumes: + - aszdata:/asz/data + - ../fixture.yaml:/asz/fixture.yaml:ro + - ../three-rounds.yaml:/asz/three-rounds.yaml:ro + networks: + - e2e + +services: + es: + image: elastic/elasticsearch:${ES_VERSION} + expose: + - 9200 + networks: + - e2e + environment: + - discovery.type=single-node + - xpack.security.enabled=false + healthcheck: + test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/9200"] + interval: 5s + timeout: 60s + retries: 120 + + oap: + extends: + file: ../../../script/docker-compose/base-compose.yml + service: oap + environment: + SW_STORAGE: elasticsearch + SW_STORAGE_ES_CLUSTER_NODES: es:9200 + ports: + - 12800 + depends_on: + es: + condition: service_healthy + networks: + - e2e + + # The asz image runs as a non-root user; a fresh named volume is root-owned. + asz-volume: + image: busybox:1.36 + command: ["sh", "-c", "chown -R 65532:65532 /asz/data"] + volumes: + - aszdata:/asz/data + networks: + - e2e + + # ---- fixture.yaml: three sessions, one stage + asz-build: + <<: *asz + command: ["scenario", "build", "/asz/fixture.yaml", "--format", "sd", "--out", "/asz/data", "--repeat", "3"] + depends_on: + asz-volume: + condition: service_completed_successfully + + # The receiver and this sender's identity, appended to the asz.yaml `build` wrote. + asz-config: + image: busybox:1.36 + command: + - sh + - -c + - "printf 'export:\\n otlp:\\n endpoint: http://oap:12800\\n service_name: e2e-ai-agent\\n instance_id: e2e-sender\\n' >> /asz/data/asz.yaml" + volumes: + - aszdata:/asz/data + depends_on: + asz-build: + condition: service_completed_successfully + networks: + - e2e + + asz-parse: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + asz-config: + condition: service_completed_successfully + + asz-push: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + asz-parse: + condition: service_completed_successfully + oap: + condition: service_healthy + + # ---- three-rounds.yaml: one session in three stages, each landed, parsed and pushed before the next + mr-build-1: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] + depends_on: + asz-push: + condition: service_completed_successfully + mr-parse-1: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-1: + condition: service_completed_successfully + mr-push-1: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-1: + condition: service_completed_successfully + + mr-build-2: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "second"] + depends_on: + mr-push-1: + condition: service_completed_successfully + mr-parse-2: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-2: + condition: service_completed_successfully + mr-push-2: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-2: + condition: service_completed_successfully + + mr-build-3: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + mr-push-2: + condition: service_completed_successfully + mr-parse-3: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-3: + condition: service_completed_successfully + mr-push-3: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-3: + condition: service_completed_successfully + + # The Sessionizer's own view of the same storage root, at /api/c/{id}/view, for the comparison. + asz: + <<: *asz + command: ["view", "-config", "/asz/data/asz.yaml", "0.0.0.0:8787"] + depends_on: + mr-parse-3: + condition: service_completed_successfully + + # The e2e runner probes a published port from inside its container with /bin/sh, which the distroless asz image + # does not have, so the port is published by a forwarder that has one. + aszview: + image: alpine/socat:1.8.0.0 + command: ["TCP-LISTEN:8787,fork,reuseaddr", "TCP:asz:8787"] + ports: + - 8787 + depends_on: + - asz + networks: + - e2e + +volumes: + aszdata: + +networks: + e2e: diff --git a/test/e2e-v2/cases/ai-agent/es/e2e.yaml b/test/e2e-v2/cases/ai-agent/es/e2e.yaml new file mode 100644 index 000000000000..a345df3f84b9 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/es/e2e.yaml @@ -0,0 +1,42 @@ +# 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, on Elasticsearch: three sessions generated from one +# scenario and one session generated in three stages, parsed, pushed over OTLP, then read back through swctl +# and the view route and compared, document for document, with what the Sessionizer's own viewer serves. + +setup: + env: compose + file: docker-compose.yml + timeout: 20m + init-system-environment: ../../../script/env + steps: + - name: set PATH + command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH + - name: install yq + command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq + - name: install swctl + command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl + +verify: + retry: + count: 30 + interval: 10s + cases: + - includes: + - ../ai-agent-cases.yaml + +cleanup: + on: always diff --git a/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml b/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml new file mode 100644 index 000000000000..1e5402457a38 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml @@ -0,0 +1,17 @@ +# 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. + +hit: 4 +miss: 0 diff --git a/test/e2e-v2/cases/ai-agent/expected/conversations-limit.yml b/test/e2e-v2/cases/ai-agent/expected/conversations-limit.yml new file mode 100644 index 000000000000..3ba516d522c3 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/conversations-limit.yml @@ -0,0 +1,17 @@ +# 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. + +rows: 1 +newest: bd16edc4-0b6b-4020-8405-3ce58724f2bc diff --git a/test/e2e-v2/cases/ai-agent/expected/conversations-window.yml b/test/e2e-v2/cases/ai-agent/expected/conversations-window.yml new file mode 100644 index 000000000000..31b80b412d07 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/conversations-window.yml @@ -0,0 +1,17 @@ +# 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. + +conversations: + - 00000001-0000-4000-8000-000000000001 diff --git a/test/e2e-v2/cases/ai-agent/expected/conversations.yml b/test/e2e-v2/cases/ai-agent/expected/conversations.yml new file mode 100644 index 000000000000..ad66e8de821b --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/conversations.yml @@ -0,0 +1,55 @@ +# 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. + +- conversation: 00000001-0000-4000-8000-000000000001 + instance: e2e-sender + title: build and check + round: 1 + talks: 3 + steps: 21 + streams: 2 + segments: 1 + unresolved: 0 + timed: true +- conversation: 00000001-0000-4000-8000-000000000002 + instance: e2e-sender + title: build and check + round: 1 + talks: 3 + steps: 21 + streams: 2 + segments: 1 + unresolved: 0 + timed: true +- conversation: 00000001-0000-4000-8000-000000000003 + instance: e2e-sender + title: build and check + round: 1 + talks: 3 + steps: 21 + streams: 2 + segments: 1 + unresolved: 0 + timed: true +- conversation: bd16edc4-0b6b-4020-8405-3ce58724f2bc + instance: e2e-sender + title: three rounds + round: 3 + talks: 7 + steps: 37 + streams: 4 + segments: 1 + unresolved: 0 + timed: true diff --git a/test/e2e-v2/cases/ai-agent/expected/instance.yml b/test/e2e-v2/cases/ai-agent/expected/instance.yml new file mode 100644 index 000000000000..bfe4a55ab255 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/instance.yml @@ -0,0 +1,22 @@ +# 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. + +{{- contains . }} +- id: {{ b64enc "e2e-ai-agent" }}.1_{{ b64enc "e2e-sender" }} + name: e2e-sender + attributes: [] + language: UNKNOWN + instanceuuid: {{ b64enc "e2e-ai-agent" }}.1_{{ b64enc "e2e-sender" }} +{{- end }} diff --git a/test/e2e-v2/cases/ai-agent/expected/multi-round.yml b/test/e2e-v2/cases/ai-agent/expected/multi-round.yml new file mode 100644 index 000000000000..dc608d71a636 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/multi-round.yml @@ -0,0 +1,52 @@ +# 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. + +title: three rounds +state: verified +problems: [] +rounds: 3 +talks: 7 +steps: 37 +streams: + - name: main + role: main + label: "" + parent: "" + - name: a0a10ef0666c4dc7e + role: child + label: checker + parent: main + - name: af8aa7c330972d1b8 + role: child + label: reviewer + parent: main + - name: ad66636b222102946 + role: child + label: writer + parent: main +files: 12 +windows: + - round: 1 + from_seq: 1 + through_seq: 1 + verified: true + - round: 2 + from_seq: 2 + through_seq: 4 + verified: true + - round: 3 + from_seq: 5 + through_seq: 9 + verified: true diff --git a/test/e2e-v2/cases/ai-agent/expected/raw-files.yml b/test/e2e-v2/cases/ai-agent/expected/raw-files.yml new file mode 100644 index 000000000000..786236fa8cbf --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/raw-files.yml @@ -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. + +files: 4 +match: true +export_match: true diff --git a/test/e2e-v2/cases/ai-agent/expected/reject.yml b/test/e2e-v2/cases/ai-agent/expected/reject.yml new file mode 100644 index 000000000000..b1bc680d8660 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/reject.yml @@ -0,0 +1,17 @@ +# 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. + +good_stored: 1 +bad_stored: 0 diff --git a/test/e2e-v2/cases/ai-agent/expected/service.yml b/test/e2e-v2/cases/ai-agent/expected/service.yml new file mode 100644 index 000000000000..8db7fe21901b --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/service.yml @@ -0,0 +1,24 @@ +# 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. + +{{- containsOnce . }} +- id: {{ b64enc "e2e-ai-agent" }}.1 + name: e2e-ai-agent + group: "" + shortname: e2e-ai-agent + normal: true + layers: + - AI_AGENT +{{- end }} diff --git a/test/e2e-v2/cases/ai-agent/expected/views.yml b/test/e2e-v2/cases/ai-agent/expected/views.yml new file mode 100644 index 000000000000..d61ee3b005e5 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/views.yml @@ -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. + +conversations: 4 +equal: 4 +yaml_equal: 4 +h2_equal: 4 +gzip_equal: 4 +format: asz.view +version: "1.0" +json_type: "application/vnd.skywalking.asz.view+json; version=1.0; charset=utf-8" +yaml_type: "application/vnd.skywalking.asz.view+yaml; version=1.0; charset=utf-8" +encoding: gzip +missing_type: "application/problem+json; charset=utf-8" +missing: + status: 404 + title: Not Found + detail: no round of conversation no-such-conversation is stored for this service +noservice: 400 +cli_missing: 1 diff --git a/test/e2e-v2/cases/ai-agent/fixture.yaml b/test/e2e-v2/cases/ai-agent/fixture.yaml new file mode 100644 index 000000000000..35b5789d566a --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/fixture.yaml @@ -0,0 +1,54 @@ +# 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. + +# The fixture session of the format pages: a person's input, an injection, +# a call in three fragments with a tool, a second tool, a child agent that is +# reported back, a final answer, a client-made error, and a context reset. +session: 00000001-0000-4000-8000-000000000001 +project: -Users-dev-01-full-conversation +title: build and check +interval: 1s +steps: + - input: run the build + - inject: {type: skill_listing, text: "skills: 1"} + after: 100ms + - call: + thinking: unavailable + text: Building now. + tool: + id: tool-run-make-build + name: Bash + input: {command: make build, description: build the project} + result: {text: build succeeded, after: 800ms} + usage: {in: 2, out: 50, cache_read: 900, cache_write: 100} + after: 900ms + - call: + tool: {id: srvtool-websearch, name: WebSearch, input: {query: go build cache}, result: search results} + usage: {out: 10} + - call: + agent: + name: checker + prompt: check the tests + after: 1s + steps: + - call: {text: Tests pass., usage: {out: 42}} + notify: true + usage: {out: 20} + checkpoint: delegated + - call: {text: Build passed and tests are green., usage: {out: 30}} + - error: "API Error: Connection lost mid-response." + - reset: {summary: "Summary: the build was run and checked."} diff --git a/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml b/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml new file mode 100644 index 000000000000..28406cb89a6e --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml @@ -0,0 +1,197 @@ +# 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, end to end with the AI Sessionizer (asz), in one storage root: +# fixture.yaml, three sessions build -> parse -> push, once +# three-rounds.yaml, one session build through the first checkpoint -> parse -> push, +# then through the second, then to the end: three rounds over +# files cut at each stage, and the final document must cover it all +# asz view the Sessionizer's own asz.view documents, the reference the OAP's must equal +# The asz image is distroless and has no shell; the one-shot steps chain through +# service_completed_successfully. `scenario build` keeps what a person appends to its asz.yaml, so the receiver +# is appended once and every later build, parse and push reads the one file. +x-asz: &asz + image: ghcr.io/apache/skywalking-ai-sessionizer:${SW_AI_SESSIONIZER_COMMIT} + volumes: + - aszdata:/asz/data + - ../fixture.yaml:/asz/fixture.yaml:ro + - ../three-rounds.yaml:/asz/three-rounds.yaml:ro + networks: + - e2e + +services: + mysql: + image: mysql/mysql-server:8.0.13 + networks: + - e2e + expose: + - 3306 + environment: + MYSQL_ROOT_PASSWORD: "root@1234" + MYSQL_DATABASE: "swtest" + MYSQL_ROOT_HOST: "%" + healthcheck: + test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/3306"] + interval: 5s + timeout: 60s + retries: 120 + + oap: + extends: + file: ../../../script/docker-compose/base-compose.yml + service: oap + environment: + SW_STORAGE: mysql + SW_JDBC_URL: jdbc:mysql://mysql:3306/swtest?allowMultiQueries=true + ports: + - 12800 + entrypoint: ['sh', '-c', '/download-mysql.sh /skywalking/oap-libs && /skywalking/docker-entrypoint.sh'] + depends_on: + mysql: + condition: service_healthy + networks: + - e2e + + # The asz image runs as a non-root user; a fresh named volume is root-owned. + asz-volume: + image: busybox:1.36 + command: ["sh", "-c", "chown -R 65532:65532 /asz/data"] + volumes: + - aszdata:/asz/data + networks: + - e2e + + # ---- fixture.yaml: three sessions, one stage + asz-build: + <<: *asz + command: ["scenario", "build", "/asz/fixture.yaml", "--format", "sd", "--out", "/asz/data", "--repeat", "3"] + depends_on: + asz-volume: + condition: service_completed_successfully + + # The receiver and this sender's identity, appended to the asz.yaml `build` wrote. + asz-config: + image: busybox:1.36 + command: + - sh + - -c + - "printf 'export:\\n otlp:\\n endpoint: http://oap:12800\\n service_name: e2e-ai-agent\\n instance_id: e2e-sender\\n' >> /asz/data/asz.yaml" + volumes: + - aszdata:/asz/data + depends_on: + asz-build: + condition: service_completed_successfully + networks: + - e2e + + asz-parse: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + asz-config: + condition: service_completed_successfully + + asz-push: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + asz-parse: + condition: service_completed_successfully + oap: + condition: service_healthy + + # ---- three-rounds.yaml: one session in three stages, each landed, parsed and pushed before the next + mr-build-1: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] + depends_on: + asz-push: + condition: service_completed_successfully + mr-parse-1: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-1: + condition: service_completed_successfully + mr-push-1: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-1: + condition: service_completed_successfully + + mr-build-2: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "second"] + depends_on: + mr-push-1: + condition: service_completed_successfully + mr-parse-2: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-2: + condition: service_completed_successfully + mr-push-2: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-2: + condition: service_completed_successfully + + mr-build-3: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + mr-push-2: + condition: service_completed_successfully + mr-parse-3: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-3: + condition: service_completed_successfully + mr-push-3: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-3: + condition: service_completed_successfully + + # The Sessionizer's own view of the same storage root, at /api/c/{id}/view, for the comparison. + asz: + <<: *asz + command: ["view", "-config", "/asz/data/asz.yaml", "0.0.0.0:8787"] + depends_on: + mr-parse-3: + condition: service_completed_successfully + + # The e2e runner probes a published port from inside its container with /bin/sh, which the distroless asz image + # does not have, so the port is published by a forwarder that has one. + aszview: + image: alpine/socat:1.8.0.0 + command: ["TCP-LISTEN:8787,fork,reuseaddr", "TCP:asz:8787"] + ports: + - 8787 + depends_on: + - asz + networks: + - e2e + +volumes: + aszdata: + +networks: + e2e: diff --git a/test/e2e-v2/cases/ai-agent/mysql/e2e.yaml b/test/e2e-v2/cases/ai-agent/mysql/e2e.yaml new file mode 100644 index 000000000000..01436eff0088 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/mysql/e2e.yaml @@ -0,0 +1,42 @@ +# 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, on MySQL: three sessions generated from one +# scenario and one session generated in three stages, parsed, pushed over OTLP, then read back through swctl +# and the view route and compared, document for document, with what the Sessionizer's own viewer serves. + +setup: + env: compose + file: docker-compose.yml + timeout: 20m + init-system-environment: ../../../script/env + steps: + - name: set PATH + command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH + - name: install yq + command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq + - name: install swctl + command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl + +verify: + retry: + count: 30 + interval: 10s + cases: + - includes: + - ../ai-agent-cases.yaml + +cleanup: + on: always diff --git a/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml b/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml new file mode 100644 index 000000000000..55297c4a7e0e --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml @@ -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. + +# AI agent conversations, end to end with the AI Sessionizer (asz), in one storage root: +# fixture.yaml, three sessions build -> parse -> push, once +# three-rounds.yaml, one session build through the first checkpoint -> parse -> push, +# then through the second, then to the end: three rounds over +# files cut at each stage, and the final document must cover it all +# asz view the Sessionizer's own asz.view documents, the reference the OAP's must equal +# The asz image is distroless and has no shell; the one-shot steps chain through +# service_completed_successfully. `scenario build` keeps what a person appends to its asz.yaml, so the receiver +# is appended once and every later build, parse and push reads the one file. +x-asz: &asz + image: ghcr.io/apache/skywalking-ai-sessionizer:${SW_AI_SESSIONIZER_COMMIT} + volumes: + - aszdata:/asz/data + - ../fixture.yaml:/asz/fixture.yaml:ro + - ../three-rounds.yaml:/asz/three-rounds.yaml:ro + networks: + - e2e + +services: + postgres: + image: postgres:13 + networks: + - e2e + expose: + - 5432 + environment: + - POSTGRES_PASSWORD=123456 + - POSTGRES_DB=skywalking + healthcheck: + test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/5432"] + interval: 5s + timeout: 60s + retries: 120 + + oap: + extends: + file: ../../../script/docker-compose/base-compose.yml + service: oap + environment: + SW_STORAGE: postgresql + SW_JDBC_URL: "jdbc:postgresql://postgres:5432/skywalking" + ports: + - 12800 + depends_on: + postgres: + condition: service_healthy + networks: + - e2e + + # The asz image runs as a non-root user; a fresh named volume is root-owned. + asz-volume: + image: busybox:1.36 + command: ["sh", "-c", "chown -R 65532:65532 /asz/data"] + volumes: + - aszdata:/asz/data + networks: + - e2e + + # ---- fixture.yaml: three sessions, one stage + asz-build: + <<: *asz + command: ["scenario", "build", "/asz/fixture.yaml", "--format", "sd", "--out", "/asz/data", "--repeat", "3"] + depends_on: + asz-volume: + condition: service_completed_successfully + + # The receiver and this sender's identity, appended to the asz.yaml `build` wrote. + asz-config: + image: busybox:1.36 + command: + - sh + - -c + - "printf 'export:\\n otlp:\\n endpoint: http://oap:12800\\n service_name: e2e-ai-agent\\n instance_id: e2e-sender\\n' >> /asz/data/asz.yaml" + volumes: + - aszdata:/asz/data + depends_on: + asz-build: + condition: service_completed_successfully + networks: + - e2e + + asz-parse: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + asz-config: + condition: service_completed_successfully + + asz-push: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + asz-parse: + condition: service_completed_successfully + oap: + condition: service_healthy + + # ---- three-rounds.yaml: one session in three stages, each landed, parsed and pushed before the next + mr-build-1: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] + depends_on: + asz-push: + condition: service_completed_successfully + mr-parse-1: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-1: + condition: service_completed_successfully + mr-push-1: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-1: + condition: service_completed_successfully + + mr-build-2: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "second"] + depends_on: + mr-push-1: + condition: service_completed_successfully + mr-parse-2: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-2: + condition: service_completed_successfully + mr-push-2: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-2: + condition: service_completed_successfully + + mr-build-3: + <<: *asz + command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + mr-push-2: + condition: service_completed_successfully + mr-parse-3: + <<: *asz + command: ["parse", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-build-3: + condition: service_completed_successfully + mr-push-3: + <<: *asz + command: ["push", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mr-parse-3: + condition: service_completed_successfully + + # The Sessionizer's own view of the same storage root, at /api/c/{id}/view, for the comparison. + asz: + <<: *asz + command: ["view", "-config", "/asz/data/asz.yaml", "0.0.0.0:8787"] + depends_on: + mr-parse-3: + condition: service_completed_successfully + + # The e2e runner probes a published port from inside its container with /bin/sh, which the distroless asz image + # does not have, so the port is published by a forwarder that has one. + aszview: + image: alpine/socat:1.8.0.0 + command: ["TCP-LISTEN:8787,fork,reuseaddr", "TCP:asz:8787"] + ports: + - 8787 + depends_on: + - asz + networks: + - e2e + +volumes: + aszdata: + +networks: + e2e: diff --git a/test/e2e-v2/cases/ai-agent/postgres/e2e.yaml b/test/e2e-v2/cases/ai-agent/postgres/e2e.yaml new file mode 100644 index 000000000000..fd2b852502b9 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/postgres/e2e.yaml @@ -0,0 +1,42 @@ +# 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, on PostgreSQL: three sessions generated from one +# scenario and one session generated in three stages, parsed, pushed over OTLP, then read back through swctl +# and the view route and compared, document for document, with what the Sessionizer's own viewer serves. + +setup: + env: compose + file: docker-compose.yml + timeout: 20m + init-system-environment: ../../../script/env + steps: + - name: set PATH + command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH + - name: install yq + command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq + - name: install swctl + command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl + +verify: + retry: + count: 30 + interval: 10s + cases: + - includes: + - ../ai-agent-cases.yaml + +cleanup: + on: always diff --git a/test/e2e-v2/cases/ai-agent/three-rounds.yaml b/test/e2e-v2/cases/ai-agent/three-rounds.yaml new file mode 100644 index 000000000000..0e7cdf4d15c6 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/three-rounds.yaml @@ -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. +# One session, landed, parsed and pushed in three stages, so three rounds come out over files cut at each stage +# and the final document must cover the whole session as if it had been parsed at once. +# stage 1, through "first": the main stream only +# stage 2, through "second": one sub-agent, reported back to main +# stage 3, to the end: two more sub-agents, then main wraps up on its own +title: three rounds +steps: + - input: start the work + - call: {text: Starting, tool: {name: Bash, input: {command: make build}, result: built}} + - call: {text: Build done.} + checkpoint: first + - input: now check it + - call: + agent: {name: checker, prompt: check the build, steps: [{call: {text: checks pass}}], notify: true} + - call: {text: Checks done.} + checkpoint: second + - input: review and document it + - call: + agent: {name: reviewer, prompt: review the change, steps: [{call: {text: looks good}}], notify: true} + - call: + agent: {name: writer, prompt: write the release note, steps: [{call: {text: note written}}], notify: true} + - call: {text: Review and note are in, tool: {name: Bash, input: {command: git status}, result: clean}} + - input: one more thing + - call: {text: All done.} diff --git a/test/e2e-v2/cases/ai-agent/verify.sh b/test/e2e-v2/cases/ai-agent/verify.sh new file mode 100755 index 000000000000..ecfa6089f418 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/verify.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# 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. + +# Each verify case is one mode of this script. The reads go through swctl's ai-agent commands, so the CLI +# pinned in script/env is proven against this OAP too; curl covers what a CLI cannot choose, the HTTP +# version, the encoding and the wire status. The framework compares the YAML each mode prints with the +# expected file. +# +# verify.sh list OAP one row per conversation +# verify.sh list-limit OAP limit 1 keeps the newest +# verify.sh list-instance OAP the sender filter, hit and miss +# verify.sh list-window OAP a window holding only the first conversation's activity +# verify.sh views OAP ASZ every conversation's asz.view equals the Sessionizer's, through +# swctl as JSON and YAML, and over the route on HTTP/2 and gzipped +# verify.sh raw-files OAP the raw files are the files the document names, and export them +# verify.sh reject OAP a file with a wrong digest is never stored, seen through the export by id +# verify.sh multi-round OAP the session landed in three stages folds to one verified document +set -euo pipefail + +MODE=$1 +OAP=${2%/} +ASZ=${3:-} +SERVICE="e2e-ai-agent" +INSTANCE="e2e-sender" +FIRST="00000001-0000-4000-8000-000000000001" +# three-rounds.yaml names no session, so its id is derived from its steps and is the same on every build. +THREE_ROUNDS="bd16edc4-0b6b-4020-8405-3ce58724f2bc" + +# swctl against this OAP, JSON out. +sw() { + swctl --display json --base-url="$OAP/graphql" "$@" +} + +# The view route straight over HTTP: $1 conversation, $2 Accept, then extra curl flags. +route() { + local c=$1 accept=$2; shift 2 + curl -sf -H "Accept: $accept" "$@" "$OAP/ai-agent/conversations/$c/v1/view?service=$SERVICE" +} + +# The document through swctl: $1 conversation, then extra flags such as --yaml. +view() { + local c=$1; shift + sw ai-agent view --service-name "$SERVICE" --conversation "$c" "$@" +} + +# "yyyy-MM-dd HHmm" (MINUTE) or "yyyy-MM-dd HHmmss" (SECOND) in UTC, from epoch seconds; GNU date, then BSD date. +fmt() { + local secs=$1 pattern=$2 + date -u -d "@$secs" +"$pattern" 2>/dev/null || date -u -r "$secs" +"$pattern" +} + +# One list query: $1 duration start, $2 duration end, then extra swctl flags. swctl reads the step from the +# time format: "yyyy-MM-dd HHmm" is MINUTE, "yyyy-MM-dd HHmmss" is SECOND. +list() { + local start=$1 end=$2; shift 2 + sw ai-agent list --service-name "$SERVICE" --start "$start" --end "$end" "$@" +} + +now=$(date -u +%s) +wide_start=$(fmt $((now - 7200)) "%Y-%m-%d %H%M") +wide_end=$(fmt $((now + 7200)) "%Y-%m-%d %H%M") + +case "$MODE" in + list) + list "$wide_start" "$wide_end" \ + | yq -P '.conversations | sort_by(.conversation) | map({"conversation": .conversation, "instance": .serviceInstanceName, "title": .title, "round": .round, "talks": .talks, "steps": .steps, "streams": .streams, "segments": .segments, "unresolved": .unresolved, "timed": (.from > 0 and .to >= .from)})' + ;; + list-limit) + list "$wide_start" "$wide_end" --limit 1 \ + | yq -P '{"rows": (.conversations | length), "newest": .conversations[0].conversation}' + ;; + list-instance) + hit=$(list "$wide_start" "$wide_end" --instance-name "$INSTANCE" | yq '.conversations | length') + miss=$(list "$wide_start" "$wide_end" --instance-name nobody | yq '.conversations | length') + printf 'hit: %s\nmiss: %s\n' "$hit" "$miss" + ;; + list-window) + # The first conversation's own range, from its list row; the next one begins a second after it ends and + # ends about ten seconds later, so a window closing one second after the first cannot hold it. + row=$(list "$wide_start" "$wide_end" | yq -o=json ".conversations[] | select(.conversation == \"$FIRST\")") + from=$(echo "$row" | yq '.from'); to=$(echo "$row" | yq '.to') + start=$(fmt $(( from / 1000 - 1 )) "%Y-%m-%d %H%M%S") + end=$(fmt $(( to / 1000 + 1 )) "%Y-%m-%d %H%M%S") + list "$start" "$end" | yq -P '{"conversations": (.conversations | map(.conversation) | sort)}' + ;; + views) + ids=$(list "$wide_start" "$wide_end" | yq '.conversations[].conversation' | sort) + total=0; equal=0; yaml_equal=0; h2_equal=0; gzip_equal=0; format=""; version="" + for id in $ids; do + total=$((total + 1)) + theirs=$(curl -sf "$ASZ/api/c/$id/view" | yq -o=json 'sort_keys(..)') + # through swctl as JSON, the UI's path + resp=$(view "$id") + format=$(echo "$resp" | yq -p=json '.format'); version=$(echo "$resp" | yq -p=json '.version') + ours=$(echo "$resp" | yq -p=json -o=json 'sort_keys(..)') + if [ "$ours" = "$theirs" ]; then equal=$((equal + 1)); else echo "conversation $id differs" >&2; diff <(echo "$theirs") <(echo "$ours") >&2 || true; fi + # through swctl as YAML, the human path + [ "$(view "$id" --yaml | yq -o=json 'sort_keys(..)')" = "$theirs" ] && yaml_equal=$((yaml_equal + 1)) + # the route itself over cleartext HTTP/2, and with the body gzipped + [ "$(route "$id" application/json --http2-prior-knowledge | yq -o=json 'sort_keys(..)')" = "$theirs" ] && h2_equal=$((h2_equal + 1)) + [ "$(route "$id" application/json --compressed | yq -o=json 'sort_keys(..)')" = "$theirs" ] && gzip_equal=$((gzip_equal + 1)) + done + encoding=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$OAP/ai-agent/conversations/$FIRST/v1/view?service=$SERVICE" | tr -d '\r' | awk -F': ' 'tolower($1) == "content-encoding" {print $2}') + [ -n "$encoding" ] || encoding=none + # the format and the version are on the wire too: the media type names the document, its version is a parameter + json_type=$(curl -s -o /dev/null -w '%{content_type}' "$OAP/ai-agent/conversations/$FIRST/v1/view?service=$SERVICE") + yaml_type=$(curl -s -o /dev/null -w '%{content_type}' -H 'Accept: application/vnd.skywalking.asz.view+yaml' "$OAP/ai-agent/conversations/$FIRST/v1/view?service=$SERVICE") + # an error is a problem document (RFC 9457) that carries its status + missing_type=$(curl -s -o /dev/null -w '%{content_type}' "$OAP/ai-agent/conversations/no-such-conversation/v1/view?service=$SERVICE") + missing=$(curl -s "$OAP/ai-agent/conversations/no-such-conversation/v1/view?service=$SERVICE" | yq -p=json -o=json -I=0 '{"status": .status, "title": .title, "detail": .detail}') + noservice=$(curl -s -o /dev/null -w '%{http_code}' "$OAP/ai-agent/conversations/$FIRST/v1/view") + # swctl says the problem in words + cli_missing=$( (view no-such-conversation 2>&1 || true) | grep -c "404 Not Found: no round of conversation no-such-conversation") + printf 'conversations: %s\nequal: %s\nyaml_equal: %s\nh2_equal: %s\ngzip_equal: %s\nformat: %s\nversion: "%s"\njson_type: "%s"\nyaml_type: "%s"\nencoding: %s\nmissing_type: "%s"\nmissing: %s\nnoservice: %s\n' "$total" "$equal" "$yaml_equal" "$h2_equal" "$gzip_equal" "$format" "$version" "$json_type" "$yaml_type" "$encoding" "$missing_type" "$missing" "$noservice" + printf 'cli_missing: %s\n' "$cli_missing" + ;; + raw-files) + raw=$(sw ai-agent files --service-name "$SERVICE" --conversation "$FIRST" \ + | yq -p=json -o=json '.files | map({"file": .id, "digest": .digest}) | sort_by(.file)') + named=$(view "$FIRST" | yq -p=json -o=json '.files | map({"file": .file, "digest": .digest}) | sort_by(.file)') + match=false; [ "$raw" = "$named" ] && match=true + # the export writes every body to its id path, and each lands with the digest the document names + root=$(mktemp -d); sw ai-agent files --service-name "$SERVICE" --conversation "$FIRST" --export "$root" > /dev/null + exported=$(cd "$root" && find . -type f | sed 's#^\./##' | while read -r f; do printf '{"file":"%s","digest":"%s"}\n' "$f" "$(sha256sum "$f" | cut -d' ' -f1)"; done | paste -sd, -) + exported=$(echo "[$exported]" | yq -p=json -o=json 'sort_by(.file)') + export_match=false; [ "$exported" = "$named" ] && export_match=true + rm -rf "$root" + printf 'files: %s\nmatch: %s\nexport_match: %s\n' "$(echo "$raw" | yq 'length')" "$match" "$export_match" + ;; + reject) + # Two files beyond the head, pushed by hand: a good one, and one whose declared digest is not its body's. The + # probe is the export of each by id, so it reads exactly the pushed seq; the good file proves the probe sees + # what the OAP stored, and the bad one must not be there. Both are stamped inside the conversation's range. + to=$(list "$wide_start" "$wide_end" | yq -o=json ".conversations[] | select(.conversation == \"$FIRST\") | .to") + push_file() { + local seq=$1 body=$2 digest=$3 lines=$4 + local id="$FIRST/streams/main/transcript-20260101T000000.000000000Z-0000$seq.sd" + B="$body" yq -n -o=json '{"resourceLogs":[{"resource":{"attributes":[ + {"key":"service.name","value":{"stringValue":"'"$SERVICE"'"}}, + {"key":"service.instance.id","value":{"stringValue":"'"$INSTANCE"'"}}, + {"key":"service.layer","value":{"stringValue":"AI_AGENT"}}, + {"key":"telemetry.sdk.name","value":{"stringValue":"asz"}}]}, + "scopeLogs":[{"scope":{"name":"e2e"},"logRecords":[{"timeUnixNano":"'"$((to * 1000000))"'", + "body":{"stringValue": strenv(B)}, + "attributes":[ + {"key":"asz.format","value":{"stringValue":"sd"}}, + {"key":"asz.file","value":{"stringValue":"'"$id"'"}}, + {"key":"asz.file.digest","value":{"stringValue":"'"$digest"'"}}, + {"key":"asz.lines","value":{"stringValue":"'"$lines"'"}}, + {"key":"asz.session","value":{"stringValue":"'"$FIRST"'"}}, + {"key":"asz.seq","value":{"stringValue":"'"$seq"'"}}]}]}]}]}' \ + | curl -sf -X POST -H 'Content-Type: application/json' "$OAP/v1/logs" --data @- > /dev/null + } + stored() { + sw ai-agent files --service-name "$SERVICE" --conversation "$FIRST" --files "$FIRST/streams/main/transcript-20260101T000000.000000000Z-0000$1.sd" \ + | yq -p=json '.files | length' + } + # $(...) would strip the file's final newline, and the OAP counts lines by newlines, so the bodies are built + # with the newline kept and the digest is taken over exactly the bytes pushed + nl=$'\n' + good='{"h":1,"schema":"sd/1","seq":98,"kind":"transcript","session":"'"$FIRST"'","stream":"main"}'"$nl"'{"t":"end","records":0,"digest":"0"}'"$nl" + push_file 98 "$good" "$(printf '%s' "$good" | sha256sum | cut -d' ' -f1)" 2 + bad='{"h":1,"schema":"sd/1","seq":99,"kind":"transcript","session":"'"$FIRST"'","stream":"main"}'"$nl"'{"t":"end","records":0,"digest":"0"}'"$nl" + push_file 99 "$bad" "0000000000000000000000000000000000000000000000000000000000000000" 2 + sleep 8 + printf 'good_stored: %s\nbad_stored: %s\n' "$(stored 98)" "$(stored 99)" + ;; + multi-round) + # Three rounds over files cut at each stage; the final document covers the whole session, verified. + view "$THREE_ROUNDS" --yaml \ + | yq -P '{"title": .summary.title, "state": .summary.state, "problems": .summary.problems, "rounds": .summary.rounds, "talks": .summary.talks, "steps": .summary.steps, "streams": [.streams[] | {"name": .name, "role": .role, "label": .label, "parent": .parent}], "files": (.files | length), "windows": [.rounds[] | {"round": .round, "from_seq": .from_seq, "through_seq": .through_seq, "verified": .verified}]}' + ;; + *) + echo "unknown mode $MODE" >&2; exit 2 + ;; +esac diff --git a/test/e2e-v2/cases/alarm/expected/silence-after-graphql-critical.yml b/test/e2e-v2/cases/alarm/expected/silence-after-graphql-critical.yml index f4f40153876c..7d69cbbe386e 100644 --- a/test/e2e-v2/cases/alarm/expected/silence-after-graphql-critical.yml +++ b/test/e2e-v2/cases/alarm/expected/silence-after-graphql-critical.yml @@ -16,6 +16,7 @@ msgs: {{- contains .msgs }} - starttime: {{ gt .starttime 0 }} + recoverytime: null scope: Service id: ZTJlLXNlcnZpY2UtcHJvdmlkZXI=.1 name: e2e-service-provider diff --git a/test/e2e-v2/cases/alarm/expected/silence-after-graphql-warn.yml b/test/e2e-v2/cases/alarm/expected/silence-after-graphql-warn.yml index d4f79281afd3..30cb25d37b70 100644 --- a/test/e2e-v2/cases/alarm/expected/silence-after-graphql-warn.yml +++ b/test/e2e-v2/cases/alarm/expected/silence-after-graphql-warn.yml @@ -16,6 +16,7 @@ msgs: {{- contains .msgs }} - starttime: {{ gt .starttime 0 }} + recoverytime: null scope: Service id: ZTJlLXNlcnZpY2UtcHJvdmlkZXI=.1 name: e2e-service-provider @@ -47,6 +48,7 @@ msgs: {{- end }} {{- end }} - starttime: {{ gt .starttime 0 }} + recoverytime: null scope: Service id: ZTJlLXNlcnZpY2UtcHJvdmlkZXI=.1 name: e2e-service-provider diff --git a/test/e2e-v2/cases/alarm/expected/silence-before-graphql-critical.yml b/test/e2e-v2/cases/alarm/expected/silence-before-graphql-critical.yml index f4f40153876c..7d69cbbe386e 100644 --- a/test/e2e-v2/cases/alarm/expected/silence-before-graphql-critical.yml +++ b/test/e2e-v2/cases/alarm/expected/silence-before-graphql-critical.yml @@ -16,6 +16,7 @@ msgs: {{- contains .msgs }} - starttime: {{ gt .starttime 0 }} + recoverytime: null scope: Service id: ZTJlLXNlcnZpY2UtcHJvdmlkZXI=.1 name: e2e-service-provider diff --git a/test/e2e-v2/cases/alarm/expected/silence-before-graphql-warn.yml b/test/e2e-v2/cases/alarm/expected/silence-before-graphql-warn.yml index dd6e258b8532..b30f6e9fb079 100644 --- a/test/e2e-v2/cases/alarm/expected/silence-before-graphql-warn.yml +++ b/test/e2e-v2/cases/alarm/expected/silence-before-graphql-warn.yml @@ -16,6 +16,7 @@ msgs: {{- contains .msgs }} - starttime: {{ gt .starttime 0 }} + recoverytime: null scope: Service id: ZTJlLXNlcnZpY2UtcHJvdmlkZXI=.1 name: e2e-service-provider @@ -47,6 +48,7 @@ msgs: {{- end }} {{- end }} - starttime: {{ gt .starttime 0 }} + recoverytime: null scope: Service id: ZTJlLXNlcnZpY2UtcHJvdmlkZXI=.1 name: e2e-service-provider diff --git a/test/e2e-v2/cases/baseline/expected/critical.yml b/test/e2e-v2/cases/baseline/expected/critical.yml index 8a0ccd4d2ee2..4263409263ab 100644 --- a/test/e2e-v2/cases/baseline/expected/critical.yml +++ b/test/e2e-v2/cases/baseline/expected/critical.yml @@ -16,6 +16,7 @@ msgs: {{- contains .msgs }} - starttime: {{ gt .starttime 0 }} + recoverytime: null scope: Service id: ZTJlLXRlc3QtZGVzdC1zZXJ2aWNl.1 name: e2e-test-dest-service diff --git a/test/e2e-v2/cases/php/Dockerfile.php b/test/e2e-v2/cases/php/Dockerfile.php index a74bc18be6a5..69210a47a898 100644 --- a/test/e2e-v2/cases/php/Dockerfile.php +++ b/test/e2e-v2/cases/php/Dockerfile.php @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM php:8.1-fpm-bullseye as builder +FROM php:8.1-fpm-bookworm AS builder ARG SW_AGENT_PHP_COMMIT @@ -25,9 +25,10 @@ WORKDIR /tmp RUN apt update \ && apt install -y wget protobuf-compiler libclang-dev git \ - && wget https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-linux-gnu/rustup-init \ + && RUST_HOST="$(uname -m)-unknown-linux-gnu" \ + && wget https://static.rust-lang.org/rustup/archive/1.28.2/$RUST_HOST/rustup-init \ && chmod +x rustup-init \ - && ./rustup-init -y --no-modify-path --profile minimal --default-toolchain $RUST_VERSION --default-host x86_64-unknown-linux-gnu \ + && ./rustup-init -y --no-modify-path --profile minimal --default-toolchain $RUST_VERSION --default-host $RUST_HOST \ && rm rustup-init \ && chmod -R a+w $RUSTUP_HOME $CARGO_HOME @@ -40,7 +41,7 @@ && make \ && make install -FROM php:8.1-fpm-bullseye +FROM php:8.1-fpm-bookworm RUN apt update \ && apt install -y nginx \ && cd / \ diff --git a/test/e2e-v2/cases/storage/expected/config-dump.yml b/test/e2e-v2/cases/storage/expected/config-dump.yml index 4c5b52a1c7c8..931bcfc22c4e 100644 --- a/test/e2e-v2/cases/storage/expected/config-dump.yml +++ b/test/e2e-v2/cases/storage/expected/config-dump.yml @@ -43,6 +43,11 @@ "agent-analyzer.default.slowDBAccessThreshold": "default:200,mongodb:100", "agent-analyzer.default.traceSamplingPolicySettingsFile": "trace-sampling-policy-settings.yml", "agent-analyzer.provider": "default", + "ai-agent-conversation.default.fileReadWindow": "16", + "ai-agent-conversation.default.maxListLimit": "10000", + "ai-agent-conversation.default.roundReadWindow": "16", + "ai-agent-conversation.default.viewRequestTimeout": "120", + "ai-agent-conversation.provider": "default", "ai-pipeline.default.baselineServerAddr": "", "ai-pipeline.default.baselineServerPort": "18080", "ai-pipeline.default.uriRecognitionServerAddr": "", @@ -135,7 +140,7 @@ "health-checker.default.checkIntervalSeconds": "30", "health-checker.provider": "default", "inspect.provider": "default", - "log-analyzer.default.lalFiles": "envoy-als,mesh-dp,mysql-slowsql,pgsql-slowsql,redis-slowsql,k8s-service,nginx,envoy-ai-gateway,miniprogram,default", + "log-analyzer.default.lalFiles": "envoy-als,mesh-dp,mysql-slowsql,pgsql-slowsql,redis-slowsql,k8s-service,nginx,envoy-ai-gateway,miniprogram,ai-agent,default", "log-analyzer.default.malFiles": "nginx,miniprogram-wechat,miniprogram-alipay", "log-analyzer.provider": "default", "logql.default.restAcceptQueueSize": "0", diff --git a/test/e2e-v2/script/env b/test/e2e-v2/script/env index 9d8f91e6b73f..3491288153cc 100644 --- a/test/e2e-v2/script/env +++ b/test/e2e-v2/script/env @@ -25,10 +25,11 @@ SW_AGENT_CLIENT_JS_TEST_COMMIT=4f1eb1dcdbde3ec4a38534bf01dded4ab5d2f016 SW_KUBERNETES_COMMIT_SHA=da0e267f877b9b8e5f7728ae4ea7dc7723a2a073 SW_ROVER_COMMIT=79292fe07f17f98f486e0c4471213e1961fb2d1d SW_BANYANDB_COMMIT=3b83e18fb0481d02e44eaa5df137fcf7b000754b +SW_AI_SESSIONIZER_COMMIT=f9e1190e3e29e0168454a94cb77e418ad47553a9 SW_AGENT_PHP_COMMIT=de311c9cd084e21becade0742cd289bc0f43181d SW_PREDICTOR_COMMIT=54a0197654a3781a6f73ce35146c712af297c994 -SW_CTL_COMMIT=85e5afdb3d55c6e5af66a472c3fe8ac024d11690 +SW_CTL_COMMIT=1b6837da6361f1d735ac9ef1ea8cfc245918ff35 # Third-party image versions used by e2e infrastructure (not skywalking # components). Pinned here so the matrix is reproducible.