From 02b1da98485edb6c0d1ca3e671b3f88702ff7bfc Mon Sep 17 00:00:00 2001 From: Wu Sheng Date: Tue, 15 Sep 2026 23:05:20 +0800 Subject: [PATCH] AI agent conversations: provider bodies in the view, and files over HTTP Provider bodies in the conversation view. The AI Sessionizer can land the request and response bodies an agent runtime exchanged with its model provider, apache/skywalking-ai-sessionizer#10. They arrive as Session Data files of kind provider_body, one directory per session: /provider_body/provider_body--.sd. The OAP already stored them like any other file, but gave them a wrong name, /runs//..., which the raw-file query could not parse back. - FileNames names the provider_body directory and parses it back. - The asz.view document follows the Sessionizer's. An llm.call step lists its request and then its response under provider_bodies, each as a role and the ref of the landed record, never the body. summary.provider_bodies and summary.captured_prompts count them. The join uses the bodies' own ids: a response by its message id, a request by the previous call's response request id and its prompt, and only when exactly one request and one call carry them. A synthetic call takes part in no join, and no request joins in a stream whose landed transcript lines have a gap. Calls on one record are ordered by id, as the Sessionizer now orders them. - What the join reads is decoded as the Sessionizer decodes it. A manifest whose known keys, or its segments' keys, have the wrong JSON type is no body. A record whose call or run is not a string is gone to the join. An ord is read from the raw line: the digits after a leading {"ord":, as an unsigned 64-bit number, or else the decoded value, where null is 0. A transcript whose records end at a line that does not decode has a gap. SessionDataFile keeps each record's leading ord digits and whether its records stopped early. - The test data is refreshed from the Sessionizer: the two existing sets gain the two summary counts, and the provider-bodies and provider-bodies-errors sets are added. Both documents equal the Sessionizer's key for key. A session without its provider_body file folds to the same nodes, a stream with a gap joins responses only, and a manifest or an ord the Sessionizer reads differently counts as it does; each of those cases gave the same counts from asz conversation. - The e2e builds the Sessionizer's provider-bodies scenario into the same root and checks the file, every call's bodies and the export by name. The views, list, sender and token expectations count the new session. The Sessionizer is pinned to the commit that lands provider bodies, since its view now carries the two summary counts. Conversation files over HTTP, not GraphQL. The query protocol drops getConversationRawFiles, apache/skywalking-query-protocol#175. A conversation's stored Session Data files are read from a second HTTP route beside the view, so a page loads what a step points at, such as an llm.call's provider bodies, only when a reader opens it: GET /ai-agent/conversations/{conversation}/v1/files ?service=&instance=&session=&seq=[&seq=...][&coldStage=true] - A file is chosen by its session and its landed seq, the two columns the storage reads it by; the Sessionizer assigns a seq once per file in a session. One to 32 seqs a request: a file is cut at 2 MiB, so a response holds about 64 MiB. There is no read of every file. - The body is application/vnd.skywalking.asz.files+ndjson: for each file a naming line {file, seq, lines, bytes, digest}, exactly that many bytes, and a newline after a non-empty file that does not end with one. Files come in seq order, the order provider bodies are read in. - The read takes its time range from the newest intact round, reading the rounds down from the head only until one is intact, then reads one storage window of files and hands it to the response before reading the next. Windows are produced one at a time and cannot overflow. - The route compresses with gzip itself, a chunk at a time. Armeria's encoder keeps every compressed chunk of a response in one growing buffer until the response ends. The view route still uses it. - Both routes require the service and the sender's instance, as a list row names them, so every read is a full series lookup; serviceId goes. A bad coldStage gets a problem document, not Armeria's plain-text 400. - The e2e reads files through swctl by session and seq taken from the document, and passes the instance on every view and route call. The raw-files case counts the three Session Data files of the first conversation, since rounds are no longer served. The e2e pins swctl to the merge commit of apache/skywalking-cli#235, which adds swctl ai-agent files with --session and --seqs. --- docs/en/api/query-protocol.md | 19 +- docs/en/changes/changes.md | 2 +- .../en/setup/backend/ai-agent-conversation.md | 136 +- .../setup/backend/configuration-vocabulary.md | 9 +- .../AIAgentConversationConfig.java | 45 +- .../AIAgentConversationProvider.java | 28 +- .../agent/conversation/format/FileNames.java | 53 +- .../conversation/format/SessionDataFile.java | 140 +- .../conversation/query/ConversationFile.java | 47 + .../query/ConversationQueryService.java | 303 ++-- .../query/IConversationQueryService.java | 48 +- .../query/NoneConversationQueryService.java | 23 +- .../query/http/CompressResponse.java | 6 +- .../query/http/ConversationFilesHandler.java | 438 ++++++ .../query/http/ConversationViewHandler.java | 92 +- .../query/input/ConversationCondition.java | 31 - .../query/type/ConversationFileFormat.java | 29 - .../query/type/ConversationRawFile.java | 37 - .../query/type/ConversationRawFiles.java | 31 - .../view/ConversationViewBuilder.java | 439 +++++- .../ConversationViewBuilderTest.java | 168 +++ .../ai/agent/conversation/Fixtures.java | 30 + .../NoneAIAgentConversationProviderTest.java | 11 +- .../conversation/SessionFormatsTest.java | 19 +- .../query/ConversationQueryServiceTest.java | 228 ++- .../http/ConversationFilesHandlerTest.java | 352 +++++ .../http/ConversationViewHandlerTest.java | 28 +- .../resources/fixtures/asz-view-example.json | 2 + .../resources/fixtures/asz-view-example.yaml | 2 + .../asz-view-example.json | 396 +++++ ..._body-20260101T000000.000000000Z-000002.sd | 6 + .../r000001-0a33a0c2d269.sf | 16 + ...cript-20260101T000000.000000000Z-000001.sd | 7 + .../provider-bodies/asz-view-example.json | 1318 +++++++++++++++++ .../provider-bodies/asz-view-example.yaml | 957 ++++++++++++ .../meta-20260101T000000.000000000Z-000003.sd | 3 + ..._body-20260101T000000.000000000Z-000004.sd | 16 + .../provider-bodies/r000001-ff8eaba03b03.sf | 51 + ...cript-20260101T000000.000000000Z-000001.sd | 16 + ...cript-20260101T000000.000000000Z-000002.sd | 7 + .../workspace-changes/asz-view-example.json | 2 + .../workspace-changes/asz-view-example.yaml | 2 + .../resolver/AIAgentConversationQuery.java | 43 +- .../src/main/resources/query-protocol | 2 +- .../src/main/resources/application.yml | 19 +- .../src/main/resources/application.yml | 16 +- .../e2e-v2/cases/ai-agent/ai-agent-cases.yaml | 4 + .../ai-agent/banyandb/docker-compose.yml | 19 +- .../cases/ai-agent/es/docker-compose.yml | 19 +- .../expected/conversations-instance.yml | 2 +- .../cases/ai-agent/expected/conversations.yml | 10 + .../cases/ai-agent/expected/list-horizon.yml | 17 + .../cases/ai-agent/expected/metrics.yml | 36 +- .../ai-agent/expected/provider-bodies.yml | 89 ++ .../cases/ai-agent/expected/raw-files.yml | 2 +- test/e2e-v2/cases/ai-agent/expected/views.yml | 13 +- .../cases/ai-agent/mysql/docker-compose.yml | 19 +- .../ai-agent/postgres/docker-compose.yml | 19 +- .../cases/ai-agent/provider-bodies.yaml | 50 + test/e2e-v2/cases/ai-agent/verify.sh | 75 +- .../cases/storage/expected/config-dump.yml | 5 +- test/e2e-v2/script/env | 4 +- 62 files changed, 5422 insertions(+), 634 deletions(-) create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationFile.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationFilesHandler.java delete mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationCondition.java delete mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationFileFormat.java delete mode 100644 oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFile.java delete 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/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationFilesHandlerTest.java create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/asz-view-example.json create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/provider_body-20260101T000000.000000000Z-000002.sd create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/r000001-0a33a0c2d269.sf create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/transcript-20260101T000000.000000000Z-000001.sd create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.json create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.yaml create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/meta-20260101T000000.000000000Z-000003.sd create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/provider_body-20260101T000000.000000000Z-000004.sd create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-ff8eaba03b03.sf create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/transcript-20260101T000000.000000000Z-000001.sd create mode 100644 oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/transcript-20260101T000000.000000000Z-000002.sd create mode 100644 test/e2e-v2/cases/ai-agent/expected/provider-bodies.yml create mode 100644 test/e2e-v2/cases/ai-agent/provider-bodies.yaml diff --git a/docs/en/api/query-protocol.md b/docs/en/api/query-protocol.md index ae99878260c6..9891d7472a07 100644 --- a/docs/en/api/query-protocol.md +++ b/docs/en/api/query-protocol.md @@ -324,24 +324,27 @@ 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. +Provide [AI agent conversation](../setup/backend/ai-agent-conversation.md) query APIs since 11.1.0: the list page is a +GraphQL query; the conversation itself and its stored files are HTTP routes on the same server, because an `asz.view` +document is as large as the conversation and both are 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}] +GET /ai-agent/conversations/{conversation}/v1/view?service={serviceName}&instance={instanceName}[&coldStage=true] +GET /ai-agent/conversations/{conversation}/v1/files?service={serviceName}&instance={instanceName}&session={session}&seq={seq}[&seq={seq}...][&coldStage=true] ``` 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`. +`Accept-Encoding`. `v1` in the path is the document version. The files route streams up to 32 Session Data files of a session, +each chosen by its landed seq, as `application/vnd.skywalking.asz.files+ndjson`: for each file a line naming it, then the +file's bytes. The naming line carries `copies` when the storage holds that seq more than once, the file served being the +first. Both routes +answer 400 without the service or the instance, 404 when the sender stores no round of the conversation, 500 on a +storage failure, each as `application/problem+json`. ## Condition ### Duration diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md index 38864e6b71d3..721d3bd3e9ff 100644 --- a/docs/en/changes/changes.md +++ b/docs/en/changes/changes.md @@ -12,7 +12,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` (with optional `conversation` and `title` conditions) / `getConversationRawFiles` GraphQL queries and the streamed `GET /ai-agent/conversations/{conversation}/v1/view` route that serves the document. The view and raw-file export leave BanyanDB's stage selection unspecified by default and select only the cold stage when the caller explicitly sets `coldStage` to true. A round from before the list attributes existed lands and lists with zero counts, and the view shows as much as landed: the chain resumes after a missing, unreadable or refused round, and the absent rounds and files are named once as ranges. A file over `maxFileBytes`, 15 MiB by default, is rejected at ingest and counted under the reason `size`, because one file over BanyanDB's 16 MiB gRPC message limit fails the bulk write it travels in and every record behind it; on MySQL the body column is `LONGTEXT`, since a body that size outgrows `MEDIUMTEXT` as Base64. Each window read is capped at `maxResponseBytes`, 100 MiB by default, as a per-call option on the BanyanDB client in place of its 50 MB default, so the module's reads are bounded by its own settings and nothing else's read changes. The document also carries the session's workspace changes: the Sessionizer's Claude Code plugin lands a Session Data file of kind `changes` beside a stream's transcript, `streams//changes--.sd`, with one change record per observed tool call, stored like any other file, and the runtime's own patch for its editing tools travels as the second data part of the call's result record; `workspace_changes` lists every record joined to its step by tool-use id with the record's fields as `changes/1` lists them, `summary.changes` counts them, and a tool step names its records under `changes`. A round's header counts, `changes`, `lines_added`, `lines_removed`, `llm_calls`, `subagents` and `bash_runs`, land on `ai_agent_session_flow` and reach the conversation list as `ConversationRow` fields, absent rather than zero when the round did not carry them. The layer also takes the agent runtime's own metrics, in the `otel-rules/ai-agent/` rule set enabled by default: Claude Code's `claude_code.token.usage`, as its own exporter sends it or as the Sessionizer derives it from the landed transcripts, per service and per sender, by type, model and query source with `session.id` summed away, plus the exporter's cost, active time, sessions, lines of code, commits, pull requests and edit decisions when they arrive. +* 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, the `listConversations` GraphQL query (with optional `conversation` and `title` conditions), and two streamed HTTP routes beside `/graphql`: `GET /ai-agent/conversations/{conversation}/v1/view`, which serves the document, and `GET /ai-agent/conversations/{conversation}/v1/files`, which serves up to 32 Session Data files of a session, each chosen by its landed seq, as `application/vnd.skywalking.asz.files+ndjson`, a line naming each file followed by the file's bytes, so a page loads the files a step points at only when a reader opens it; there is no read of every file. Both routes require the service and the sender's instance, as a list row names them, so every read is a full series lookup. The view and the files route leave BanyanDB's stage selection unspecified by default and select only the cold stage when the caller explicitly sets `coldStage` to true. A round from before the list attributes existed lands and lists with zero counts, and the view shows as much as landed: the chain resumes after a missing, unreadable or refused round, and the absent rounds and files are named once as ranges. A file over `maxFileBytes`, 15 MiB by default, is rejected at ingest and counted under the reason `size`, because one file over BanyanDB's 16 MiB gRPC message limit fails the bulk write it travels in and every record behind it; on MySQL the body column is `LONGTEXT`, since a body that size outgrows `MEDIUMTEXT` as Base64. A conversation's rounds and files are read `readWindow` at a time, 16 by default, and each of those reads is capped at `maxResponseBytes`, 100 MiB by default, as a per-call option on the BanyanDB client in place of its 50 MB default, so the module's reads are bounded by its own settings and nothing else's read changes. The document also carries the session's workspace changes: the Sessionizer's Claude Code plugin lands a Session Data file of kind `changes` beside a stream's transcript, `streams//changes--.sd`, with one change record per observed tool call, stored like any other file, and the runtime's own patch for its editing tools travels as the second data part of the call's result record; `workspace_changes` lists every record joined to its step by tool-use id with the record's fields as `changes/1` lists them, `summary.changes` counts them, and a tool step names its records under `changes`. The document also names the session's provider bodies: the request and response bodies a runtime exchanged with its model provider land as Session Data files of kind `provider_body`, `/provider_body/provider_body--.sd`, stored like any other file; an `llm.call` step lists where its request and response landed under `provider_bodies`, joined by the bodies' own ids, and `summary.provider_bodies` and `summary.captured_prompts` count them. A round's header counts, `changes`, `lines_added`, `lines_removed`, `llm_calls`, `subagents` and `bash_runs`, land on `ai_agent_session_flow` and reach the conversation list as `ConversationRow` fields, absent rather than zero when the round did not carry them. The layer also takes the agent runtime's own metrics, in the `otel-rules/ai-agent/` rule set enabled by default: Claude Code's `claude_code.token.usage`, as its own exporter sends it or as the Sessionizer derives it from the landed transcripts, per service and per sender, by type, model and query source with `session.id` summed away, plus the exporter's cost, active time, sessions, lines of code, commits, pull requests and edit decisions when they arrive. * Support querying Zipkin traces from the BanyanDB cold stage through the Zipkin HTTP query API. `/api/v2/traces` takes an optional `coldStage` parameter, and `/api/v2/trace/{traceId}` and `/api/v2/traceMany` take optional `coldStage`, `endTs` and `lookback` parameters; the two by-id lookups had no time range at all before. All of them are SkyWalking additions to the Zipkin API and default to the previous behavior, so existing Zipkin clients such as the Lens UI keep working unchanged, and storages other than BanyanDB ignore `coldStage`. The admin debugging endpoints `/debugging/query/zipkin/api/v2/traces` and `/debugging/query/zipkin/api/v2/trace` accept the same parameters. The BanyanDB stages e2e now seeds Zipkin traces into the generated cold data and verifies the three endpoints against it. * BanyanDB: a query without a time range now covers everything the group's hot/warm stages retain instead of only the last 24 hours. The hard-coded day was a fallback added with the trace model, which requires a time range; it made `queryTrace(traceId)` without a `duration`, the Zipkin `/api/v2/trace/{traceId}` and `/api/v2/traceMany` lookups, and the TraceQL by-id lookup return "not found" for any trace older than a day even though it was still retained. The unbounded range is now bound instead, and BanyanDB's own retention limits the scan. `queryTraces` now rejects a condition with neither `queryDuration` nor `traceId` instead of silently searching the last day; a `traceId` lookup without `queryDuration` searches everything the hot/warm stages retain and reports a 0-to-now `retrievedTimeRange`. * Fix the JDBC storages, MySQL, PostgreSQL and H2, reading only the first day's table of a time range shorter than a day that crosses midnight. `TableHelper` walked the range in 24-hour steps from its first instant, so a range that began late on one day and ended early on the next never reached the second day; every time-ranged read, metrics, traces, logs, alarms, records and the AI agent conversation list, was missing the newer day's rows for the length of the range after each midnight, the last thirty minutes read at 00:10 among them. The range is now walked by calendar day from the start of its first day. diff --git a/docs/en/setup/backend/ai-agent-conversation.md b/docs/en/setup/backend/ai-agent-conversation.md index dd16f441c231..5de27bc5b8e0 100644 --- a/docs/en/setup/backend/ai-agent-conversation.md +++ b/docs/en/setup/backend/ai-agent-conversation.md @@ -75,8 +75,8 @@ service or sender makes another. ## 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. +The list is a GraphQL query in `ai-agent-conversation.graphqls`. The conversation itself and its stored files are +HTTP routes on the same server, because a document is as large as the conversation, and the files larger still. - `listConversations(condition, duration)` lists one row per conversation of a service, optionally of one sender, from the newest round's attributes: its title, talks, steps, streams, segments and unresolved references, and the counts @@ -86,15 +86,11 @@ HTTP route on the same server, because its document is as large as the conversat one conversation by id, and an optional `title` keeps only the rows whose title contains the text, case-insensitively — matched after folding, on the newest round's title, so it never widens the rounds read. On BanyanDB, `duration.coldStage: true` selects the cold stage; otherwise the query uses the default hot/warm stages. -- `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. On BanyanDB, `condition.coldStage: true` selects the cold stage, - defaulting to the hot/warm stages when omitted or false. ### The conversation view route ``` -GET /ai-agent/conversations/{conversation}/v1/view?service={serviceName}[&instance={instanceName}][&coldStage=true] +GET /ai-agent/conversations/{conversation}/v1/view?service={serviceName}&instance={instanceName}[&coldStage=true] ``` It answers with the whole conversation, once, as one `asz.view` version 1.0 document, the document the @@ -115,13 +111,13 @@ 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 | +| `service` | required, the service name | +| `instance` | required, the sender's instance name, as the list row names it, so every storage read is a full series lookup. Ingest stores an empty instance as `unknown`, so every row names one. A conversation whose sender was renamed partway has rounds and files under two instances; the route reads the named one, and the document names what it did not find under `summary.problems` | | `coldStage` | optional, false by default. On BanyanDB, true selects only the cold stage; otherwise the read uses the default hot/warm stages. The UI passes its selected stage when opening a conversation. Other storages ignore it. | | `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": "..."}` | +| status | 200 with the document; 400 when the service or the instance is not named, or when `coldStage` is neither true nor false; 404 when the sender 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 @@ -129,7 +125,57 @@ rendered, never held whole in memory, and a slow client holds back the render. T 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. +The conversation page of the UI opens a conversation with this route. What a step only points at, such as the provider +bodies of an `llm.call`, it loads through the files route when a reader opens it. + +### The conversation files route + +``` +GET /ai-agent/conversations/{conversation}/v1/files?service={serviceName}&instance={instanceName}&session={session}&seq={seq}[&seq={seq}...][&coldStage=true] +``` + +It answers with chosen Session Data files of a conversation's session, streamed, so a page loads what a step points at, +such as the provider bodies of an `llm.call`, only when a reader opens it. A file is chosen by its session and its +landed seq: the Sessionizer assigns a seq once per file within a session, one counter for every stream and kind, and +the storage reads a file by exactly those two. The document's `files[]` gives every file's `seq` and its name, whose +first segment is its session. There is no read of every file: a reader chooses each one. The route reads the named +session under the named sender, so the session is the caller's to choose, within what that sender stores. + +| Parameter or header | Meaning | +|---|---| +| `service`, `instance`, `coldStage` | as for the view route | +| `session` | required, the session the files belong to | +| `seq` | required, one to 32 times, a file's landed seq. The Sessionizer cuts a file at 2 MiB, so a response holds about 64 MiB at most; a reader wanting more asks again | +| `Accept` | chooses the format. There is one, which any `Accept` gets: `application/vnd.skywalking.asz.files+ndjson` | +| `Accept-Encoding` | the body is compressed with gzip when the client allows. The route compresses it itself, a chunk at a time, so nothing compressed accumulates in memory | +| status | 200 with the files, none when no seq is stored; 400 when the service, the instance or the session is not named, when no seq is, when more than 32 are, when one is not a positive whole number, or when `coldStage` is neither true nor false; 404 when the sender stores no round of the conversation; 500 on a storage failure before the first file. A failure after the first file ends the response early. | + +For each stored file, the body holds a naming line, then the file: + +``` +{"file":"/provider_body/provider_body--000004.sd","seq":4,"lines":16,"bytes":27874,"digest":"..."} +{"h":1,"schema":"sd/1","seq":4,"kind":"provider_body",...} +... +{"t":"end","records":14,"digest":"..."} +``` + +The naming line carries the file's name as the document lists it, its `seq`, its own newline count `lines`, its size +`bytes`, and the sha256 of its bytes `digest`. It also carries `copies` where the read saw that seq more than +once, which happens when the same seq was stored with different bytes - two roots of one session pushed by one sender, +after a repack. The file served is the first, and `copies` says the others are there, so a reader can say so rather +than show one copy as the whole truth; the field is absent when there is one. It counts what the read returned rather +than what the storage holds, since a storage caps what one query answers with, so read it as "more than one". Exactly `bytes` bytes follow: the file, +byte for byte. A non-empty file +that does not end with a newline is followed by one, which is not part of it, so the next naming line starts a line; an +empty file is followed by nothing. A file the Sessionizer wrote ends with a newline, so a reader may equally take +`lines` lines. Nothing in a file is escaped. The files come in seq order, which is the order a reader must add provider +bodies in, because a body refers to pieces and bodies that landed before it. A seq no stored file answers is left out +rather than failing the request. A line can be as large as the largest file, so a reader must not assume short lines. + +The files are read one storage window at a time and each window is written before the next is read, so a response is +never held whole. The files are read over the time range of the conversation's newest intact round, from its session's +first activity to its last or the round's own stored time, whichever is later, even when the view cannot fold that +round; up to the head round's own time when no round is intact. A file stamped outside that range is left out. ## Workspace changes @@ -164,6 +210,38 @@ it. The record and the entry are defined by the Sessionizer under the plugin under [The Claude Code plugin](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/setup/claude-code-plugin/). +## Provider bodies + +The Sessionizer can also land the request and response bodies an agent runtime exchanged with its model provider. +A request carries what no transcript records: the system prompt, the tool definitions and the reminders the runtime +inserted. Every call sends its whole message list again, so the Sessionizer cuts each body into what the session +did not hold yet and a manifest that rebuilds it byte for byte, from its own pieces and from pieces and bodies that +landed before it. + +- **A `provider_body` file**, a Session Data file of kind `provider_body`, one directory for the session, + `/provider_body/provider_body--.sd`, one record per body. It lands, is verified and is stored + like any other Session Data file, and a round's window covers it. Nothing about it is decoded at ingest, and a body + is never rebuilt by the OAP. + +In the `asz.view` document: + +- an `llm.call` step lists its bodies under `provider_bodies`, its request and then its response, each as its `role` + and the `ref` of the landed record, never the body itself; +- `summary.provider_bodies` counts the session's bodies, and `summary.captured_prompts` the calls whose request is + listed. + +A response joins to the call whose message id it carries. A request joins to the call of a stream whose previous +call's response carries the request id the request names, and whose prompt is the prompt the request names, when +exactly one request and one call carry those two ids. A synthetic call takes part in no join, and no request joins in +a stream whose landed transcript lines have a gap. Nothing is matched by position or by time. A body refers to earlier +records of the same session, sometimes in an earlier file, so a reader that wants a body takes the `provider_body` +entries of `files[]` with a seq up to the one its `ref` names, reads them through the files route by session and seq, +and rebuilds the body as the Sessionizer describes. A session folds to +the same nodes with and without its `provider_body` files. The record, the manifest and the join are defined by the +Sessionizer under +[Session Data](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/session-data/) and +[The asz.view document](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/asz-view/). + ## Configuration ```yaml @@ -171,29 +249,26 @@ ai-agent-conversation: selector: ${SW_AI_AGENT_CONVERSATION:default} none: 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} + conversationListMaxLimit: ${SW_AI_AGENT_CONVERSATION_LIST_MAX_LIMIT:10000} viewRequestTimeout: ${SW_AI_AGENT_CONVERSATION_VIEW_REQUEST_TIMEOUT:120} - maxFileBytes: ${SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES:15728640} + readWindow: ${SW_AI_AGENT_CONVERSATION_READ_WINDOW:16} maxResponseBytes: ${SW_AI_AGENT_CONVERSATION_MAX_RESPONSE_BYTES:104857600} + maxFileBytes: ${SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES:15728640} ``` | Key | Meaning | |------------------|---------------------------------------------------------------------------------------------------------------------------------------------| -| `fileReadWindow` | how many Session Data files one storage query fetches, a batch size and not a limit: the view and the raw-file export read every file of the conversation, this many per query. Files are cut at 2 MiB, so a window is a few tens of megabytes; the window times `maxFileBytes` is the most one query can answer with. | -| `roundReadWindow` | how many Session Flow rounds one storage query fetches, the same way: the head round is fixed first, then the chain is read from round 1 to the head, this many per query. A round is cut at 2 MiB by the Sessionizer, and the same bound applies. | -| `maxListLimit` | the most rounds one list query reads before folding, and the ceiling of the query's `limit` argument. | +| `conversationListMaxLimit` | the most rounds one list query reads before folding, and the ceiling of the query's `limit` argument. It counts rounds, not conversations, so a busy conversation spends the budget of the quiet ones and a quiet one can fall off the list. | | `viewRequestTimeout` | how long one conversation view request may take, in seconds. | -| `maxFileBytes` | the largest file stored, in bytes; a larger one is rejected at ingest and counted under the reason `size`. 15 MiB by default, under BanyanDB's 16 MiB gRPC message limit. The Sessionizer cuts files and rounds at 2 MiB; only a round from before that cut is larger. | -| `maxResponseBytes` | the most bytes one window read may answer with, applied to that read alone on a storage that caps a response per call. The BanyanDB client holds every other read to 50 MB; this module's two window reads carry it as a call option on the same connection, so nothing else changes. 100 MiB by default, above sixteen files at the 2 MiB cut with room for files landed whole. For a root of larger files, raise it or lower the windows, so that the window times `maxFileBytes` stays under it; a read over the limit fails as a storage error. | +| `readWindow` | how many Session Data files, or Session Flow rounds, one storage query fetches. A batch size and not a limit: a view reads every round of the chain and every file of the conversation, and the files route the named ones, this many per query, so a conversation of 865 rounds is 55 queries at 16. Raising it trades bytes in one response for round trips, which are most of the wait before a view's first byte; it must stay within `maxResponseBytes`. Both are cut at 2 MiB by the Sessionizer, so a window is a few tens of megabytes. | +| `maxResponseBytes` | the most bytes one storage query may answer with. **BanyanDB alone accepts it**, carried as a call option on the shared client in place of the 50 MB it holds every other read to, so nothing else's read changes; Elasticsearch and JDBC ignore it and bound a read by hits and by rows. 100 MiB by default, above sixteen files at the 2 MiB cut with room for files landed whole. For a root whose files land whole, raise it or lower `readWindow`; a read over the limit fails as a storage error. | +| `maxFileBytes` | the largest file stored, in bytes; a larger one is rejected at ingest and counted under the reason `size`. 15 MiB by default, under BanyanDB's 16 MiB gRPC message limit. The Sessionizer cuts files at 2 MiB, so only a record landed whole comes near it; a test lowers this to prove the rejection without pushing a file that size. | ### Turning the feature off The GraphQL query module requires this module, so the `-` selector cannot remove it; `SW_AI_AGENT_CONVERSATION=none` -selects the `none` provider instead, which answers `listConversations` and `getConversationRawFiles` with an empty -result and an `errorReason` saying the module is disabled, and registers no conversation view route, so a `GET` on it -is a 404. +selects the `none` provider instead, which answers `listConversations` with an empty result and an `errorReason` saying +the module is disabled, and registers no conversation route, so a `GET` on the view or the files route is a 404. It also disables the two record models, so nothing of the feature reaches the storage: neither table is created, nor the BanyanDB `recordsAIAgent` group, whose only members they are. A file the bundled LAL rule still verifies is @@ -203,18 +278,19 @@ dropped for want of a record worker; drop `ai-agent` from `SW_LOG_LAL_FILES` as - The OAP's OTLP/HTTP endpoint accepts requests of up to 10 MiB, the HTTP server's default. The Sessionizer's request budget defaults to 8 MiB for that reason; a single file is cut at 2 MiB, so it always fits. -- The files of a conversation are read in windows of `fileReadWindow` files, and its rounds in windows of - `roundReadWindow` rounds, per storage query, inside one view request. On BanyanDB each of those queries may answer +- The files of a conversation, and its rounds, are read in windows of `readWindow` per storage query, inside one + view request. On BanyanDB each of those queries may answer with up to `maxResponseBytes`, 100 MiB by default, as a call option on the shared client in place of its 50 MB - default, which every other read keeps; the window times `maxFileBytes` must stay under it. Elasticsearch answers + default, which every other read keeps. Elasticsearch answers at most 10,000 hits to one search. -- The view and the export read over the retention window of the caller's selected stages. On BanyanDB, the +- The view and the files route read over the retention window of the caller's selected stages. On BanyanDB, the default is hot/warm; cold is queried only when the caller explicitly sets `coldStage: true`. Every round and file read uses that same selection. A conversation spanning stages can therefore report missing rounds or files that are outside the selected stages. -- 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. +- Both routes read one sender, the one the caller names, so every read is a full series lookup. A Sessionizer whose + instance was renamed between pushes leaves a conversation's rounds and files under two instances; reading the + newer instance, the document names what it did not find under `summary.problems`. A file or round the one sender + pushed twice is kept once. - 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 1f7799edf923..48597a77dc2c 100644 --- a/docs/en/setup/backend/configuration-vocabulary.md +++ b/docs/en/setup/backend/configuration-vocabulary.md @@ -173,12 +173,11 @@ It divided into several modules, each of which has its own settings. The followi | - | - | 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 | +| - | - | readWindow | How many Session Data files, or Session Flow rounds, one storage query fetches; a conversation is read batch by batch up to its head. | SW_AI_AGENT_CONVERSATION_READ_WINDOW | 16 | +| - | - | conversationListMaxLimit | The most rounds one list query reads before folding to one row per conversation. | SW_AI_AGENT_CONVERSATION_LIST_MAX_LIMIT | 10000 | | - | - | viewRequestTimeout | How long one conversation view request may take, in seconds, in place of the HTTP server's default. | SW_AI_AGENT_CONVERSATION_VIEW_REQUEST_TIMEOUT | 120 | -| - | - | maxFileBytes | The largest file stored, in bytes; a larger one is rejected at ingest and counted under the reason `size`. Under BanyanDB's 16 MiB gRPC message limit. | SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES | 15728640 | -| - | - | maxResponseBytes | The most bytes one window read may answer with, applied to that read alone where the storage caps a response per call, in place of the BanyanDB client's 50 MB. | SW_AI_AGENT_CONVERSATION_MAX_RESPONSE_BYTES | 104857600 | +| - | - | maxResponseBytes | The most bytes one storage query may answer with. BanyanDB alone accepts it, in place of its client's 50 MB; Elasticsearch and JDBC ignore it. | SW_AI_AGENT_CONVERSATION_MAX_RESPONSE_BYTES | 104857600 | +| - | - | maxFileBytes | The largest file stored, in bytes; a larger one is rejected at ingest under the reason `size`. Below BanyanDB's 16 MiB message limit. | SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES | 15728640 | | - | none | - | Turns the feature off: the `ai_agent_session_data` and `ai_agent_session_flow` models are not created in the storage, every conversation query answers empty and no conversation view route is registered. The GraphQL query module requires this module, so the `-` selector cannot remove it. | - | - | | event-analyzer | default | Event Analyzer. | SW_EVENT_ANALYZER | default | | | receiver-register | default | gRPC and HTTPRestful services that provide service, service instance and endpoint register. | - | - | | 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 index 0a33a68e58e0..432352a457ae 100644 --- 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 @@ -26,38 +26,35 @@ @Setter public class AIAgentConversationConfig extends ModuleConfig { /** - * How many Session Data files one storage read fetches. Files are cut at 2 MiB, so a window is a few tens of - * megabytes; the window times {@link #maxFileBytes} bounds what one read can answer with, and - * {@link #maxResponseBytes} must cover it. + * The most rounds one conversation list reads, and the ceiling of the limit a caller may ask for. It + * counts rounds, not conversations: the newest rounds in the window are folded to one row each, so a + * busy conversation spends the budget of the quiet ones and a quiet one can fall off the list. */ - private int fileReadWindow = 16; - /** - * How many Session Flow rounds one storage read fetches. A round is cut at 2 MiB by the Sessionizer, and the - * same bound as for files applies. - */ - private int roundReadWindow = 16; - /** - * The most bytes one window read may answer with, applied to that read alone on a storage that caps a - * response per call: BanyanDB's client holds every other read to 50 MB. 100 MiB by default, above sixteen - * files at the 2 MiB cut with room for files landed whole; a root of larger files needs it raised or the - * windows lowered, so that the window times {@link #maxFileBytes} stays under it. - */ - private int maxResponseBytes = 100 * 1024 * 1024; + private int conversationListMaxLimit = 10000; /** * 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. + * ten. The whole chain is folded before the first byte is written, 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. + * How many Session Data files, or Session Flow rounds, one storage query fetches. A batch size and not a + * limit: a view reads every round and every file of its conversation, this many per query, so raising it + * trades bytes in one response for round trips. It must stay within {@link #maxResponseBytes}. + */ + private int readWindow = 16; + /** + * The most bytes one storage query may answer with. BanyanDB alone accepts it, as a per-call option + * raising the 50 MB its client holds every other read to; Elasticsearch and JDBC ignore it and bound a + * read by hits and by rows. A window of sixteen files at the Sessionizer's 2 MiB cut is a few tens of + * megabytes, so only a root whose files land whole needs this raised. */ - private int maxListLimit = 10000; + private int maxResponseBytes = 100 * 1024 * 1024; /** - * The largest file stored, in bytes; a larger one is rejected at ingest and counted under the reason - * size. Below BanyanDB's 16 MiB gRPC message limit, because one file over the limit fails the - * storage write it travels in, and every record behind it in that write is lost with it. The Sessionizer cuts files and rounds at 2 MiB; a round from before that cut can be larger. + * The largest file stored; a larger one is rejected at ingest and counted under the reason + * size, because one file over the storage's message limit fails the bulk write it travels + * in and every record behind it. 15 MiB, under BanyanDB's 16 MiB; lowering it is how a test proves the + * rejection without pushing a file that size. */ private int maxFileBytes = 15 * 1024 * 1024; } diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationProvider.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/AIAgentConversationProvider.java index c5a737ae7ed9..ac11ee96e4bc 100644 --- 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 @@ -24,6 +24,7 @@ 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.ConversationFilesHandler; 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; @@ -68,21 +69,18 @@ public void onInitialized(final AIAgentConversationConfig initialized) { @Override public void prepare() throws ServiceNotProvidedException, ModuleStartException { - if (config.getFileReadWindow() <= 0) { - throw new ModuleStartException("fileReadWindow should be greater than 0"); + if (config.getReadWindow() <= 0) { + throw new ModuleStartException("readWindow should be greater than 0"); } - if (config.getRoundReadWindow() <= 0) { - throw new ModuleStartException("roundReadWindow should be greater than 0"); + if (config.getMaxFileBytes() <= 0) { + throw new ModuleStartException("maxFileBytes should be greater than 0"); } - if (config.getMaxListLimit() <= 0) { - throw new ModuleStartException("maxListLimit should be greater than 0"); + if (config.getConversationListMaxLimit() <= 0) { + throw new ModuleStartException("conversationListMaxLimit should be greater than 0"); } if (config.getViewRequestTimeout() <= 0) { throw new ModuleStartException("viewRequestTimeout should be greater than 0"); } - if (config.getMaxFileBytes() <= 0) { - throw new ModuleStartException("maxFileBytes should be greater than 0"); - } if (config.getMaxResponseBytes() <= 0) { throw new ModuleStartException("maxResponseBytes should be greater than 0"); } @@ -92,12 +90,12 @@ public void prepare() throws ServiceNotProvidedException, ModuleStartException { @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 HTTPHandlerRegister http = getManager().find(CoreModule.NAME) + .provider() + .getService(HTTPHandlerRegister.class); + final Duration timeout = Duration.ofSeconds(config.getViewRequestTimeout()); + http.addHandler(new ConversationViewHandler(queryService, timeout), Collections.singletonList(HttpMethod.GET)); + http.addHandler(new ConversationFilesHandler(queryService, timeout), Collections.singletonList(HttpMethod.GET)); final MetricsCreator metricsCreator = getManager().find(TelemetryModule.NAME) .provider() .getService(MetricsCreator.class); 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 index 21e30420a6d3..a3a9e9b324dc 100644 --- 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 @@ -19,17 +19,11 @@ 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. + * derived on read. A query chooses a file by what the storage holds, its session and its seq, not by its name. * *
  * <session>/streams/<stream>/transcript-<stamp>-<seq>.sd
@@ -38,15 +32,11 @@
  * <session>/runs/<run>/journal-<stamp>-<seq>.sd
  * <session>/runs/<run>/manifest-<stamp>-<seq>.sd
  * <session>/runs/<run>/script-<stamp>-<seq>.sd
+ * <session>/provider_body/provider_body-<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() { } @@ -83,6 +73,12 @@ public static String dataFile(final SessionDataFile.Header header) { prefix = "script"; dir = "runs/" + header.getBatch(); break; + case "provider_body": + // the bodies a runtime exchanged with its model provider, one directory for the session: a body is + // evidence beside a call of any stream, and a later body refers to earlier ones of every stream + prefix = "provider_body"; + dir = "provider_body"; + break; default: prefix = header.getKind() == null ? "file" : header.getKind(); dir = StringUtil.isNotEmpty(header.getStream()) @@ -106,37 +102,4 @@ public static String roundFile(final String conversation, final long round, fina 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/SessionDataFile.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java index 41a4a68f8db2..08c255dda9be 100644 --- 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 @@ -46,13 +46,19 @@ public final class SessionDataFile { 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; + /** + * Whether the records end at a line that does not decode, before the closing line. The Sessionizer's raw reader + * still returns that line, and a reader that needs every line, such as the gap check, must know it is there. + */ + private final boolean stoppedEarly; /** 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) { + final String fileDigest, final boolean stoppedEarly, final long fromTime, + final long throughTime) { this.header = header; this.records = records; this.declaredRecords = declaredRecords; @@ -60,6 +66,7 @@ private SessionDataFile(final Header header, final List records, final i this.lines = lines; this.bytes = bytes; this.fileDigest = fileDigest; + this.stoppedEarly = stoppedEarly; this.fromTime = fromTime; this.throughTime = throughTime; } @@ -89,17 +96,20 @@ public static SessionDataFile parse(final byte[] body) { String declaredDigest = null; long from = 0; long through = 0; + boolean stoppedEarly = false; // 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()) { + stoppedEarly = true; break; } final JsonObject json; try { json = JsonParser.parseString(line).getAsJsonObject(); } catch (final RuntimeException e) { + stoppedEarly = true; break; } if (i == lineCount - 1 && "end".equals(string(json, "t"))) { @@ -108,6 +118,13 @@ public static SessionDataFile parse(final byte[] body) { break; } final Record record = new Record(i, line, json); + if (!record.decodes()) { + // The Sessionizer decodes a whole typed record and stops the file where one does not: a + // record whose `off` is a string, say. Reading past it here would report bodies its own + // reader never sees. + stoppedEarly = true; + break; + } records.add(record); final long time = record.getTime(); if (time != 0) { @@ -121,7 +138,24 @@ public static SessionDataFile parse(final byte[] body) { } return new SessionDataFile( header, Collections.unmodifiableList(records), declaredRecords, declaredDigest, - Digests.countLines(body), body.length, Digests.sha256Hex(body), from, through); + Digests.countLines(body), body.length, Digests.sha256Hex(body), stoppedEarly, from, through); + } + + /** + * @param body the file bytes as stored + * @return the header line alone, without reading the records: what the file is and where it belongs + * @throws IllegalArgumentException when the first line is not a Session Data header + */ + public static Header header(final byte[] body) { + int end = 0; + while (end < body.length && body[end] != '\n') { + end++; + } + final JsonObject json = JsonParser.parseString(new String(body, 0, end, StandardCharsets.UTF_8)).getAsJsonObject(); + if (!json.has("h")) { + throw new IllegalArgumentException("the first line is not a Session Data header"); + } + return new Header(json); } /** @@ -203,9 +237,16 @@ public static final class Record { /** The same moment in nanoseconds, the precision the Sessionizer computes intervals with. */ private final long timeNanos; private final List parts; + /** + * The digits after the line's leading {"ord":, as the Sessionizer reads an ord without decoding + * the line; empty when none follow, and null when the line does not start so. + */ + @Nullable + private final String leadingOrd; Record(final int row, final String line, final JsonObject json) { this.row = row; + this.leadingOrd = leadingOrd(line); this.json = json; this.id = string(json, "id"); this.timeNanos = Times.nanos(string(json, "time")); @@ -223,6 +264,19 @@ public static final class Record { this.parts = Collections.unmodifiableList(list); } + @Nullable + private static String leadingOrd(final String line) { + final String prefix = "{\"ord\":"; + if (!line.startsWith(prefix)) { + return null; + } + int i = prefix.length(); + while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') { + i++; + } + return line.substring(prefix.length(), i); + } + /** * @return the record's readable text: every text part joined by a newline, as the * Sessionizer's Record.Text() returns it @@ -280,10 +334,90 @@ private List strings(final String key) { } final List out = new ArrayList<>(); for (final JsonElement x : e.getAsJsonArray()) { - out.add(x.getAsString()); + // a null element decodes to the empty string, as it does into a Go []string + out.add(x.isJsonNull() ? "" : x.getAsString()); } return out; } + + /** + * @return whether the Sessionizer's own reader decodes this record. Its fields are typed, so a value + * of another type fails the whole record there, and the file's records end with it. Only the types + * are checked here; what the values mean is the reader's business. + */ + boolean decodes() { + for (final String key : STRING_FIELDS) { + final JsonElement v = json.get(key); + if (v != null && !v.isJsonNull() && !(v.isJsonPrimitive() && v.getAsJsonPrimitive().isString())) { + return false; + } + } + for (final String key : UNSIGNED_FIELDS) { + if (!wholeNumber(json.get(key), true)) { + return false; + } + } + if (!wholeNumber(json.get("bytes"), false)) { + return false; + } + for (final String key : ARRAY_FIELDS) { + final JsonElement v = json.get(key); + if (v != null && !v.isJsonNull() && !v.isJsonArray()) { + return false; + } + } + final JsonElement flags = json.get("flags"); + if (flags != null && flags.isJsonArray()) { + for (final JsonElement x : flags.getAsJsonArray()) { + if (!x.isJsonNull() && !(x.isJsonPrimitive() && x.getAsJsonPrimitive().isString())) { + return false; + } + } + } + return true; + } + + /** + * @param unsigned whether the Go type is an unsigned 64-bit integer rather than a signed one + * @return whether the value is absent, null, or a number Go decodes into that type: no fraction, no + * exponent, no sign where the type has none, and within its range. A JSON number this reader would + * round is one the Sessionizer refuses outright. + * + *

The record's own fields are checked, and the shape of the lists it carries; what is inside + * `parts`, `dropped` and `usage` is not. That is on purpose: Go ignores a field it does not declare, + * so a check that guessed at those types would refuse records the Sessionizer reads, and refusing + * one hides it and every record behind it in the file. Reading a record the Sessionizer would refuse + * is the safer way to be wrong. + */ + private static boolean wholeNumber(@Nullable final JsonElement v, final boolean unsigned) { + if (v == null || v.isJsonNull()) { + return true; + } + if (!v.isJsonPrimitive() || !v.getAsJsonPrimitive().isNumber()) { + return false; + } + final String text = v.getAsString(); + if (text.indexOf('.') >= 0 || text.indexOf('e') >= 0 || text.indexOf('E') >= 0) { + return false; + } + if (unsigned && text.startsWith("-")) { + return false; + } + try { + final java.math.BigInteger n = new java.math.BigInteger(text); + return unsigned ? n.bitLength() <= 64 : n.bitLength() < 64; + } catch (final NumberFormatException e) { + return false; + } + } + + private static final String[] STRING_FIELDS = { + "sha", "id", "parent", "call", "run", "continues", "tool", "child", "batch", "label", + "started_by", "from", "time", "trigger", "model" + }; + private static final String[] UNSIGNED_FIELDS = {"ord", "off"}; + + private static final String[] ARRAY_FIELDS = {"flags", "parts", "dropped"}; } /** diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationFile.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationFile.java new file mode 100644 index 000000000000..988bfae2ea26 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationFile.java @@ -0,0 +1,47 @@ +/* + * 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 lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * One stored Session Data file of a conversation's session, with its bytes as they were sent. + */ +@Getter +@RequiredArgsConstructor +public final class ConversationFile { + /** The file's relative path in the Sessionizer's storage root. */ + private final String id; + /** The file's landed seq, unique within its session. */ + private final long seq; + /** The sha256 of the body, as stored. */ + private final String digest; + private final byte[] body; + /** + * How many files this read saw under that sequence. One, unless the same sequence was stored with + * different bytes, which takes two roots of one session pushed by one sender. The file served is the + * first; a reader is told the others are there so it can say so rather than show one copy as the truth. + * + *

It counts what the read returned, not what the storage holds: Elasticsearch caps a search by hits + * and BanyanDB by its result window, so a sequence stored more times than a window carries counts as + * what came back. It says "more than one", never "exactly this many". + */ + private final int copies; +} 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 index be5765412f70..f1649e7e9fce 100644 --- 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 @@ -19,16 +19,21 @@ 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.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.NoSuchElementException; import java.util.Set; +import java.util.function.BooleanSupplier; import java.util.TreeMap; +import java.util.TreeSet; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationConfig; @@ -37,10 +42,7 @@ 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; @@ -87,7 +89,7 @@ public ConversationList listConversations(final String serviceId, final Duration duration, @Nullable final Integer limit) throws IOException { final int rounds = Math.min( - limit == null || limit <= 0 ? DEFAULT_LIST_LIMIT : limit, config.getMaxListLimit()); + limit == null || limit <= 0 ? DEFAULT_LIST_LIMIT : limit, config.getConversationListMaxLimit()); final List newestFirst = dao().queryRoundsDebuggable( serviceId, serviceInstanceId, StringUtil.isEmpty(conversation) ? null : conversation.trim(), duration, rounds, false); @@ -153,8 +155,9 @@ private static String instanceName(final String instanceId) { @Nullable public Map buildConversationView(final String serviceId, @Nullable final String serviceInstanceId, - final String conversation, final boolean coldStage) throws IOException { - final Chain chain = readChain(serviceId, serviceInstanceId, conversation, coldStage); + final String conversation, final boolean coldStage, + final BooleanSupplier alive) throws IOException { + final Chain chain = readChain(serviceId, serviceInstanceId, conversation, coldStage, alive); if (chain.roundInputs.isEmpty()) { return null; } @@ -162,123 +165,176 @@ public Map buildConversationView(final String serviceId, } @Override - public ConversationRawFiles getConversationRawFiles(final String serviceId, - @Nullable final String serviceInstanceId, - final String conversation, - @Nullable final List files, - final boolean includeBody, final boolean coldStage) 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); - } - } + public boolean readConversationFiles(final String serviceId, final String serviceInstanceId, + final String conversation, final String session, + final Collection seqs, final boolean coldStage, + final BooleanSupplier alive, final FileSink sink) throws IOException { + final long headRound = dao().queryHeadRoundDebuggable(serviceId, serviceInstanceId, conversation, coldStage); + if (headRound == 0) { + return false; } - final List rounds = readRounds(serviceId, serviceInstanceId, conversation, coldStage); - final ConversationRawFiles out = new ConversationRawFiles(); - if (rounds.isEmpty()) { - out.setErrorReason("no round of conversation " + conversation + " is stored for this service"); - return out; + if (seqs.isEmpty()) { + return true; } - final AIAgentSessionFlowRecord headRow = rounds.get(rounds.size() - 1); - // the newest round that reads names the session and the range; one that does not read is still exported - // below, as stored, and must not block the export of what does - SessionFlowRound.Header head = null; - for (int i = rounds.size() - 1; i >= 0 && head == null; i--) { - try { - head = SessionFlowRound.parse(rounds.get(i).getBody()).getHeader(); - } catch (final RuntimeException e) { - log.debug("round {} of conversation {} does not read: {}", rounds.get(i).getRound(), conversation, - e.getMessage()); + final long[] range = fileRange(serviceId, serviceInstanceId, conversation, headRound, alive, coldStage); + final int window = config.getReadWindow(); + for (final long[] run : runs(new TreeSet<>(seqs))) { + long last = -1; + for (final long[] w : windows(run[0], run[1], window)) { + if (!alive.getAsBoolean()) { + throw new IOException("the caller of session " + session + " is gone"); + } + // one window read, sorted and handed on before the next is read, so no more than one window of + // bodies is held + final List read = new ArrayList<>(dao().queryFilesDebuggable( + serviceId, serviceInstanceId, session, range[0], range[1], w[0], w[1], + config.getMaxResponseBytes(), coldStage)); + read.sort(Comparator.comparingLong(AIAgentSessionDataRecord::getSeq)); + // A sequence normally has one file. It has more where the same sequence was stored with + // different bytes, and then the first is served and the count says the others are there: + // a reader that showed one copy as the whole truth would be wrong without knowing it. + final Map copies = new HashMap<>(); + for (final AIAgentSessionDataRecord f : read) { + if (f.getSeq() >= w[0] && f.getSeq() <= w[1]) { + copies.merge(f.getSeq(), 1, Integer::sum); + } + } + for (final AIAgentSessionDataRecord f : read) { + if (f.getSeq() < w[0] || f.getSeq() > w[1] || f.getSeq() == last) { + continue; + } + last = f.getSeq(); + sink.accept(new ConversationFile( + dataFileId(f.getBody(), session, f.getSeq()), f.getSeq(), f.getDigest(), f.getBody(), + copies.getOrDefault(f.getSeq(), 1))); + } } } - // the caller's sender, or every sender of the service: a Sessionizer renamed between pushes leaves a - // conversation's files under two instances, and a read must see both - final String instance = StringUtil.isNotEmpty(serviceInstanceId) ? serviceInstanceId : null; - final long from = head == null ? 0 : Times.millis(head.getSessionFromTime()); - final long to = rangeEnd(head == null ? null : head.getSessionThroughTime(), headRow.getTimestamp()); + return true; + } - // Session Data files first, then the rounds, each list in its own order. - final Set sessions = new LinkedHashSet<>(); - if (head != null && StringUtil.isNotEmpty(head.getSession())) { - sessions.add(head.getSession()); - } - for (final FileNames.Parsed p : wanted) { - if (p.isDataFile()) { - sessions.add(p.getSession()); - } - } - for (final String session : sessions) { - final Set 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; + /** + * The time range the conversation's files are stamped in: the newest intact round's, from its session's first + * activity to its last or its own row's time, whichever is later, read one round at a time down from the head, so + * only the rounds above it are read. The view takes its range from the last round it folds, which is this round + * unless the fold refused it. A conversation with no intact round is read over everything up to the head row's own + * time, and so is one whose head round and the fifteen below it are all unreadable. + * + * @return the first and the last millisecond + */ + private long[] fileRange(final String serviceId, final String serviceInstanceId, final String conversation, + final long headRound, final BooleanSupplier alive, final boolean coldStage) + throws IOException { + long headRowTimestamp = 0; + // One round at a time, down from the head, which answers in a single read for every conversation + // whose head round is intact - and that is all of them until one is damaged. A window would read + // sixteen round bodies to find one, and a round is cut at 2 MiB, so the read a healthy conversation + // pays would grow by that much for nothing. + // + // The walk stops after ROUNDS_SEARCHED rounds. Its purpose is to find any round that carries the + // session's time range; if that many consecutive rounds from the head are unreadable, the chain is + // damaged far past what one more read would fix, and the head row's own time is the answer. Walking + // to round 1 instead cost one storage read per round - a hundred thousand of them on a long chain, + // for one request, and they all ran on after the caller had gone. + final long floor = Math.max(1, headRound - ROUNDS_SEARCHED + 1); + for (long round = headRound; round >= floor; round--) { + if (!alive.getAsBoolean()) { + throw new IOException("the caller of conversation " + conversation + " is gone"); } - final long throughSeq = all ? (head == null ? 0 : head.getThroughSeq()) - : seqs.stream().mapToLong(Long::longValue).max().orElse(0); - final long fromSeq = all ? 1 : seqs.stream().mapToLong(Long::longValue).min().orElse(1); - final Set seen = new HashSet<>(); - for (final AIAgentSessionDataRecord f : readFiles(serviceId, instance, session, from, to, fromSeq, throughSeq, coldStage)) { - if (!all && !seqs.contains(f.getSeq()) || !seen.add(f.getSeq())) { + for (final AIAgentSessionFlowRecord r : dao().queryRoundsByNumberDebuggable( + serviceId, serviceInstanceId, conversation, round, round, config.getMaxResponseBytes(), coldStage)) { + if (r.getRound() != round) { 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 (headRowTimestamp == 0) { + // the head row's own time bounds the range only when no round is intact + headRowTimestamp = r.getTimestamp(); } - if (!named) { + final SessionFlowRound parsed; + try { + parsed = SessionFlowRound.parse(r.getBody()); + } catch (final RuntimeException e) { continue; } + if (parsed.isIntact()) { + final SessionFlowRound.Header h = parsed.getHeader(); + // the intact round's own row, as the view takes its range from the last round it folds + return new long[] { + Times.millis(h.getSessionFromTime()), rangeEnd(h.getSessionThroughTime(), r.getTimestamp())}; + } } - String commit; - try { - commit = SessionFlowRound.parse(r.getBody()).getCommitDigest(); - } catch (final RuntimeException e) { - // a round that does not read has no commit digest to be named by; the digest of its file names it - commit = r.getDigest(); - } - final ConversationRawFile raw = new ConversationRawFile(); - raw.setId(FileNames.roundFile(conversation, r.getRound(), commit)); - raw.setFormat(ConversationFileFormat.SF); - raw.setRound((int) r.getRound()); - raw.setDigest(r.getDigest()); - raw.setBytes(r.getBody().length); - raw.setTimestamp(r.getTimestamp()); - if (includeBody) { - raw.setBody(new String(r.getBody(), StandardCharsets.UTF_8)); + } + return new long[] {0, headRowTimestamp}; + } + + /** + * How many rounds down from the head a file read looks for one that carries the session's time range. + * One is enough unless the head is damaged; past this many the chain is broken, not merely dented. + */ + private static final int ROUNDS_SEARCHED = 16; + + /** + * @return the file's name from its own header line, or one built from its session and seq when the header does + * not read + */ + private static String dataFileId(final byte[] body, final String session, final long seq) { + try { + return FileNames.dataFile(SessionDataFile.header(body)); + } catch (final RuntimeException e) { + return session + "/unknown-" + String.format(Locale.ROOT, "%06d", seq) + ".sd"; + } + } + + /** + * @param numbers ascending numbers + * @return each run of consecutive numbers as its first and last + */ + static List runs(final Iterable numbers) { + final List out = new ArrayList<>(); + long[] run = null; + for (final long n : numbers) { + if (run != null && run[1] != Long.MAX_VALUE && n == run[1] + 1) { + run[1] = n; + continue; } - out.getFiles().add(raw); + run = new long[] {n, n}; + out.add(run); } return out; } + /** + * @return the windows of at most size numbers that cover first through last, one at a time as they + * are iterated, so no bound overflows and no list of them is built, even for a window a round claims up to the + * largest number + */ + static Iterable windows(final long first, final long last, final int size) { + return () -> new Iterator() { + private long start = first; + private boolean done = first > last; + + @Override + public boolean hasNext() { + return !done; + } + + @Override + public long[] next() { + if (done) { + throw new NoSuchElementException(); + } + final long end = last - start < size - 1L ? last : start + size - 1L; + final long[] w = {start, end}; + if (end >= last) { + done = true; + } else { + start = end + 1; + } + return w; + } + }; + } + // ---------------------------------------------------------------- the two-pass read private static final class Chain { @@ -299,14 +355,18 @@ private static final class Chain { * senders or by a redelivery, is kept once, the first copy. */ private List readRounds(final String serviceId, @Nullable final String instance, - final String conversation, final boolean coldStage) throws IOException { + final String conversation, final boolean coldStage, + final BooleanSupplier alive) throws IOException { final long headRound = dao().queryHeadRoundDebuggable(serviceId, instance, conversation, coldStage); if (headRound == 0) { return new ArrayList<>(); } final Map byRound = new TreeMap<>(); - final int window = config.getRoundReadWindow(); + final int window = config.getReadWindow(); for (long start = 1; start <= headRound; start += window) { + if (!alive.getAsBoolean()) { + throw new IOException("the caller of conversation " + conversation + " is gone"); + } final long end = Math.min(headRound, start + window - 1); for (final AIAgentSessionFlowRecord r : dao().queryRoundsByNumberDebuggable( serviceId, instance, conversation, start, end, config.getMaxResponseBytes(), coldStage)) { @@ -323,10 +383,10 @@ private List readRounds(final String serviceId, @Nulla * Every stored round is listed, readable or not. */ private Chain readChain(final String serviceId, @Nullable final String serviceInstanceId, - final String conversation, final boolean coldStage) throws IOException { + final String conversation, final boolean coldStage, final BooleanSupplier alive) + throws IOException { final Chain chain = new Chain(); - final List rounds = readRounds(serviceId, serviceInstanceId, conversation, coldStage); - final String instance = StringUtil.isNotEmpty(serviceInstanceId) ? serviceInstanceId : null; + final List rounds = readRounds(serviceId, serviceInstanceId, conversation, coldStage, alive); long throughSeq = 0; AIAgentSessionFlowRecord headRow = null; for (final AIAgentSessionFlowRecord r : rounds) { @@ -373,7 +433,7 @@ private Chain readChain(final String serviceId, @Nullable final String serviceIn 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, coldStage)) { + for (final AIAgentSessionDataRecord f : readFiles(serviceId, serviceInstanceId, session, from, to, 1, throughSeq, coldStage, alive)) { if (chain.files.containsKey(f.getSeq())) { // the same file under two senders; the chain check judges the copy that was kept continue; @@ -398,13 +458,18 @@ private static String first12(@Nullable final String s) { 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, - final boolean coldStage) throws IOException { + final boolean coldStage, final BooleanSupplier alive) + 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); + if (fromSeq > throughSeq) { + return out; + } + for (final long[] w : windows(fromSeq, throughSeq, config.getReadWindow())) { + if (!alive.getAsBoolean()) { + throw new IOException("the caller of session " + session + " is gone"); + } out.addAll(dao().queryFilesDebuggable( - serviceId, instance, session, from, to, start, end, config.getMaxResponseBytes(), coldStage)); + serviceId, instance, session, from, to, w[0], w[1], config.getMaxResponseBytes(), coldStage)); } return out; } 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 index 248a3fde63a8..5e62bfcf9607 100644 --- 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 @@ -19,17 +19,17 @@ package org.apache.skywalking.oap.server.ai.agent.conversation.query; import java.io.IOException; -import java.util.List; +import java.util.Collection; +import java.util.function.BooleanSupplier; 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. + * The GraphQL list of ai-agent-conversation.graphqls, and what the two HTTP routes serve: the + * conversation's asz.view document, and its chosen stored files. */ public interface IConversationQueryService extends Service { /** @@ -52,29 +52,43 @@ ConversationList listConversations(String serviceId, @Nullable String serviceIns * The whole conversation, once, as one asz.view document, built on every call. * * @param serviceId the service - * @param serviceInstanceId the sender, or null + * @param serviceInstanceId the sender * @param conversation the conversation * @param coldStage whether the caller explicitly selected BanyanDB's cold stage - * @return the document as ordered maps, or null when the service stores no round of the conversation + * @return the document as ordered maps, or null when the sender stores no round of the conversation * @throws IOException on a storage failure */ @Nullable Map buildConversationView(String serviceId, @Nullable String serviceInstanceId, - String conversation, boolean coldStage) throws IOException; + String conversation, boolean coldStage, BooleanSupplier alive) throws IOException; /** - * Every landed file and round of a conversation as stored, or only the named ones. + * The chosen Session Data files of a conversation's session, as stored, handed to the sink one by one as they + * are read, one storage window at a time. A file is chosen by its landed seq, which the Sessionizer assigns once + * per file in a session. The files come in seq order, which is the order a reader of provider bodies must add + * them in. A seq no stored file answers is left out. * * @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 + * @param serviceInstanceId the sender + * @param conversation the conversation, whose rounds give the time range the files are read over + * @param session the session the seqs belong to + * @param seqs the landed seqs * @param coldStage whether the caller explicitly selected BanyanDB's cold stage - * @return the files - * @throws IOException on a storage failure + * @param sink takes each file as it is read + * @return false when the sender stores no round of the conversation, before the sink is called + * @throws IOException on a storage failure, or when the sink fails + */ + boolean readConversationFiles(String serviceId, String serviceInstanceId, String conversation, String session, + Collection seqs, boolean coldStage, BooleanSupplier alive, FileSink sink) throws IOException; + + /** + * Takes the stored files of {@link #readConversationFiles} as they are read. */ - ConversationRawFiles getConversationRawFiles(String serviceId, @Nullable String serviceInstanceId, - String conversation, @Nullable List files, - boolean includeBody, boolean coldStage) throws IOException; + interface FileSink { + /** + * @param file one stored file + * @throws IOException when the file cannot be passed on, such as when the client went away + */ + void accept(ConversationFile file) throws IOException; + } } diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/NoneConversationQueryService.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/NoneConversationQueryService.java index 477fe96c34c2..4b7980db5fa6 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/NoneConversationQueryService.java +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/NoneConversationQueryService.java @@ -18,17 +18,18 @@ package org.apache.skywalking.oap.server.ai.agent.conversation.query; -import java.util.List; +import java.util.Collection; +import java.util.function.BooleanSupplier; 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; /** * Answers every query of a disabled module with nothing, so that the GraphQL query module, which requires the * {@link org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationModule}, still boots and its - * two conversation queries answer instead of failing. + * conversation list answers instead of failing. The module registers no HTTP route, so the document and the files + * are never asked for; asked directly, they are not there. */ public class NoneConversationQueryService implements IConversationQueryService { private static final String DISABLED = @@ -50,18 +51,16 @@ public ConversationList listConversations(final String serviceId, @Override public Map buildConversationView(final String serviceId, @Nullable final String serviceInstanceId, - final String conversation, final boolean coldStage) { + final String conversation, final boolean coldStage, + final BooleanSupplier alive) { return null; } @Override - public ConversationRawFiles getConversationRawFiles(final String serviceId, - @Nullable final String serviceInstanceId, - final String conversation, - @Nullable final List files, - final boolean includeBody, final boolean coldStage) { - final ConversationRawFiles rawFiles = new ConversationRawFiles(); - rawFiles.setErrorReason(DISABLED); - return rawFiles; + public boolean readConversationFiles(final String serviceId, final String serviceInstanceId, + final String conversation, final String session, + final Collection seqs, final boolean coldStage, + final BooleanSupplier alive, final FileSink sink) { + return false; } } 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 index a437745edfbd..3af3efe94d04 100644 --- 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 @@ -27,12 +27,12 @@ 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. + * Compresses a document or a files response when the client's Accept-Encoding allows it, chunk by + * chunk, so a streamed response stays streamed. Both are repetitive text and shrink several times over. */ public final class CompressResponse implements DecoratingHttpServiceFunction { private static final Function ENCODING = EncodingService.builder() - .encodableContentTypes(ConversationViewHandler.JSON, ConversationViewHandler.YAML) + .encodableContentTypes(ConversationViewHandler.JSON, ConversationViewHandler.YAML, ConversationFilesHandler.FILES) .newDecorator(); @Override diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationFilesHandler.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationFilesHandler.java new file mode 100644 index 000000000000..f25415ac1404 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationFilesHandler.java @@ -0,0 +1,438 @@ +/* + * 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.HttpHeaderNames; +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.ResponseHeadersBuilder; +import com.linecorp.armeria.common.util.TimeoutMode; +import com.linecorp.armeria.server.ServiceRequestContext; +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.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Locale; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.zip.CRC32; +import java.util.zip.Deflater; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; +import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests; +import org.apache.skywalking.oap.server.ai.agent.conversation.query.ConversationFile; +import org.apache.skywalking.oap.server.ai.agent.conversation.query.IConversationQueryService; +import org.apache.skywalking.oap.server.core.analysis.IDManager; +import org.apache.skywalking.oap.server.library.util.StringUtil; + +/** + * GET /ai-agent/conversations/{conversation}/v1/files: chosen Session Data files of a conversation's + * session, streamed. A page loads what a step points at only when a reader opens it, such as the provider bodies an + * llm.call names. There is no mode that reads every file: a reader chooses each file it wants. It lives + * beside the view route and follows it: the same server, parameters, timeout and problem documents. + * + *

Query parameters: service and instance, both required, and coldStage, + * optional and false by default, as for the view. session is required, and so is seq, one to + * {@value #MAX_SEQS} times: a file's landed seq, which the Sessionizer assigns once per file in a session, and by which + * with the session the storage reads it. The view document's files[] gives every file's seq and its + * name, whose first part is its session. The session is the caller's choice within the sender and need not be one the + * conversation names. Files are read over the time range of the conversation's newest intact round, even one the view + * cannot fold, and a file stamped outside it is left out, as is any seq no stored file answers. + * + *

The body is chosen by Accept, and there is one format, which any Accept gets: + * application/vnd.skywalking.asz.files+ndjson. Each stored file is a naming line, + * {"file","seq","lines","bytes","digest"}, followed by exactly bytes bytes, the file, whose + * sha256 is digest. A non-empty file that does not end with a newline is followed by one, which is not part + * of it, so the next naming line starts a line; an empty file is followed by nothing. lines is the file's + * own newline count: a file the Sessionizer wrote ends with a newline, so a reader may equally take that many lines. + * The files come in seq order, which is the order a reader of provider bodies must add them in. + * + *

The body is compressed with gzip when Accept-Encoding allows it. The route compresses it itself, a + * chunk at a time, because Armeria's encoder keeps every compressed chunk of a response in one growing buffer until the + * response ends, which for large files is the whole compressed response held in memory. + * + *

Status: 200 with the files, none when no seq is stored; 400 when the service, the instance or the session is not + * named, when no seq is, when more than {@value #MAX_SEQS} are, when one is not a positive whole number, or when + * coldStage is neither true nor false; 404 when the sender stores no round of the conversation; 500 on a + * storage failure before the first file. A failure after the first file ends the response early, and a reader sees a + * file shorter than its naming line says, or a response that does not complete. + */ +@Slf4j +public class ConversationFilesHandler { + public static final String PATH = "/ai-agent/conversations/{conversation}/v1/files"; + /** + * The most seqs one request chooses. The Sessionizer cuts a file at 2 MiB, so a response holds about 64 MiB at + * most and takes at most two storage reads of the default window; this many seqs, even as the largest numbers, stay + * far under the OAP's 4 KB HTTP/1 request line. + */ + static final int MAX_SEQS = 32; + private static final String GZIP = "gzip"; + static final MediaType FILES = MediaType.parse("application/vnd.skywalking.asz.files+ndjson"); + private static final MediaType FILES_UTF_8 = FILES.withCharset(StandardCharsets.UTF_8); + /** Bytes of a file handed to the response at a time. */ + private static final int CHUNK_BYTES = 64 * 1024; + + private final IConversationQueryService service; + private final Duration timeout; + + public ConversationFilesHandler(final IConversationQueryService service, final Duration timeout) { + this.service = service; + this.timeout = timeout; + } + + @Get(PATH) + public HttpResponse files(final ServiceRequestContext ctx, + @Param("conversation") final String conversation, + @Param("service") @Nullable final String serviceName, + @Param("instance") @Nullable final String instanceName, + @Param("session") @Nullable final String session, + @Param("seq") @Nullable final List seqParams, + @Param("coldStage") @Nullable final String coldStageParam, + @Header("Accept-Encoding") @Nullable final List acceptEncoding) { + if (StringUtil.isEmpty(serviceName) || StringUtil.isEmpty(instanceName)) { + return ConversationViewHandler.badRequest("service and instance are required"); + } + final Boolean coldStage = ConversationViewHandler.coldStage(coldStageParam); + if (coldStage == null) { + return ConversationViewHandler.badRequest("coldStage " + coldStageParam + " is neither true nor false"); + } + if (StringUtil.isEmpty(session)) { + return ConversationViewHandler.badRequest("session is required"); + } + if (seqParams == null || seqParams.isEmpty()) { + return ConversationViewHandler.badRequest("at least one seq is required"); + } + if (seqParams.size() > MAX_SEQS) { + return ConversationViewHandler.badRequest( + "at most " + MAX_SEQS + " seqs are read at once, " + seqParams.size() + " were named"); + } + final List seqs = new ArrayList<>(seqParams.size()); + for (final String text : seqParams) { + final Long n = positive(text); + if (n == null) { + return ConversationViewHandler.badRequest("seq " + text + " is not a positive whole number"); + } + seqs.add(n); + } + final String serviceId = IDManager.ServiceID.buildId(serviceName, true); + final String instanceId = IDManager.ServiceInstanceID.buildId(serviceId, instanceName); + final boolean gzip = acceptsGzip(acceptEncoding == null ? null : String.join(",", acceptEncoding)); + + ctx.setRequestTimeout(TimeoutMode.SET_FROM_NOW, timeout); + final HttpResponseWriter res = HttpResponse.streaming(); + // Every refusal this route makes is a problem document. A request that runs out of time would + // otherwise take the server's own answer, which is plain text, so it is answered here instead - + // while nothing has been written, which is the only moment a status can still be chosen. + final AtomicBoolean answered = new AtomicBoolean(); + ctx.whenRequestCancelling().thenAccept(cause -> { + if (answered.compareAndSet(false, true)) { + ConversationViewHandler.problem( + res, HttpStatus.SERVICE_UNAVAILABLE, + "the request took longer than the " + timeout.toSeconds() + " seconds allowed"); + } + }); + ctx.blockingTaskExecutor().execute( + () -> stream(res, serviceId, instanceId, conversation, session, seqs, coldStage, gzip, answered)); + return res; + } + + /** + * @param header every Accept-Encoding field of the request, joined by commas, or null + * @return whether the client takes gzip: its own entry decides when it has one, a zero weight refusing it; + * otherwise a * entry decides the same way + */ + static boolean acceptsGzip(@Nullable final String header) { + if (header == null) { + return false; + } + Boolean named = null; + Boolean any = null; + for (final String part : header.split(",", -1)) { + final String[] fields = part.trim().split(";", -1); + final String coding = fields[0].trim().toLowerCase(Locale.ROOT); + if (coding.isEmpty()) { + // an empty entry, such as a stray separator, names no coding + continue; + } + boolean taken = true; + for (int i = 1; i < fields.length; i++) { + final String f = fields[i].trim().replace(" ", "").toLowerCase(Locale.ROOT); + if (f.startsWith("q=")) { + try { + taken = Double.parseDouble(f.substring(2)) > 0; + } catch (final NumberFormatException e) { + taken = false; + } + } + } + if (GZIP.equals(coding) || "x-gzip".equals(coding)) { + named = named == null ? taken : named || taken; + } else if ("*".equals(coding)) { + any = any == null ? taken : any || taken; + } + } + if (named != null) { + return named; + } + return any != null && any; + } + + @Nullable + private static Long positive(final String text) { + try { + final long n = Long.parseLong(text.trim()); + return n > 0 ? n : null; + } catch (final NumberFormatException e) { + return null; + } + } + + private void stream(final HttpResponseWriter res, final String serviceId, final String instanceId, + final String conversation, final String session, final List seqs, + final boolean coldStage, final boolean gzip, final AtomicBoolean answered) { + final boolean[] started = {false}; + final Body body = gzip ? new GzipBody(res) : new Body(res); + try { + final boolean found; + try { + found = service.readConversationFiles(serviceId, instanceId, conversation, session, seqs, coldStage, res::isOpen, file -> { + if (!res.isOpen()) { + // the caller is gone, or the request ran out of time and was answered without us + throw new IllegalStateException("the response is closed"); + } + if (!started[0]) { + if (!answered.compareAndSet(false, true)) { + throw new IllegalStateException("the request was answered before the first file"); + } + res.write(headers(gzip)); + started[0] = true; + } + write(body, file); + }); + if (found) { + if (!started[0]) { + if (!answered.compareAndSet(false, true)) { + return; + } + res.write(headers(gzip)); + started[0] = true; + } + body.finish(); + } + } catch (final Exception e) { + if (started[0]) { + log.debug("AI agent conversation {} files response ended early: {}", conversation, e.getMessage()); + res.close(e); + } else if (answered.compareAndSet(false, true)) { + // a storage client can surface a checked failure it never declared; whatever it is, the response + // must say so, or the caller waits for the request timeout + log.error("AI agent conversation {} files of service {} could not be read", conversation, serviceId, e); + ConversationViewHandler.problem(res, HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } else { + // the request ran out of time and was answered without us; the answer is being written, + // and closing the response here would cut it in half + log.debug("AI agent conversation {} files read ended after the request was answered: {}", + conversation, e.getMessage()); + } + return; + } + if (!found) { + if (answered.compareAndSet(false, true)) { + ConversationViewHandler.problem(res, HttpStatus.NOT_FOUND, ConversationViewHandler.notFound(conversation)); + } + return; + } + res.close(); + } finally { + // the compressor holds native memory; a client gone at any point, the header write included, must not keep it + body.release(); + } + } + + private static ResponseHeaders headers(final boolean gzip) { + final ResponseHeadersBuilder headers = ResponseHeaders.builder(HttpStatus.OK).contentType(FILES_UTF_8); + if (gzip) { + headers.add(HttpHeaderNames.CONTENT_ENCODING, GZIP); + } + headers.add(HttpHeaderNames.VARY, "Accept-Encoding"); + return headers.build(); + } + + /** + * One file: its naming line, its bytes, and a newline after them when a non-empty file does not end with one. + */ + private static void write(final Body res, final ConversationFile file) throws IOException { + final byte[] body = file.getBody(); + final JsonObject naming = new JsonObject(); + naming.addProperty("file", file.getId()); + naming.addProperty("seq", file.getSeq()); + naming.addProperty("lines", Digests.countLines(body)); + naming.addProperty("bytes", body.length); + naming.addProperty("digest", file.getDigest()); + if (file.getCopies() > 1) { + // the storage holds this sequence more than once; the bytes below are the first copy + naming.addProperty("copies", file.getCopies()); + } + res.write((naming + "\n").getBytes(StandardCharsets.UTF_8), 0, -1); + for (int off = 0; off < body.length; off += CHUNK_BYTES) { + res.write(body, off, Math.min(CHUNK_BYTES, body.length - off)); + } + if (body.length > 0 && body[body.length - 1] != '\n') { + res.write(NEWLINE, 0, 1); + } + // a reader handles each file as soon as it arrives, so the compressor gives up what it holds at a file's end + res.flushFile(); + } + + private static final byte[] NEWLINE = {'\n'}; + + /** + * The response body, written a chunk at a time. Each chunk waits for the client to take it before the next is + * written, so a slow client holds back the read instead of growing a buffer, and a client that went away ends the + * read with an IOException. + */ + private class Body { + final HttpResponseWriter res; + + Body(final HttpResponseWriter res) { + this.res = res; + } + + /** Writes len bytes from off, or all of bytes when len is negative. */ + void write(final byte[] bytes, final int off, final int len) throws IOException { + final int n = len < 0 ? bytes.length : len; + if (n > 0) { + send(HttpData.copyOf(bytes, off, n)); + } + } + + void flushFile() throws IOException { + } + + void finish() throws IOException { + } + + void release() { + } + + final void send(final HttpData data) throws IOException { + if (!res.tryWrite(data)) { + throw new IOException("the response is closed"); + } + 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); + } + } + } + + /** + * The body compressed with gzip, RFC 1952, by a deflater whose output is handed on as soon as a buffer of it fills, + * so nothing compressed accumulates: one input chunk and one output buffer are all that is held. + */ + private final class GzipBody extends Body { + private static final int OUT_BYTES = 64 * 1024; + private final Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true); + private final CRC32 crc = new CRC32(); + private final byte[] out = new byte[OUT_BYTES]; + private long size; + private boolean headerSent; + + GzipBody(final HttpResponseWriter res) { + super(res); + } + + @Override + void write(final byte[] bytes, final int off, final int len) throws IOException { + final int n = len < 0 ? bytes.length : len; + header(); + if (n == 0) { + return; + } + crc.update(bytes, off, n); + size += n; + deflater.setInput(bytes, off, n); + while (!deflater.needsInput()) { + drain(Deflater.NO_FLUSH); + } + } + + @Override + void flushFile() throws IOException { + header(); + int n; + do { + n = drain(Deflater.SYNC_FLUSH); + } while (n == OUT_BYTES); + } + + @Override + void finish() throws IOException { + header(); + deflater.finish(); + while (!deflater.finished()) { + drain(Deflater.NO_FLUSH); + } + final byte[] trailer = new byte[8]; + final long value = crc.getValue(); + for (int i = 0; i < 4; i++) { + trailer[i] = (byte) (value >>> (8 * i)); + trailer[4 + i] = (byte) (size >>> (8 * i)); + } + send(HttpData.wrap(trailer)); + deflater.end(); + } + + @Override + void release() { + deflater.end(); + } + + private void header() throws IOException { + if (!headerSent) { + headerSent = true; + // magic, deflate, no flags, no time, no extra flags, unknown system + send(HttpData.wrap(new byte[] {0x1f, (byte) 0x8b, 8, 0, 0, 0, 0, 0, 0, (byte) 0xff})); + } + } + + private int drain(final int flush) throws IOException { + final int n = deflater.deflate(out, 0, out.length, flush); + if (n > 0) { + send(HttpData.copyOf(out, 0, n)); + } + return n; + } + } +} 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 index ae7640602f79..92b002425a53 100644 --- 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 @@ -28,7 +28,6 @@ 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.Default; import com.linecorp.armeria.server.annotation.Get; import com.linecorp.armeria.server.annotation.Header; import com.linecorp.armeria.server.annotation.Param; @@ -36,7 +35,9 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Locale; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -55,14 +56,14 @@ * 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, and coldStage, false by default, to query - * BanyanDB's cold stage. What the body is, the HTTP layer says: the media type + *

Query parameters: service, the service name, and instance, the sender's instance + * name, both required, as the conversation list names them, so every storage read is a full series lookup; and + * coldStage, optional and false by default, to query BanyanDB's cold stage. 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): + *

Status: 200 with the document; 400 when the service or the instance is not named; 404 when the sender 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 @@ -91,42 +92,60 @@ public ConversationViewHandler(final IConversationQueryService service, final Du 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, - @Param("coldStage") @Default("false") final boolean coldStage, + @Param("coldStage") @Nullable final String coldStageParam, @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")); + if (StringUtil.isEmpty(serviceName) || StringUtil.isEmpty(instanceName)) { + return badRequest("service and instance are required"); } - final String instanceId = StringUtil.isEmpty(instanceName) - ? null : IDManager.ServiceInstanceID.buildId(serviceId, instanceName); + final Boolean coldStage = coldStage(coldStageParam); + if (coldStage == null) { + return badRequest("coldStage " + coldStageParam + " is neither true nor false"); + } + final String serviceId = IDManager.ServiceID.buildId(serviceName, true); + final String instanceId = 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, coldStage)); + // Every refusal this route makes is a problem document. A request that runs out of time would + // otherwise take the server's own answer, which is plain text, so it is answered here instead - + // while nothing has been written, which is the only moment a status can still be chosen. + final AtomicBoolean answered = new AtomicBoolean(); + ctx.whenRequestCancelling().thenAccept(cause -> { + if (answered.compareAndSet(false, true)) { + problem(res, HttpStatus.SERVICE_UNAVAILABLE, + "the request took longer than the " + timeout.toSeconds() + " seconds allowed"); + } + }); + ctx.blockingTaskExecutor().execute( + () -> stream(res, serviceId, instanceId, conversation, yaml, coldStage, answered)); return res; } - private void stream(final HttpResponseWriter res, final String serviceId, @Nullable final String instanceId, - final String conversation, final boolean yaml, final boolean coldStage) { + private void stream(final HttpResponseWriter res, final String serviceId, final String instanceId, + final String conversation, final boolean yaml, final boolean coldStage, + final AtomicBoolean answered) { final Map doc; try { - doc = service.buildConversationView(serviceId, instanceId, conversation, coldStage); + // The whole chain is read before a byte is written, so the read itself asks whether anyone is + // still waiting: a caller who gave up must not leave thousands of storage reads behind. + doc = service.buildConversationView(serviceId, instanceId, conversation, coldStage, res::isOpen); } catch (final Exception e) { + if (!answered.compareAndSet(false, true)) { + return; + } // a storage client can surface a checked failure it never declared; whatever it is, the response // must say so, or the caller waits for the request timeout log.error("AI agent conversation {} of service {} could not be read", conversation, serviceId, e); problem(res, HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); return; } + if (!answered.compareAndSet(false, true)) { + return; + } if (doc == null) { - problem(res, HttpStatus.NOT_FOUND, "no round of conversation " + conversation + " is stored for this service"); + problem(res, HttpStatus.NOT_FOUND, notFound(conversation)); return; } res.write(ResponseHeaders.builder(HttpStatus.OK).contentType(yaml ? YAML_UTF_8 : JSON_UTF_8).build()); @@ -144,7 +163,34 @@ private void stream(final HttpResponseWriter res, final String serviceId, @Nulla res.close(); } - private static void problem(final HttpResponseWriter res, final HttpStatus status, @Nullable final String detail) { + /** + * @return the coldStage parameter: false when absent, the boolean it names, or null when it names + * neither, which Armeria would otherwise refuse with a plain-text 400 before the handler could answer + */ + @Nullable + static Boolean coldStage(@Nullable final String param) { + if (param == null) { + return false; + } + switch (param.trim().toLowerCase(Locale.ROOT)) { + case "true": + return true; + case "false": + return false; + default: + return null; + } + } + + static HttpResponse badRequest(final String detail) { + return HttpResponse.of(HttpStatus.BAD_REQUEST, PROBLEM, problem(HttpStatus.BAD_REQUEST, detail)); + } + + static String notFound(final String conversation) { + return "no round of conversation " + conversation + " is stored for this sender"; + } + + 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(); 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 deleted file mode 100644 index 74b6d83399b4..000000000000 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/input/ConversationCondition.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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; - private boolean coldStage; -} 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 deleted file mode 100644 index 57973ead739e..000000000000 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationFileFormat.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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/ConversationRawFile.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFile.java deleted file mode 100644 index a0fa97efe11e..000000000000 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFile.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 deleted file mode 100644 index 341179e50d1d..000000000000 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/type/ConversationRawFiles.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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/view/ConversationViewBuilder.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/ConversationViewBuilder.java index 66ec45bb759a..a42d18440e4e 100644 --- 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 @@ -26,6 +26,7 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -66,6 +67,13 @@ public final class ConversationViewBuilder { static final String STATE_VERIFIED = "verified"; static final String STATE_INCOMPLETE = "incomplete"; static final String STATE_MISMATCH = "mismatch"; + static final String KIND_PROVIDER_BODY = "provider_body"; + static final String PROVIDER_BODY_SCHEMA = "provider_body/1"; + static final String ROLE_REQUEST = "request"; + static final String ROLE_RESPONSE = "response"; + private static final String JOIN_EXACT = "exact"; + private static final String JOIN_AMBIGUOUS = "ambiguous"; + private static final String JOIN_UNRESOLVED = "unresolved"; private final ConversationFold fold; private final List rounds; @@ -75,6 +83,8 @@ public final class ConversationViewBuilder { private final Map at = new HashMap<>(); /** Each step's workspace change ids, in the order the document lists the records; filled by {@link #workspaceChanges}. */ private final Map> changesByStep = new HashMap<>(); + /** Each call step's joined provider bodies, its request then its response; filled by {@link #providerBodies}. */ + private final Map>> providerBodiesByStep = new HashMap<>(); /** * @param fold the fold of the rounds, in order @@ -108,6 +118,15 @@ public Map build() { final Chain chain = chain(); // before the nodes are rendered: each step lists the ids of its records final List> workspaceChanges = workspaceChanges(); + final int providerBodies = providerBodies(); + int capturedPrompts = 0; + for (final List> bodies : providerBodiesByStep.values()) { + for (final Map b : bodies) { + if (ROLE_REQUEST.equals(b.get("role"))) { + capturedPrompts++; + } + } + } final Map doc = new LinkedHashMap<>(); doc.put("format", ViewYaml.FORMAT); @@ -132,6 +151,8 @@ public Map build() { summary.put("rounds", chain.rounds.size()); summary.put("unresolved", fold.openUnresolved().size()); summary.put("changes", workspaceChanges.size()); + summary.put("provider_bodies", providerBodies); + summary.put("captured_prompts", capturedPrompts); 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"))); @@ -255,15 +276,29 @@ private Chain chain() { ok = false; } final List added = new ArrayList<>(); - for (long seq = h.getFromSeq(); seq <= h.getThroughSeq(); seq++) { + // A round names a range of sequences, and a round that names an impossible one - or a chain + // whose files are all gone - would otherwise cost one entry per absent sequence before they + // are coalesced, and the counter itself would wrap at the end of the range. + long missingFrom = 0; + long missingTo = 0; + for (long seq = h.getFromSeq(); seq <= h.getThroughSeq() && seq >= h.getFromSeq(); seq++) { final SessionDataFile f = files.get(seq); if (f == null) { - missingFiles.add(new long[] {h.getRound(), seq}); + if (missingFrom == 0) { + missingFrom = seq; + } else if (seq != missingTo + 1) { + missingFiles.add(new long[] {h.getRound(), missingFrom, missingTo}); + missingFrom = seq; + } + missingTo = seq; ok = false; continue; } added.add(f.getFileDigest()); } + if (missingFrom != 0) { + missingFiles.add(new long[] {h.getRound(), missingFrom, missingTo}); + } 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; @@ -313,21 +348,23 @@ private Chain chain() { } /** - * @param missing the round and seq of every landed file a round names and the read did not find, in chain order - * @return one problem per run of consecutive seqs, worded as one file when the run is one + * @param missing one entry per run of absent seqs a round names, as the round and the run's first and last + * seq, in chain order + * @return one problem per run of consecutive seqs, joined across rounds where they meet, worded as one file + * when the run is one */ private static List missingFileProblems(final List missing) { final List out = new ArrayList<>(); int i = 0; while (i < missing.size()) { int j = i; - while (j + 1 < missing.size() && missing.get(j + 1)[1] == missing.get(j)[1] + 1) { + while (j + 1 < missing.size() && missing.get(j + 1)[1] == missing.get(j)[2] + 1) { j++; } final long firstRound = missing.get(i)[0]; final long lastRound = missing.get(j)[0]; final long firstSeq = missing.get(i)[1]; - final long lastSeq = missing.get(j)[1]; + final long lastSeq = missing.get(j)[2]; out.add((firstRound == lastRound ? "round " + firstRound : "rounds " + firstRound + "-" + lastRound) + (firstSeq == lastSeq ? ": landed file seq " + firstSeq + " is missing" : ": landed files seq " + firstSeq + "-" + lastSeq + " are missing")); @@ -904,6 +941,10 @@ private Map step(final SessionFlowRound.Node n, final int depth, if (changes != null && !changes.isEmpty()) { out.put("changes", new ArrayList<>(changes)); } + final List> bodies = providerBodiesByStep.get(n.getId()); + if (bodies != null && !bodies.isEmpty()) { + out.put("provider_bodies", bodies); + } if (depth < MAX_DEPTH) { final List> children = new ArrayList<>(); for (final SessionFlowRound.Node k : fold.children(n.getId())) { @@ -1281,6 +1322,392 @@ private static final class WorkspaceChange { } } + // ---------------------------------------------------------------- provider bodies + + /** + * Joins the session's provider bodies to their calls, as the Sessionizer's view joins them. A body is a + * provider_body record: the request or the response a runtime exchanged with its model provider, + * cut so it keeps only what the session did not hold yet, with a manifest as its last part. The document names + * where each joined body landed and never carries the body; a reader loads the files up to that seq and rebuilds + * it. Fills {@link #providerBodiesByStep} on the way, which {@link #step} reads, so this runs before the nodes are + * rendered. + * + *

A response joins by its message id, which is the call's own. A request names no call, only the request + * before it and its prompt, so it joins to the call of its stream whose previous call's response carries that + * request id and whose prompt is the one it names, when exactly one request and exactly one call carry those two + * ids. A synthetic call was never sent to a provider and takes part in no join. No request joins in a stream + * whose landed transcript lines have a gap, since a call may be missing between two that look consecutive. + * Nothing is joined by position or by time. + * + * @return how many bodies the session holds + */ + private int providerBodies() { + final List out = new ArrayList<>(); + final Set seen = new HashSet<>(); + final List seqs = new ArrayList<>(files.keySet()); + Collections.sort(seqs); + for (final Long seq : seqs) { + final SessionDataFile f = files.get(seq); + if (!KIND_PROVIDER_BODY.equals(f.getHeader().getKind())) { + continue; + } + for (final SessionDataFile.Record rec : f.getRecords()) { + final JsonObject m = manifest(rec); + // a body landed twice by an interrupted pass is one body + if (m == null || !seen.add(nullToEmpty(rec.getId()))) { + continue; + } + out.add(new Body(new Ref(seq, rec.getRow(), null), nullToEmpty(string(m, "role")), + nullToEmpty(string(m, "run")), nullToEmpty(string(m, "previous_request")), + nullToEmpty(string(m, "request")), nullToEmpty(string(m, "call")))); + } + } + if (out.isEmpty()) { + return 0; + } + + // the calls, with their message id, their prompt and their stream, in line order within a stream + final List calls = new ArrayList<>(); + for (final SessionFlowRound.Node n : fold.getNodes().values()) { + if (!"llm.call".equals(n.getKind()) || n.getRef() == null) { + continue; + } + // a call whose record is gone stays in its stream with no message id, so the call after it has no + // previous response to name and is left unjoined rather than taken for the first of its stream + final Call k = new Call(n.getId(), nullToEmpty(n.getStream()), n.getRef()); + final SessionDataFile.Record rec = decodable(record(n.getRef())); + if (rec != null) { + // a synthetic record sits in the stream like a response, but no provider was called + if (rec.flags().contains("synthetic")) { + continue; + } + k.msg = nullToEmpty(string(rec.getJson(), "call")); + } + final SessionFlowRound.Node p = StringUtil.isEmpty(n.getParent()) ? null : fold.node(n.getParent()); + if (p != null && "run".equals(p.getKind()) && p.getRef() != null) { + final SessionDataFile.Record run = decodable(record(p.getRef())); + if (run != null) { + k.prompt = nullToEmpty(string(run.getJson(), "run")); + } + } + calls.add(k); + } + calls.sort(Comparator.comparing((Call c) -> c.stream) + .thenComparingLong(c -> c.at.getSeq()) + .thenComparingLong(c -> c.at.getRow()) + .thenComparing(c -> c.id)); + + // responses, by message id + final Map> byMsg = new HashMap<>(); + for (int i = 0; i < out.size(); i++) { + final Body b = out.get(i); + if (ROLE_RESPONSE.equals(b.role) && !b.call.isEmpty()) { + byMsg.computeIfAbsent(b.call, x -> new ArrayList<>()).add(i); + } + } + final Map requestOf = new HashMap<>(); + final Map responseOf = new HashMap<>(); + for (final Call k : calls) { + if (k.msg.isEmpty()) { + continue; + } + final List hits = byMsg.getOrDefault(k.msg, Collections.emptyList()); + if (hits.size() == 1) { + final Body b = out.get(hits.get(0)); + b.join = JOIN_EXACT; + requestOf.put(k.id, b.request); + responseOf.put(k.id, hits.get(0)); + } else { + for (final int i : hits) { + out.get(i).join = JOIN_AMBIGUOUS; + } + } + } + + // requests, by the request before them and their prompt + final Map, List> byKey = new HashMap<>(); + for (int i = 0; i < out.size(); i++) { + final Body b = out.get(i); + if (ROLE_REQUEST.equals(b.role) && !b.run.isEmpty()) { + byKey.computeIfAbsent(Arrays.asList(b.previousRequest, b.run), x -> new ArrayList<>()).add(i); + } + } + // the key each call's request would carry: none when the previous call has no response carrying its request + // id, and none in a stream whose landed lines have a gap + final Map gapped = new HashMap<>(); + for (final Call k : calls) { + gapped.computeIfAbsent(k.stream, this::streamHasGap); + } + final Map> callKey = new HashMap<>(); + final Map, Integer> callsByKey = new HashMap<>(); + for (int i = 0; i < calls.size(); i++) { + final Call k = calls.get(i); + if (gapped.get(k.stream)) { + continue; + } + String prev = ""; + if (i > 0 && calls.get(i - 1).stream.equals(k.stream)) { + prev = requestOf.get(calls.get(i - 1).id); + if (StringUtil.isEmpty(prev)) { + continue; + } + } + if (k.prompt.isEmpty()) { + continue; + } + final List key = Arrays.asList(prev, k.prompt); + callKey.put(k.id, key); + callsByKey.merge(key, 1, Integer::sum); + } + final Map requestFor = new HashMap<>(); + for (final Call k : calls) { + final List key = callKey.get(k.id); + if (key == null) { + continue; + } + final List hits = byKey.getOrDefault(key, Collections.emptyList()); + if (hits.isEmpty()) { + continue; + } + if (hits.size() == 1 && callsByKey.get(key) == 1) { + // one request and one call carry the key: nothing else could be this call's request + final Body b = out.get(hits.get(0)); + if (JOIN_UNRESOLVED.equals(b.join)) { + b.join = JOIN_EXACT; + requestFor.put(k.id, hits.get(0)); + } + } else { + for (final int i : hits) { + if (JOIN_UNRESOLVED.equals(out.get(i).join)) { + out.get(i).join = JOIN_AMBIGUOUS; + } + } + } + } + for (final Call k : calls) { + final Integer request = requestFor.get(k.id); + if (request != null) { + providerBodiesByStep.computeIfAbsent(k.id, x -> new ArrayList<>()).add(out.get(request).toMap()); + } + final Integer response = responseOf.get(k.id); + if (response != null) { + providerBodiesByStep.computeIfAbsent(k.id, x -> new ArrayList<>()).add(out.get(response).toMap()); + } + } + return out.size(); + } + + /** + * @param rec a record of a provider_body file + * @return its manifest: the last part, when it is a data part holding a provider_body/1 object that + * decodes as the Sessionizer's providerbody.Manifest, or null + */ + @Nullable + private static JsonObject manifest(final SessionDataFile.Record rec) { + final List parts = rec.getParts(); + if (parts.isEmpty()) { + return null; + } + final SessionDataFile.Part last = parts.get(parts.size() - 1); + final String data = last.data(); + if (!"data".equals(last.getKind()) || data == null) { + return null; + } + final JsonObject m; + try { + final JsonElement e = JsonParser.parseString(data); + if (!e.isJsonObject()) { + return null; + } + m = e.getAsJsonObject(); + } catch (final RuntimeException e) { + return null; + } + if (!fieldsDecode(m, MANIFEST_STRINGS, MANIFEST_INTS)) { + return null; + } + final JsonElement segments = m.get("segments"); + if (segments != null && !segments.isJsonNull()) { + if (!segments.isJsonArray()) { + return null; + } + for (final JsonElement seg : segments.getAsJsonArray()) { + if (seg.isJsonNull()) { + continue; + } + if (!seg.isJsonObject() || !fieldsDecode(seg.getAsJsonObject(), SEGMENT_STRINGS, SEGMENT_INTS)) { + return null; + } + final JsonElement copy = seg.getAsJsonObject().get("copy"); + if (copy != null && !copy.isJsonNull() + && (!copy.isJsonObject() || !fieldsDecode(copy.getAsJsonObject(), COPY_STRINGS, COPY_INTS))) { + return null; + } + } + } + return PROVIDER_BODY_SCHEMA.equals(string(m, "schema")) ? m : null; + } + + private static final String[] MANIFEST_STRINGS = { + "schema", "role", "src", "sha256", "chain", "why", "model", "session", "run", "call", "request", "previous_request"}; + private static final String[] MANIFEST_INTS = {"bytes", "depth"}; + private static final String[] SEGMENT_STRINGS = {"lit", "piece"}; + private static final String[] SEGMENT_INTS = {"part"}; + private static final String[] COPY_STRINGS = {"from", "sha256"}; + private static final String[] COPY_INTS = {"len"}; + + /** + * @return whether each named key is absent, null, or of the type Go decodes it into: a string, or an integer that + * fits a 64-bit int. A value of another type fails Go's decoding of the whole object. + */ + private static boolean fieldsDecode(final JsonObject o, final String[] strings, final String[] ints) { + for (final String key : strings) { + final JsonElement v = o.get(key); + if (v != null && !v.isJsonNull() && !(v.isJsonPrimitive() && v.getAsJsonPrimitive().isString())) { + return false; + } + } + for (final String key : ints) { + final JsonElement v = o.get(key); + if (v != null && !v.isJsonNull() && integer(v, false) == null) { + return false; + } + } + return true; + } + + /** + * @param v a JSON value + * @param unsigned whether the Go type is an unsigned 64-bit integer rather than a signed one + * @return the value's bits as Go decodes a JSON number into that type, or null when Go refuses it: not a number, + * a fraction or an exponent, or out of range + */ + @Nullable + private static Long integer(final JsonElement v, final boolean unsigned) { + if (!v.isJsonPrimitive() || !v.getAsJsonPrimitive().isNumber() || !INTEGER_LITERAL.matcher(v.getAsString()).matches()) { + return null; + } + final BigInteger n = new BigInteger(v.getAsString()); + final BigInteger min = unsigned ? BigInteger.ZERO : BigInteger.valueOf(Long.MIN_VALUE); + final BigInteger max = unsigned ? BigInteger.ONE.shiftLeft(64).subtract(BigInteger.ONE) : BigInteger.valueOf(Long.MAX_VALUE); + return n.compareTo(min) < 0 || n.compareTo(max) > 0 ? null : n.longValue(); + } + + /** + * @param rec a record, or null + * @return the record, or null when the call or run it names is not a string: the Sessionizer does not decode such + * a record, so to the join it is gone + */ + @Nullable + private static SessionDataFile.Record decodable(@Nullable final SessionDataFile.Record rec) { + return rec == null || !fieldsDecode(rec.getJson(), RECORD_STRINGS, new String[0]) ? null : rec; + } + + private static final String[] RECORD_STRINGS = {"call", "run"}; + + /** + * @param stream a stream + * @return whether the stream's landed transcript lines skip a line: the first is not line 1, or a line after a + * later one is missing. A line landed twice is a repeat, not a gap. Only each record's ord is read, as the + * Sessionizer reads it from the raw line: the digits after a leading {"ord":, or else the line + * decoded. A line that does not decode, or a file the reader refuses, may hide a missing call, so it counts as a + * gap. + */ + private boolean streamHasGap(final String stream) { + final List seqs = new ArrayList<>(files.keySet()); + Collections.sort(seqs); + long prev = 0; + for (final Long seq : seqs) { + final SessionDataFile f = files.get(seq); + final SessionDataFile.Header h = f.getHeader(); + if (!"transcript".equals(h.getKind()) || !stream.equals(nullToEmpty(h.getStream()))) { + continue; + } + if (!h.isValid()) { + return true; + } + for (final SessionDataFile.Record rec : f.getRecords()) { + final long ord; + if (rec.getLeadingOrd() != null) { + if (rec.getLeadingOrd().isEmpty()) { + return true; + } + // Go reads the digits into a uint64 and lets it wrap, so the long's bits are that value + long n = 0; + for (int i = 0; i < rec.getLeadingOrd().length(); i++) { + n = n * 10 + (rec.getLeadingOrd().charAt(i) - '0'); + } + ord = n; + } else { + final JsonElement v = rec.getJson().get("ord"); + if (v == null || v.isJsonNull()) { + ord = 0; + } else { + final Long n = integer(v, true); + if (n == null) { + return true; + } + ord = n; + } + } + // unsigned, as the Sessionizer compares uint64 values + if (Long.compareUnsigned(ord, prev + 1) > 0) { + return true; + } + if (Long.compareUnsigned(ord, prev) > 0) { + prev = ord; + } + } + if (f.isStoppedEarly()) { + return true; + } + } + return false; + } + + /** One landed provider body, and how it joined. */ + private static final class Body { + final Ref ref; + final String role; + final String run; + final String previousRequest; + final String request; + final String call; + String join = JOIN_UNRESOLVED; + + Body(final Ref ref, final String role, final String run, final String previousRequest, final String request, + final String call) { + this.ref = ref; + this.role = role; + this.run = run; + this.previousRequest = previousRequest; + this.request = request; + this.call = call; + } + + Map toMap() { + final Map m = new LinkedHashMap<>(); + m.put("role", role); + m.put("ref", ref.toMap()); + return m; + } + } + + /** One call step, with the ids its bodies are joined by. */ + private static final class Call { + final String id; + final String stream; + final Ref at; + String msg = ""; + String prompt = ""; + + Call(final String id, final String stream, final Ref at) { + this.id = id; + this.stream = stream; + this.at = at; + } + } + /** * @param rec a record * @param block the part a reference names, or null 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 index ba946a7a0d0b..545eef7e9920 100644 --- 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 @@ -236,6 +236,174 @@ public void theRuntimesOwnPatchIsShownWithoutThePluginsFiles() throws Exception assertNull(node(doc, "tool/tidier-s1-tool").get("changes")); } + /** + * The Sessionizer's provider-bodies scenario: a main stream and a subagent, every call's request and response + * landed in one provider_body file. The document equals the Sessionizer's. Each call names its request + * and then its response by where they landed, never their bytes, and the summary counts the bodies and the calls + * whose request is captured. + */ + @Test + @SuppressWarnings("unchecked") + public void providerBodiesJoinTheirCallsAsTheSessionizerJoinsThem() throws Exception { + final Map doc = view( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), + Fixtures.providerBodiesDataFiles(), Collections.emptyList()); + final JsonElement expected = JsonParser.parseString(new String( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.VIEW_EXAMPLE_JSON), StandardCharsets.UTF_8)); + final JsonElement actual = GSON.toJsonTree(doc); + assertEquals(expected, actual); + assertEquals(GSON.toJson(expected), GSON.toJson(actual)); + + final Map summary = (Map) doc.get("summary"); + assertEquals(14, summary.get("provider_bodies")); + assertEquals(7, summary.get("captured_prompts")); + final Map first = node(doc, "call/s2-call-fdae022ac306"); + assertEquals(Arrays.asList( + Map.of("role", "request", "ref", Map.of("seq", 4L, "row", 1L)), + Map.of("role", "response", "ref", Map.of("seq", 4L, "row", 2L))), first.get("provider_bodies")); + // a subagent's calls join in its own stream, from the same file + assertEquals(2, ((List) node(doc, "call/searcher-s1-call-fdae022ac306").get("provider_bodies")).size()); + } + + /** + * Without its provider bodies the session folds to the same document, less the keys that name them: the bodies + * are evidence beside the calls, not steps. + */ + @Test + @SuppressWarnings("unchecked") + public void aSessionWithoutItsProviderBodiesListsNone() throws Exception { + final byte[] round = Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE); + final Map files = Fixtures.providerBodiesDataFiles(); + final Map whole = view(round, files, Collections.emptyList()); + files.remove(4L); + final Map without = view(round, files, Collections.emptyList()); + final Map summary = (Map) without.get("summary"); + assertEquals(0, summary.get("provider_bodies")); + assertEquals(0, summary.get("captured_prompts")); + assertEquals(stripProviderBodies(GSON.toJsonTree(whole.get("talks"))), GSON.toJsonTree(without.get("talks"))); + assertEquals(stripProviderBodies(GSON.toJsonTree(whole.get("loose"))), GSON.toJsonTree(without.get("loose"))); + } + + /** + * A stream whose landed lines skip one may be missing a call between two that look consecutive, so no request + * joins in it; its responses still join by message id, and the other stream is untouched. + */ + @Test + @SuppressWarnings("unchecked") + public void noRequestJoinsInAStreamWithAGap() throws Exception { + final Map files = Fixtures.providerBodiesDataFiles(); + final String main = new String( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_DATA_FILES[0]), StandardCharsets.UTF_8); + assertTrue(main.contains("\n{\"ord\":3,")); + files.put(1L, SessionDataFile.parse(main.replace("\n{\"ord\":3,", "\n{\"ord\":4,").getBytes(StandardCharsets.UTF_8))); + final Map doc = view( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList()); + final Map summary = (Map) doc.get("summary"); + assertEquals(14, summary.get("provider_bodies")); + assertEquals(2, summary.get("captured_prompts")); + assertEquals(Collections.singletonList(Map.of("role", "response", "ref", Map.of("seq", 4L, "row", 2L))), + node(doc, "call/s2-call-fdae022ac306").get("provider_bodies")); + assertEquals(2, ((List) node(doc, "call/searcher-s2-call-fdae022ac306").get("provider_bodies")).size()); + } + + /** + * An API error the runtime wrote as a call, between two calls it sent. No provider was called for it, so it lists + * no bodies and is not the call before the next one: the next call's request names the call before the error, + * and joins. The document equals the Sessionizer's. + */ + @Test + @SuppressWarnings("unchecked") + public void aSyntheticCallTakesPartInNoJoin() throws Exception { + final Map doc = view( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_ERRORS_DIR + Fixtures.PROVIDER_BODIES_ERRORS_ROUND_FILE), + Fixtures.providerBodiesErrorsDataFiles(), Collections.emptyList()); + final JsonElement expected = JsonParser.parseString(new String( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_ERRORS_DIR + Fixtures.VIEW_EXAMPLE_JSON), StandardCharsets.UTF_8)); + final JsonElement actual = GSON.toJsonTree(doc); + assertEquals(expected, actual); + assertEquals(GSON.toJson(expected), GSON.toJson(actual)); + + final Map summary = (Map) doc.get("summary"); + assertEquals(4, summary.get("provider_bodies")); + assertEquals(2, summary.get("captured_prompts")); + assertNull(node(doc, "call/s3-synthetic-call").get("provider_bodies")); + assertEquals(Arrays.asList( + Map.of("role", "request", "ref", Map.of("seq", 2L, "row", 3L)), + Map.of("role", "response", "ref", Map.of("seq", 2L, "row", 4L))), node(doc, "call/s4-call-f7d240c7da38").get("provider_bodies")); + } + + /** + * A manifest the Sessionizer does not decode is no body: a known key of the wrong type, at the top or inside a + * segment, fails Go's decoding of the whole manifest. The first request goes, and nothing else changes. + */ + @Test + @SuppressWarnings("unchecked") + public void aManifestTheSessionizerDoesNotDecodeIsNoBody() throws Exception { + final String bodies = new String( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_DATA_FILES[3]), StandardCharsets.UTF_8); + final String first = "\"sha256\":\"25a4cf0c2c2a0f485f56519e56e1f6bcf79466df6599fc4f38fd0f0df84540fa\",\"bytes\":8155,\"depth\":0,"; + assertTrue(bodies.contains(first)); + for (final String broken : new String[] { + bodies.replace(first, first.replace("\"depth\":0,", "\"depth\":\"0\",")), + bodies.replace(first, first.replace("\"bytes\":8155,", "\"bytes\":8155.5,")), + bodies.replace(first + "\"chain\":\"f48484e6a7a8135e\",", first + "\"chain\":7,"), + }) { + assertFalse(broken.equals(bodies)); + final Map files = Fixtures.providerBodiesDataFiles(); + files.put(4L, SessionDataFile.parse(broken.getBytes(StandardCharsets.UTF_8))); + final Map doc = view( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList()); + final Map summary = (Map) doc.get("summary"); + assertEquals(13, summary.get("provider_bodies")); + assertEquals(6, summary.get("captured_prompts")); + assertEquals(Collections.singletonList(Map.of("role", "response", "ref", Map.of("seq", 4L, "row", 2L))), + node(doc, "call/s2-call-fdae022ac306").get("provider_bodies")); + } + } + + /** + * An ord is read as the Sessionizer reads it. A null ord that does not lead the line decodes as 0, which on the last + * line is no gap. + * The digits after a leading {"ord": are an unsigned 64-bit number, so the largest one is far past the + * next line, a gap. A line that does not decode, even after every record, may hide a call, and is one too. Each + * file is one the Sessionizer's reader decodes up to that line, so its whole document is comparable. + */ + @Test + @SuppressWarnings("unchecked") + public void ordsAreReadAsTheSessionizerReadsThem() throws Exception { + final String main = new String( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_DATA_FILES[0]), StandardCharsets.UTF_8); + final String third = "\n{\"ord\":3,\"off\":498,"; + final String last = "\n{\"ord\":14,\"off\":4332,"; + assertTrue(main.contains(third) && main.contains(last)); + final int end = main.lastIndexOf("{\"t\":\"end\""); + final String[][] cases = { + {main.replace(last, "\n{\"off\":4332,\"ord\":null,"), "7"}, + {main.replace(third, "\n{\"ord\":18446744073709551615,\"off\":498,"), "2"}, + {main.substring(0, end) + "}\n" + main.substring(end), "2"}, + }; + for (final String[] c : cases) { + assertFalse(c[0].equals(main)); + final Map files = Fixtures.providerBodiesDataFiles(); + files.put(1L, SessionDataFile.parse(c[0].getBytes(StandardCharsets.UTF_8))); + final Map doc = view( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList()); + final Map summary = (Map) doc.get("summary"); + assertEquals(14, summary.get("provider_bodies")); + assertEquals(Integer.parseInt(c[1]), summary.get("captured_prompts")); + } + } + + private static JsonElement stripProviderBodies(final JsonElement e) { + if (e.isJsonArray()) { + e.getAsJsonArray().forEach(ConversationViewBuilderTest::stripProviderBodies); + } else if (e.isJsonObject()) { + e.getAsJsonObject().remove("provider_bodies"); + e.getAsJsonObject().entrySet().forEach(x -> stripProviderBodies(x.getValue())); + } + return e; + } + /** * @return the node of that id under talks or loose */ 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 index 63a148899ed6..a9f26655fb28 100644 --- 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 @@ -37,6 +37,11 @@ *

Under workspace-changes/, the same for tests/scenarios/workspace-changes.yaml, which * exercises every producer of a change record: a shell command the plugin observed on the main stream, an * Edit whose patch the runtime recorded on its own result, and a shell command inside a subagent. + * + *

Under provider-bodies/, the same for tests/scenarios/provider-bodies.yaml: a session + * with a subagent whose calls' request and response bodies landed in one provider_body file. Under + * provider-bodies-errors/, the same for tests/scenarios/provider-bodies-errors.yaml: an API + * error the runtime wrote as a synthetic call, between two calls it did send. */ public final class Fixtures { public static final String SESSION = "00000001-0000-4000-8000-000000000001"; @@ -61,6 +66,23 @@ public final class Fixtures { }; public static final String WORKSPACE_CHANGES_ROUND_FILE = "r000001-475193a5f44b.sf"; + public static final String PROVIDER_BODIES_DIR = "provider-bodies/"; + public static final String PROVIDER_BODIES_SESSION = "6b7a6063-8714-4f6b-87cd-6c2da3a5094d"; + public static final String[] PROVIDER_BODIES_DATA_FILES = { + "transcript-20260101T000000.000000000Z-000001.sd", + "transcript-20260101T000000.000000000Z-000002.sd", + "meta-20260101T000000.000000000Z-000003.sd", + "provider_body-20260101T000000.000000000Z-000004.sd", + }; + public static final String PROVIDER_BODIES_ROUND_FILE = "r000001-ff8eaba03b03.sf"; + + public static final String PROVIDER_BODIES_ERRORS_DIR = "provider-bodies-errors/"; + public static final String[] PROVIDER_BODIES_ERRORS_DATA_FILES = { + "transcript-20260101T000000.000000000Z-000001.sd", + "provider_body-20260101T000000.000000000Z-000002.sd", + }; + public static final String PROVIDER_BODIES_ERRORS_ROUND_FILE = "r000001-0a33a0c2d269.sf"; + private Fixtures() { } @@ -81,6 +103,14 @@ public static Map workspaceChangesDataFiles() throws IOEx return dataFiles(WORKSPACE_CHANGES_DIR, WORKSPACE_CHANGES_DATA_FILES); } + public static Map providerBodiesDataFiles() throws IOException { + return dataFiles(PROVIDER_BODIES_DIR, PROVIDER_BODIES_DATA_FILES); + } + + public static Map providerBodiesErrorsDataFiles() throws IOException { + return dataFiles(PROVIDER_BODIES_ERRORS_DIR, PROVIDER_BODIES_ERRORS_DATA_FILES); + } + private static Map dataFiles(final String dir, final String[] names) throws IOException { final Map out = new TreeMap<>(); for (final String name : names) { diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/NoneAIAgentConversationProviderTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/NoneAIAgentConversationProviderTest.java index 87e88004f232..56f74d445b63 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/NoneAIAgentConversationProviderTest.java +++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/NoneAIAgentConversationProviderTest.java @@ -27,8 +27,9 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.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 NoneAIAgentConversationProviderTest { @@ -53,9 +54,9 @@ public void testTheDisabledModuleStoresAndAnswersNothing() throws Exception { .getConversations() .isEmpty()); assertNotNull(service.listConversations("1", null, null, null, new Duration(), null).getErrorReason()); - assertNull(service.buildConversationView("1", null, "c", false)); - assertTrue(service.getConversationRawFiles("1", null, "c", Collections.emptyList(), true, false) - .getFiles() - .isEmpty()); + assertNull(service.buildConversationView("1", null, "c", false, () -> true)); + assertFalse(service.readConversationFiles("1", "i", "c", "s", Collections.singletonList(1L), false, () -> true, file -> { + throw new AssertionError("no file is served"); + })); } } 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 index eb2db243644e..fac7716a5324 100644 --- 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 @@ -31,7 +31,6 @@ 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; @@ -131,20 +130,10 @@ public void fileNamesFollowTheStorageRootLayout() throws Exception { 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 changes = FileNames.parse(Fixtures.SESSION + "/streams/main/" + Fixtures.DATA_FILES[1]); - assertNotNull(changes); - assertTrue(changes.isDataFile()); - assertEquals(2, changes.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")); + // the provider bodies of a session share one directory, beside its streams + final Map provider = Fixtures.providerBodiesDataFiles(); + assertEquals(Fixtures.PROVIDER_BODIES_SESSION + "/provider_body/" + Fixtures.PROVIDER_BODIES_DATA_FILES[3], + FileNames.dataFile(provider.get(4L).getHeader())); } @Test diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryServiceTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryServiceTest.java index f9f4c6fbc4a9..97e7cfd0dd2a 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryServiceTest.java +++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryServiceTest.java @@ -26,11 +26,7 @@ import org.apache.skywalking.oap.server.ai.agent.conversation.AIAgentConversationConfig; import org.apache.skywalking.oap.server.ai.agent.conversation.Fixtures; import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests; -import org.apache.skywalking.oap.server.ai.agent.conversation.format.FileNames; import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound; -import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationFileFormat; -import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFile; -import org.apache.skywalking.oap.server.ai.agent.conversation.query.type.ConversationRawFiles; import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionDataRecord; import org.apache.skywalking.oap.server.core.analysis.manual.aiagent.AIAgentSessionFlowRecord; import org.apache.skywalking.oap.server.core.storage.StorageModule; @@ -44,6 +40,7 @@ import org.junit.jupiter.params.provider.ValueSource; 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; @@ -60,7 +57,7 @@ /** * The reads of the query service over a storage that answers from memory: the head is what the storage names - * as the highest round, whatever page the list would read, and the raw-file export serves every stored round, + * as the highest round, whatever page the list would read, and the files read by name serve every named round, * readable or not. The service reads through the interface's tracing wrappers, so those are what the mock answers. */ public class ConversationQueryServiceTest { @@ -105,46 +102,59 @@ private static List storedFiles() throws Exception { return files; } + private static final List ALL_SEQS = Arrays.asList(1L, 2L, 3L, 4L); + + private static List read(final ConversationQueryService service, final String conversation, + final List seqs, final boolean coldStage) throws Exception { + final List out = new ArrayList<>(); + assertTrue(service.readConversationFiles(SERVICE, "sender", conversation, Fixtures.SESSION, seqs, coldStage, () -> true, out::add)); + return out; + } + /** * Two stored rounds and a list page of one: the head is still round 2, because it is read as the highest - * round stored and not off the page, so the export names both rounds. + * round stored and not off the page, so the files are read over round 2's range. */ @Test public void theHeadIsTheHighestRoundStoredNotTheNewestOfAListPage() throws Exception { final SessionFlowRound first = Fixtures.round(); final String conversation = first.getHeader().getConversation(); final byte[] second = Fixtures.emptyRound(first, 2, first.getCommitDigest(), 4, 4, first.getHeader().getParser()); + // a third round, so the chain spans two round windows and the stage is proven on both + final byte[] third = Fixtures.emptyRound( + first, 3, SessionFlowRound.parse(second).getCommitDigest(), 5, 5, first.getHeader().getParser()); final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class); when(dao.queryHeadRoundDebuggable(eq(SERVICE), any(), eq(conversation), eq(false))).thenReturn(2L); when(dao.queryRoundsByNumberDebuggable(eq(SERVICE), any(), eq(conversation), anyLong(), anyLong(), anyInt(), eq(false))) - .thenReturn(Arrays.asList( - storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)), storedRound(conversation, 2, second))); + .thenAnswer(inv -> roundsIn(Arrays.asList( + storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)), storedRound(conversation, 2, second)), + inv.getArgument(3), inv.getArgument(4))); when(dao.queryFilesDebuggable(anyString(), any(), anyString(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt(), eq(false))) .thenReturn(storedFiles()); final AIAgentConversationConfig config = new AIAgentConversationConfig(); - config.setMaxListLimit(1); + config.setConversationListMaxLimit(1); - final ConversationRawFiles out = service(dao, config) - .getConversationRawFiles(SERVICE, null, conversation, null, false, false); + final List out = read(service(dao, config), conversation, Arrays.asList(4L, 2L, 3L, 1L), false); - assertNull(out.getErrorReason()); - final List rounds = new ArrayList<>(); - for (final ConversationRawFile f : out.getFiles()) { - if (f.getFormat() == ConversationFileFormat.SF) { - rounds.add((long) f.getRound()); - } + final List seqs = new ArrayList<>(); + for (final ConversationFile f : out) { + seqs.add(f.getSeq()); } - assertEquals(Arrays.asList(1L, 2L), rounds); - assertEquals(Fixtures.DATA_FILES.length + 2, out.getFiles().size()); + // in seq order, each named from its own header + assertEquals(Arrays.asList(1L, 2L, 3L, 4L), seqs); + assertEquals(Fixtures.SESSION + "/streams/main/" + Fixtures.DATA_FILES[0], out.get(0).getId()); + verify(dao).queryRoundsByNumberDebuggable(eq(SERVICE), any(), eq(conversation), eq(2L), eq(2L), anyInt(), eq(false)); + verify(dao, never()).queryRoundsByNumberDebuggable(eq(SERVICE), any(), eq(conversation), eq(1L), eq(1L), anyInt(), eq(false)); + assertEquals(Fixtures.SESSION + "/streams/" + Fixtures.CHILD_STREAM + "/" + Fixtures.DATA_FILES[2], out.get(2).getId()); verify(dao, never()).queryRoundsDebuggable(anyString(), any(), any(), any(), anyInt(), eq(false)); } /** - * The newest stored round is not a round at all, though ingest could not tell: the export still lists it, named - * by the digest of its file, serves the readable round before it, and honours a selection of that round alone. + * The newest stored round is not a round at all, though ingest could not tell: the files are read over the range + * of the readable round before it. */ @Test - public void anUnreadableNewestRoundDoesNotBlockTheExport() throws Exception { + public void anUnreadableNewestRoundDoesNotBlockTheRead() throws Exception { final SessionFlowRound first = Fixtures.round(); final String conversation = first.getHeader().getConversation(); final byte[] truncated = "{\"t\":\"header\",\"schema\":\"sf/1\"".getBytes(StandardCharsets.UTF_8); @@ -152,25 +162,23 @@ public void anUnreadableNewestRoundDoesNotBlockTheExport() throws Exception { final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class); when(dao.queryHeadRoundDebuggable(eq(SERVICE), any(), eq(conversation), eq(false))).thenReturn(2L); when(dao.queryRoundsByNumberDebuggable(eq(SERVICE), any(), eq(conversation), anyLong(), anyLong(), anyInt(), eq(false))) - .thenReturn(Arrays.asList(storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)), broken)); + .thenAnswer(inv -> roundsIn(Arrays.asList(storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)), broken), + inv.getArgument(3), inv.getArgument(4))); when(dao.queryFilesDebuggable(anyString(), any(), anyString(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt(), eq(false))) .thenReturn(storedFiles()); final ConversationQueryService service = service(dao, new AIAgentConversationConfig()); - final String firstId = FileNames.roundFile(conversation, 1, first.getCommitDigest()); - final ConversationRawFiles selected = service.getConversationRawFiles( - SERVICE, null, conversation, Collections.singletonList(firstId), true, false); - assertNull(selected.getErrorReason()); - assertEquals(1, selected.getFiles().size()); - assertEquals(firstId, selected.getFiles().get(0).getId()); - assertEquals(new String(Fixtures.bytes(Fixtures.ROUND_FILE), StandardCharsets.UTF_8), selected.getFiles().get(0).getBody()); - - final ConversationRawFiles all = service.getConversationRawFiles(SERVICE, null, conversation, null, false, false); - assertNull(all.getErrorReason()); - assertEquals(Fixtures.DATA_FILES.length + 2, all.getFiles().size()); - final ConversationRawFile last = all.getFiles().get(all.getFiles().size() - 1); - assertEquals(2, last.getRound()); - assertTrue(last.getId().endsWith(broken.getDigest().substring(0, 12) + ".sf"), last.getId()); + final List all = read(service, conversation, ALL_SEQS, false); + assertEquals(Fixtures.DATA_FILES.length, all.size()); + assertEquals(new String(Fixtures.bytes(Fixtures.DATA_FILES[0]), StandardCharsets.UTF_8), + new String(all.get(0).getBody(), StandardCharsets.UTF_8)); + // the files are read over the readable round's range, ended by its own row's time and not the broken one's + final SessionFlowRound.Header h = first.getHeader(); + final long from = org.apache.skywalking.oap.server.ai.agent.conversation.format.Times.millis(h.getSessionFromTime()); + final long through = Math.max( + org.apache.skywalking.oap.server.ai.agent.conversation.format.Times.millis(h.getSessionThroughTime()), SENT_AT + 1); + verify(dao).queryFilesDebuggable(eq(SERVICE), eq("sender"), eq(Fixtures.SESSION), eq(from), eq(through), + eq(1L), eq(4L), anyInt(), eq(false)); } @ParameterizedTest @@ -179,16 +187,20 @@ public void everyViewAndExportWindowUsesTheRequestedStage(final boolean export, final SessionFlowRound first = Fixtures.round(); final String conversation = first.getHeader().getConversation(); final byte[] second = Fixtures.emptyRound(first, 2, first.getCommitDigest(), 4, 4, first.getHeader().getParser()); + // a third round, so the chain spans two round windows and the stage is proven on both + final byte[] third = Fixtures.emptyRound( + first, 3, SessionFlowRound.parse(second).getCommitDigest(), 5, 5, first.getHeader().getParser()); final List files = storedFiles(); final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class); final AIAgentConversationConfig config = new AIAgentConversationConfig(); - config.setRoundReadWindow(1); - config.setFileReadWindow(2); - when(dao.queryHeadRoundDebuggable(SERVICE, null, conversation, coldStage)).thenReturn(2L); - when(dao.queryRoundsByNumberDebuggable(SERVICE, null, conversation, 1, 1, config.getMaxResponseBytes(), coldStage)) - .thenReturn(Collections.singletonList(storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)))); - when(dao.queryRoundsByNumberDebuggable(SERVICE, null, conversation, 2, 2, config.getMaxResponseBytes(), coldStage)) - .thenReturn(Collections.singletonList(storedRound(conversation, 2, second))); + config.setReadWindow(2); + final String sender = export ? "sender" : null; + when(dao.queryHeadRoundDebuggable(SERVICE, sender, conversation, coldStage)).thenReturn(3L); + when(dao.queryRoundsByNumberDebuggable(SERVICE, sender, conversation, 1, 2, config.getMaxResponseBytes(), coldStage)) + .thenReturn(Arrays.asList(storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)), + storedRound(conversation, 2, second))); + when(dao.queryRoundsByNumberDebuggable(SERVICE, sender, conversation, 3, 3, config.getMaxResponseBytes(), coldStage)) + .thenReturn(Collections.singletonList(storedRound(conversation, 3, third))); when(dao.queryFilesDebuggable(eq(SERVICE), any(), eq(Fixtures.SESSION), anyLong(), anyLong(), eq(1L), eq(2L), eq(config.getMaxResponseBytes()), eq(coldStage))) .thenReturn(files.subList(0, 2)); @@ -198,31 +210,141 @@ public void everyViewAndExportWindowUsesTheRequestedStage(final boolean export, final ConversationQueryService service = service(dao, config); if (export) { - final ConversationRawFiles result = service.getConversationRawFiles( - SERVICE, null, conversation, null, false, coldStage); - assertNull(result.getErrorReason()); - assertEquals(files.size() + 2, result.getFiles().size()); + assertEquals(files.size(), read(service, conversation, ALL_SEQS, coldStage).size()); } else { - assertNotNull(service.buildConversationView(SERVICE, null, conversation, coldStage)); + assertNotNull(service.buildConversationView(SERVICE, null, conversation, coldStage, () -> true)); } - verify(dao).queryHeadRoundDebuggable(SERVICE, null, conversation, coldStage); - verify(dao).queryRoundsByNumberDebuggable(SERVICE, null, conversation, 1, 1, config.getMaxResponseBytes(), coldStage); - verify(dao).queryRoundsByNumberDebuggable(SERVICE, null, conversation, 2, 2, config.getMaxResponseBytes(), coldStage); + verify(dao).queryHeadRoundDebuggable(SERVICE, sender, conversation, coldStage); + // a file read takes its range from the head round alone; the view reads every round, a window at a time + if (export) { + verify(dao).queryRoundsByNumberDebuggable(SERVICE, sender, conversation, 3, 3, config.getMaxResponseBytes(), coldStage); + } else { + // both windows of the chain carry the stage, not only the first + verify(dao).queryRoundsByNumberDebuggable(SERVICE, sender, conversation, 1, 2, config.getMaxResponseBytes(), coldStage); + verify(dao).queryRoundsByNumberDebuggable(SERVICE, sender, conversation, 3, 3, config.getMaxResponseBytes(), coldStage); + } verify(dao).queryFilesDebuggable(eq(SERVICE), any(), eq(Fixtures.SESSION), anyLong(), anyLong(), eq(1L), eq(2L), eq(config.getMaxResponseBytes()), eq(coldStage)); verify(dao).queryFilesDebuggable(eq(SERVICE), any(), eq(Fixtures.SESSION), anyLong(), anyLong(), eq(3L), eq(4L), eq(config.getMaxResponseBytes()), eq(coldStage)); + if (!export) { + // the view reads every file the chain names, and the third round names one more + verify(dao).queryFilesDebuggable(eq(SERVICE), any(), eq(Fixtures.SESSION), anyLong(), anyLong(), + eq(5L), eq(5L), eq(config.getMaxResponseBytes()), eq(coldStage)); + } verifyNoMoreInteractions(dao); } + /** + * Names scattered over a session are read one run of consecutive seqs at a time, never the stretch between + * them, and each file once, in seq order, whatever order the storage answers in. + */ + @Test + public void namedFilesAreReadInRunsOfConsecutiveSeqs() throws Exception { + final SessionFlowRound first = Fixtures.round(); + final String conversation = first.getHeader().getConversation(); + final List files = storedFiles(); + final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class); + when(dao.queryHeadRoundDebuggable(eq(SERVICE), any(), eq(conversation), eq(false))).thenReturn(1L); + when(dao.queryRoundsByNumberDebuggable(eq(SERVICE), any(), eq(conversation), anyLong(), anyLong(), anyInt(), eq(false))) + .thenReturn(Collections.singletonList(storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)))); + when(dao.queryFilesDebuggable(eq(SERVICE), eq("sender"), eq(Fixtures.SESSION), anyLong(), anyLong(), eq(1L), eq(2L), anyInt(), eq(false))) + .thenReturn(Arrays.asList(files.get(1), files.get(0), files.get(1))); + when(dao.queryFilesDebuggable(eq(SERVICE), eq("sender"), eq(Fixtures.SESSION), anyLong(), anyLong(), eq(4L), eq(4L), anyInt(), eq(false))) + .thenReturn(Collections.singletonList(files.get(3))); + final List out = read(service(dao, new AIAgentConversationConfig()), conversation, + Arrays.asList(4L, 2L, 1L, 2L), false); + + final List seqs = new ArrayList<>(); + for (final ConversationFile f : out) { + seqs.add(f.getSeq()); + } + assertEquals(Arrays.asList(1L, 2L, 4L), seqs); + assertEquals(files.get(3).getDigest(), out.get(2).getDigest()); + verify(dao).queryFilesDebuggable(eq(SERVICE), eq("sender"), eq(Fixtures.SESSION), anyLong(), anyLong(), eq(1L), eq(2L), anyInt(), eq(false)); + verify(dao).queryFilesDebuggable(eq(SERVICE), eq("sender"), eq(Fixtures.SESSION), anyLong(), anyLong(), eq(4L), eq(4L), anyInt(), eq(false)); + verify(dao, never()).queryFilesDebuggable(anyString(), any(), anyString(), anyLong(), anyLong(), eq(3L), anyLong(), anyInt(), eq(false)); + } + + @Test + public void aConversationTheSenderDoesNotStoreServesNoFile() throws Exception { + final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class); + final List out = new ArrayList<>(); + assertFalse(service(dao, new AIAgentConversationConfig()).readConversationFiles( + SERVICE, "sender", Fixtures.SESSION, Fixtures.SESSION, ALL_SEQS, false, () -> true, out::add)); + assertTrue(out.isEmpty()); + } + + /** + * Each storage window is handed to the sink before the next window is read, so a response holds one window of + * bodies at a time. + */ + @Test + public void eachWindowReachesTheSinkBeforeTheNextIsRead() throws Exception { + final SessionFlowRound first = Fixtures.round(); + final String conversation = first.getHeader().getConversation(); + final List files = storedFiles(); + final List events = new ArrayList<>(); + final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class); + final AIAgentConversationConfig config = new AIAgentConversationConfig(); + config.setReadWindow(2); + when(dao.queryHeadRoundDebuggable(eq(SERVICE), any(), eq(conversation), eq(false))).thenReturn(1L); + when(dao.queryRoundsByNumberDebuggable(eq(SERVICE), any(), eq(conversation), anyLong(), anyLong(), anyInt(), eq(false))) + .thenReturn(Collections.singletonList(storedRound(conversation, 1, Fixtures.bytes(Fixtures.ROUND_FILE)))); + when(dao.queryFilesDebuggable(eq(SERVICE), any(), eq(Fixtures.SESSION), anyLong(), anyLong(), anyLong(), anyLong(), anyInt(), eq(false))) + .thenAnswer(inv -> { + final long from = inv.getArgument(5); + final long through = inv.getArgument(6); + events.add("read " + from + ".." + through); + return files.subList((int) from - 1, (int) through); + }); + + assertTrue(service(dao, config).readConversationFiles(SERVICE, "sender", conversation, Fixtures.SESSION, ALL_SEQS, false, () -> true, + f -> events.add("file " + f.getSeq()))); + + assertEquals(Arrays.asList("read 1..2", "file 1", "file 2", "read 3..4", "file 3", "file 4"), events); + } + + /** + * Windows and runs never overflow, even at the largest seq a request can name. + */ + @Test + public void windowsAndRunsHoldAtTheLargestNumbers() { + final List windows = new ArrayList<>(); + ConversationQueryService.windows(Long.MAX_VALUE - 20, Long.MAX_VALUE, 16).forEach(windows::add); + assertEquals(2, windows.size()); + assertEquals(Long.MAX_VALUE - 4, windows.get(1)[0]); + assertEquals(Long.MAX_VALUE, windows.get(1)[1]); + assertTrue(ConversationQueryService.windows(7, 7, 16).iterator().hasNext()); + assertFalse(ConversationQueryService.windows(8, 7, 16).iterator().hasNext()); + // a window a round claims up to the largest number is produced one window at a time, never as a list + final java.util.Iterator huge = ConversationQueryService.windows(1, Long.MAX_VALUE, 16).iterator(); + assertEquals(1L, huge.next()[0]); + assertEquals(17L, huge.next()[0]); + final List runs = ConversationQueryService.runs(Arrays.asList(1L, 2L, 4L, Long.MAX_VALUE - 1, Long.MAX_VALUE)); + assertEquals(3, runs.size()); + assertEquals(Long.MAX_VALUE, runs.get(2)[1]); + } + + private static List roundsIn(final List stored, final long from, + final long through) { + final List out = new ArrayList<>(); + for (final AIAgentSessionFlowRecord r : stored) { + if (r.getRound() >= from && r.getRound() <= through) { + out.add(r); + } + } + return out; + } + @ParameterizedTest @ValueSource(booleans = {false, true}) public void aMissingConversationDoesNotFallBackToAnotherStage(final boolean coldStage) throws Exception { final IAIAgentConversationQueryDAO dao = mock(IAIAgentConversationQueryDAO.class); assertNull(service(dao, new AIAgentConversationConfig()).buildConversationView( - SERVICE, null, Fixtures.SESSION, coldStage)); + SERVICE, null, Fixtures.SESSION, coldStage, () -> true)); verify(dao).queryHeadRoundDebuggable(SERVICE, null, Fixtures.SESSION, coldStage); verifyNoMoreInteractions(dao); diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationFilesHandlerTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationFilesHandlerTest.java new file mode 100644 index 000000000000..08e5a4b6bcb1 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/http/ConversationFilesHandlerTest.java @@ -0,0 +1,352 @@ +/* + * 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.google.gson.JsonParser; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.common.RequestHeadersBuilder; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Collection; +import java.util.function.BooleanSupplier; +import java.util.Map; +import java.util.TreeSet; +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.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.query.ConversationFile; +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.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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The files route on a real server: chosen files come back framed so a reader recovers each one byte for byte, + * compressed or not, and the error paths answer with their statuses. + */ +public class ConversationFilesHandlerTest { + private static final String SERVICE = "agent"; + private static final String SERVICE_ID = IDManager.ServiceID.buildId(SERVICE, true); + private static final AtomicReference LAST_INSTANCE_ID = new AtomicReference<>(); + private static final AtomicReference LAST_SESSION = new AtomicReference<>(); + private static final AtomicReference LAST_COLD_STAGE = new AtomicReference<>(); + /** The last stored files the stub serves: the fixture's, and two whose framing is at its edges. */ + private static final byte[] UNENDED = "{\"h\":1}\n{\"t\":\"end\"}".getBytes(StandardCharsets.UTF_8); + private static final byte[] LARGE = large(); + + private static byte[] large() { + final StringBuilder b = new StringBuilder("{\"h\":1}\n{\"data\":\""); + while (b.length() < 200 * 1024) { + b.append("caf\u00e9 \uD83D\uDE00 "); + } + return b.append("\"}\n").toString().getBytes(StandardCharsets.UTF_8); + } + + /** Serves the fixture's Session Data files by seq 1 to 4, and seq 5 without a final newline, seq 6 large and + * seq 7 empty, as the query service would, in seq order. Conversation "fails-after-one" fails after its first + * file. */ + private static final IConversationQueryService STUB = new IConversationQueryService() { + @Override + public ConversationList listConversations(final String serviceId, @Nullable final String serviceInstanceId, + @Nullable final String conversation, @Nullable final String title, + final Duration duration, @Nullable final Integer limit) { + throw new UnsupportedOperationException(); + } + + @Override + public Map buildConversationView(final String serviceId, final String serviceInstanceId, + final String conversation, final boolean coldStage, + final BooleanSupplier alive) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean readConversationFiles(final String serviceId, final String serviceInstanceId, + final String conversation, final String session, + final Collection seqs, final boolean coldStage, + final BooleanSupplier alive, final FileSink sink) throws IOException { + LAST_INSTANCE_ID.set(serviceInstanceId); + LAST_SESSION.set(session); + LAST_COLD_STAGE.set(coldStage); + if ("broken".equals(conversation)) { + throw new IOException("storage is down"); + } + final boolean failAfterOne = "fails-after-one".equals(conversation); + if (!SERVICE_ID.equals(serviceId) || !Fixtures.SESSION.equals(conversation) && !failAfterOne) { + return false; + } + for (final long seq : new TreeSet<>(seqs)) { + final byte[] body; + if (seq <= Fixtures.DATA_FILES.length) { + body = Fixtures.bytes(Fixtures.DATA_FILES[(int) seq - 1]); + } else if (seq == 5) { + body = UNENDED; + } else if (seq == 6) { + body = LARGE; + } else if (seq == 7) { + body = new byte[0]; + } else { + continue; + } + final String id = seq <= Fixtures.DATA_FILES.length + ? FileNames.dataFile(SessionDataFile.header(body)) : session + "/unknown-00000" + seq + ".sd"; + sink.accept(new ConversationFile(id, seq, Digests.sha256Hex(body), body, 1)); + if (failAfterOne) { + throw new IOException("storage went away"); + } + } + return true; + } + }; + + @RegisterExtension + static final ServerExtension SERVER = new ServerExtension() { + @Override + protected void configure(final ServerBuilder sb) { + sb.annotatedService(new ConversationFilesHandler(STUB, java.time.Duration.ofSeconds(30))); + } + }; + + private static String path(final String conversation, final long... seqs) { + final StringBuilder path = new StringBuilder( + "/ai-agent/conversations/" + conversation + "/v1/files?service=" + SERVICE + "&instance=sender-1&session=" + Fixtures.SESSION); + for (final long seq : seqs) { + path.append("&seq=").append(seq); + } + return path.toString(); + } + + 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(); + } + + /** One file as a reader recovers it: its naming line, and the bytes of the lines that follow. */ + private static final class Framed { + final JsonObject naming; + final byte[] body; + + Framed(final JsonObject naming, final byte[] body) { + this.naming = naming; + this.body = body; + } + } + + /** + * Reads the stream the way a client does: a naming line, then exactly bytes bytes, then the one + * newline that follows a file not ending with its own. + */ + private static List frames(final byte[] stream) { + final List out = new ArrayList<>(); + int pos = 0; + while (pos < stream.length) { + int end = pos; + while (stream[end] != '\n') { + end++; + } + final JsonObject naming = JsonParser.parseString(new String(stream, pos, end - pos, StandardCharsets.UTF_8)).getAsJsonObject(); + pos = end + 1; + final int bytes = naming.get("bytes").getAsInt(); + final byte[] body = Arrays.copyOfRange(stream, pos, pos + bytes); + pos += bytes; + if (bytes > 0 && body[bytes - 1] != '\n') { + assertEquals('\n', stream[pos], "the newline after a file that does not end with one"); + pos++; + } + out.add(new Framed(naming, body)); + } + return out; + } + + @Test + public void eachChosenFileComesBackByteForByte() throws Exception { + final AggregatedHttpResponse res = get(path(Fixtures.SESSION, 3, 1)); + assertEquals(200, res.status().code()); + assertEquals("application/vnd.skywalking.asz.files+ndjson; charset=utf-8", String.valueOf(res.contentType())); + final List files = frames(res.content().array()); + assertEquals(2, files.size()); + // in seq order, whatever order they were named in + assertEquals(1, files.get(0).naming.get("seq").getAsLong()); + assertEquals(3, files.get(1).naming.get("seq").getAsLong()); + for (final Framed f : files) { + final byte[] expected = Fixtures.bytes(Fixtures.DATA_FILES[(int) f.naming.get("seq").getAsLong() - 1]); + assertArrayEquals(expected, f.body); + assertEquals(Digests.sha256Hex(expected), f.naming.get("digest").getAsString()); + assertEquals(expected.length, f.naming.get("bytes").getAsInt()); + assertEquals(5, f.naming.size(), "file, seq, lines, bytes, digest"); + } + // a file is named by where it lives, from its own header + assertEquals(Fixtures.SESSION + "/streams/" + Fixtures.CHILD_STREAM + "/" + Fixtures.DATA_FILES[2], + files.get(1).naming.get("file").getAsString()); + assertEquals(IDManager.ServiceInstanceID.buildId(SERVICE_ID, "sender-1"), LAST_INSTANCE_ID.get()); + assertEquals(Fixtures.SESSION, LAST_SESSION.get()); + } + + /** + * A file without a final newline, one past several chunks with characters of every width across the chunk + * boundaries, and an empty one each come back whole, and a reader that takes lines counts the unended file's own. + */ + @Test + public void theFramingHoldsAtItsEdges() throws Exception { + for (final boolean gzip : new boolean[] {false, true}) { + // the empty file sits between two others, so the naming line after it must start right after its own + final AggregatedHttpResponse res = gzip + ? get(path(Fixtures.SESSION, 5, 6, 7, 1), "accept-encoding", "gzip") + : get(path(Fixtures.SESSION, 5, 6, 7, 1)); + assertEquals(200, res.status().code()); + final byte[] stream = gzip ? inflate(res.content().array()) : res.content().array(); + final List files = frames(stream); + assertEquals(4, files.size()); + assertArrayEquals(Fixtures.bytes(Fixtures.DATA_FILES[0]), files.get(0).body); + assertArrayEquals(UNENDED, files.get(1).body); + assertEquals(1, files.get(1).naming.get("lines").getAsInt()); + assertEquals(Digests.sha256Hex(UNENDED), files.get(1).naming.get("digest").getAsString()); + assertArrayEquals(LARGE, files.get(2).body); + assertEquals(0, files.get(3).body.length); + assertEquals(Digests.sha256Hex(new byte[0]), files.get(3).naming.get("digest").getAsString()); + } + } + + private static byte[] inflate(final byte[] gzipped) throws IOException { + final ByteArrayOutputStream inflated = new ByteArrayOutputStream(); + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(gzipped))) { + in.transferTo(inflated); + } + return inflated.toByteArray(); + } + + /** + * gzip is used only when the client takes it: named or *, and not weighted zero. + */ + @Test + public void gzipOnlyWhenTheClientTakesIt() { + assertTrue(ConversationFilesHandler.acceptsGzip("gzip")); + assertTrue(ConversationFilesHandler.acceptsGzip("br, gzip;q=0.5")); + assertTrue(ConversationFilesHandler.acceptsGzip("*")); + assertFalse(ConversationFilesHandler.acceptsGzip(null)); + assertFalse(ConversationFilesHandler.acceptsGzip("identity")); + assertFalse(ConversationFilesHandler.acceptsGzip("gzip;q=0")); + assertFalse(ConversationFilesHandler.acceptsGzip("gzip; q=0.0, br")); + // gzip's own weight decides over the wildcard, whichever comes first + assertFalse(ConversationFilesHandler.acceptsGzip("gzip;q=0, *;q=1")); + assertFalse(ConversationFilesHandler.acceptsGzip("*, gzip;q=0")); + assertTrue(ConversationFilesHandler.acceptsGzip("identity;q=0, *")); + assertFalse(ConversationFilesHandler.acceptsGzip("*;q=0")); + // stray separators name nothing and break nothing + assertFalse(ConversationFilesHandler.acceptsGzip(";")); + assertFalse(ConversationFilesHandler.acceptsGzip(",")); + assertTrue(ConversationFilesHandler.acceptsGzip(";, gzip")); + assertEquals(200, get(path(Fixtures.SESSION, 1), "accept-encoding", ";").status().code()); + // two header fields count as one list + assertEquals("gzip", get(path(Fixtures.SESSION, 1), "accept-encoding", "br", "accept-encoding", "gzip") + .headers().get(HttpHeaderNames.CONTENT_ENCODING)); + assertEquals(null, get(path(Fixtures.SESSION, 1), "accept-encoding", "gzip;q=0").headers().get(HttpHeaderNames.CONTENT_ENCODING)); + } + + /** + * A failure after the first file ends the response early: the client does not get a complete response. + */ + @Test + public void aFailureAfterTheFirstFileEndsTheResponse() { + assertThrows(Exception.class, () -> get(path("fails-after-one", 1, 2))); + } + + @Test + public void gzipOnAcceptEncoding() throws Exception { + final AggregatedHttpResponse res = get(path(Fixtures.SESSION, 1, 2, 3, 4), "accept-encoding", "gzip"); + assertEquals(200, res.status().code()); + assertEquals("gzip", res.headers().get(HttpHeaderNames.CONTENT_ENCODING)); + final byte[] stream = inflate(res.content().array()); + assertTrue(res.content().length() < stream.length / 2, "compressed " + res.content().length() + " of " + stream.length); + final List files = frames(stream); + assertEquals(4, files.size()); + for (int i = 0; i < files.size(); i++) { + assertArrayEquals(Fixtures.bytes(Fixtures.DATA_FILES[i]), files.get(i).body); + } + } + + @Test + public void aChoiceNoFileAnswersIsLeftOut() { + final AggregatedHttpResponse res = get(path(Fixtures.SESSION, 9)); + assertEquals(200, res.status().code()); + assertEquals(0, res.content().length()); + } + + @Test + public void statusesOfTheErrorPaths() { + final String base = "/ai-agent/conversations/" + Fixtures.SESSION + "/v1/files?session=" + Fixtures.SESSION + "&seq=1"; + assertEquals(400, get(base).status().code()); + assertEquals(400, get(base + "&service=" + SERVICE).status().code()); + assertEquals(400, get(base + "&instance=sender-1").status().code()); + // no seq, a seq without its session, rounds, which the route does not read, and bad seqs and stages + assertEquals(400, get(path(Fixtures.SESSION)).status().code()); + assertEquals(400, get("/ai-agent/conversations/" + Fixtures.SESSION + "/v1/files?service=" + SERVICE + "&instance=sender-1&seq=1").status().code()); + assertEquals(400, get("/ai-agent/conversations/" + Fixtures.SESSION + "/v1/files?service=" + SERVICE + "&instance=sender-1&round=1").status().code()); + for (final String bad : new String[] {"&seq=x", "&seq=0", "&seq=-1", "&seq=99999999999999999999", "&seq=1.5", + "&seq=1&coldStage=garbage", "&seq=1&coldStage="}) { + final AggregatedHttpResponse res = get(path(Fixtures.SESSION) + bad); + assertEquals(400, res.status().code(), bad); + assertTrue(res.contentType().is(MediaType.parse("application/problem+json")), bad); + } + // the most a request chooses, and one more; the most fits the 4 KB request line even as the largest numbers + final long[] most = new long[ConversationFilesHandler.MAX_SEQS]; + Arrays.fill(most, Long.MAX_VALUE); + final String mostPath = path(Fixtures.SESSION, most); + assertTrue(("GET " + mostPath + " HTTP/1.1").length() < 4096, "request line " + mostPath.length()); + assertEquals(200, get(mostPath).status().code()); + assertEquals(400, get(mostPath + "&seq=1").status().code()); + LAST_COLD_STAGE.set(null); + assertEquals(200, get(path(Fixtures.SESSION, 1) + "&coldStage=true").status().code()); + assertEquals(Boolean.TRUE, LAST_COLD_STAGE.get()); + assertEquals(404, get(path("no-such-conversation", 1)).status().code()); + final AggregatedHttpResponse broken = get(path("broken", 1)); + assertEquals(500, broken.status().code()); + 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/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 index 196acd4c9662..69a9808b57ae 100644 --- 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 @@ -43,6 +43,8 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; +import java.util.function.BooleanSupplier; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -56,7 +58,6 @@ 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; @@ -94,7 +95,8 @@ public ConversationList listConversations(final String serviceId, @Nullable fina public Map buildConversationView(final String serviceId, @Nullable final String serviceInstanceId, final String conversation, - final boolean coldStage) throws IOException { + final boolean coldStage, + final BooleanSupplier alive) throws IOException { LAST_INSTANCE_ID.set(serviceInstanceId); LAST_COLD_STAGE.set(coldStage); if ("broken".equals(conversation)) { @@ -104,12 +106,10 @@ public Map buildConversationView(final String serviceId, } @Override - public ConversationRawFiles getConversationRawFiles(final String serviceId, - @Nullable final String serviceInstanceId, - final String conversation, - @Nullable final List files, - final boolean includeBody, - final boolean coldStage) { + public boolean readConversationFiles(final String serviceId, final String serviceInstanceId, + final String conversation, final String session, + final Collection seqs, final boolean coldStage, + final BooleanSupplier alive, final FileSink sink) { throw new UnsupportedOperationException(); } }; @@ -137,7 +137,7 @@ private static Map fixtureDocument() { } private static String path(final String conversation) { - return "/ai-agent/conversations/" + conversation + "/v1/view?service=" + SERVICE; + return "/ai-agent/conversations/" + conversation + "/v1/view?service=" + SERVICE + "&instance=sender-1"; } private static AggregatedHttpResponse get(final String path, final String... headers) { @@ -240,7 +240,7 @@ public void aCodePointAtTheChunkBoundaryStaysWhole() throws Exception { @Test public void theInstanceParameterNamesTheSender() { - get(path(Fixtures.SESSION) + "&instance=sender-1"); + get(path(Fixtures.SESSION)); assertEquals(IDManager.ServiceInstanceID.buildId(SERVICE_ID, "sender-1"), LAST_INSTANCE_ID.get()); } @@ -256,6 +256,14 @@ public void coldStageRequiresAnExplicitRequest(final String parameter, final boo public void statusesOfTheErrorPaths() { assertEquals(404, get(path("no-such-conversation")).status().code()); assertEquals(400, get("/ai-agent/conversations/" + Fixtures.SESSION + "/v1/view").status().code()); + // the list names both the service and the sender, so the route needs both + assertEquals(400, get("/ai-agent/conversations/" + Fixtures.SESSION + "/v1/view?service=" + SERVICE).status().code()); + assertEquals(400, get("/ai-agent/conversations/" + Fixtures.SESSION + "/v1/view?instance=sender-1").status().code()); + assertEquals(400, get("/ai-agent/conversations/" + Fixtures.SESSION + "/v1/view?serviceId=" + SERVICE_ID + "&instance=sender-1").status().code()); + // a coldStage that is neither true nor false is refused with a problem document, not Armeria's plain text + final AggregatedHttpResponse garbage = get(path(Fixtures.SESSION) + "&coldStage=garbage"); + assertEquals(400, garbage.status().code()); + assertTrue(garbage.contentType().is(MediaType.parse("application/problem+json")), String.valueOf(garbage.contentType())); final AggregatedHttpResponse broken = get(path("broken")); assertEquals(500, broken.status().code()); assertTrue(broken.contentType().is(MediaType.parse("application/problem+json")), String.valueOf(broken.contentType())); 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 index 518de0b28484..9471d8697df4 100644 --- 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 @@ -22,6 +22,8 @@ "rounds": 1, "unresolved": 0, "changes": 1, + "provider_bodies": 0, + "captured_prompts": 0, "from": 1767225600000, "to": 1767225611100, "kinds": { 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 index 655f45efff63..e8e09609a154 100644 --- 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 @@ -19,6 +19,8 @@ summary: rounds: 1 unresolved: 0 changes: 1 + provider_bodies: 0 + captured_prompts: 0 from: 1767225600000 to: 1767225611100 kinds: diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/asz-view-example.json b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/asz-view-example.json new file mode 100644 index 000000000000..577d9458b992 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/asz-view-example.json @@ -0,0 +1,396 @@ +{ + "format": "asz.view", + "version": "1.0", + "conversation": "f41b7a16-49fa-44ea-8ef6-bb39db57ab1d", + "sessions": [ + "f41b7a16-49fa-44ea-8ef6-bb39db57ab1d" + ], + "head": { + "round": 1, + "digest": "0a33a0c2d269a8b66804075c0eb46e0546187e9a9f45d8eca10486bbcb040699" + }, + "parser": "v1", + "policy": "v1+idle=10m0s", + "summary": { + "title": "an error between two calls", + "state": "verified", + "problems": [], + "talks": 1, + "steps": 7, + "streams": 1, + "segments": 1, + "rounds": 1, + "unresolved": 0, + "changes": 0, + "provider_bodies": 4, + "captured_prompts": 2, + "from": 1767225600000, + "to": 1767225603000, + "kinds": { + "epoch": 1, + "llm.call": 3, + "message.assistant": 2, + "message.external": 1, + "message.synthetic": 1, + "run": 1, + "segment": 1, + "session": 1, + "stream": 1, + "talk": 1 + }, + "relation_types": { + "in_segment": 1 + }, + "quality": { + "exact_unique": 1 + } + }, + "rounds": [ + { + "round": 1, + "digest": "0a33a0c2d269a8b66804075c0eb46e0546187e9a9f45d8eca10486bbcb040699", + "previous": null, + "from_seq": 1, + "through_seq": 2, + "input_digest": "950a569a9d180b4b09b44f954d6cbfe8e1a85a717f285edd7f4f437440bb2778", + "from_time": 1767225600000, + "through_time": 1767225603000, + "verified": true + } + ], + "files": [ + { + "file": "f41b7a16-49fa-44ea-8ef6-bb39db57ab1d/streams/main/transcript-20260101T000000.000000000Z-000001.sd", + "format": "sd", + "kind": "transcript", + "seq": 1, + "round": null, + "stream": "main", + "run": null, + "lines": 7, + "bytes": 1896, + "digest": "90d0142cc915362f13328f96cc51b818ef116e7f977b9ef2e3e9165f7c999efa", + "from_time": 1767225600000, + "through_time": 1767225603000 + }, + { + "file": "f41b7a16-49fa-44ea-8ef6-bb39db57ab1d/provider_body/provider_body-20260101T000000.000000000Z-000002.sd", + "format": "sd", + "kind": "provider_body", + "seq": 2, + "round": null, + "stream": null, + "run": null, + "lines": 6, + "bytes": 12454, + "digest": "bb4e2514f2da98c009ce6fcf716c82b091582929800c98c76de6ec18b0434ec4", + "from_time": null, + "through_time": null + }, + { + "file": "_conversations/f41b7a16-49fa-44ea-8ef6-bb39db57ab1d/rounds/r000001-0a33a0c2d269.sf", + "format": "sf", + "kind": "round", + "seq": null, + "round": 1, + "stream": null, + "run": null, + "lines": 16, + "bytes": 3840, + "digest": "078b6f3f50c11dbbc522d8246fd12c561276a95e66ce7102eb9bead4ce26ae23", + "from_time": 1767225600000, + "through_time": 1767225603000 + } + ], + "streams": [ + { + "id": "stream/main", + "name": "main", + "role": "main", + "label": "", + "parent": "", + "records": 5, + "steps": 7, + "talk": "talk/main/s1-cycle", + "named_by": "", + "opened_by": [] + } + ], + "segments": [ + { + "id": "segment/at_1_2", + "state": "open", + "committable": false, + "talks": 1, + "from": 1767225600000, + "to": 1767225603000 + } + ], + "talks": [ + { + "id": "talk/main/s1-cycle", + "kind": "talk", + "parent": "epoch/main/0", + "stream": "main", + "at": 1767225600000, + "ref": { + "seq": 1, + "row": 2 + }, + "attrs": { + "loops": 1, + "runs": 1, + "trigger": "external" + }, + "label": "summarise the file", + "reply": "It is a configuration file.", + "runs": 1, + "steps": 7, + "from": 1767225600000, + "to": 1767225603000, + "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": "summarise the file", + "state": "available", + "bytes": 18, + "flags": [ + "external_input" + ] + }, + { + "id": "call/s2-call-f7d240c7da38", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225601000, + "ref": { + "seq": 1, + "row": 3 + }, + "refs": [ + { + "seq": 1, + "row": 3 + } + ], + "attrs": { + "fragments": 1, + "usage": "observed_replayable", + "usage_at": { + "row": 3, + "seq": 1 + }, + "usage_from": "last_fragment_in_line_order" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 2, + "row": 1 + } + }, + { + "role": "response", + "ref": { + "seq": 2, + "row": 2 + } + } + ], + "children": [ + { + "id": "msg/1/3:0", + "kind": "message.assistant", + "parent": "call/s2-call-f7d240c7da38", + "stream": "main", + "at": 1767225601000, + "ref": { + "seq": 1, + "row": 3, + "block": 0 + }, + "text": "Reading it.", + "state": "available", + "bytes": 11, + "flags": [ + "finished" + ] + } + ] + }, + { + "id": "call/s3-synthetic-call", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225602000, + "ref": { + "seq": 1, + "row": 4 + }, + "refs": [ + { + "seq": 1, + "row": 4 + } + ], + "attrs": { + "fragments": 1, + "stop_reason": "unavailable", + "usage": "unavailable", + "usage_from": "last_fragment_in_line_order" + }, + "children": [ + { + "id": "msg/1/4:0", + "kind": "message.synthetic", + "parent": "call/s3-synthetic-call", + "stream": "main", + "at": 1767225602000, + "ref": { + "seq": 1, + "row": 4, + "block": 0 + }, + "text": "API Error: 529 overloaded", + "state": "available", + "bytes": 25, + "flags": [ + "synthetic" + ] + } + ] + }, + { + "id": "call/s4-call-f7d240c7da38", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225603000, + "ref": { + "seq": 1, + "row": 5 + }, + "refs": [ + { + "seq": 1, + "row": 5 + } + ], + "attrs": { + "fragments": 1, + "usage": "observed_replayable", + "usage_at": { + "row": 5, + "seq": 1 + }, + "usage_from": "last_fragment_in_line_order" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 2, + "row": 3 + } + }, + { + "role": "response", + "ref": { + "seq": 2, + "row": 4 + } + } + ], + "children": [ + { + "id": "msg/1/5:0", + "kind": "message.assistant", + "parent": "call/s4-call-f7d240c7da38", + "stream": "main", + "at": 1767225603000, + "ref": { + "seq": 1, + "row": 5, + "block": 0 + }, + "text": "It is a configuration file.", + "state": "available", + "bytes": 27, + "flags": [ + "finished" + ] + } + ] + } + ] + } + ], + "edges": [ + { + "type": "in_segment", + "other": "segment/at_1_2", + "dir": "out", + "quality": "exact_unique", + "via": "activity window" + } + ] + } + ], + "loose": [], + "relations": [ + { + "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 + } + ] + } + ], + "unresolved": [], + "workspace_changes": [] +} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/provider_body-20260101T000000.000000000Z-000002.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/provider_body-20260101T000000.000000000Z-000002.sd new file mode 100644 index 000000000000..3e7280dcd07a --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/provider_body-20260101T000000.000000000Z-000002.sd @@ -0,0 +1,6 @@ +{"h":1,"schema":"sd/1","seq":2,"at":"2026-01-01T00:00:00Z","kind":"provider_body","adapter":"mock/0.2.0","dialect":"mock/1","src":".","session":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d"} +{"ord":1,"off":0,"sha":"6fcc13bd46ca","bytes":8134,"id":"e62d8105-a1c8-4589-8ab4-1d1d4628dd9f.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":"\nCodebase and user instructions are shown below.\nFollow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. \n","state":"available","bytes":1770},{"k":"data","data":"You are Claude Code, working in a scenario. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. ","state":"available","bytes":2046},{"k":"data","data":{"description":"Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. ","input_schema":{"properties":{"file_path":{"type":"string"}},"type":"object"},"name":"Read"},"state":"available","bytes":1710},{"k":"data","data":{"description":"Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. ","input_schema":{"properties":{"command":{"type":"string"}},"type":"object"},"name":"Bash"},"state":"available","bytes":2068},{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"e62d8105-a1c8-4589-8ab4-1d1d4628dd9f.request.json","sha256":"6fcc13bd46ca07860d3404c4b6ea749540e567319032db87a3046348bc5d99ae","bytes":8134,"depth":0,"chain":"1d93f56d39742f1a","model":"claude-opus-5","session":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","run":"s1-cycle","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"part":0},{"lit":",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"summarise the file\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prompt_id=s1-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"part":1},{"lit":",\"type\":\"text\"}],\"tools\":["},{"part":2},{"lit":","},{"part":3},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1059}]} +{"ord":1,"off":0,"sha":"5925d886f7c8","bytes":163,"id":"s2-req-f7d240c7da38.response","call":"s2-call-f7d240c7da38","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s2-req-f7d240c7da38.response.json","sha256":"5925d886f7c87c6f7bcc06fe1514bb54c822d1f0ff8bd11a04980d34d152c446","bytes":163,"depth":0,"model":"claude-opus-5","call":"s2-call-f7d240c7da38","request":"s2-req-f7d240c7da38","segments":[{"lit":"{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"}],\"id\":\"s2-call-f7d240c7da38\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":489}]} +{"ord":1,"off":0,"sha":"8c19758d9fb7","bytes":8237,"id":"aca29c78-e4bf-46c6-83f4-f4d50763336b.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"aca29c78-e4bf-46c6-83f4-f4d50763336b.request.json","sha256":"8c19758d9fb7af7fbbdd57fc5206325a0bbdce17744b00395fe1f9763bd4122a","bytes":8237,"depth":0,"chain":"1d93f56d39742f1a","model":"claude-opus-5","session":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","run":"s1-cycle","previous_request":"s2-req-f7d240c7da38","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"summarise the file\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"Reading it.\",\"type\":\"text\"}],\"role\":\"assistant\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s2-req-f7d240c7da38; cc_prompt_id=s1-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1481}]} +{"ord":1,"off":0,"sha":"fa5b1bd72222","bytes":179,"id":"s4-req-f7d240c7da38.response","call":"s4-call-f7d240c7da38","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s4-req-f7d240c7da38.response.json","sha256":"fa5b1bd722227582c3d9a48383fee4618f88e90d86da0ba17763c1828a1eb2ac","bytes":179,"depth":0,"model":"claude-opus-5","call":"s4-call-f7d240c7da38","request":"s4-req-f7d240c7da38","segments":[{"lit":"{\"content\":[{\"text\":\"It is a configuration file.\",\"type\":\"text\"}],\"id\":\"s4-call-f7d240c7da38\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":505}]} +{"t":"end","records":4,"digest":"d8bbc389639d63bea5e577738050699841ea1c8550abadd12fdbc7d5b81e1008"} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/r000001-0a33a0c2d269.sf b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/r000001-0a33a0c2d269.sf new file mode 100644 index 000000000000..f977786c3bf7 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/r000001-0a33a0c2d269.sf @@ -0,0 +1,16 @@ +{"t":"header","schema":"sf/1","conversation":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","session":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","round":1,"from_seq":1,"through_seq":2,"input_digest":"950a569a9d180b4b09b44f954d6cbfe8e1a85a717f285edd7f4f437440bb2778","parser":"v1","policy":"v1+idle=10m0s","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:03Z","session_from_time":"2026-01-01T00:00:00Z","session_through_time":"2026-01-01T00:00:03Z","title":"an error between two calls","talks":1,"steps":7,"streams":1,"segments":1,"unresolved":0,"changes":0,"lines_added":0,"lines_removed":0,"llm_calls":3,"subagents":0,"bash_runs":0} +{"t":"node","id":"call/s2-call-f7d240c7da38","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":3},"refs":[{"seq":1,"row":3}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":3,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/s3-synthetic-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}],"attrs":{"fragments":1,"stop_reason":"unavailable","usage":"unavailable","usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/s4-call-f7d240c7da38","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":5},"refs":[{"seq":1,"row":5}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":5,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"epoch/main/0","revision":1,"kind":"epoch","parent":"stream/main","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":5,"reset":"none"}} +{"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/3:0","revision":1,"kind":"message.assistant","parent":"call/s2-call-f7d240c7da38","stream":"main","ref":{"seq":1,"row":3,"block":0}} +{"t":"node","id":"msg/1/4:0","revision":1,"kind":"message.synthetic","parent":"call/s3-synthetic-call","stream":"main","ref":{"seq":1,"row":4,"block":0}} +{"t":"node","id":"msg/1/5:0","revision":1,"kind":"message.assistant","parent":"call/s4-call-f7d240c7da38","stream":"main","ref":{"seq":1,"row":5,"block":0}} +{"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":"segment/at_1_2","revision":1,"kind":"segment","parent":"session/f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","ref":{"seq":1,"row":2},"attrs":{"committable":false,"gates_unmet":["activity_boundary","lateness_watermark"],"state":"open","talks":1}} +{"t":"node","id":"session/f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","revision":1,"kind":"session","attrs":{"conversation":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:03Z","title":"an error between two calls","title_from":"observed_replayable"}} +{"t":"node","id":"stream/main","revision":1,"kind":"stream","parent":"session/f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":5,"role":"main"}} +{"t":"node","id":"talk/main/s1-cycle","revision":1,"kind":"talk","parent":"epoch/main/0","stream":"main","ref":{"seq":1,"row":2},"attrs":{"loops":1,"runs":1,"trigger":"external"}} +{"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":"commit","digest":"0a33a0c2d269a8b66804075c0eb46e0546187e9a9f45d8eca10486bbcb040699","counts":{"nodes":13,"relations":1,"unresolved":0}} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/transcript-20260101T000000.000000000Z-000001.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/transcript-20260101T000000.000000000Z-000001.sd new file mode 100644 index 000000000000..d7cd4a2d391d --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/transcript-20260101T000000.000000000Z-000001.sd @@ -0,0 +1,7 @@ +{"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-scenario/f41b7a16-49fa-44ea-8ef6-bb39db57ab1d/streams/main","session":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","stream":"main"} +{"ord":1,"off":0,"sha":"3179d92372b3","bytes":210,"label":"an error between two calls","from":"runtime","parts":[{"k":"data","data":{"aiTitle":"an error between two calls","type":"ai-title"},"state":"available","bytes":58}]} +{"ord":2,"off":211,"sha":"197e3a445b18","bytes":253,"id":"s1-input","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:00.000Z","trigger":"external","flags":["external_input"],"parts":[{"k":"text","text":"summarise the file","state":"available","bytes":18}]} +{"ord":3,"off":465,"sha":"75c1dfc54859","bytes":336,"id":"s2-call-f1","parent":"s1-input","call":"s2-call-f7d240c7da38","from":"agent","time":"2026-01-01T00:00:01.000Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"Reading it.","state":"available","bytes":11}]} +{"ord":4,"off":802,"sha":"0f63d6f4c69f","bytes":300,"id":"s3-synthetic","parent":"s2-call-f1","call":"s3-synthetic-call","from":"agent","time":"2026-01-01T00:00:02.000Z","flags":["synthetic"],"usage":{},"model":"","parts":[{"k":"text","text":"API Error: 529 overloaded","state":"available","bytes":25}]} +{"ord":5,"off":1103,"sha":"d34cfe448149","bytes":357,"id":"s4-call-f1","parent":"s3-synthetic","call":"s4-call-f7d240c7da38","from":"agent","time":"2026-01-01T00:00:03.000Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"It is a configuration file.","state":"available","bytes":27}]} +{"t":"end","records":5,"digest":"7f8a18a33ac26b8a7f73990e3234e7e51addfed305836ba9630f259c423c17f7"} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.json b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.json new file mode 100644 index 000000000000..c4d468eeb4dd --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.json @@ -0,0 +1,1318 @@ +{ + "format": "asz.view", + "version": "1.0", + "conversation": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d", + "sessions": [ + "6b7a6063-8714-4f6b-87cd-6c2da3a5094d" + ], + "head": { + "round": 1, + "digest": "ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920" + }, + "parser": "v1", + "policy": "v1+idle=10m0s", + "summary": { + "title": "provider bodies beside the calls", + "state": "verified", + "problems": [], + "talks": 5, + "steps": 23, + "streams": 2, + "segments": 1, + "rounds": 1, + "unresolved": 0, + "changes": 0, + "provider_bodies": 14, + "captured_prompts": 7, + "from": 1767225600000, + "to": 1767225608400, + "kinds": { + "agent.call": 1, + "agent.launch_ack": 1, + "agent.output": 1, + "epoch": 3, + "epoch.boundary": 1, + "epoch.summary": 1, + "llm.call": 7, + "message.assistant": 6, + "message.external": 3, + "run": 5, + "segment": 1, + "session": 1, + "stream": 2, + "talk": 5, + "tool": 2 + }, + "relation_types": { + "ends_with": 1, + "follows": 1, + "in_segment": 5, + "starts": 1, + "summarizes": 1 + }, + "quality": { + "exact_unique": 8, + "strong_inference": 1 + } + }, + "rounds": [ + { + "round": 1, + "digest": "ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920", + "previous": null, + "from_seq": 1, + "through_seq": 4, + "input_digest": "bb774e3f497f8618d3e944070b5d074c0ab6589f0c089db4c2371a99dc4528e3", + "from_time": 1767225600000, + "through_time": 1767225608400, + "verified": true + } + ], + "files": [ + { + "file": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/main/transcript-20260101T000000.000000000Z-000001.sd", + "format": "sd", + "kind": "transcript", + "seq": 1, + "round": null, + "stream": "main", + "run": null, + "lines": 16, + "bytes": 5242, + "digest": "293e8d9afbcf0746496e84ad5ce6edc5e7ba5a41c49c015e2294566c337b6a9b", + "from_time": 1767225600000, + "through_time": 1767225608400 + }, + { + "file": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/a02ba01b82410e9e3/transcript-20260101T000000.000000000Z-000002.sd", + "format": "sd", + "kind": "transcript", + "seq": 2, + "round": null, + "stream": "a02ba01b82410e9e3", + "run": null, + "lines": 7, + "bytes": 2220, + "digest": "b86dbc3bb6b8aaaaa5beaaa4b2226a63ba442fdc0dafa3d0ffe5fd10d941445d", + "from_time": 1767225605400, + "through_time": 1767225607800 + }, + { + "file": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/a02ba01b82410e9e3/meta-20260101T000000.000000000Z-000003.sd", + "format": "sd", + "kind": "agent_meta", + "seq": 3, + "round": null, + "stream": "a02ba01b82410e9e3", + "run": null, + "lines": 3, + "bytes": 666, + "digest": "ad1b164dea912697af058c40edcb855838ace434a26a675c3feae499056a66e0", + "from_time": null, + "through_time": null + }, + { + "file": "6b7a6063-8714-4f6b-87cd-6c2da3a5094d/provider_body/provider_body-20260101T000000.000000000Z-000004.sd", + "format": "sd", + "kind": "provider_body", + "seq": 4, + "round": null, + "stream": null, + "run": null, + "lines": 16, + "bytes": 27874, + "digest": "a8cfc9e0f6e06ab3ce944fd1db40468d6502e7e1d7549c01292f9326a83fd331", + "from_time": null, + "through_time": null + }, + { + "file": "_conversations/6b7a6063-8714-4f6b-87cd-6c2da3a5094d/rounds/r000001-ff8eaba03b03.sf", + "format": "sf", + "kind": "round", + "seq": null, + "round": 1, + "stream": null, + "run": null, + "lines": 51, + "bytes": 12009, + "digest": "2a0088c80cdf2abd2d9ff4517ccba7ec070035f904019d25f1c24eeec8346f10", + "from_time": 1767225600000, + "through_time": 1767225608400 + } + ], + "streams": [ + { + "id": "stream/main", + "name": "main", + "role": "main", + "label": "", + "parent": "", + "records": 14, + "steps": 15, + "talk": "talk/main/s1-cycle", + "named_by": "", + "opened_by": [] + }, + { + "id": "stream/a02ba01b82410e9e3", + "name": "a02ba01b82410e9e3", + "role": "child", + "label": "searcher", + "parent": "main", + "records": 6, + "steps": 6, + "talk": "talk/a02ba01b82410e9e3", + "named_by": "", + "opened_by": [ + { + "step": "tool/s5-tool", + "stream": "main", + "talk": "talk/main/s4-cycle", + "quality": "exact_unique" + } + ] + } + ], + "segments": [ + { + "id": "segment/at_1_2", + "state": "open", + "committable": false, + "talks": 5, + "from": 1767225600000, + "to": 1767225608400 + } + ], + "talks": [ + { + "id": "talk/main/s1-cycle", + "kind": "talk", + "parent": "epoch/main/0", + "stream": "main", + "at": 1767225600000, + "ref": { + "seq": 1, + "row": 2 + }, + "attrs": { + "loops": 1, + "runs": 1, + "trigger": "external" + }, + "label": "read the configuration and summarise it", + "reply": "The timeout is 30 seconds, with 3 retries.", + "runs": 1, + "steps": 6, + "tools": 1, + "from": 1767225600000, + "to": 1767225602300, + "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": "read the configuration and summarise it", + "state": "available", + "bytes": 39, + "flags": [ + "external_input" + ] + }, + { + "id": "call/s2-call-fdae022ac306", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225601000, + "ref": { + "seq": 1, + "row": 3 + }, + "refs": [ + { + "seq": 1, + "row": 3 + }, + { + "seq": 1, + "row": 4 + } + ], + "attrs": { + "fragments": 2, + "usage": "observed_replayable", + "usage_at": { + "row": 4, + "seq": 1 + }, + "usage_from": "last_fragment_in_line_order" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 4, + "row": 1 + } + }, + { + "role": "response", + "ref": { + "seq": 4, + "row": 2 + } + } + ], + "children": [ + { + "id": "msg/1/3:0", + "kind": "message.assistant", + "parent": "call/s2-call-fdae022ac306", + "stream": "main", + "at": 1767225601000, + "ref": { + "seq": 1, + "row": 3, + "block": 0 + }, + "text": "Reading it.", + "state": "available", + "bytes": 11 + }, + { + "id": "tool/s2-tool", + "kind": "tool", + "parent": "call/s2-call-fdae022ac306", + "stream": "main", + "at": 1767225601100, + "ref": { + "seq": 1, + "row": 4, + "block": 0 + }, + "refs": [ + { + "seq": 1, + "row": 4, + "block": 0 + }, + { + "seq": 1, + "row": 5, + "block": 0 + } + ], + "attrs": { + "name": "Read", + "result": "available", + "result_join": "exact_unique", + "timing": "unavailable" + }, + "text": "{\"file_path\":\"/Users/dev/scenario/config.yaml\"}", + "state": "available", + "bytes": 47, + "flags": [ + "finished" + ], + "name": "Read", + "result": "timeout: 30\nretries: 3\n", + "result_state": "available", + "result_bytes": 23, + "request_to_result_ms": 200, + "request_to_result_join": "exact_unique" + } + ] + }, + { + "id": "call/s3-call-fdae022ac306", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225602300, + "ref": { + "seq": 1, + "row": 6 + }, + "refs": [ + { + "seq": 1, + "row": 6 + } + ], + "attrs": { + "fragments": 1, + "usage": "observed_replayable", + "usage_at": { + "row": 6, + "seq": 1 + }, + "usage_from": "last_fragment_in_line_order" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 4, + "row": 3 + } + }, + { + "role": "response", + "ref": { + "seq": 4, + "row": 4 + } + } + ], + "children": [ + { + "id": "msg/1/6:0", + "kind": "message.assistant", + "parent": "call/s3-call-fdae022ac306", + "stream": "main", + "at": 1767225602300, + "ref": { + "seq": 1, + "row": 6, + "block": 0 + }, + "text": "The timeout is 30 seconds, with 3 retries.", + "state": "available", + "bytes": 42, + "flags": [ + "finished" + ] + } + ] + } + ] + } + ], + "edges": [ + { + "type": "in_segment", + "other": "segment/at_1_2", + "dir": "out", + "quality": "exact_unique", + "via": "activity window" + } + ] + }, + { + "id": "talk/main/s4-cycle", + "kind": "talk", + "parent": "epoch/main/0", + "stream": "main", + "at": 1767225603300, + "ref": { + "seq": 1, + "row": 7 + }, + "attrs": { + "loops": 1, + "runs": 1, + "trigger": "external" + }, + "label": "find where the timeout is used", + "reply": "The searcher found it in server.go.", + "runs": 1, + "steps": 6, + "tools": 1, + "from": 1767225603300, + "to": 1767225605400, + "segment": "segment/at_1_2", + "children": [ + { + "id": "run/talk_main_s4-cycle/s4-cycle", + "kind": "run", + "parent": "talk/main/s4-cycle", + "stream": "main", + "at": 1767225603300, + "ref": { + "seq": 1, + "row": 7 + }, + "attrs": { + "trigger": "external" + }, + "children": [ + { + "id": "input/1/7", + "kind": "message.external", + "parent": "run/talk_main_s4-cycle/s4-cycle", + "stream": "main", + "at": 1767225603300, + "ref": { + "seq": 1, + "row": 7 + }, + "text": "find where the timeout is used", + "state": "available", + "bytes": 30, + "flags": [ + "external_input" + ] + }, + { + "id": "call/s5-call-fdae022ac306", + "kind": "llm.call", + "parent": "run/talk_main_s4-cycle/s4-cycle", + "stream": "main", + "at": 1767225604300, + "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": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 4, + "row": 5 + } + }, + { + "role": "response", + "ref": { + "seq": 4, + "row": 6 + } + } + ], + "children": [ + { + "id": "tool/s5-tool", + "kind": "agent.call", + "parent": "call/s5-call-fdae022ac306", + "stream": "main", + "at": 1767225604300, + "ref": { + "seq": 1, + "row": 8, + "block": 0 + }, + "refs": [ + { + "seq": 1, + "row": 8, + "block": 0 + }, + { + "seq": 1, + "row": 9, + "block": 0 + } + ], + "attrs": { + "name": "Agent", + "result": "available", + "result_join": "exact_unique", + "timing": "unavailable" + }, + "text": "{\"description\":\"searcher\",\"prompt\":\"find every use of the timeout\"}", + "state": "available", + "bytes": 67, + "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/a02ba01b82410e9e3", + "dir": "out", + "quality": "exact_unique", + "via": "parent tool result" + } + ] + } + ] + }, + { + "id": "ack/1/9", + "kind": "agent.launch_ack", + "parent": "run/talk_main_s4-cycle/s4-cycle", + "stream": "main", + "at": 1767225604400, + "ref": { + "seq": 1, + "row": 9 + }, + "text": "launched", + "state": "available", + "bytes": 8, + "flags": [ + "launch_ack" + ] + }, + { + "id": "call/s6-call-fdae022ac306", + "kind": "llm.call", + "parent": "run/talk_main_s4-cycle/s4-cycle", + "stream": "main", + "at": 1767225605400, + "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": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 4, + "row": 7 + } + }, + { + "role": "response", + "ref": { + "seq": 4, + "row": 8 + } + } + ], + "children": [ + { + "id": "msg/1/10:0", + "kind": "message.assistant", + "parent": "call/s6-call-fdae022ac306", + "stream": "main", + "at": 1767225605400, + "ref": { + "seq": 1, + "row": 10, + "block": 0 + }, + "text": "The searcher found it in server.go.", + "state": "available", + "bytes": 35, + "flags": [ + "finished" + ] + } + ] + } + ] + } + ], + "edges": [ + { + "type": "in_segment", + "other": "segment/at_1_2", + "dir": "out", + "quality": "exact_unique", + "via": "activity window" + } + ] + }, + { + "id": "talk/main/s7-cycle-compact", + "kind": "talk", + "parent": "epoch/main/s7-boundary", + "stream": "main", + "at": 1767225606000, + "ref": { + "seq": 1, + "row": 12 + }, + "attrs": { + "loops": 1, + "runs": 1, + "trigger": "external" + }, + "runs": 1, + "from": 1767225606000, + "to": 1767225606000, + "segment": "segment/at_1_2", + "children": [ + { + "id": "run/talk_main_s7-cycle-compact/s7-cycle-compact", + "kind": "run", + "parent": "talk/main/s7-cycle-compact", + "stream": "main", + "at": 1767225606000, + "ref": { + "seq": 1, + "row": 12 + }, + "attrs": { + "trigger": "external" + } + } + ], + "edges": [ + { + "type": "in_segment", + "other": "segment/at_1_2", + "dir": "out", + "quality": "exact_unique", + "via": "activity window" + } + ] + }, + { + "id": "talk/main/s8-cycle", + "kind": "talk", + "parent": "epoch/main/s7-boundary", + "stream": "main", + "at": 1767225607400, + "ref": { + "seq": 1, + "row": 13 + }, + "attrs": { + "loops": 1, + "runs": 1, + "trigger": "external" + }, + "label": "and the retries?", + "reply": "There are 3 retries.", + "runs": 1, + "steps": 3, + "from": 1767225607400, + "to": 1767225608400, + "segment": "segment/at_1_2", + "children": [ + { + "id": "run/talk_main_s8-cycle/s8-cycle", + "kind": "run", + "parent": "talk/main/s8-cycle", + "stream": "main", + "at": 1767225607400, + "ref": { + "seq": 1, + "row": 13 + }, + "attrs": { + "trigger": "external" + }, + "children": [ + { + "id": "input/1/13", + "kind": "message.external", + "parent": "run/talk_main_s8-cycle/s8-cycle", + "stream": "main", + "at": 1767225607400, + "ref": { + "seq": 1, + "row": 13 + }, + "text": "and the retries?", + "state": "available", + "bytes": 16, + "flags": [ + "external_input" + ] + }, + { + "id": "call/s9-call-fdae022ac306", + "kind": "llm.call", + "parent": "run/talk_main_s8-cycle/s8-cycle", + "stream": "main", + "at": 1767225608400, + "ref": { + "seq": 1, + "row": 14 + }, + "refs": [ + { + "seq": 1, + "row": 14 + } + ], + "attrs": { + "fragments": 1, + "usage": "observed_replayable", + "usage_at": { + "row": 14, + "seq": 1 + }, + "usage_from": "last_fragment_in_line_order" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 4, + "row": 13 + } + }, + { + "role": "response", + "ref": { + "seq": 4, + "row": 14 + } + } + ], + "children": [ + { + "id": "msg/1/14:0", + "kind": "message.assistant", + "parent": "call/s9-call-fdae022ac306", + "stream": "main", + "at": 1767225608400, + "ref": { + "seq": 1, + "row": 14, + "block": 0 + }, + "text": "There are 3 retries.", + "state": "available", + "bytes": 20, + "flags": [ + "finished" + ] + } + ] + } + ] + } + ], + "edges": [ + { + "type": "in_segment", + "other": "segment/at_1_2", + "dir": "out", + "quality": "exact_unique", + "via": "activity window" + } + ] + }, + { + "id": "talk/a02ba01b82410e9e3", + "kind": "talk", + "parent": "epoch/a02ba01b82410e9e3/0", + "stream": "a02ba01b82410e9e3", + "at": 1767225605400, + "ref": { + "seq": 2, + "row": 1 + }, + "attrs": { + "loops": 1, + "runs": 1, + "trigger": "unknown" + }, + "reply": "It is used in server.go.", + "runs": 1, + "steps": 6, + "tools": 1, + "from": 1767225605400, + "to": 1767225607800, + "child": true, + "segment": "segment/at_1_2", + "children": [ + { + "id": "run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle", + "kind": "run", + "parent": "talk/a02ba01b82410e9e3", + "stream": "a02ba01b82410e9e3", + "at": 1767225605400, + "ref": { + "seq": 2, + "row": 1 + }, + "attrs": { + "trigger": "external" + }, + "children": [ + { + "id": "call/searcher-s1-call-fdae022ac306", + "kind": "llm.call", + "parent": "run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle", + "stream": "a02ba01b82410e9e3", + "at": 1767225606400, + "ref": { + "seq": 2, + "row": 2 + }, + "refs": [ + { + "seq": 2, + "row": 2 + }, + { + "seq": 2, + "row": 3 + } + ], + "attrs": { + "fragments": 2, + "usage": "observed_replayable", + "usage_at": { + "row": 3, + "seq": 2 + }, + "usage_from": "last_fragment_in_line_order" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 4, + "row": 9 + } + }, + { + "role": "response", + "ref": { + "seq": 4, + "row": 10 + } + } + ], + "children": [ + { + "id": "msg/2/2:0", + "kind": "message.assistant", + "parent": "call/searcher-s1-call-fdae022ac306", + "stream": "a02ba01b82410e9e3", + "at": 1767225606400, + "ref": { + "seq": 2, + "row": 2, + "block": 0 + }, + "text": "Searching.", + "state": "available", + "bytes": 10 + }, + { + "id": "tool/searcher-s1-tool", + "kind": "tool", + "parent": "call/searcher-s1-call-fdae022ac306", + "stream": "a02ba01b82410e9e3", + "at": 1767225606500, + "ref": { + "seq": 2, + "row": 3, + "block": 0 + }, + "refs": [ + { + "seq": 2, + "row": 3, + "block": 0 + }, + { + "seq": 2, + "row": 4, + "block": 0 + } + ], + "attrs": { + "name": "Bash", + "result": "available", + "result_join": "exact_unique", + "timing": "unavailable" + }, + "text": "{\"command\":\"grep -rn timeout .\"}", + "state": "available", + "bytes": 32, + "flags": [ + "finished" + ], + "name": "Bash", + "result": "server.go:3: var timeout = 30", + "result_state": "available", + "result_bytes": 29, + "request_to_result_ms": 300, + "request_to_result_join": "exact_unique" + } + ] + }, + { + "id": "call/searcher-s2-call-fdae022ac306", + "kind": "llm.call", + "parent": "run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle", + "stream": "a02ba01b82410e9e3", + "at": 1767225607800, + "ref": { + "seq": 2, + "row": 5 + }, + "refs": [ + { + "seq": 2, + "row": 5 + } + ], + "attrs": { + "fragments": 1, + "usage": "observed_replayable", + "usage_at": { + "row": 5, + "seq": 2 + }, + "usage_from": "last_fragment_in_line_order" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "provider_bodies": [ + { + "role": "request", + "ref": { + "seq": 4, + "row": 11 + } + }, + { + "role": "response", + "ref": { + "seq": 4, + "row": 12 + } + } + ], + "children": [ + { + "id": "msg/2/5:0", + "kind": "message.assistant", + "parent": "call/searcher-s2-call-fdae022ac306", + "stream": "a02ba01b82410e9e3", + "at": 1767225607800, + "ref": { + "seq": 2, + "row": 5, + "block": 0 + }, + "text": "It is used in server.go.", + "state": "available", + "bytes": 24, + "flags": [ + "finished" + ] + } + ] + }, + { + "id": "output/a02ba01b82410e9e3", + "kind": "agent.output", + "parent": "run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle", + "stream": "a02ba01b82410e9e3", + "at": 1767225607800, + "ref": { + "seq": 2, + "row": 5 + }, + "refs": [ + { + "seq": 2, + "row": 5 + } + ], + "attrs": { + "returned_value": "unavailable" + }, + "text": "It is used in server.go.", + "state": "available", + "bytes": 24, + "flags": [ + "finished" + ], + "edges": [ + { + "type": "ends_with", + "other": "stream/a02ba01b82410e9e3", + "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/11", + "kind": "epoch.boundary", + "parent": "epoch/main/s7-boundary", + "stream": "main", + "at": 1767225606400, + "ref": { + "seq": 1, + "row": 11 + }, + "text": "{\"compactMetadata\":{\"preservedMessages\":{\"allUuids\":[\"s6-call-f1\"]},\"trigger\":\"auto\"},\"logicalParentUuid\":\"s6-call-f1\",\"subtype\":\"compact_boundary\",\"type\":\"system\"}", + "state": "available", + "bytes": 164, + "flags": [ + "context_reset" + ], + "edges": [ + { + "type": "summarizes", + "other": "summary/1/12", + "dir": "in", + "quality": "exact_unique", + "via": "containment parent" + } + ] + }, + { + "id": "summary/1/12", + "kind": "epoch.summary", + "parent": "epoch/main/s7-boundary", + "stream": "main", + "at": 1767225606000, + "ref": { + "seq": 1, + "row": 12 + }, + "text": "The configuration sets a 30 second timeout, used in server.go.", + "state": "available", + "bytes": 62, + "flags": [ + "reset_summary" + ], + "edges": [ + { + "type": "summarizes", + "other": "boundary/1/11", + "dir": "out", + "quality": "exact_unique", + "via": "containment parent" + } + ] + } + ], + "relations": [ + { + "id": "rel/ends_with/stream_a02ba01b82410e9e3/output_a02ba01b82410e9e3", + "type": "ends_with", + "from": "stream/a02ba01b82410e9e3", + "to": "output/a02ba01b82410e9e3", + "quality": "exact_unique", + "via": "the last response in the child stream, and what it returned", + "evidence": [ + { + "seq": 2, + "row": 5 + } + ] + }, + { + "id": "rel/follows/epoch_main_s7-boundary/epoch_main_0", + "type": "follows", + "from": "epoch/main/s7-boundary", + "to": "epoch/main/0", + "quality": "exact_unique", + "via": "explicit context reset", + "evidence": [ + { + "seq": 1, + "row": 11 + } + ] + }, + { + "id": "rel/in_segment/talk_a02ba01b82410e9e3/segment_at_1_2", + "type": "in_segment", + "from": "talk/a02ba01b82410e9e3", + "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_s4-cycle/segment_at_1_2", + "type": "in_segment", + "from": "talk/main/s4-cycle", + "to": "segment/at_1_2", + "quality": "exact_unique", + "via": "activity window", + "evidence": [ + { + "seq": 1, + "row": 7 + } + ] + }, + { + "id": "rel/in_segment/talk_main_s7-cycle-compact/segment_at_1_2", + "type": "in_segment", + "from": "talk/main/s7-cycle-compact", + "to": "segment/at_1_2", + "quality": "exact_unique", + "via": "activity window", + "evidence": [ + { + "seq": 1, + "row": 12 + } + ] + }, + { + "id": "rel/in_segment/talk_main_s8-cycle/segment_at_1_2", + "type": "in_segment", + "from": "talk/main/s8-cycle", + "to": "segment/at_1_2", + "quality": "exact_unique", + "via": "activity window", + "evidence": [ + { + "seq": 1, + "row": 13 + } + ] + }, + { + "id": "rel/starts/tool_s5-tool/stream_a02ba01b82410e9e3", + "type": "starts", + "from": "tool/s5-tool", + "to": "stream/a02ba01b82410e9e3", + "quality": "exact_unique", + "via": "parent tool result", + "evidence": [ + { + "seq": 1, + "row": 9 + } + ] + }, + { + "id": "rel/summarizes/summary_1_12/boundary_1_11", + "type": "summarizes", + "from": "summary/1/12", + "to": "boundary/1/11", + "quality": "exact_unique", + "via": "containment parent", + "evidence": [ + { + "seq": 1, + "row": 12 + } + ] + } + ], + "unresolved": [], + "workspace_changes": [] +} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.yaml b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.yaml new file mode 100644 index 000000000000..73391b4f7dc9 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.yaml @@ -0,0 +1,957 @@ +format: asz.view +version: "1.0" +conversation: 6b7a6063-8714-4f6b-87cd-6c2da3a5094d +sessions: + - 6b7a6063-8714-4f6b-87cd-6c2da3a5094d +head: + round: 1 + digest: ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920 +parser: v1 +policy: v1+idle=10m0s +summary: + title: provider bodies beside the calls + state: verified + problems: [] + talks: 5 + steps: 23 + streams: 2 + segments: 1 + rounds: 1 + unresolved: 0 + changes: 0 + provider_bodies: 14 + captured_prompts: 7 + from: 1767225600000 + to: 1767225608400 + kinds: + agent.call: 1 + agent.launch_ack: 1 + agent.output: 1 + epoch: 3 + epoch.boundary: 1 + epoch.summary: 1 + llm.call: 7 + message.assistant: 6 + message.external: 3 + run: 5 + segment: 1 + session: 1 + stream: 2 + talk: 5 + tool: 2 + relation_types: + ends_with: 1 + follows: 1 + in_segment: 5 + starts: 1 + summarizes: 1 + quality: + exact_unique: 8 + strong_inference: 1 +rounds: + - round: 1 + digest: ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920 + previous: null + from_seq: 1 + through_seq: 4 + input_digest: bb774e3f497f8618d3e944070b5d074c0ab6589f0c089db4c2371a99dc4528e3 + from_time: 1767225600000 + through_time: 1767225608400 + verified: true +files: + - file: 6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/main/transcript-20260101T000000.000000000Z-000001.sd + format: sd + kind: transcript + seq: 1 + round: null + stream: main + run: null + lines: 16 + bytes: 5242 + digest: 293e8d9afbcf0746496e84ad5ce6edc5e7ba5a41c49c015e2294566c337b6a9b + from_time: 1767225600000 + through_time: 1767225608400 + - file: 6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/a02ba01b82410e9e3/transcript-20260101T000000.000000000Z-000002.sd + format: sd + kind: transcript + seq: 2 + round: null + stream: a02ba01b82410e9e3 + run: null + lines: 7 + bytes: 2220 + digest: b86dbc3bb6b8aaaaa5beaaa4b2226a63ba442fdc0dafa3d0ffe5fd10d941445d + from_time: 1767225605400 + through_time: 1767225607800 + - file: 6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/a02ba01b82410e9e3/meta-20260101T000000.000000000Z-000003.sd + format: sd + kind: agent_meta + seq: 3 + round: null + stream: a02ba01b82410e9e3 + run: null + lines: 3 + bytes: 666 + digest: ad1b164dea912697af058c40edcb855838ace434a26a675c3feae499056a66e0 + from_time: null + through_time: null + - file: 6b7a6063-8714-4f6b-87cd-6c2da3a5094d/provider_body/provider_body-20260101T000000.000000000Z-000004.sd + format: sd + kind: provider_body + seq: 4 + round: null + stream: null + run: null + lines: 16 + bytes: 27874 + digest: a8cfc9e0f6e06ab3ce944fd1db40468d6502e7e1d7549c01292f9326a83fd331 + from_time: null + through_time: null + - file: _conversations/6b7a6063-8714-4f6b-87cd-6c2da3a5094d/rounds/r000001-ff8eaba03b03.sf + format: sf + kind: round + seq: null + round: 1 + stream: null + run: null + lines: 51 + bytes: 12009 + digest: 2a0088c80cdf2abd2d9ff4517ccba7ec070035f904019d25f1c24eeec8346f10 + from_time: 1767225600000 + through_time: 1767225608400 +streams: + - id: stream/main + name: main + role: main + label: "" + parent: "" + records: 14 + steps: 15 + talk: talk/main/s1-cycle + named_by: "" + opened_by: [] + - id: stream/a02ba01b82410e9e3 + name: a02ba01b82410e9e3 + role: child + label: searcher + parent: main + records: 6 + steps: 6 + talk: talk/a02ba01b82410e9e3 + named_by: "" + opened_by: + - step: tool/s5-tool + stream: main + talk: talk/main/s4-cycle + quality: exact_unique +segments: + - id: segment/at_1_2 + state: open + committable: false + talks: 5 + from: 1767225600000 + to: 1767225608400 +talks: + - id: talk/main/s1-cycle + kind: talk + parent: epoch/main/0 + stream: main + at: 1767225600000 + ref: + seq: 1 + row: 2 + attrs: + loops: 1 + runs: 1 + trigger: external + label: read the configuration and summarise it + reply: The timeout is 30 seconds, with 3 retries. + runs: 1 + steps: 6 + tools: 1 + from: 1767225600000 + to: 1767225602300 + 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: read the configuration and summarise it + state: available + bytes: 39 + flags: + - external_input + - id: call/s2-call-fdae022ac306 + kind: llm.call + parent: run/talk_main_s1-cycle/s1-cycle + stream: main + at: 1767225601000 + ref: + seq: 1 + row: 3 + refs: + - seq: 1 + row: 3 + - seq: 1 + row: 4 + attrs: + fragments: 2 + usage: observed_replayable + usage_at: + row: 4 + seq: 1 + usage_from: last_fragment_in_line_order + usage: + in: 2 + out: 40 + cache_read: 900 + cache_write: 100 + provider_bodies: + - role: request + ref: + seq: 4 + row: 1 + - role: response + ref: + seq: 4 + row: 2 + children: + - id: msg/1/3:0 + kind: message.assistant + parent: call/s2-call-fdae022ac306 + stream: main + at: 1767225601000 + ref: + seq: 1 + row: 3 + block: 0 + text: Reading it. + state: available + bytes: 11 + - id: tool/s2-tool + kind: tool + parent: call/s2-call-fdae022ac306 + stream: main + at: 1767225601100 + ref: + seq: 1 + row: 4 + block: 0 + refs: + - seq: 1 + row: 4 + block: 0 + - seq: 1 + row: 5 + block: 0 + attrs: + name: Read + result: available + result_join: exact_unique + timing: unavailable + text: '{"file_path":"/Users/dev/scenario/config.yaml"}' + state: available + bytes: 47 + flags: + - finished + name: Read + result: | + timeout: 30 + retries: 3 + result_state: available + result_bytes: 23 + request_to_result_ms: 200 + request_to_result_join: exact_unique + - id: call/s3-call-fdae022ac306 + kind: llm.call + parent: run/talk_main_s1-cycle/s1-cycle + stream: main + at: 1767225602300 + ref: + seq: 1 + row: 6 + refs: + - seq: 1 + row: 6 + attrs: + fragments: 1 + usage: observed_replayable + usage_at: + row: 6 + seq: 1 + usage_from: last_fragment_in_line_order + usage: + in: 2 + out: 40 + cache_read: 900 + cache_write: 100 + provider_bodies: + - role: request + ref: + seq: 4 + row: 3 + - role: response + ref: + seq: 4 + row: 4 + children: + - id: msg/1/6:0 + kind: message.assistant + parent: call/s3-call-fdae022ac306 + stream: main + at: 1767225602300 + ref: + seq: 1 + row: 6 + block: 0 + text: The timeout is 30 seconds, with 3 retries. + state: available + bytes: 42 + flags: + - finished + edges: + - type: in_segment + other: segment/at_1_2 + dir: out + quality: exact_unique + via: activity window + - id: talk/main/s4-cycle + kind: talk + parent: epoch/main/0 + stream: main + at: 1767225603300 + ref: + seq: 1 + row: 7 + attrs: + loops: 1 + runs: 1 + trigger: external + label: find where the timeout is used + reply: The searcher found it in server.go. + runs: 1 + steps: 6 + tools: 1 + from: 1767225603300 + to: 1767225605400 + segment: segment/at_1_2 + children: + - id: run/talk_main_s4-cycle/s4-cycle + kind: run + parent: talk/main/s4-cycle + stream: main + at: 1767225603300 + ref: + seq: 1 + row: 7 + attrs: + trigger: external + children: + - id: input/1/7 + kind: message.external + parent: run/talk_main_s4-cycle/s4-cycle + stream: main + at: 1767225603300 + ref: + seq: 1 + row: 7 + text: find where the timeout is used + state: available + bytes: 30 + flags: + - external_input + - id: call/s5-call-fdae022ac306 + kind: llm.call + parent: run/talk_main_s4-cycle/s4-cycle + stream: main + at: 1767225604300 + 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: 40 + cache_read: 900 + cache_write: 100 + provider_bodies: + - role: request + ref: + seq: 4 + row: 5 + - role: response + ref: + seq: 4 + row: 6 + children: + - id: tool/s5-tool + kind: agent.call + parent: call/s5-call-fdae022ac306 + stream: main + at: 1767225604300 + ref: + seq: 1 + row: 8 + block: 0 + refs: + - seq: 1 + row: 8 + block: 0 + - seq: 1 + row: 9 + block: 0 + attrs: + name: Agent + result: available + result_join: exact_unique + timing: unavailable + text: '{"description":"searcher","prompt":"find every use of the timeout"}' + state: available + bytes: 67 + 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/a02ba01b82410e9e3 + dir: out + quality: exact_unique + via: parent tool result + - id: ack/1/9 + kind: agent.launch_ack + parent: run/talk_main_s4-cycle/s4-cycle + stream: main + at: 1767225604400 + ref: + seq: 1 + row: 9 + text: launched + state: available + bytes: 8 + flags: + - launch_ack + - id: call/s6-call-fdae022ac306 + kind: llm.call + parent: run/talk_main_s4-cycle/s4-cycle + stream: main + at: 1767225605400 + 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: 40 + cache_read: 900 + cache_write: 100 + provider_bodies: + - role: request + ref: + seq: 4 + row: 7 + - role: response + ref: + seq: 4 + row: 8 + children: + - id: msg/1/10:0 + kind: message.assistant + parent: call/s6-call-fdae022ac306 + stream: main + at: 1767225605400 + ref: + seq: 1 + row: 10 + block: 0 + text: The searcher found it in server.go. + state: available + bytes: 35 + flags: + - finished + edges: + - type: in_segment + other: segment/at_1_2 + dir: out + quality: exact_unique + via: activity window + - id: talk/main/s7-cycle-compact + kind: talk + parent: epoch/main/s7-boundary + stream: main + at: 1767225606000 + ref: + seq: 1 + row: 12 + attrs: + loops: 1 + runs: 1 + trigger: external + runs: 1 + from: 1767225606000 + to: 1767225606000 + segment: segment/at_1_2 + children: + - id: run/talk_main_s7-cycle-compact/s7-cycle-compact + kind: run + parent: talk/main/s7-cycle-compact + stream: main + at: 1767225606000 + ref: + seq: 1 + row: 12 + attrs: + trigger: external + edges: + - type: in_segment + other: segment/at_1_2 + dir: out + quality: exact_unique + via: activity window + - id: talk/main/s8-cycle + kind: talk + parent: epoch/main/s7-boundary + stream: main + at: 1767225607400 + ref: + seq: 1 + row: 13 + attrs: + loops: 1 + runs: 1 + trigger: external + label: and the retries? + reply: There are 3 retries. + runs: 1 + steps: 3 + from: 1767225607400 + to: 1767225608400 + segment: segment/at_1_2 + children: + - id: run/talk_main_s8-cycle/s8-cycle + kind: run + parent: talk/main/s8-cycle + stream: main + at: 1767225607400 + ref: + seq: 1 + row: 13 + attrs: + trigger: external + children: + - id: input/1/13 + kind: message.external + parent: run/talk_main_s8-cycle/s8-cycle + stream: main + at: 1767225607400 + ref: + seq: 1 + row: 13 + text: and the retries? + state: available + bytes: 16 + flags: + - external_input + - id: call/s9-call-fdae022ac306 + kind: llm.call + parent: run/talk_main_s8-cycle/s8-cycle + stream: main + at: 1767225608400 + ref: + seq: 1 + row: 14 + refs: + - seq: 1 + row: 14 + attrs: + fragments: 1 + usage: observed_replayable + usage_at: + row: 14 + seq: 1 + usage_from: last_fragment_in_line_order + usage: + in: 2 + out: 40 + cache_read: 900 + cache_write: 100 + provider_bodies: + - role: request + ref: + seq: 4 + row: 13 + - role: response + ref: + seq: 4 + row: 14 + children: + - id: msg/1/14:0 + kind: message.assistant + parent: call/s9-call-fdae022ac306 + stream: main + at: 1767225608400 + ref: + seq: 1 + row: 14 + block: 0 + text: There are 3 retries. + state: available + bytes: 20 + flags: + - finished + edges: + - type: in_segment + other: segment/at_1_2 + dir: out + quality: exact_unique + via: activity window + - id: talk/a02ba01b82410e9e3 + kind: talk + parent: epoch/a02ba01b82410e9e3/0 + stream: a02ba01b82410e9e3 + at: 1767225605400 + ref: + seq: 2 + row: 1 + attrs: + loops: 1 + runs: 1 + trigger: unknown + reply: It is used in server.go. + runs: 1 + steps: 6 + tools: 1 + from: 1767225605400 + to: 1767225607800 + child: true + segment: segment/at_1_2 + children: + - id: run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle + kind: run + parent: talk/a02ba01b82410e9e3 + stream: a02ba01b82410e9e3 + at: 1767225605400 + ref: + seq: 2 + row: 1 + attrs: + trigger: external + children: + - id: call/searcher-s1-call-fdae022ac306 + kind: llm.call + parent: run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle + stream: a02ba01b82410e9e3 + at: 1767225606400 + ref: + seq: 2 + row: 2 + refs: + - seq: 2 + row: 2 + - seq: 2 + row: 3 + attrs: + fragments: 2 + usage: observed_replayable + usage_at: + row: 3 + seq: 2 + usage_from: last_fragment_in_line_order + usage: + in: 2 + out: 40 + cache_read: 900 + cache_write: 100 + provider_bodies: + - role: request + ref: + seq: 4 + row: 9 + - role: response + ref: + seq: 4 + row: 10 + children: + - id: msg/2/2:0 + kind: message.assistant + parent: call/searcher-s1-call-fdae022ac306 + stream: a02ba01b82410e9e3 + at: 1767225606400 + ref: + seq: 2 + row: 2 + block: 0 + text: Searching. + state: available + bytes: 10 + - id: tool/searcher-s1-tool + kind: tool + parent: call/searcher-s1-call-fdae022ac306 + stream: a02ba01b82410e9e3 + at: 1767225606500 + ref: + seq: 2 + row: 3 + block: 0 + refs: + - seq: 2 + row: 3 + block: 0 + - seq: 2 + row: 4 + block: 0 + attrs: + name: Bash + result: available + result_join: exact_unique + timing: unavailable + text: '{"command":"grep -rn timeout ."}' + state: available + bytes: 32 + flags: + - finished + name: Bash + result: 'server.go:3: var timeout = 30' + result_state: available + result_bytes: 29 + request_to_result_ms: 300 + request_to_result_join: exact_unique + - id: call/searcher-s2-call-fdae022ac306 + kind: llm.call + parent: run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle + stream: a02ba01b82410e9e3 + at: 1767225607800 + ref: + seq: 2 + row: 5 + refs: + - seq: 2 + row: 5 + attrs: + fragments: 1 + usage: observed_replayable + usage_at: + row: 5 + seq: 2 + usage_from: last_fragment_in_line_order + usage: + in: 2 + out: 40 + cache_read: 900 + cache_write: 100 + provider_bodies: + - role: request + ref: + seq: 4 + row: 11 + - role: response + ref: + seq: 4 + row: 12 + children: + - id: msg/2/5:0 + kind: message.assistant + parent: call/searcher-s2-call-fdae022ac306 + stream: a02ba01b82410e9e3 + at: 1767225607800 + ref: + seq: 2 + row: 5 + block: 0 + text: It is used in server.go. + state: available + bytes: 24 + flags: + - finished + - id: output/a02ba01b82410e9e3 + kind: agent.output + parent: run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle + stream: a02ba01b82410e9e3 + at: 1767225607800 + ref: + seq: 2 + row: 5 + refs: + - seq: 2 + row: 5 + attrs: + returned_value: unavailable + text: It is used in server.go. + state: available + bytes: 24 + flags: + - finished + edges: + - type: ends_with + other: stream/a02ba01b82410e9e3 + 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/11 + kind: epoch.boundary + parent: epoch/main/s7-boundary + stream: main + at: 1767225606400 + ref: + seq: 1 + row: 11 + text: '{"compactMetadata":{"preservedMessages":{"allUuids":["s6-call-f1"]},"trigger":"auto"},"logicalParentUuid":"s6-call-f1","subtype":"compact_boundary","type":"system"}' + state: available + bytes: 164 + flags: + - context_reset + edges: + - type: summarizes + other: summary/1/12 + dir: in + quality: exact_unique + via: containment parent + - id: summary/1/12 + kind: epoch.summary + parent: epoch/main/s7-boundary + stream: main + at: 1767225606000 + ref: + seq: 1 + row: 12 + text: The configuration sets a 30 second timeout, used in server.go. + state: available + bytes: 62 + flags: + - reset_summary + edges: + - type: summarizes + other: boundary/1/11 + dir: out + quality: exact_unique + via: containment parent +relations: + - id: rel/ends_with/stream_a02ba01b82410e9e3/output_a02ba01b82410e9e3 + type: ends_with + from: stream/a02ba01b82410e9e3 + to: output/a02ba01b82410e9e3 + quality: exact_unique + via: the last response in the child stream, and what it returned + evidence: + - seq: 2 + row: 5 + - id: rel/follows/epoch_main_s7-boundary/epoch_main_0 + type: follows + from: epoch/main/s7-boundary + to: epoch/main/0 + quality: exact_unique + via: explicit context reset + evidence: + - seq: 1 + row: 11 + - id: rel/in_segment/talk_a02ba01b82410e9e3/segment_at_1_2 + type: in_segment + from: talk/a02ba01b82410e9e3 + 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_s4-cycle/segment_at_1_2 + type: in_segment + from: talk/main/s4-cycle + to: segment/at_1_2 + quality: exact_unique + via: activity window + evidence: + - seq: 1 + row: 7 + - id: rel/in_segment/talk_main_s7-cycle-compact/segment_at_1_2 + type: in_segment + from: talk/main/s7-cycle-compact + to: segment/at_1_2 + quality: exact_unique + via: activity window + evidence: + - seq: 1 + row: 12 + - id: rel/in_segment/talk_main_s8-cycle/segment_at_1_2 + type: in_segment + from: talk/main/s8-cycle + to: segment/at_1_2 + quality: exact_unique + via: activity window + evidence: + - seq: 1 + row: 13 + - id: rel/starts/tool_s5-tool/stream_a02ba01b82410e9e3 + type: starts + from: tool/s5-tool + to: stream/a02ba01b82410e9e3 + quality: exact_unique + via: parent tool result + evidence: + - seq: 1 + row: 9 + - id: rel/summarizes/summary_1_12/boundary_1_11 + type: summarizes + from: summary/1/12 + to: boundary/1/11 + quality: exact_unique + via: containment parent + evidence: + - seq: 1 + row: 12 +unresolved: [] +workspace_changes: [] + diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/meta-20260101T000000.000000000Z-000003.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/meta-20260101T000000.000000000Z-000003.sd new file mode 100644 index 000000000000..8795c5af731c --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/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-scenario/6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/a02ba01b82410e9e3.meta","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","stream":"a02ba01b82410e9e3"} +{"ord":1,"off":0,"sha":"0647b0ec92b4","bytes":255,"child":"a02ba01b82410e9e3","label":"searcher","from":"runtime","parts":[{"k":"data","data":{"agentType":"general-purpose","description":"searcher","spawnDepth":1,"toolUseId":"s5-tool"},"state":"available","bytes":93}]} +{"t":"end","records":1,"digest":"91bb7b2a97e7c0f47b0eb924c4c9fed5b6d9b0653e21ab8b8c7335cc5e0ed0a7"} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/provider_body-20260101T000000.000000000Z-000004.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/provider_body-20260101T000000.000000000Z-000004.sd new file mode 100644 index 000000000000..ae2ea551c232 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/provider_body-20260101T000000.000000000Z-000004.sd @@ -0,0 +1,16 @@ +{"h":1,"schema":"sd/1","seq":4,"at":"2026-01-01T00:00:00Z","kind":"provider_body","adapter":"mock/0.2.0","dialect":"mock/1","src":".","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d"} +{"ord":1,"off":0,"sha":"25a4cf0c2c2a","bytes":8155,"id":"62c5142f-0b52-4b3d-8f5b-5ba0a44cb6b7.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":"\nCodebase and user instructions are shown below.\nFollow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. \n","state":"available","bytes":1770},{"k":"data","data":"You are Claude Code, working in a scenario. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. ","state":"available","bytes":2046},{"k":"data","data":{"description":"Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. ","input_schema":{"properties":{"file_path":{"type":"string"}},"type":"object"},"name":"Read"},"state":"available","bytes":1710},{"k":"data","data":{"description":"Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. ","input_schema":{"properties":{"command":{"type":"string"}},"type":"object"},"name":"Bash"},"state":"available","bytes":2068},{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"62c5142f-0b52-4b3d-8f5b-5ba0a44cb6b7.request.json","sha256":"25a4cf0c2c2a0f485f56519e56e1f6bcf79466df6599fc4f38fd0f0df84540fa","bytes":8155,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s1-cycle","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"part":0},{"lit":",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"read the configuration and summarise it\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prompt_id=s1-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"part":1},{"lit":",\"type\":\"text\"}],\"tools\":["},{"part":2},{"lit":","},{"part":3},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1080}]} +{"ord":1,"off":0,"sha":"6854dac2921c","bytes":268,"id":"s2-req-fdae022ac306.response","call":"s2-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s2-req-fdae022ac306.response.json","sha256":"6854dac2921cbbb9de25c2e7b640b45a0148e9870102a47fe719476ff37997d2","bytes":268,"depth":0,"model":"claude-opus-5","call":"s2-call-fdae022ac306","request":"s2-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"id\":\"s2-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\"}"}]},"state":"available","bytes":612}]} +{"ord":1,"off":0,"sha":"60c205135751","bytes":8476,"id":"e6250647-cc72-4db4-87f8-35d5d8532118.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"e6250647-cc72-4db4-87f8-35d5d8532118.request.json","sha256":"60c20513575143a9f0166a04f236954c6d0831eb62418ff105c6f30f86151505","bytes":8476,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s1-cycle","previous_request":"s2-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"read the configuration and summarise it\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"content\":\"timeout: 30\\nretries: 3\\n\",\"tool_use_id\":\"s2-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s2-req-fdae022ac306; cc_prompt_id=s1-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1758}]} +{"ord":1,"off":0,"sha":"07626e6a5551","bytes":194,"id":"s3-req-fdae022ac306.response","call":"s3-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s3-req-fdae022ac306.response.json","sha256":"07626e6a55516660d4045b4bd4615992f71539ed94b9e38aee5b5c1fc563ed68","bytes":194,"depth":0,"model":"claude-opus-5","call":"s3-call-fdae022ac306","request":"s3-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"The timeout is 30 seconds, with 3 retries.\",\"type\":\"text\"}],\"id\":\"s3-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":520}]} +{"ord":1,"off":0,"sha":"fcf4469b068b","bytes":8636,"id":"2e0ed3d7-b6d2-46eb-8c3f-c06a030dd7d5.request","run":"s4-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"2e0ed3d7-b6d2-46eb-8c3f-c06a030dd7d5.request.json","sha256":"fcf4469b068bc21e97d6e4754a3b2454c0876da594b6f0fb8b00e3c7eb056eab","bytes":8636,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s4-cycle","previous_request":"s3-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"read the configuration and summarise it\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"content\":\"timeout: 30\\nretries: 3\\n\",\"tool_use_id\":\"s2-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"},{\"content\":\"The timeout is 30 seconds, with 3 retries.\",\"role\":\"assistant\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"find where the timeout is used\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s3-req-fdae022ac306; cc_prompt_id=s4-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1940}]} +{"ord":1,"off":0,"sha":"f66b868a1f3b","bytes":252,"id":"s5-req-fdae022ac306.response","call":"s5-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s5-req-fdae022ac306.response.json","sha256":"f66b868a1f3b5d2c80e28a8314f7ce57f5f55e8e4625ba403fff5ab0e22b613e","bytes":252,"depth":0,"model":"claude-opus-5","call":"s5-call-fdae022ac306","request":"s5-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"id\":\"s5-tool\",\"input\":{\"description\":\"searcher\",\"prompt\":\"find every use of the timeout\"},\"name\":\"Agent\",\"type\":\"tool_use\"}],\"id\":\"s5-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\"}"}]},"state":"available","bytes":592}]} +{"ord":1,"off":0,"sha":"faa5ca836651","bytes":8866,"id":"935833f9-330c-4a2f-84e7-90072ac2f4db.request","run":"s4-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"935833f9-330c-4a2f-84e7-90072ac2f4db.request.json","sha256":"faa5ca836651fe57bf3b41d154a72d46560bba35dfc15d872cf86606ad195b27","bytes":8866,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s4-cycle","previous_request":"s5-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"read the configuration and summarise it\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"content\":\"timeout: 30\\nretries: 3\\n\",\"tool_use_id\":\"s2-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"},{\"content\":\"The timeout is 30 seconds, with 3 retries.\",\"role\":\"assistant\"},{\"content\":\"find where the timeout is used\",\"role\":\"user\"},{\"content\":[{\"id\":\"s5-tool\",\"input\":{\"description\":\"searcher\",\"prompt\":\"find every use of the timeout\"},\"name\":\"Agent\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"content\":\"launched\",\"tool_use_id\":\"s5-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s5-req-fdae022ac306; cc_prompt_id=s4-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":2210}]} +{"ord":1,"off":0,"sha":"c4f1645ec661","bytes":187,"id":"s6-req-fdae022ac306.response","call":"s6-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s6-req-fdae022ac306.response.json","sha256":"c4f1645ec661dd0072bccb3c9addc19563fc22d140f5e66e3bfd7d5c96a436e2","bytes":187,"depth":0,"model":"claude-opus-5","call":"s6-call-fdae022ac306","request":"s6-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"The searcher found it in server.go.\",\"type\":\"text\"}],\"id\":\"s6-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":513}]} +{"ord":1,"off":0,"sha":"a564142b6ea8","bytes":3753,"id":"32c40efc-db2a-4811-85e8-0d40b51c6fe7.request","run":"a02ba01b82410e9e3-cycle","model":"claude-opus-5","parts":[{"k":"data","data":"You are an agent that searches a repository. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. ","state":"available","bytes":1367},{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"32c40efc-db2a-4811-85e8-0d40b51c6fe7.request.json","sha256":"a564142b6ea86784fb801d22f151f7817d7dcbce32de577a6cadd35026f9ea2a","bytes":3753,"depth":0,"chain":"13f2eed00f2e44d1","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"a02ba01b82410e9e3-cycle","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":\"\\nAs you answer the user's questions, you can use the following context.\\n\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"find every use of the timeout\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prompt_id=a02ba01b82410e9e3-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"part":0},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1236}]} +{"ord":1,"off":0,"sha":"db0df7cae147","bytes":270,"id":"searcher-s1-req-fdae022ac306.response","call":"searcher-s1-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"searcher-s1-req-fdae022ac306.response.json","sha256":"db0df7cae14773759eb967e78b5999c47377673475c09e0da2cd44d81356ef56","bytes":270,"depth":0,"model":"claude-opus-5","call":"searcher-s1-call-fdae022ac306","request":"searcher-s1-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"Searching.\",\"type\":\"text\"},{\"id\":\"searcher-s1-tool\",\"input\":{\"command\":\"grep -rn timeout .\"},\"name\":\"Bash\",\"type\":\"tool_use\"}],\"id\":\"searcher-s1-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\"}"}]},"state":"available","bytes":641}]} +{"ord":1,"off":0,"sha":"0047b6fad9bb","bytes":4089,"id":"c58ece40-0a74-4e25-8426-53f9ccca9203.request","run":"a02ba01b82410e9e3-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"c58ece40-0a74-4e25-8426-53f9ccca9203.request.json","sha256":"0047b6fad9bbe463e67ed5ed18a023e62959433c12d427b14cce04b8de492248","bytes":4089,"depth":0,"chain":"13f2eed00f2e44d1","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"a02ba01b82410e9e3-cycle","previous_request":"searcher-s1-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":\"\\nAs you answer the user's questions, you can use the following context.\\n\",\"type\":\"text\"},{\"text\":\"find every use of the timeout\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"Searching.\",\"type\":\"text\"},{\"id\":\"searcher-s1-tool\",\"input\":{\"command\":\"grep -rn timeout .\"},\"name\":\"Bash\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"content\":\"server.go:3: var timeout = 30\",\"tool_use_id\":\"searcher-s1-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=searcher-s1-req-fdae022ac306; cc_prompt_id=a02ba01b82410e9e3-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"210fbe461835a4f21a27c136f311b69ab52a1440e2e23f2395c102b67744f2be"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1738}]} +{"ord":1,"off":0,"sha":"9aa0936b42dc","bytes":185,"id":"searcher-s2-req-fdae022ac306.response","call":"searcher-s2-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"searcher-s2-req-fdae022ac306.response.json","sha256":"9aa0936b42dc784b266936891a85359e54500e1f3fb9e490cc53b192b313d499","bytes":185,"depth":0,"model":"claude-opus-5","call":"searcher-s2-call-fdae022ac306","request":"searcher-s2-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"It is used in server.go.\",\"type\":\"text\"}],\"id\":\"searcher-s2-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":538}]} +{"ord":1,"off":0,"sha":"c98fdf59718d","bytes":8281,"id":"89c950ad-d61b-47bb-84da-bf0f39990473.request","run":"s8-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"89c950ad-d61b-47bb-84da-bf0f39990473.request.json","sha256":"c98fdf59718dda1877f1107a8a3eae19ec0c55d0989ce6d03d6b33aaf20758ff","bytes":8281,"depth":0,"chain":"d4550b67cba4cf93","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s8-cycle","previous_request":"s6-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"The configuration sets a 30 second timeout, used in server.go.\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"and the retries?\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s6-req-fdae022ac306; cc_prompt_id=s8-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1525}]} +{"ord":1,"off":0,"sha":"b61d2be4bb7b","bytes":172,"id":"s9-req-fdae022ac306.response","call":"s9-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s9-req-fdae022ac306.response.json","sha256":"b61d2be4bb7bc571ab39192cfe0219474fa688a9690861226d5f2b67509e325e","bytes":172,"depth":0,"model":"claude-opus-5","call":"s9-call-fdae022ac306","request":"s9-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"There are 3 retries.\",\"type\":\"text\"}],\"id\":\"s9-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":498}]} +{"t":"end","records":14,"digest":"979442eae01734ce54d96f1bbec3df5a1382295a8cd80880475b0df3ba4510c0"} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-ff8eaba03b03.sf b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-ff8eaba03b03.sf new file mode 100644 index 000000000000..4fe5058174f9 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-ff8eaba03b03.sf @@ -0,0 +1,51 @@ +{"t":"header","schema":"sf/1","conversation":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","round":1,"from_seq":1,"through_seq":4,"input_digest":"bb774e3f497f8618d3e944070b5d074c0ab6589f0c089db4c2371a99dc4528e3","parser":"v1","policy":"v1+idle=10m0s","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:08.4Z","session_from_time":"2026-01-01T00:00:00Z","session_through_time":"2026-01-01T00:00:08.4Z","title":"provider bodies beside the calls","talks":5,"steps":23,"streams":2,"segments":1,"unresolved":0,"changes":0,"lines_added":0,"lines_removed":0,"llm_calls":7,"subagents":1,"bash_runs":1} +{"t":"node","id":"ack/1/9","revision":1,"kind":"agent.launch_ack","parent":"run/talk_main_s4-cycle/s4-cycle","stream":"main","ref":{"seq":1,"row":9}} +{"t":"node","id":"boundary/1/11","revision":1,"kind":"epoch.boundary","parent":"epoch/main/s7-boundary","stream":"main","ref":{"seq":1,"row":11}} +{"t":"node","id":"call/s2-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":3},"refs":[{"seq":1,"row":3},{"seq":1,"row":4}],"attrs":{"fragments":2,"usage":"observed_replayable","usage_at":{"row":4,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/s3-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":6},"refs":[{"seq":1,"row":6}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":6,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/s5-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s4-cycle/s4-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/s6-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s4-cycle/s4-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/s9-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s8-cycle/s8-cycle","stream":"main","ref":{"seq":1,"row":14},"refs":[{"seq":1,"row":14}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":14,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/searcher-s1-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":2},"refs":[{"seq":2,"row":2},{"seq":2,"row":3}],"attrs":{"fragments":2,"usage":"observed_replayable","usage_at":{"row":3,"seq":2},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/searcher-s2-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":5},"refs":[{"seq":2,"row":5}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":5,"seq":2},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"epoch/a02ba01b82410e9e3/0","revision":1,"kind":"epoch","parent":"stream/a02ba01b82410e9e3","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":1},"attrs":{"records":6,"reset":"none"}} +{"t":"node","id":"epoch/main/0","revision":1,"kind":"epoch","parent":"stream/main","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":10,"reset":"none"}} +{"t":"node","id":"epoch/main/s7-boundary","revision":1,"kind":"epoch","parent":"stream/main","stream":"main","ref":{"seq":1,"row":11},"refs":[{"seq":1,"row":11}],"attrs":{"continues_from":"s6-call-f1","records":4,"reset":"observed_replayable"}} +{"t":"node","id":"input/1/13","revision":1,"kind":"message.external","parent":"run/talk_main_s8-cycle/s8-cycle","stream":"main","ref":{"seq":1,"row":13}} +{"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":"input/1/7","revision":1,"kind":"message.external","parent":"run/talk_main_s4-cycle/s4-cycle","stream":"main","ref":{"seq":1,"row":7}} +{"t":"node","id":"msg/1/10:0","revision":1,"kind":"message.assistant","parent":"call/s6-call-fdae022ac306","stream":"main","ref":{"seq":1,"row":10,"block":0}} +{"t":"node","id":"msg/1/14:0","revision":1,"kind":"message.assistant","parent":"call/s9-call-fdae022ac306","stream":"main","ref":{"seq":1,"row":14,"block":0}} +{"t":"node","id":"msg/1/3:0","revision":1,"kind":"message.assistant","parent":"call/s2-call-fdae022ac306","stream":"main","ref":{"seq":1,"row":3,"block":0}} +{"t":"node","id":"msg/1/6:0","revision":1,"kind":"message.assistant","parent":"call/s3-call-fdae022ac306","stream":"main","ref":{"seq":1,"row":6,"block":0}} +{"t":"node","id":"msg/2/2:0","revision":1,"kind":"message.assistant","parent":"call/searcher-s1-call-fdae022ac306","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":2,"block":0}} +{"t":"node","id":"msg/2/5:0","revision":1,"kind":"message.assistant","parent":"call/searcher-s2-call-fdae022ac306","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":5,"block":0}} +{"t":"node","id":"output/a02ba01b82410e9e3","revision":1,"kind":"agent.output","parent":"run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":5},"refs":[{"seq":2,"row":5}],"attrs":{"returned_value":"unavailable"}} +{"t":"node","id":"run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle","revision":1,"kind":"run","parent":"talk/a02ba01b82410e9e3","stream":"a02ba01b82410e9e3","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_s4-cycle/s4-cycle","revision":1,"kind":"run","parent":"talk/main/s4-cycle","stream":"main","ref":{"seq":1,"row":7},"attrs":{"trigger":"external"}} +{"t":"node","id":"run/talk_main_s7-cycle-compact/s7-cycle-compact","revision":1,"kind":"run","parent":"talk/main/s7-cycle-compact","stream":"main","ref":{"seq":1,"row":12},"attrs":{"trigger":"external"}} +{"t":"node","id":"run/talk_main_s8-cycle/s8-cycle","revision":1,"kind":"run","parent":"talk/main/s8-cycle","stream":"main","ref":{"seq":1,"row":13},"attrs":{"trigger":"external"}} +{"t":"node","id":"segment/at_1_2","revision":1,"kind":"segment","parent":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","ref":{"seq":1,"row":2},"attrs":{"committable":false,"gates_unmet":["activity_boundary","lateness_watermark"],"state":"open","talks":4}} +{"t":"node","id":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","revision":1,"kind":"session","attrs":{"conversation":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:08.4Z","title":"provider bodies beside the calls","title_from":"observed_replayable"}} +{"t":"node","id":"stream/a02ba01b82410e9e3","revision":1,"kind":"stream","parent":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":1},"attrs":{"label":"searcher","records":6,"role":"child"}} +{"t":"node","id":"stream/main","revision":1,"kind":"stream","parent":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":14,"role":"main"}} +{"t":"node","id":"summary/1/12","revision":1,"kind":"epoch.summary","parent":"epoch/main/s7-boundary","stream":"main","ref":{"seq":1,"row":12}} +{"t":"node","id":"talk/a02ba01b82410e9e3","revision":1,"kind":"talk","parent":"epoch/a02ba01b82410e9e3/0","stream":"a02ba01b82410e9e3","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":1,"runs":1,"trigger":"external"}} +{"t":"node","id":"talk/main/s4-cycle","revision":1,"kind":"talk","parent":"epoch/main/0","stream":"main","ref":{"seq":1,"row":7},"attrs":{"loops":1,"runs":1,"trigger":"external"}} +{"t":"node","id":"talk/main/s7-cycle-compact","revision":1,"kind":"talk","parent":"epoch/main/s7-boundary","stream":"main","ref":{"seq":1,"row":12},"attrs":{"loops":1,"runs":1,"trigger":"external"}} +{"t":"node","id":"talk/main/s8-cycle","revision":1,"kind":"talk","parent":"epoch/main/s7-boundary","stream":"main","ref":{"seq":1,"row":13},"attrs":{"loops":1,"runs":1,"trigger":"external"}} +{"t":"node","id":"tool/s2-tool","revision":1,"kind":"tool","parent":"call/s2-call-fdae022ac306","stream":"main","ref":{"seq":1,"row":4,"block":0},"refs":[{"seq":1,"row":4,"block":0},{"seq":1,"row":5,"block":0}],"attrs":{"name":"Read","result":"available","result_join":"exact_unique","timing":"unavailable"}} +{"t":"node","id":"tool/s5-tool","revision":1,"kind":"agent.call","parent":"call/s5-call-fdae022ac306","stream":"main","ref":{"seq":1,"row":8,"block":0},"refs":[{"seq":1,"row":8,"block":0},{"seq":1,"row":9,"block":0}],"attrs":{"name":"Agent","result":"available","result_join":"exact_unique","timing":"unavailable"}} +{"t":"node","id":"tool/searcher-s1-tool","revision":1,"kind":"tool","parent":"call/searcher-s1-call-fdae022ac306","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":3,"block":0},"refs":[{"seq":2,"row":3,"block":0},{"seq":2,"row":4,"block":0}],"attrs":{"name":"Bash","result":"available","result_join":"exact_unique","timing":"unavailable"}} +{"t":"relation","id":"rel/ends_with/stream_a02ba01b82410e9e3/output_a02ba01b82410e9e3","revision":1,"type":"ends_with","from":"stream/a02ba01b82410e9e3","to":"output/a02ba01b82410e9e3","quality":"exact_unique","via":"the last response in the child stream, and what it returned","evidence":[{"seq":2,"row":5}]} +{"t":"relation","id":"rel/follows/epoch_main_s7-boundary/epoch_main_0","revision":1,"type":"follows","from":"epoch/main/s7-boundary","to":"epoch/main/0","quality":"exact_unique","via":"explicit context reset","evidence":[{"seq":1,"row":11}]} +{"t":"relation","id":"rel/in_segment/talk_a02ba01b82410e9e3/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/a02ba01b82410e9e3","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_s4-cycle/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/main/s4-cycle","to":"segment/at_1_2","quality":"exact_unique","via":"activity window","evidence":[{"seq":1,"row":7}]} +{"t":"relation","id":"rel/in_segment/talk_main_s7-cycle-compact/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/main/s7-cycle-compact","to":"segment/at_1_2","quality":"exact_unique","via":"activity window","evidence":[{"seq":1,"row":12}]} +{"t":"relation","id":"rel/in_segment/talk_main_s8-cycle/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/main/s8-cycle","to":"segment/at_1_2","quality":"exact_unique","via":"activity window","evidence":[{"seq":1,"row":13}]} +{"t":"relation","id":"rel/starts/tool_s5-tool/stream_a02ba01b82410e9e3","revision":1,"type":"starts","from":"tool/s5-tool","to":"stream/a02ba01b82410e9e3","quality":"exact_unique","via":"parent tool result","evidence":[{"seq":1,"row":9}]} +{"t":"relation","id":"rel/summarizes/summary_1_12/boundary_1_11","revision":1,"type":"summarizes","from":"summary/1/12","to":"boundary/1/11","quality":"exact_unique","via":"containment parent","evidence":[{"seq":1,"row":12}]} +{"t":"commit","digest":"ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920","counts":{"nodes":40,"relations":9,"unresolved":0}} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/transcript-20260101T000000.000000000Z-000001.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/transcript-20260101T000000.000000000Z-000001.sd new file mode 100644 index 000000000000..d80b3b4dab02 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/transcript-20260101T000000.000000000Z-000001.sd @@ -0,0 +1,16 @@ +{"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-scenario/6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/main","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","stream":"main"} +{"ord":1,"off":0,"sha":"553d947ffc0c","bytes":222,"label":"provider bodies beside the calls","from":"runtime","parts":[{"k":"data","data":{"aiTitle":"provider bodies beside the calls","type":"ai-title"},"state":"available","bytes":64}]} +{"ord":2,"off":223,"sha":"948b06fe80bb","bytes":274,"id":"s1-input","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:00.000Z","trigger":"external","flags":["external_input"],"parts":[{"k":"text","text":"read the configuration and summarise it","state":"available","bytes":39}]} +{"ord":3,"off":498,"sha":"fff670b0dbf5","bytes":315,"id":"s2-call-f1","parent":"s1-input","call":"s2-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:01.000Z","usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"Reading it.","state":"available","bytes":11}]} +{"ord":4,"off":814,"sha":"4eac6299921d","bytes":401,"id":"s2-call-f2","parent":"s2-call-f1","call":"s2-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:01.100Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"call","data":{"file_path":"/Users/dev/scenario/config.yaml"},"id":"s2-tool","name":"Read","state":"available","bytes":47}]} +{"ord":5,"off":1216,"sha":"8a04d7c1d85a","bytes":311,"id":"s2-result","parent":"s2-call-f2","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:01.300Z","parts":[{"k":"result","text":"timeout: 30\nretries: 3\n","data":{"stderr":"","stdout":"timeout: 30\nretries: 3\n"},"of":"s2-tool","state":"available","bytes":23}]} +{"ord":6,"off":1528,"sha":"0f00dde61cc1","bytes":369,"id":"s3-call-f1","parent":"s2-result","call":"s3-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:02.300Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"The timeout is 30 seconds, with 3 retries.","state":"available","bytes":42}]} +{"ord":7,"off":1898,"sha":"a8d0525e1f8f","bytes":266,"id":"s4-input","run":"s4-cycle","from":"external","time":"2026-01-01T00:00:03.300Z","trigger":"external","flags":["external_input"],"parts":[{"k":"text","text":"find where the timeout is used","state":"available","bytes":30}]} +{"ord":8,"off":2165,"sha":"ede14ff4e470","bytes":421,"id":"s5-call-f1","parent":"s4-input","call":"s5-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:04.300Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"call","data":{"description":"searcher","prompt":"find every use of the timeout"},"id":"s5-tool","name":"Agent","state":"available","bytes":67}]} +{"ord":9,"off":2587,"sha":"f3e8ba521e7f","bytes":429,"id":"s5-ack","parent":"s5-call-f1","run":"s4-cycle","child":"a02ba01b82410e9e3","from":"external","time":"2026-01-01T00:00:04.400Z","flags":["launch_ack"],"parts":[{"k":"result","text":"launched","data":{"agentId":"a02ba01b82410e9e3","description":"searcher","isAsync":true,"prompt":"find every use of the timeout","status":"async_launched"},"of":"s5-tool","state":"available","bytes":8}]} +{"ord":10,"off":3017,"sha":"9730334c5ab9","bytes":360,"id":"s6-call-f1","parent":"s5-ack","call":"s6-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:05.400Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"The searcher found it in server.go.","state":"available","bytes":35}]} +{"ord":11,"off":3378,"sha":"7e40530d61d7","bytes":388,"id":"s7-boundary","continues":"s6-call-f1","from":"runtime","time":"2026-01-01T00:00:06.400Z","flags":["context_reset"],"parts":[{"k":"data","data":{"compactMetadata":{"preservedMessages":{"allUuids":["s6-call-f1"]},"trigger":"auto"},"logicalParentUuid":"s6-call-f1","subtype":"compact_boundary","type":"system"},"state":"available","bytes":164}]} +{"ord":12,"off":3767,"sha":"99050a0698bc","bytes":310,"id":"s7-summary","parent":"s7-boundary","run":"s7-cycle-compact","from":"external","time":"2026-01-01T00:00:06.000Z","flags":["reset_summary"],"parts":[{"k":"text","text":"The configuration sets a 30 second timeout, used in server.go.","state":"available","bytes":62}]} +{"ord":13,"off":4078,"sha":"22acd2b4efc6","bytes":253,"id":"s8-input","run":"s8-cycle","from":"external","time":"2026-01-01T00:00:07.400Z","trigger":"external","flags":["external_input"],"parts":[{"k":"text","text":"and the retries?","state":"available","bytes":16}]} +{"ord":14,"off":4332,"sha":"80d831410efa","bytes":347,"id":"s9-call-f1","parent":"s8-input","call":"s9-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:08.400Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"There are 3 retries.","state":"available","bytes":20}]} +{"t":"end","records":14,"digest":"459c9e363d8ed7feeec4cc6ca85d62ae826d864d17ee752b0a12ff1bef0d5423"} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/transcript-20260101T000000.000000000Z-000002.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/transcript-20260101T000000.000000000Z-000002.sd new file mode 100644 index 000000000000..b3039dd23911 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/transcript-20260101T000000.000000000Z-000002.sd @@ -0,0 +1,7 @@ +{"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-scenario/6b7a6063-8714-4f6b-87cd-6c2da3a5094d/streams/a02ba01b82410e9e3","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","stream":"a02ba01b82410e9e3"} +{"ord":1,"off":0,"sha":"4cb83322ecf9","bytes":245,"id":"a02ba01b82410e9e3-prompt","run":"a02ba01b82410e9e3-cycle","from":"external","time":"2026-01-01T00:00:05.400Z","parts":[{"k":"text","text":"find every use of the timeout","state":"available","bytes":29}]} +{"ord":2,"off":246,"sha":"f216888ac802","bytes":348,"id":"searcher-s1-call-f1","parent":"a02ba01b82410e9e3-prompt","call":"searcher-s1-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:06.400Z","usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"Searching.","state":"available","bytes":10}]} +{"ord":3,"off":595,"sha":"3158866f8d3e","bytes":422,"id":"searcher-s1-call-f2","parent":"searcher-s1-call-f1","call":"searcher-s1-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:06.500Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"call","data":{"command":"grep -rn timeout ."},"id":"searcher-s1-tool","name":"Bash","state":"available","bytes":32}]} +{"ord":4,"off":1018,"sha":"1b872d179867","bytes":361,"id":"searcher-s1-result","parent":"searcher-s1-call-f2","run":"a02ba01b82410e9e3-cycle","from":"external","time":"2026-01-01T00:00:06.800Z","parts":[{"k":"result","text":"server.go:3: var timeout = 30","data":{"stderr":"","stdout":"server.go:3: var timeout = 30"},"of":"searcher-s1-tool","state":"available","bytes":29}]} +{"ord":5,"off":1380,"sha":"2555356f63dc","bytes":378,"id":"searcher-s2-call-f1","parent":"searcher-s1-result","call":"searcher-s2-call-fdae022ac306","from":"agent","time":"2026-01-01T00:00:07.800Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"It is used in server.go.","state":"available","bytes":24}]} +{"t":"end","records":5,"digest":"b0353e8a15ee7c966fbf8a343c59ca1d4d528299407e9361cc38b09f874b9825"} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json index 2bacf2466734..4754730ff1c3 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json @@ -22,6 +22,8 @@ "rounds": 1, "unresolved": 0, "changes": 3, + "provider_bodies": 0, + "captured_prompts": 0, "from": 1767225600000, "to": 1767225609400, "kinds": { diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.yaml b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.yaml index 2072784fd17d..dcc2c27ced65 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.yaml +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.yaml @@ -19,6 +19,8 @@ summary: rounds: 1 unresolved: 0 changes: 3 + provider_bodies: 0 + captured_prompts: 0 from: 1767225600000 to: 1767225609400 kinds: 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 index 56e43bc2c663..9d99c889a6c6 100644 --- 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 @@ -19,15 +19,11 @@ 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; @@ -38,8 +34,9 @@ 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. + * Resolvers of ai-agent-conversation.graphqls. The conversation view and its files are not GraphQL + * queries; they are the module's own HTTP routes on the same server, see ConversationViewHandler and + * ConversationFilesHandler. */ public class AIAgentConversationQuery implements GraphQLQueryResolver { private final ModuleManager moduleManager; @@ -87,40 +84,6 @@ public CompletableFuture listConversations(final ConversationL }); } - 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, - condition.isColdStage() - ); - 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 35ae8f2a7662..689ddfea3e7c 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 35ae8f2a7662df3d0b9681f635f24bf4e3f23d28 +Subproject commit 689ddfea3e7ccf32400a5be34a0538a013d23eb9 diff --git a/oap-server/server-starter/src/main/resources/application.yml b/oap-server/server-starter/src/main/resources/application.yml index 3a536648d4ca..61589065fa1c 100644 --- a/oap-server/server-starter/src/main/resources/application.yml +++ b/oap-server/server-starter/src/main/resources/application.yml @@ -274,17 +274,18 @@ ai-agent-conversation: # conversation view route is registered. The GraphQL query module requires this module, so `-` can't remove it. none: 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} + # The most rounds one conversation list reads. It counts rounds, not conversations, so a busy + # conversation spends the budget of the quiet ones. + conversationListMaxLimit: ${SW_AI_AGENT_CONVERSATION_LIST_MAX_LIMIT:10000} + # How long one conversation view request may take, in seconds; the whole chain is folded before the first byte. viewRequestTimeout: ${SW_AI_AGENT_CONVERSATION_VIEW_REQUEST_TIMEOUT:120} - # The largest file stored, in bytes; a larger one is rejected at ingest. Under BanyanDB's 16 MiB gRPC message limit. - maxFileBytes: ${SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES:15728640} - # The most bytes one window read may answer with, applied to that read alone where the storage caps a response per call. + # How many files or rounds one storage query fetches; a conversation is read batch by batch. + readWindow: ${SW_AI_AGENT_CONVERSATION_READ_WINDOW:16} + # The most bytes one storage query may answer with. BanyanDB alone accepts it, raising its client's + # 50 MB per-call cap for this module; Elasticsearch and JDBC bound a read by hits and by rows. maxResponseBytes: ${SW_AI_AGENT_CONVERSATION_MAX_RESPONSE_BYTES:104857600} + # The largest file stored; a larger one is rejected at ingest, under BanyanDB's 16 MiB message limit. + maxFileBytes: ${SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES:15728640} receiver-sharing-server: selector: ${SW_RECEIVER_SHARING_SERVER:default} 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 72a216c227f7..9f1b7ff8d8e4 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 @@ -188,14 +188,18 @@ event-analyzer: 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} + # The most rounds one conversation list reads. It counts rounds, not conversations, so a busy + # conversation spends the budget of the quiet ones. + conversationListMaxLimit: ${SW_AI_AGENT_CONVERSATION_LIST_MAX_LIMIT:10000} + # How long one conversation view request may take, in seconds; the whole chain is folded before the first byte. viewRequestTimeout: ${SW_AI_AGENT_CONVERSATION_VIEW_REQUEST_TIMEOUT:120} - # The largest file stored, in bytes; a larger one is rejected at ingest. Under BanyanDB's 16 MiB gRPC message limit. - maxFileBytes: ${SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES:15728640} - # The most bytes one window read may answer with, applied to that read alone where the storage caps a response per call. + # How many files or rounds one storage query fetches; a conversation is read batch by batch. + readWindow: ${SW_AI_AGENT_CONVERSATION_READ_WINDOW:16} + # The most bytes one storage query may answer with. BanyanDB alone accepts it, raising its client's + # 50 MB per-call cap for this module; Elasticsearch and JDBC bound a read by hits and by rows. maxResponseBytes: ${SW_AI_AGENT_CONVERSATION_MAX_RESPONSE_BYTES:104857600} + # The largest file stored; a larger one is rejected at ingest, under BanyanDB's 16 MiB message limit. + maxFileBytes: ${SW_AI_AGENT_CONVERSATION_MAX_FILE_BYTES:15728640} query: selector: ${SW_QUERY: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 index 377bb9620b28..399b33fdf2a4 100644 --- a/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml +++ b/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml @@ -67,6 +67,10 @@ cases: # joins its step, and the export names the files - query: bash test/e2e-v2/cases/ai-agent/verify.sh changes http://${oap_host}:${oap_12800} expected: expected/changes.yml + # the provider-bodies conversation: the provider_body file landed beside the transcripts, every call names where its + # request and response landed, and the export names the file + - query: bash test/e2e-v2/cases/ai-agent/verify.sh provider-bodies http://${oap_host}:${oap_12800} + expected: expected/provider-bodies.yml # the runtime's token metric the Sessionizer derived, summed per minute over every session and sender, and over the # run equal to what the scenarios declare; the service and the sender both - query: bash test/e2e-v2/cases/ai-agent/verify.sh metrics http://${oap_host}:${oap_12800} diff --git a/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml b/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml index 8d5cf3458007..623c11607848 100644 --- a/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml +++ b/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml @@ -17,6 +17,8 @@ # fixture.yaml, three sessions build -> collect, once # workspace-changes.yaml, one build -> collect, once, after the fixture's pass: a session whose every # session tool call changed files, in the same root +# provider-bodies.yaml, one build -> collect, once, after the workspace-changes pass: a session whose +# session calls' request and response bodies landed as provider_body records # three-rounds.yaml, one session build through the first checkpoint -> collect, then through the second, # then to the end: three rounds over files cut at each stage, and the final # document must cover it all @@ -38,6 +40,7 @@ x-asz: &asz - ../three-rounds.yaml:/asz/three-rounds.yaml:ro - ../lost-file.yaml:/asz/lost-file.yaml:ro - ../workspace-changes.yaml:/asz/workspace-changes.yaml:ro + - ../provider-bodies.yaml:/asz/provider-bodies.yaml:ro networks: - e2e @@ -118,12 +121,26 @@ services: depends_on: wc-build: condition: service_completed_successfully + # ---- provider-bodies.yaml: the request and response bodies of every call, one session in the same root. The build + # lands them as provider_body records beside the transcripts, and the pass sends them like any landed file. + pb-build: + <<: *asz + command: ["scenario", "build", "/asz/provider-bodies.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + wc-collect: + condition: service_completed_successfully + pb-collect: + <<: *asz + command: ["collect", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + pb-build: + condition: service_completed_successfully # ---- three-rounds.yaml: one session in three stages, each parsed and sent before the next is built mr-build-1: <<: *asz command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] depends_on: - wc-collect: + pb-collect: condition: service_completed_successfully mr-collect-1: <<: *asz diff --git a/test/e2e-v2/cases/ai-agent/es/docker-compose.yml b/test/e2e-v2/cases/ai-agent/es/docker-compose.yml index 6e01bf8daf8b..1e27557550ff 100644 --- a/test/e2e-v2/cases/ai-agent/es/docker-compose.yml +++ b/test/e2e-v2/cases/ai-agent/es/docker-compose.yml @@ -17,6 +17,8 @@ # fixture.yaml, three sessions build -> collect, once # workspace-changes.yaml, one build -> collect, once, after the fixture's pass: a session whose every # session tool call changed files, in the same root +# provider-bodies.yaml, one build -> collect, once, after the workspace-changes pass: a session whose +# session calls' request and response bodies landed as provider_body records # three-rounds.yaml, one session build through the first checkpoint -> collect, then through the second, # then to the end: three rounds over files cut at each stage, and the final # document must cover it all @@ -38,6 +40,7 @@ x-asz: &asz - ../three-rounds.yaml:/asz/three-rounds.yaml:ro - ../lost-file.yaml:/asz/lost-file.yaml:ro - ../workspace-changes.yaml:/asz/workspace-changes.yaml:ro + - ../provider-bodies.yaml:/asz/provider-bodies.yaml:ro networks: - e2e @@ -127,12 +130,26 @@ services: depends_on: wc-build: condition: service_completed_successfully + # ---- provider-bodies.yaml: the request and response bodies of every call, one session in the same root. The build + # lands them as provider_body records beside the transcripts, and the pass sends them like any landed file. + pb-build: + <<: *asz + command: ["scenario", "build", "/asz/provider-bodies.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + wc-collect: + condition: service_completed_successfully + pb-collect: + <<: *asz + command: ["collect", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + pb-build: + condition: service_completed_successfully # ---- three-rounds.yaml: one session in three stages, each parsed and sent before the next is built mr-build-1: <<: *asz command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] depends_on: - wc-collect: + pb-collect: condition: service_completed_successfully mr-collect-1: <<: *asz diff --git a/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml b/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml index 949887f6e42f..c138a54c835e 100644 --- a/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml +++ b/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml @@ -13,5 +13,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -hit: 6 +hit: 7 miss: 0 diff --git a/test/e2e-v2/cases/ai-agent/expected/conversations.yml b/test/e2e-v2/cases/ai-agent/expected/conversations.yml index 3124a47f3634..1715e864c3df 100644 --- a/test/e2e-v2/cases/ai-agent/expected/conversations.yml +++ b/test/e2e-v2/cases/ai-agent/expected/conversations.yml @@ -53,6 +53,16 @@ segments: 1 unresolved: 0 timed: true +- conversation: 6b7a6063-8714-4f6b-87cd-6c2da3a5094d + instance: e2e-sender + title: provider bodies beside the calls + round: 1 + talks: 5 + steps: 23 + streams: 2 + segments: 1 + unresolved: 0 + timed: true - conversation: bd16edc4-0b6b-4020-8405-3ce58724f2bc instance: e2e-sender title: three rounds diff --git a/test/e2e-v2/cases/ai-agent/expected/list-horizon.yml b/test/e2e-v2/cases/ai-agent/expected/list-horizon.yml index c9048efc5c75..ee2d9a44d40f 100644 --- a/test/e2e-v2/cases/ai-agent/expected/list-horizon.yml +++ b/test/e2e-v2/cases/ai-agent/expected/list-horizon.yml @@ -81,6 +81,23 @@ subagents: 1 bashRuns: 2 timed: true +- conversation: 6b7a6063-8714-4f6b-87cd-6c2da3a5094d + instance: e2e-sender + instanceIdSet: true + title: provider bodies beside the calls + round: 1 + talks: 5 + steps: 23 + streams: 2 + segments: 1 + unresolved: 0 + changes: 0 + linesAdded: 0 + linesRemoved: 0 + llmCalls: 7 + subagents: 1 + bashRuns: 1 + timed: true - conversation: bd16edc4-0b6b-4020-8405-3ce58724f2bc instance: e2e-sender instanceIdSet: true diff --git a/test/e2e-v2/cases/ai-agent/expected/metrics.yml b/test/e2e-v2/cases/ai-agent/expected/metrics.yml index 86acea96ef79..ee5c1c103d6a 100644 --- a/test/e2e-v2/cases/ai-agent/expected/metrics.yml +++ b/test/e2e-v2/cases/ai-agent/expected/metrics.yml @@ -13,97 +13,97 @@ # See the License for the specific language governing permissions and # limitations under the License. -tokens: 37368 -instance_tokens: 37368 +tokens: 44662 +instance_tokens: 44662 by_type: - labels: - key: type value: cacheCreation - value: 3600 + value: 4300 - labels: - key: type value: cacheRead - value: 32400 + value: 38700 - labels: - key: type value: input - value: 72 + value: 86 - labels: - key: type value: output - value: 1296 + value: 1576 by_model: - labels: - key: model value: claude-opus-5 - key: type value: cacheCreation - value: 3600 + value: 4300 - labels: - key: model value: claude-opus-5 - key: type value: cacheRead - value: 32400 + value: 38700 - labels: - key: model value: claude-opus-5 - key: type value: input - value: 72 + value: 86 - labels: - key: model value: claude-opus-5 - key: type value: output - value: 1296 + value: 1576 by_source: - labels: - key: query_source value: main - key: type value: cacheCreation - value: 2700 + value: 3200 - labels: - key: query_source value: main - key: type value: cacheRead - value: 24300 + value: 28800 - labels: - key: query_source value: main - key: type value: input - value: 54 + value: 64 - labels: - key: query_source value: main - key: type value: output - value: 930 + value: 1130 - labels: - key: query_source value: subagent - key: type value: cacheCreation - value: 900 + value: 1100 - labels: - key: query_source value: subagent - key: type value: cacheRead - value: 8100 + value: 9900 - labels: - key: query_source value: subagent - key: type value: input - value: 18 + value: 22 - labels: - key: query_source value: subagent - key: type value: output - value: 366 + value: 446 cache_read_share_in_range: true diff --git a/test/e2e-v2/cases/ai-agent/expected/provider-bodies.yml b/test/e2e-v2/cases/ai-agent/expected/provider-bodies.yml new file mode 100644 index 000000000000..c94f248ef3d9 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/provider-bodies.yml @@ -0,0 +1,89 @@ +# 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. + +state: verified +provider_bodies: 14 +captured_prompts: 7 +provider_files: + - seq: 4 + stream: null + run: null + lines: 16 +calls: + - id: call/s2-call-fdae022ac306 + stream: main + bodies: + - role: request + seq: 4 + row: 1 + - role: response + seq: 4 + row: 2 + - id: call/s3-call-fdae022ac306 + stream: main + bodies: + - role: request + seq: 4 + row: 3 + - role: response + seq: 4 + row: 4 + - id: call/s5-call-fdae022ac306 + stream: main + bodies: + - role: request + seq: 4 + row: 5 + - role: response + seq: 4 + row: 6 + - id: call/s6-call-fdae022ac306 + stream: main + bodies: + - role: request + seq: 4 + row: 7 + - role: response + seq: 4 + row: 8 + - id: call/s9-call-fdae022ac306 + stream: main + bodies: + - role: request + seq: 4 + row: 13 + - role: response + seq: 4 + row: 14 + - id: call/searcher-s1-call-fdae022ac306 + stream: a02ba01b82410e9e3 + bodies: + - role: request + seq: 4 + row: 9 + - role: response + seq: 4 + row: 10 + - id: call/searcher-s2-call-fdae022ac306 + stream: a02ba01b82410e9e3 + bodies: + - role: request + seq: 4 + row: 11 + - role: response + seq: 4 + row: 12 +exported_provider_files: + - 6b7a6063-8714-4f6b-87cd-6c2da3a5094d/provider_body/provider_body-*-000004.sd diff --git a/test/e2e-v2/cases/ai-agent/expected/raw-files.yml b/test/e2e-v2/cases/ai-agent/expected/raw-files.yml index 786236fa8cbf..af9c6f826cd5 100644 --- a/test/e2e-v2/cases/ai-agent/expected/raw-files.yml +++ b/test/e2e-v2/cases/ai-agent/expected/raw-files.yml @@ -13,6 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -files: 4 +files: 3 match: true export_match: true diff --git a/test/e2e-v2/cases/ai-agent/expected/views.yml b/test/e2e-v2/cases/ai-agent/expected/views.yml index ad1be7c86d5b..129cb0489490 100644 --- a/test/e2e-v2/cases/ai-agent/expected/views.yml +++ b/test/e2e-v2/cases/ai-agent/expected/views.yml @@ -13,11 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -conversations: 6 -equal: 6 -yaml_equal: 6 -h2_equal: 6 -gzip_equal: 6 +conversations: 7 +equal: 7 +yaml_equal: 7 +h2_equal: 7 +gzip_equal: 7 format: asz.view version: "1.0" json_type: "application/vnd.skywalking.asz.view+json; version=1.0; charset=utf-8" @@ -27,6 +27,7 @@ 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 + detail: no round of conversation no-such-conversation is stored for this sender noservice: 400 +noinstance: 400 cli_missing: 1 diff --git a/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml b/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml index 3dfa0bc7a397..8d023aad8240 100644 --- a/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml +++ b/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml @@ -17,6 +17,8 @@ # fixture.yaml, three sessions build -> collect, once # workspace-changes.yaml, one build -> collect, once, after the fixture's pass: a session whose every # session tool call changed files, in the same root +# provider-bodies.yaml, one build -> collect, once, after the workspace-changes pass: a session whose +# session calls' request and response bodies landed as provider_body records # three-rounds.yaml, one session build through the first checkpoint -> collect, then through the second, # then to the end: three rounds over files cut at each stage, and the final # document must cover it all @@ -38,6 +40,7 @@ x-asz: &asz - ../three-rounds.yaml:/asz/three-rounds.yaml:ro - ../lost-file.yaml:/asz/lost-file.yaml:ro - ../workspace-changes.yaml:/asz/workspace-changes.yaml:ro + - ../provider-bodies.yaml:/asz/provider-bodies.yaml:ro networks: - e2e @@ -129,12 +132,26 @@ services: depends_on: wc-build: condition: service_completed_successfully + # ---- provider-bodies.yaml: the request and response bodies of every call, one session in the same root. The build + # lands them as provider_body records beside the transcripts, and the pass sends them like any landed file. + pb-build: + <<: *asz + command: ["scenario", "build", "/asz/provider-bodies.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + wc-collect: + condition: service_completed_successfully + pb-collect: + <<: *asz + command: ["collect", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + pb-build: + condition: service_completed_successfully # ---- three-rounds.yaml: one session in three stages, each parsed and sent before the next is built mr-build-1: <<: *asz command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] depends_on: - wc-collect: + pb-collect: condition: service_completed_successfully mr-collect-1: <<: *asz diff --git a/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml b/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml index fdbf27f86546..eaf20faa885b 100644 --- a/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml +++ b/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml @@ -17,6 +17,8 @@ # fixture.yaml, three sessions build -> collect, once # workspace-changes.yaml, one build -> collect, once, after the fixture's pass: a session whose every # session tool call changed files, in the same root +# provider-bodies.yaml, one build -> collect, once, after the workspace-changes pass: a session whose +# session calls' request and response bodies landed as provider_body records # three-rounds.yaml, one session build through the first checkpoint -> collect, then through the second, # then to the end: three rounds over files cut at each stage, and the final # document must cover it all @@ -38,6 +40,7 @@ x-asz: &asz - ../three-rounds.yaml:/asz/three-rounds.yaml:ro - ../lost-file.yaml:/asz/lost-file.yaml:ro - ../workspace-changes.yaml:/asz/workspace-changes.yaml:ro + - ../provider-bodies.yaml:/asz/provider-bodies.yaml:ro networks: - e2e @@ -127,12 +130,26 @@ services: depends_on: wc-build: condition: service_completed_successfully + # ---- provider-bodies.yaml: the request and response bodies of every call, one session in the same root. The build + # lands them as provider_body records beside the transcripts, and the pass sends them like any landed file. + pb-build: + <<: *asz + command: ["scenario", "build", "/asz/provider-bodies.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + wc-collect: + condition: service_completed_successfully + pb-collect: + <<: *asz + command: ["collect", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + pb-build: + condition: service_completed_successfully # ---- three-rounds.yaml: one session in three stages, each parsed and sent before the next is built mr-build-1: <<: *asz command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] depends_on: - wc-collect: + pb-collect: condition: service_completed_successfully mr-collect-1: <<: *asz diff --git a/test/e2e-v2/cases/ai-agent/provider-bodies.yaml b/test/e2e-v2/cases/ai-agent/provider-bodies.yaml new file mode 100644 index 000000000000..1e8d591cce84 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/provider-bodies.yaml @@ -0,0 +1,50 @@ +# 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. + +# Provider bodies beside the steps: every call writes its request and its +# response, as Claude Code does when OTEL_LOG_RAW_API_BODIES names a +# directory. A child's calls land between two calls of the main stream, and +# a compaction starts the main stream's message list again. The bodies land in +# two passes, into files cut small, so a body refers to bodies and pieces in +# earlier files. Each body lands in the session, rebuilds from the files up to +# its own, and leaves the fold as it is. +title: provider bodies beside the calls +provider_bodies: true +steps: + - input: read the configuration and summarise it + - call: + text: Reading it. + tool: + name: Read + input: {file_path: /Users/dev/scenario/config.yaml} + result: {text: "timeout: 30\nretries: 3\n", after: 200ms} + - call: {text: "The timeout is 30 seconds, with 3 retries."} + checkpoint: read + - input: find where the timeout is used + - call: + agent: + name: searcher + prompt: find every use of the timeout + steps: + - call: + text: Searching. + tool: {name: Bash, input: {command: grep -rn timeout .}, result: {text: "server.go:3: var timeout = 30", after: 300ms}} + - call: {text: It is used in server.go.} + - call: {text: The searcher found it in server.go.} + - reset: {summary: "The configuration sets a 30 second timeout, used in server.go."} + - input: and the retries? + - call: {text: There are 3 retries.} diff --git a/test/e2e-v2/cases/ai-agent/verify.sh b/test/e2e-v2/cases/ai-agent/verify.sh index 4caf7022741b..9df7701046fb 100755 --- a/test/e2e-v2/cases/ai-agent/verify.sh +++ b/test/e2e-v2/cases/ai-agent/verify.sh @@ -26,7 +26,7 @@ # verify.sh list-filters OAP one conversation by id, and a title fragment in any case # 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 raw-files OAP the Session Data files the document names, read and exported by seq # 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 # verify.sh lost-file OAP a landed file deleted after a round bound to it: named once, the rest folds @@ -34,6 +34,8 @@ # verify.sh size OAP a file over maxFileBytes is never stored; one under it is # verify.sh changes OAP the workspace-changes conversation: the plugin's changes files landed beside # the transcripts, every change record joins its step, the export names the files +# verify.sh provider-bodies OAP the provider-bodies conversation: the provider_body file landed beside the +# transcripts, every call names its request and response, the export names the file # verify.sh metrics OAP the runtime's token metric the Sessionizer derived, summed over every session # and sender per minute, and over the run equal to what the scenarios declare set -euo pipefail @@ -50,6 +52,8 @@ THREE_ROUNDS="bd16edc4-0b6b-4020-8405-3ce58724f2bc" LOST="c9d9b18b-be85-4906-850d-40a1cd240171" # workspace-changes.yaml likewise: every producer of a change record in one session. WC="3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3" +# provider-bodies.yaml likewise: every call's request and response bodies in one session. +PB="6b7a6063-8714-4f6b-87cd-6c2da3a5094d" # swctl against this OAP, JSON out. sw() { @@ -59,13 +63,13 @@ sw() { # 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" + curl -sf -H "Accept: $accept" "$@" "$OAP/ai-agent/conversations/$c/v1/view?service=$SERVICE&instance=$INSTANCE" } # 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" "$@" + sw ai-agent view --service-name "$SERVICE" --instance-name "$INSTANCE" --conversation "$c" "$@" } # "yyyy-MM-dd HHmm" (MINUTE) or "yyyy-MM-dd HHmmss" (SECOND) in UTC, from epoch seconds; GNU date, then BSD date. @@ -90,10 +94,22 @@ list_where() { EOF } -# How many files the export by id returns for one Session Data seq of the first conversation: 1 when the OAP -# stored it, 0 when it did not. +# The files route has no read of every file: a reader names each one by its session and landed seq. These give the +# seqs a conversation's document lists, comma separated, of one kind when $2 names it, and read those files through +# swctl: $1 the conversation, whose own session holds them, $2 the kind or empty, then extra swctl flags. +seqs_of() { + view "$1" | yq -p=json '[.files[] | select(.seq != null and (strenv(KIND) == "" or .kind == strenv(KIND))) | .seq] | join(",")' +} +files_of() { + local c=$1 kind=$2; shift 2 + sw ai-agent files --service-name "$SERVICE" --instance-name "$INSTANCE" --conversation "$c" --session "$c" \ + --seqs "$(KIND="$kind" seqs_of "$c")" "$@" +} + +# How many files the files route returns for one Session Data seq of the first conversation: 1 when the OAP stored +# it, 0 when it did not. stored() { - sw ai-agent files --service-name "$SERVICE" --conversation "$FIRST" --files "$FIRST/streams/main/transcript-20260101T000000.000000000Z-0000$1.sd" \ + sw ai-agent files --service-name "$SERVICE" --instance-name "$INSTANCE" --conversation "$FIRST" --session "$FIRST" --seqs "$1" \ | yq -p=json '.files | length' } @@ -167,27 +183,28 @@ case "$MODE" in [ "$(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}') + encoding=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "$OAP/ai-agent/conversations/$FIRST/v1/view?service=$SERVICE&instance=$INSTANCE" | 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") + json_type=$(curl -s -o /dev/null -w '%{content_type}' "$OAP/ai-agent/conversations/$FIRST/v1/view?service=$SERVICE&instance=$INSTANCE") + 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&instance=$INSTANCE") # 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") + missing_type=$(curl -s -o /dev/null -w '%{content_type}' "$OAP/ai-agent/conversations/no-such-conversation/v1/view?service=$SERVICE&instance=$INSTANCE") + missing=$(curl -s "$OAP/ai-agent/conversations/no-such-conversation/v1/view?service=$SERVICE&instance=$INSTANCE" | yq -p=json -o=json -I=0 '{"status": .status, "title": .title, "detail": .detail}') + # the service and the sender are both required, as a list row names both + noservice=$(curl -s -o /dev/null -w '%{http_code}' "$OAP/ai-agent/conversations/$FIRST/v1/view?instance=$INSTANCE") + noinstance=$(curl -s -o /dev/null -w '%{http_code}' "$OAP/ai-agent/conversations/$FIRST/v1/view?service=$SERVICE") # 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" + printf 'noinstance: %s\ncli_missing: %s\n' "$noinstance" "$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)') + raw=$(files_of "$FIRST" "" | yq -p=json -o=json '.files | map({"file": .file, "digest": .digest}) | sort_by(.file)') + named=$(view "$FIRST" | yq -p=json -o=json '.files | map(select(.seq != null)) | 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 + # the export writes every file to its name, and each lands with the digest the document names + root=$(mktemp -d); files_of "$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 @@ -295,8 +312,23 @@ case "$MODE" in "steps": ([.. | select(tag == "!!map" and has("kind") and has("changes")) | {"id": .id, "changes": .changes}] | sort_by(.id)) }' # the file names carry the build's stamp, which differs on every run; the stream and the seq are what matter - sw ai-agent files --service-name "$SERVICE" --conversation "$WC" \ - | yq -p=json -P '{"exported_changes_files": (.files | map(.id) | map(select(test("/changes-"))) | map(sub("changes-[^/]*-0", "changes-*-0")) | sort)}' + files_of "$WC" changes \ + | yq -p=json -P '{"exported_changes_files": (.files | map(.file) | map(select(test("/changes-"))) | map(sub("changes-[^/]*-0", "changes-*-0")) | sort)}' + ;; + provider-bodies) + # The request and response bodies of every call, on the main stream and in a subagent, landed as provider_body + # records in one file under the session's provider_body directory. The document names where each call's bodies + # landed, its request then its response, and never carries their bytes; the summary counts the bodies and the + # calls whose request is captured; the export names the file by that directory. The document equals the + # Sessionizer's, which the views case checks; this case says in words what it holds. + view "$PB" --yaml | yq -P '{ + "state": .summary.state, "provider_bodies": .summary.provider_bodies, "captured_prompts": .summary.captured_prompts, + "provider_files": [.files[] | select(.kind == "provider_body") | {"seq": .seq, "stream": .stream, "run": .run, "lines": .lines}], + "calls": ([.. | select(tag == "!!map" and .kind == "llm.call") | {"id": .id, "stream": .stream, "bodies": [(.provider_bodies // [])[] | {"role": .role, "seq": .ref.seq, "row": .ref.row}]}] | sort_by(.id)) + }' + # the file name carries the build's stamp, which differs on every run; the directory and the seq are what matter + files_of "$PB" provider_body \ + | yq -p=json -P '{"exported_provider_files": (.files | map(.file) | map(select(test("/provider_body/"))) | map(sub("provider_body-[^/]*-0", "provider_body-*-0")) | sort)}' ;; list-horizon) # The list exactly as Horizon's conversation page queries it: the same condition, the service and the sender, @@ -330,7 +362,8 @@ GQL # The runtime's token metric: one delta point per minute per series, sent by the Sessionizer beside the files, # kept by the receiver as the point's value at the point's time, and summed by the rules over every session and # sender of a minute. Over the run the totals are what the scenarios declare, every call once: the three fixture - # sessions, the three-round session, the lost-file session and the workspace-changes session, all on one model. + # sessions, the three-round session, the lost-file session, the workspace-changes session and the provider-bodies + # session, all on one model. m() { sw metrics exec --expression="$1" --service-name "$SERVICE" --start "$wide_start" --end "$wide_end" "${@:2}"; } total() { m "$@" | yq -p=json -o=json '.results[0].values[0].value | tonumber'; } by() { m "$1" | yq -p=json -o=json '[.results[] | {"labels": (.metric.labels | map({"key": .key, "value": .value}) | sort_by(.key)), "value": (.values[0].value | tonumber)}] | sort_by(.labels | map(.value) | join("/"))'; } diff --git a/test/e2e-v2/cases/storage/expected/config-dump.yml b/test/e2e-v2/cases/storage/expected/config-dump.yml index 8cc778eb11c1..6170b7f589a2 100644 --- a/test/e2e-v2/cases/storage/expected/config-dump.yml +++ b/test/e2e-v2/cases/storage/expected/config-dump.yml @@ -43,11 +43,10 @@ "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.readWindow": "16", + "ai-agent-conversation.default.conversationListMaxLimit": "10000", "ai-agent-conversation.default.maxFileBytes": "15728640", - "ai-agent-conversation.default.maxListLimit": "10000", "ai-agent-conversation.default.maxResponseBytes": "104857600", - "ai-agent-conversation.default.roundReadWindow": "16", "ai-agent-conversation.default.viewRequestTimeout": "120", "ai-agent-conversation.provider": "default", "ai-pipeline.default.baselineServerAddr": "", diff --git a/test/e2e-v2/script/env b/test/e2e-v2/script/env index 65f64719ed1d..c3812d59a96e 100644 --- a/test/e2e-v2/script/env +++ b/test/e2e-v2/script/env @@ -25,11 +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=dd083ccb141d2bd2678003f1a31094b2a37b40c1 +SW_AI_SESSIONIZER_COMMIT=73733153a851006d33bfdde587770e619849090f SW_AGENT_PHP_COMMIT=de311c9cd084e21becade0742cd289bc0f43181d SW_PREDICTOR_COMMIT=54a0197654a3781a6f73ce35146c712af297c994 -SW_CTL_COMMIT=1b6837da6361f1d735ac9ef1ea8cfc245918ff35 +SW_CTL_COMMIT=30aaacd00e0df1f48db3fcd3e263149f84e00391 # Third-party image versions used by e2e infrastructure (not skywalking # components). Pinned here so the matrix is reproducible.