You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/26 13:41:04 UTC
(skywalking) 01/01: AI agent conversations: MCP endpoints, and adopt AI Sessionizer 130601c
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch ai-agent-mcp-endpoints
in repository https://gitbox.apache.org/repos/asf/skywalking.git
commit f6dbaa31718a027e7aaad8823b1eef983b5e19fa
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat Sep 26 21:40:53 2026 +0800
AI agent conversations: MCP endpoints, and adopt AI Sessionizer 130601c
Add otel-rules/ai-agent/mcp_endpoint.yaml: one endpoint per MCP server and
tool under the agent's service, with calls, calls by outcome and duration,
from the Sessionizer's agent.mcp.calls and agent.mcp.duration. The token
rules read agent.token.usage.
The conversation document follows AI Sessionizer 130601c, so the OAP
answers with the document asz conversation -json prints for the same files:
- execution records under tool_executions, and each tool step's executions;
- provider bodies taken from the join the round carries;
- a stream's origins, the relations and a step's edges in the order they
happened, by position inside one stream or workflow run and by time
across them;
- records and attributes read as the Sessionizer's Go code reads them, by
a scanner that follows Go's encoding/json, Go's RFC 3339 parser and Go's
order of strings.
The e2e adds an mcp-calls case and pins the Sessionizer commit.
---
docs/en/changes/changes.md | 3 +-
docs/en/setup/backend/ai-agent-conversation.md | 375 ++++---
.../agent/conversation/fold/ConversationFold.java | 7 +-
.../agent/conversation/format/ChangesRecord.java | 369 ++-----
.../agent/conversation/format/ExecutionRecord.java | 99 ++
.../ai/agent/conversation/format/FileNames.java | 81 +-
.../ai/agent/conversation/format/GoJson.java | 815 +++++++++++++++
.../ai/agent/conversation/format/GoStrings.java | 53 +
.../server/ai/agent/conversation/format/Ref.java | 5 +-
.../conversation/format/SessionFlowRound.java | 163 ++-
.../server/ai/agent/conversation/format/Times.java | 104 +-
.../agent/conversation/format/ToolCallRecord.java | 47 +
.../conversation/view/ConversationViewBuilder.java | 1076 +++++++-------------
.../server/ai/agent/conversation/view/Order.java | 152 +++
.../conversation/ConversationViewBuilderTest.java | 596 +++++++++--
.../ai/agent/conversation/ExecutionRecordTest.java | 102 ++
.../oap/server/ai/agent/conversation/Fixtures.java | 39 +-
.../ai/agent/conversation/GoDecodingTest.java | 177 ++++
.../ai/agent/conversation/SessionFormatsTest.java | 177 ++++
.../ai/agent/conversation/view/OrderTest.java | 78 ++
.../agent/conversation/view/RecordReadingTest.java | 110 ++
.../test/resources/fixtures/asz-view-example.json | 165 +--
.../test/resources/fixtures/asz-view-example.yaml | 133 +--
.../test/resources/fixtures/go-json-syntax.json | 371 +++++++
.../langchain-subagent/asz-view-example.json | 1064 +++++++++++++++++++
...vider_body-20260920T110158.000000000Z-000004.sd | 14 +
.../langchain-subagent/r000001-3fd311176e49.sf | 38 +
...transcript-20260920T110158.000000000Z-000001.sd | 17 +
...transcript-20260920T110158.000000000Z-000002.sd | 27 +
...transcript-20260920T110158.000000000Z-000003.sd | 3 +
.../asz-view-example.json | 922 +++++++++--------
.../execution-20260101T000000.000000000Z-000002.sd | 6 +
.../execution-20260101T000000.000000000Z-000004.sd | 3 +
.../meta-20260101T000000.000000000Z-000005.sd | 3 +
.../fixtures/mcp-calls/r000001-0465bcdcdb72.sf | 43 +
...transcript-20260101T000000.000000000Z-000001.sd | 20 +
...transcript-20260101T000000.000000000Z-000003.sd | 7 +
.../provider-bodies-errors/asz-view-example.json | 19 +-
...vider_body-20260101T000000.000000000Z-000002.sd | 6 +-
...001-0a33a0c2d269.sf => r000001-76f9606aacf3.sf} | 10 +-
.../fixtures/provider-bodies/asz-view-example.json | 475 ++++-----
.../fixtures/provider-bodies/asz-view-example.yaml | 363 +++----
...vider_body-20260101T000000.000000000Z-000004.sd | 16 +-
...001-ff8eaba03b03.sf => r000001-e292bd0f6de6.sf} | 20 +-
.../workspace-changes/asz-view-example.json | 59 +-
.../workspace-changes/asz-view-example.yaml | 47 +-
.../ai-agent/mcp_endpoint.data.yaml | 242 +++++
.../ai-agent/runtime-instance.data.yaml | 2 +-
.../ai-agent/runtime-service.data.yaml | 2 +-
.../otel-rules/ai-agent/mcp_endpoint.yaml | 42 +
.../otel-rules/ai-agent/runtime-instance.yaml | 16 +-
.../otel-rules/ai-agent/runtime-service.yaml | 24 +-
test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml | 4 +
.../cases/ai-agent/banyandb/docker-compose.yml | 20 +-
test/e2e-v2/cases/ai-agent/es/docker-compose.yml | 20 +-
.../ai-agent/expected/conversations-instance.yml | 2 +-
.../cases/ai-agent/expected/conversations.yml | 10 +
.../cases/ai-agent/expected/list-horizon.yml | 17 +
test/e2e-v2/cases/ai-agent/expected/mcp.yml | 110 ++
test/e2e-v2/cases/ai-agent/expected/metrics.yml | 36 +-
test/e2e-v2/cases/ai-agent/expected/views.yml | 10 +-
test/e2e-v2/cases/ai-agent/mcp-calls.yaml | 76 ++
.../e2e-v2/cases/ai-agent/mysql/docker-compose.yml | 20 +-
.../cases/ai-agent/postgres/docker-compose.yml | 20 +-
test/e2e-v2/cases/ai-agent/verify.sh | 27 +
test/e2e-v2/script/env | 2 +-
66 files changed, 6810 insertions(+), 2371 deletions(-)
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index 063b1127c0..d72ab81ba6 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- [...]
* Fix a second `CounterWindow` key collision in the v2 MAL engine, this time ACROSS rules. `rate()` / `increase()` / `irate()` resolve their lower bound from a process-wide window keyed on the counter's own name plus its post-`.sum(...)` label set, with nothing identifying the rule doing the evaluation. Two rules that read one wire family, tell their streams apart with `tagEqual(...)`, and then `.sum(...)` away the label they filtered on therefore collapse onto one window slot and differ [...]
* Fix `meter_rabbitmq_node_outgoing_messages_total` double-counting one of its terms. The rule summed six delivery-rate terms but `rabbitmq_global_messages_delivered_get_auto_ack_total` appeared twice, so auto-ack `basic.get` deliveries were counted once more than the other four delivery paths and the reported outgoing rate ran high whenever polling consumers were in use. The duplicate term is removed, leaving the five distinct families (redelivered, consume auto/manual ack, get auto/man [...]
-* Add AI agent conversations landed by the AI Sessionizer: the `AI_AGENT` layer, the bundled `lal/ai-agent.yaml` rule with the `ConversationFile` output builder that verifies and stores Session Data and Session Flow files, the `ai_agent_session_data` and `ai_agent_session_flow` models in a new BanyanDB group `recordsAIAgent`, the `ai-agent-conversation` module that folds a conversation into one `asz.view` document, the `listConversations` GraphQL query (with optional `conversation` and ` [...]
+* 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 ` [...]
* 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, an [...]
* 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 [...]
* 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, [...]
@@ -28,6 +28,7 @@
* Bring the TraceQL module closer to the Tempo API. Every datasource lists only the spans that matched a search in each span set, capped at `spss`, fills `serviceStats`, filters `span.http.status_code`, honours `scope` and the per-scope `limit` on the tag endpoints, whose `intrinsic` scope lists what each datasource can filter, accepts Tempo's unscoped `.key` tag names on the tag-value endpoints, and answers a tag-name or tag-value lookup filtered by `q` from a sample of the newest 50 ma [...]
* Refuse unsupported TraceQL with `400` instead of dropping the predicate and answering with unfiltered traces: syntax errors (`||` inside a spanset, regex, an attribute without a scope or leading dot), `!=` and other unsupported operators, negation, attribute-existence checks, multiple spansets, unknown intrinsics and values, and conditions a datasource cannot filter (`kind` on Zipkin and SkyWalking, `resource.instance` on Zipkin, `resource.remote.service` and `status = unset` on SkyWal [...]
* Support the OpenTelemetry Collector `hostmetrics` receiver as an alternative source for Linux and Windows host monitoring. `vm.yaml` and `windows.yaml` map it to the same `meter_vm_*` / `meter_win_*` metrics as node-exporter and windows_exporter, whose metrics keep their meaning, and add metrics only hostmetrics provides: CPU core count and normalized CPU usage, plus CPU load, file-system usage, system handle count and pagefile usage on Windows. node-exporter metrics without a matching [...]
+* AI agent conversations adopt AI Sessionizer `130601c`. Calls to MCP servers: the `execution` kind the Sessionizer's Claude Code plugin writes, `streams/<stream>/execution-<stamp>-<seq>.sd`, is stored like any other file; the document lists every `execution/1` record under `tool_executions`, joined to its step by tool-use id and kept once by its own id, a tool step names its records under `executions`, and a call to an MCP server carries `mcp_server` and `mcp_tool` in its `attrs`. The n [...]
#### UI
* Add a Virtual GenAI evaluation-record page and evaluation-score chart in Horizon UI, so operators can inspect evaluation result, level, reason, judge model, timestamp, trace linkage, and the `gen_ai_model_evaluation_score_ppm` trend for evaluated records.
diff --git a/docs/en/setup/backend/ai-agent-conversation.md b/docs/en/setup/backend/ai-agent-conversation.md
index 5de27bc5b8..8ce56f7bdd 100644
--- a/docs/en/setup/backend/ai-agent-conversation.md
+++ b/docs/en/setup/backend/ai-agent-conversation.md
@@ -32,15 +32,16 @@ documented by the Sessionizer under
the wire attributes under
[Export over OpenTelemetry](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/setup/export-otlp/).
-The OAP routes these records like every other OTLP log: by layer, to the bundled LAL rule
-`lal/ai-agent.yaml`. The rule's output type, `ConversationFile`, checks the body's sha256 against
-`asz.file.digest` and its line count against `asz.lines`, and stores the file in the table its format names. A
-file that fails either check is dropped and counted in the `ai_agent_conversation_files_rejected` self-observability
-metric with the reason as a label, and so is a file larger than `maxFileBytes`, under the reason `size`: one
-file over the storage's message limit fails the write it travels in, and every record behind it in that write
-with it, so the limit is applied where one file is one record; a stored file is a verified file. A round's title and counts are read only
-when the record carries them: they came with a later round header, and a round from before them lands and lists
-with zero counts. The service and its instance appear on the service list under the `AI_AGENT` layer as for any
+The OAP routes these records like every other OTLP log: by layer, to the bundled LAL rule `lal/ai-agent.yaml`. The
+rule's output type, `ConversationFile`, checks the body's sha256 against `asz.file.digest` and its line count against
+`asz.lines`, and stores the file in the table its format names. A file that fails either check is dropped and counted
+in the `ai_agent_conversation_files_rejected` self-observability metric with the reason as a label, and so is a file
+larger than `maxFileBytes`, under the reason `size`: one file over the storage's message limit fails the write it
+travels in, and every record behind it in that write with it, so the limit is applied where one file is one record; a
+stored file is a verified file. A round's title and counts are read only when the record carries them: they came with
+a later round header, and a round from before them lands and lists with zero talks, steps, streams, segments and
+unresolved references, and no value for the counts added later: changes, lines added and removed, model calls,
+subagents and Bash runs. The service and its instance appear on the service list under the `AI_AGENT` layer as for any
other log sender.
Nothing is folded or decoded at ingest, so an OAP cluster needs no shared state for this feature.
@@ -54,18 +55,21 @@ Two record models, both super datasets:
| `ai_agent_session_data` | Session Data file | `service_id`, `service_instance_id`, `session`, indexed `seq` | `digest`, `body` |
| `ai_agent_session_flow` | Session Flow round | `service_id`, `service_instance_id`, indexed `conversation`, `round` | `session_from_time`, `title`, `talks`, `steps`, `streams`, `segments`, `unresolved`, `changes`, `lines_added`, `lines_removed`, `llm_calls`, `subagents`, `bash_runs`, `digest`, `body` |
-A Session Data row carries nothing but its keys and the file: the file's kind, stream or run, time range and name
-are on its first line and are read from there. A round's stored-only columns exist for the list page, which reads
-them without opening a body; its `round` number is queryable so a long chain is read window by window. The row's
-timestamp is the file's latest record time, or the conversation's last activity for a round, so a conversation's
-files are found by its own time range. A row belongs to its sender: its id is the service, the instance and the
-file's digest, so the same file pushed again by the same sender lands on the same row, and pushed by another
+A Session Data row carries nothing but its keys and the file: the file's kind, its stream or run, its collected time
+and its seq are on its first line, its name is made from them, and its time range is read from its records. A round's
+stored-only columns exist for the list page, which reads them without opening a body; its `round` number is queryable
+so a long chain is read window by window. The row's timestamp is the file's latest record time, or the conversation's
+last activity for a round, so a conversation's files are found by its own time range. A file whose records carry no
+time, such as a manifest, a script or a child's meta file, takes the session's latest record time as known when it was
+sent. When no record of the session has a time yet, it takes its own collected time. A round from before the last
+activity was carried takes the time it was sent. A row belongs to its sender: its id is the service, the instance and
+the file's digest, so the same file pushed again by the same sender lands on the same row, and pushed by another
service or sender makes another.
-- **BanyanDB**: both models live in their own group, `recordsAIAgent`, configured like the log group with hot,
- warm and cold stages under `SW_STORAGE_BANYANDB_AI_AGENT_*`, 30 days hot by default. Both tables expire
- together, because a round whose files are gone is a broken chain. See the
- [BanyanDB storage document](storages/banyandb.md).
+- **BanyanDB**: both models live in their own group, `recordsAIAgent`, configured like the log group with hot, warm
+ and cold stages under `SW_STORAGE_BANYANDB_AI_AGENT_*`, 30 days hot by default. Both models follow the one
+ retention, each row by its own time, so the oldest files of a long conversation can expire before the newest round
+ that read them; the document then names them as missing. See the [BanyanDB storage document](storages/banyandb.md).
- **Elasticsearch**: two super-dataset index families, `sw_ai_agent_session_data-*` and
`sw_ai_agent_session_flow-*`, sharded by `superDatasetIndexShardsFactor`; retention is the single
`recordDataTTL`. The columns the reads sort and range on, `seq`, `round` and `timestamp`, keep doc values.
@@ -73,6 +77,37 @@ service or sender makes another.
MySQL, since a body near `maxFileBytes` outgrows `MEDIUMTEXT` once encoded, `MEDIUMTEXT` on H2, a CLOB there,
and `TEXT` on PostgreSQL; retention is `recordDataTTL`.
+## Session Data files and how they connect
+
+A conversation is two kinds of file. Session Data files are the records as the Sessionizer collected them. Session
+Flow rounds are the structure it assembled from them. Every Session Data file of a session has a `seq`, one counter
+across every stream and every kind, so a session and a seq name exactly one file. A round names the range of files it
+read, `from_seq` to `through_seq`, and its input digest chains the digest of every file in that range, so the OAP can
+tell when a file is missing or changed. A node of a round points at the record it stands on by `seq`, `row` and
+`block`: the file; the record in it, counted from 1 without the header, so row 1 is the file's second line; and, when
+the node stands on one part of the record, the part, counted from 0. The document takes a step's text and time from
+that record.
+
+A file's kind is on its first line, the header, and so are its stream or run and its collected time `at`. The OAP
+names the file from the header as the Sessionizer does: a prefix for its kind, the stamp from `at`, and the seq, such
+as `transcript-20260101T000000.000000000Z-000001.sd`. These are the kinds:
+
+| Kind | Path under the session | Holds | Connects to the conversation |
+|---------------------|-------------------------------------------|-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| `transcript` | `streams/<stream>/transcript-…` | one execution stream: the main one, a child agent's, or an auxiliary one, the model calls made inside a tool | the round's nodes point at its records; almost every step takes its text and time from here |
+| `agent_meta` | `streams/<stream>/meta-…` | what the runtime recorded about a child agent when it began it | the child's stream node takes its label from it, and a child of a child is tied to the agent that started it by it |
+| `journal` | `runs/<run>/journal-…` | a workflow run's journal | ties the run's children to the call that launched them, and gives the value each child returned; a stream without a label is named from it |
+| `workflow_manifest` | `runs/<run>/manifest-…` | a workflow run's manifest | the call that launched the run, found by the run's batch, points at it and takes the run's name as `launched` in its attributes |
+| `workflow_script` | `runs/<run>/script-…` | the program a workflow ran | nothing; it is kept because it is part of the session |
+| `changes` | `streams/<stream>/changes-…` | the files an observation saw change, one line per observation | no node of a round; the document joins each record to its step by tool-use id, see [Workspace changes](#workspace-changes) |
+| `execution` | `streams/<stream>/execution-…` | what the plugin saw of each call to an MCP server, one per call | no node of a round; the document joins each record to its step by tool-use id, see [Tool executions](#tool-executions) |
+| `provider_body` | `provider_body/provider_body-…` | the request and response bodies of model calls | the round carries the join: an `llm.call` names its bodies, and a reader rebuilds a body from the files, see [Provider bodies](#provider-bodies) |
+
+Every kind is verified and stored the same way, nothing in it is decoded at ingest, and the document lists each file
+under `files` with its kind. A kind the OAP does not know is stored and listed too, under its stream, or its run when
+it names no stream. The kinds and their records are defined by the Sessionizer under
+[Session Data](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/session-data/).
+
## Query
The list is a GraphQL query in `ai-agent-conversation.graphqls`. The conversation itself and its stored files are
@@ -84,8 +119,8 @@ HTTP routes on the same server, because a document is as large as the conversati
and `bashRuns`, each absent rather than zero when the round did not carry it. The rounds are read newest first, at
most `limit` (default 1000), then folded to one row per conversation. An optional `conversation` narrows the read to
one conversation by id, and an optional `title` keeps only the rows whose title contains the text,
- case-insensitively — matched after folding, on the newest round's title, so it never widens the rounds read.
- On BanyanDB, `duration.coldStage: true` selects the cold stage; otherwise the query uses the default hot/warm stages.
+ 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.
### The conversation view route
@@ -93,21 +128,21 @@ HTTP routes on the same server, because a document is as large as the conversati
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
-Sessionizer defines under
-[The asz.view document](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/asz-view/)
-and serves from its own viewer; the OAP's document equals it, key for key, for the same files. `v1` in the path
-is the document version. The OAP reads the conversation's rounds over the whole retention window, then the
-files of each session the head round names over the time range the head round carries, checks the chain, folds
-the rounds, resolves every reference into the landed records, and renders the document. Verification is
-content, not an error: a missing round or file, or a failed digest, is written into the document's
-`summary.state` and `summary.problems`, and the rest of the document holds whatever could still be folded. The
-fold shows as much as landed: a round that is missing, that does not read, or that the fold refuses is skipped,
-the chain resumes at the next stored round, and the absent rounds are named once as a range, as are the files a
-round names that did not land. The round the chain resumes at is listed unverified, because nothing links it to
-what is absent; the rounds after it verify against it; `head` names the last round folded. This goes further
-than the Sessionizer's own viewer, whose fold stops before the first gap. The document is built on every call
-and nothing is cached.
+It answers with the whole conversation, once, as one `asz.view` version 1.0 document, the document the Sessionizer
+defines under [The asz.view
+document](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/asz-view/) and serves from its
+own viewer; the OAP's document equals it, key for key, for the same files. `v1` in the path is the document version.
+The OAP reads the conversation's rounds over the whole retention window, then the files of each session the head round
+names over the time range the head round carries, checks the chain, folds the rounds, resolves every reference into
+the landed records, and renders the document. Verification is content, not an error: a missing round or file, or a
+failed digest, is written into the document's `summary.state` and `summary.problems`, and the rest of the document
+holds whatever could still be folded. The fold shows as much as landed: a round that is missing, that does not read,
+or that the fold refuses is skipped, the chain resumes at the next stored round, and the absent rounds are named once
+as a range, as are the files a round names that did not land. After a round that is missing or does not read, the
+round the chain resumes at is listed unverified, because nothing links it to what is absent; a round the fold refused
+still reads, so the round after it verifies against it. The rounds after the resumed one verify against it; `head`
+names the last round folded. This goes further than the Sessionizer's own viewer, whose fold stops before the first
+gap. The document is built on every call and nothing is cached.
| Parameter or header | Meaning |
|---|---|
@@ -117,11 +152,12 @@ and nothing is cached.
| `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 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": "..."}` |
+| 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; 503 when `viewRequestTimeout` runs out before the document starts, and a response already started is cut short instead. An error is `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)): `{"type": "about:blank", "title": "Not Found", "status": 404, "d [...]
The route is on the core HTTP server beside `/graphql`, so it has the same host, port, context path and TLS
-settings, and serves HTTP/1.1 and HTTP/2 alike. The body is streamed: it is written to the response as it is
-rendered, never held whole in memory, and a slow client holds back the render. The route runs under its own
+settings, and serves HTTP/1.1 and HTTP/2 alike. The document is built whole from the folded rounds and the files it
+reads, so its memory grows with the conversation; its text is then streamed, written to the response as it is
+rendered, and a slow client holds back the render. The route runs under its own
timeout, `viewRequestTimeout`, in place of the server's default of ten seconds, because the floor for a large
conversation is seconds of storage reads plus seconds of fold and render.
@@ -130,12 +166,12 @@ 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]
+``` 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
+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
@@ -145,10 +181,10 @@ session under the named sender, so the session is the caller's to choose, within
|---|---|
| `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 |
+| `seq` | required, one to 32 times, a file's landed seq. The Sessionizer cuts a file at 2 MiB by default, so a response is usually no more than about 64 MiB; a file holding one larger record is larger, up to `maxFileBytes`. A reader wanting more files 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. |
+| 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; 503 when the route's timeout runs out 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:
@@ -160,36 +196,41 @@ For each stored file, the body holds a naming line, then the file:
```
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.
+`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.
+never held whole. The files are read over the time range of the conversation's newest intact round among its newest
+16, 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; from the start of time up to the head round's own time when none of those 16 is intact. A
+file stamped outside that range is left out.
## Workspace changes
-The Sessionizer's Claude Code plugin records, beside each tool call, which files the call changed and how, as a
-git-style diff. Those records reach the OAP two ways, and the document shows both:
+The Sessionizer's Claude Code plugin records which files changed while a tool call ran, within the directories it
+watches, and how, as a git-style diff. A record says how much it saw: a scan cut short has `coverage: partial` and may
+list its `gaps`, and a file whose hunks could not be kept, such as one over the size cap, carries its hashes without
+them. Those records reach the OAP two ways, and the document shows both:
- **A `changes` file**, a Session Data file of kind `changes` under the stream the tool ran on,
- `<session>/streams/<stream>/changes-<stamp>-<seq>.sd`, one line per observed call. It lands, is verified and is
- stored like any other Session Data file: it takes a seq of its own between the transcript files that landed around
- it, a round's window covers it and its input digest chains it, and it is listed under `files` with its kind. Nothing
- about it is decoded at ingest.
-- **The runtime's own patch.** Claude Code records a patch for its own `Edit`, `Write` and `NotebookEdit` calls, and
- the Sessionizer lands it as a second `data` part on the call's result record in the transcript, beside the raw
- result, which stays byte for byte.
+ `<session>/streams/<stream>/changes-<stamp>-<seq>.sd`, one line per observation: a call watched in several
+ directories has one record for each, and a change no call's window covered has an unattributed one of its own, which
+ names no tool. It lands, is verified and is stored like any other Session Data file: it takes a seq of its own
+ between the transcript files that landed around it, a round's window covers it and its input digest chains it, and
+ it is listed under `files` with its kind. Nothing about it is decoded at ingest.
+- **The runtime's own patch.** Claude Code records what its own `Edit`, `Write` and `NotebookEdit` calls changed: a
+ patch for the first two, and the notebook before and after for the third, whose hunks the Sessionizer works out. The
+ Sessionizer lands the result as a second `data` part on the call's result record in the transcript, beside the raw
+ result, which is kept as the runtime wrote it, apart from the white space between its JSON tokens.
Each record is a `changes/1` document: the tool-use id it belongs to, who captured it, `claude-code` for a patch the
runtime recorded or `asz-plugin` for one the plugin observed, the basis of the observation, the windows scanned, and
@@ -197,26 +238,59 @@ one entry per file with its operation, the hashes on both sides and the hunks. T
by the tool-use id, which the record names and the step's call part carries; nothing is matched by time. In the
`asz.view` document:
-- `workspace_changes` lists every record of the session in time order, each with the `step` it belongs to and the
- `ref` it was read from, then the record's own fields as `changes/1` lists them;
+- `workspace_changes` lists, in time order, every record of the session's `changes` files and the runtime's own patch
+ of every tool step the fold holds, each with the `step` it belongs to and the `ref` it was read from, then the
+ record's own fields as `changes/1` lists them; a patch on a result record no step reads is not listed;
- `summary.changes` counts them;
- a tool step lists the ids of its records under `changes`.
-The runtime's record and the plugin's record of one call share its id and are both kept, the runtime's first. A record
-with `basis: skipped_read_only` carries no changes and means the call was not observed, never that nothing changed. A
-session folds to the same nodes with and without its `changes` files: they are evidence beside a stream, not steps of
-it. The record and the entry are defined by the Sessionizer under
-[The asz.view document](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/asz-view/), and
-the plugin under
-[The Claude Code plugin](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/setup/claude-code-plugin/).
+A record is kept once by who captured it and its id. The runtime's record and the plugin's record of one call name the
+same tool-use id and are both kept, and when their times are equal the runtime's comes first. A record that joins no
+step, such as an unattributed one, is kept with an empty `step`. A record with `basis: skipped_read_only` carries no
+changes and means the call was not observed, never that nothing changed. A session folds to the same nodes with and
+without its `changes` files: they are evidence beside a stream, not steps of it. The record and the entry are defined
+by the Sessionizer under [The asz.view
+document](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/asz-view/), and the plugin
+under [The Claude Code
+plugin](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/setup/claude-code-plugin/).
+
+## Tool executions
+
+The Sessionizer's Claude Code plugin also records what each call to an MCP server did: which server ran it, where the
+server's configuration came from, how the call ended and how long the runtime waited for it.
+
+- **An `execution` file**, a Session Data file of kind `execution` under the stream the call ran on,
+ `<session>/streams/<stream>/execution-<stamp>-<seq>.sd`, one line per observed call. It lands, is verified and is
+ stored like any other Session Data file, and nothing about it is decoded at ingest.
+
+Each record is an `execution/1` document: its own id, the tool-use id of the call it observed, who observed it and
+where, the server, and the outcome, `returned`, `failed` or `interrupted`. When the hook reported them, it also has
+the milliseconds the runtime measured around the call, the size and the SHA-256 of the arguments, and, for a call that
+returned, the size and the SHA-256 of the answer; never their text. In the `asz.view` document:
+
+- `tool_executions` lists every record of the session in time order, each with the `step` it belongs to and the
+ `ref` it was read from, then the record's own fields as `execution/1` lists them;
+- a tool step lists the ids of its records under `executions`;
+- a call to an MCP server carries `mcp_server` and `mcp_tool` in its `attrs` when its name, `mcp__<server>__<tool>`,
+ splits into exactly one server and one tool. The attributes come with the round.
+
+The join is the tool-use id, which the record names and the step's call part carries; nothing is matched by time. One
+call can have several records, and a record is kept once by its own id, so a line landed twice is one record. A record
+whose call is not a step of the document is kept with no step. A session folds to the same nodes with and without its
+`execution` files. The record is defined by the Sessionizer under [The asz.view
+document](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/asz-view/), and 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.
+The Sessionizer can also land what each model call was sent and what came back. For Claude Code these are the request
+and response bodies it exchanged with its model provider: a request carries what no transcript records, the system
+prompt, the tool definitions and the reminders the runtime inserted. For LangChain and LangGraph they are each model
+run's inputs and outputs, as the LangSmith client sent them. 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 body it cannot cut safely is kept whole, as an
+`unknown` part that says why.
- **A `provider_body` file**, a Session Data file of kind `provider_body`, one directory for the session,
`<session>/provider_body/provider_body-<stamp>-<seq>.sd`, one record per body. It lands, is verified and is stored
@@ -227,20 +301,26 @@ 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/).
+- `summary.provider_bodies` counts the session's bodies as of the folded chain, joined or not, and
+ `summary.captured_prompts` the calls whose request is listed.
+
+The OAP does not make the join. The Sessionizer makes it when it parses a round, and the round carries it: an
+`llm.call` node names its bodies in its `provider_bodies` attribute, and the `session` node states
+`provider_bodies_landed`, how many bodies the session holds. The OAP reads both from the fold and opens no body to do
+it. A round whose `provider_bodies` attribute is malformed, a value other than null that is not a list of bodies, a
+role that is not `request` or `response`, a zero seq or row, or a reference past the round's own range, is refused
+like a round with any other bad reference. Whether the record a body names exists and rebuilds is not checked here: a
+missing file is the chain's problem, and a body is only rebuilt by a reader. The document lists the bodies on the
+step, not in its `attrs`. A missing `provider_body` file does not change what a call names: the file is the chain's
+problem, named under `summary.problems`.
+
+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. The record, the manifest and the rules
+of the join are defined by the Sessionizer under [Session
+Data](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/session-data/), [Session
+Flow](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/session-flow/) and [The asz.view
+document](https://skywalking.apache.org/docs/skywalking-ai-sessionizer/next/en/formats/asz-view/).
## Configuration
@@ -260,15 +340,16 @@ ai-agent-conversation:
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| `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. |
-| `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 [...]
+| `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 defa [...]
| `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 a [...]
-| `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. |
+| `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 by default, and a file can still be larger: for a Claude Code transcript the budget is on the source bytes read, and when one source line is over it the file takes that line and every complete line after it; a provider body over it lands alone; a test lowers this to [...]
### 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` 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.
+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
@@ -277,7 +358,9 @@ dropped for want of a record worker; drop `ai-agent` from `SW_LOG_LAL_FILES` as
## Limits on the path
- The OAP's OTLP/HTTP endpoint accepts requests of up to 10 MiB, the HTTP server's default. The Sessionizer's
- request budget defaults to 8 MiB for that reason; a single file is cut at 2 MiB, so it always fits.
+ request budget defaults to 8 MiB for that reason, and a file is cut at 2 MiB by default. A file holding one record
+ larger than that, and a round covering only such a file, are sent whole, so the receiver's limit and `maxFileBytes`
+ must allow the largest one.
- 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
@@ -297,17 +380,28 @@ dropped for want of a record worker; drop `ai-agent` from `SW_LOG_LAL_FILES` as
## Metrics of the agent runtime
-Beside the files, the layer takes the agent runtime's own OpenTelemetry metrics, from either of two senders:
-
-- **The Sessionizer**, with `metrics: true` on its `claude-code-local` adapter: `asz collect` derives Claude Code's
- token metric from the landed transcripts and sends it beside the files over the same connection, as `asz push` does
- for a storage root that is already there. It is a reconstructed subset of what the runtime's exporter sends: the
- same name, unit and kind, the four token types, `main` and `subagent` as the query source, the model and the
- session. Cost, active time and the user, terminal and attribution labels are not in a transcript, and are never
- estimated.
-- **Claude Code's own exporter**, pointed at the OAP directly, `OTEL_EXPORTER_OTLP_ENDPOINT` on 11800 over gRPC or on
- 12800 over HTTP, or through the Sessionizer's `claude-code-otlp` adapter, which lands each request as received: the
- full family with every label, and the exporter's other metrics with it.
+Beside the files, the Sessionizer's collection pipeline derives metrics from the files it lands and sends them over
+the same connection; `asz push` sends the metrics a storage root already holds, and derives none. It derives them when
+its `metrics.enabled` is on and sends them when its `export.otlp.metrics` is on, both by default. Its first derivation
+over a root reaches back only `metrics.lookback`, 72 hours by default, so a conversation older than that has its files
+in the OAP but not its metrics; `0` or `none` derives all of it. They are one family, named for any agent:
+
+- `agent.token.usage`, the tokens of each finished model call of a Claude Code transcript: the four token types,
+ `main` and `subagent` as the query source, the model and the session. For those calls it counts what Claude Code's
+ own exporter calls `claude_code.token.usage`. The exporter also counts auxiliary calls, such as the one that names a
+ session, which never reach a transcript, so their tokens are not here. Cost, active time and the user, terminal and
+ attribution labels are not in a transcript either, and are never estimated.
+- `agent.mcp.calls` and `agent.mcp.duration`, one per call to an MCP server the Claude Code plugin recorded, and the
+ milliseconds the runtime measured around it: the server, the tool, the source of the server's configuration, the
+ outcome and the query source. A call whose record has no duration counts as a call and adds no time; on Claude Code
+ 2.1.282 every record had one.
+
+Claude Code's own exporter can also be pointed at the OAP directly, `OTEL_EXPORTER_OTLP_ENDPOINT` on 11800 over gRPC
+or on 12800 over HTTP. The rules read its other metrics, cost, active time, sessions, lines of code, commits, pull
+requests and edit decisions, and not its token metric: the tokens come from the Sessionizer, so a call is never
+counted twice when both send. The exporter names its service `claude-code` unless told otherwise, and the rules group
+by service, so set its `service.name` in `OTEL_RESOURCE_ATTRIBUTES` to the Sessionizer's service, `Claude Code` by
+default, to see both on one service.
Whichever sends, the resource must carry `service.layer=AI_AGENT`, which is what the rules filter on, and a
`service.instance.id` naming the sender; the Sessionizer sets both, `AI_AGENT` and `user@host` by default, and the
@@ -316,30 +410,45 @@ exporter takes them from `OTEL_RESOURCE_ATTRIBUTES`. The rule set is `otel-rules
`Claude Code` by default, and `runtime-instance.yaml` the same per sender, under the prefixes `meter_ai_agent_` and
`meter_ai_agent_instance_`.
-| Metric | Labels | Value | From |
-|----------------------------|-----------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|--------------|
-| `tokens` | | tokens per minute, every type | both |
-| `tokens_by_type` | `type`: `input`, `output`, `cacheRead`, `cacheCreation` | tokens per minute | both |
-| `tokens_by_model` | `model`, `type` | tokens per minute | both |
-| `tokens_by_source` | `query_source`: `main`, `subagent`, and from the exporter also `auxiliary`; `type` | tokens per minute | both |
-| `cache_read_share` | | percent of what the model read that came from cache: `cacheRead` over every type but `output` | both |
-| `cost_by_model` | `model` | micro-dollars (USD × 1,000,000) per minute | the exporter |
-| `active_time` | `type`: `user`, `cli` | milliseconds per minute | the exporter |
-| `sessions` | | sessions started per minute | the exporter |
-| `lines_of_code` | `type`: `added`, `removed` | lines per minute | the exporter |
-| `commits`, `pull_requests` | | per minute | the exporter |
-| `edit_decisions` | `decision`: `accept`, `reject` | permission decisions on the editing tools per minute | the exporter |
-
-Three things to know when reading them:
+| Metric | Labels | Value | From |
+|----------------------------|-----------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|------------------------|
+| `tokens` | | tokens per minute, every type | the Sessionizer |
+| `tokens_by_type` | `type`: `input`, `output`, `cacheRead`, `cacheCreation` | tokens per minute | the Sessionizer |
+| `tokens_by_model` | `model`, `type` | tokens per minute | the Sessionizer |
+| `tokens_by_source` | `query_source`: `main`, `subagent`; `type` | tokens per minute | the Sessionizer |
+| `cache_read_share` | | percent of what the model read that came from cache: `cacheRead` over every type but `output` | the Sessionizer |
+| `cost_by_model` | `model` | micro-dollars (USD × 1,000,000) per minute | Claude Code's exporter |
+| `active_time` | `type`: `user`, `cli` | milliseconds per minute | Claude Code's exporter |
+| `sessions` | | sessions started per minute | Claude Code's exporter |
+| `lines_of_code` | `type`: `added`, `removed` | lines per minute | Claude Code's exporter |
+| `commits`, `pull_requests` | | per minute | Claude Code's exporter |
+| `edit_decisions` | `decision`: `accept`, `reject` | permission decisions on the editing tools per minute | Claude Code's exporter |
+
+`mcp_endpoint.yaml` gives the MCP metrics per endpoint, under the prefix `meter_ai_agent_mcp_`. An endpoint is one MCP
+target the agent called, named `<server>/<tool>` under the agent's service, such as `status/lookup`. When the
+runtime's name for a call does not split into exactly one server and one tool, the tool is that whole name.
+
+| Metric | Labels | Value |
+|--------------------|-----------------------------------------------|--------------------------------------------------------------------------------------------|
+| `calls` | | calls per minute |
+| `calls_by_outcome` | `outcome`: `returned`, `failed`, `interrupted` | calls per minute |
+| `duration` | | milliseconds per minute, summed over the calls; divided by `calls`, the mean when every call had a duration, as every one did on Claude Code 2.1.282 |
+
+When reading them:
- Every point is a delta, the tokens of the minute a call ended and not of the minute it ran, so a long call's tokens
- land in one minute. The receiver keeps a delta point as its value at its time, and a request that carries a series
- of minutes, as the Sessionizer's does, is analysed a minute at a time, see the
- [OpenTelemetry receiver](opentelemetry-receiver.md). The rules sum a minute's points over every session and sender
- and downsample by `SUM`, so an hour is the total over its minutes. `session.id` is summed away on purpose: a series
- per session is the cardinality of a busy team, and the conversation page knows a session's tokens from its own
- records.
+ land in one minute; an MCP call counts in the minute it was observed. The Sessionizer stamps a point with the end of
+ that minute, and the receiver keeps a delta point as its value at its time, so a call's tokens show under the next
+ minute; a point the Sessionizer places after a later one of its series, to keep a series' windows apart, shows later
+ still. A request that carries a series of minutes, as the Sessionizer's does, is analysed a minute at a time, see
+ the [OpenTelemetry receiver](opentelemetry-receiver.md). The rules sum a minute's points over every session and
+ sender and downsample by `SUM`, so an hour is the total over its minutes. `cache_read_share` is the exception: it is
+ a percent computed for each request's points in a minute, and a minute or an hour holds the average of those
+ percents, not the share of all the tokens in it. `session.id` is summed away on purpose: a series per session is the
+ cardinality of a busy team, and the conversation page knows a session's tokens from its own records.
- A metric value is a whole number, so a fraction is scaled first: cost to micro-dollars, active time to milliseconds,
the cache share to percent.
+- An MCP call's duration is the runtime's own time around the call. It includes any wait before the call started, and
+ it is not the server's own time.
- Cache reads dominate. On one five-day conversation they were 98% of all tokens, so a chart that stacks the four
types shows a flat line for the other three unless it is split.
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/fold/ConversationFold.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/fold/ConversationFold.java
index 8fcd6e44da..35e0d9fdea 100644
--- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/fold/ConversationFold.java
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/fold/ConversationFold.java
@@ -27,6 +27,7 @@ import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import lombok.Getter;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.GoStrings;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.Ref;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
@@ -227,7 +228,7 @@ public final class ConversationFold {
out.add(u);
}
}
- out.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId));
+ out.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId, GoStrings.ORDER));
return out;
}
@@ -255,14 +256,14 @@ public final class ConversationFold {
return Long.compare(ap.getRow(), bp.getRow());
}
if (ap.getBlock() != null && bp.getBlock() != null && !ap.getBlock().equals(bp.getBlock())) {
- return Integer.compare(ap.getBlock(), bp.getBlock());
+ return Long.compare(ap.getBlock(), bp.getBlock());
}
} else if (ap != null) {
return -1;
} else if (bp != null) {
return 1;
}
- return a.getId().compareTo(b.getId());
+ return GoStrings.compare(a.getId(), b.getId());
}
private void index() {
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ChangesRecord.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ChangesRecord.java
index e2173513f1..74b43f2df5 100644
--- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ChangesRecord.java
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ChangesRecord.java
@@ -18,117 +18,109 @@
package org.apache.skywalking.oap.server.ai.agent.conversation.format;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.google.gson.JsonPrimitive;
-import java.util.ArrayList;
import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
import java.util.Map;
-import java.util.regex.Pattern;
import javax.annotation.Nullable;
import lombok.Getter;
-import org.apache.skywalking.oap.server.library.util.StringUtil;
+
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.BOOL;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.INT;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.INT_POINTER;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.STRING;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.STRINGS;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.STRUCT;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.STRUCTS;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.STRUCT_POINTER;
/**
* One workspace change record, <code>changes/1</code>: which files one tool call changed, as a git-style change
- * log. It is read from a <code>data</code> part and rendered with its keys in the order the Sessionizer's
- * <code>changes.Record</code> lists them, so the document carries the record as the Sessionizer prints it and not
- * as its producer spelled it.
- *
- * <p>Decoding follows Go's <code>json.Unmarshal</code> into that struct: a key the struct does not have is dropped,
- * a null leaves the field empty, and a value of the wrong type, a string where a number goes or a fraction where an
- * integer goes, makes the part not a change record at all. Rendering follows its <code>json.Marshal</code>: a
- * string, list or nested object marked <code>omitempty</code> there is absent here when empty, a pointer to a
- * number is null when unset, and a list without the mark is null when it was never given.
+ * log. It is read from a <code>data</code> part, decoded and rendered as {@link GoJson} describes, so the document
+ * carries the record as the Sessionizer prints it and not as its producer spelled it.
*/
-public final class ChangesRecord {
+public final class ChangesRecord implements ToolCallRecord {
public static final String SCHEMA = "changes/1";
/** The runtime recorded the patch itself, on its own editing tool. */
public static final String CAPTURED_BY_CLAUDE_CODE = "claude-code";
- private static final Pattern INTEGER_LITERAL = Pattern.compile("-?\\d+");
+ /** The Sessionizer's <code>changes.Record</code>, field for field and tag for tag. */
+ private static final GoJson.Struct INTERVAL = new GoJson.Struct()
+ .field("from", STRING)
+ .field("to", STRING);
+ private static final GoJson.Struct ENDPOINT = new GoJson.Struct()
+ .field("present", BOOL)
+ .field("bytes", INT_POINTER)
+ .field("sha256,omitempty", STRING)
+ .field("no_newline_at_end,omitempty", BOOL);
+ private static final GoJson.Struct HUNK = new GoJson.Struct()
+ .field("old_start", INT)
+ .field("old_lines", INT)
+ .field("new_start", INT)
+ .field("new_lines", INT)
+ .field("lines", STRINGS);
+ private static final GoJson.Struct FILE_CHANGE = new GoJson.Struct()
+ .field("path", STRING)
+ .field("operation", STRING)
+ .field("before", STRUCT, ENDPOINT)
+ .field("after", STRUCT, ENDPOINT)
+ .field("diff", STRING)
+ .field("attribution,omitempty", STRING)
+ .field("windows,omitempty", STRINGS)
+ .field("additions", INT_POINTER)
+ .field("deletions", INT_POINTER)
+ .field("hunks,omitempty", STRUCTS, HUNK);
+ private static final GoJson.Struct RECORD = new GoJson.Struct()
+ .field("schema", STRING)
+ .field("id", STRING)
+ .field("captured_by", STRING)
+ .field("session", STRING)
+ .field("stream", STRING)
+ .field("tool,omitempty", STRING)
+ .field("tool_name,omitempty", STRING)
+ .field("time", STRING)
+ .field("basis", STRING)
+ .field("root,omitempty", STRUCT_POINTER, new GoJson.Struct()
+ .field("path", STRING)
+ .field("id,omitempty", STRING))
+ .field("policy,omitempty", STRUCT_POINTER, new GoJson.Struct()
+ .field("exclusions,omitempty", STRING)
+ .field("read_only,omitempty", STRING)
+ .field("expanded,omitempty", STRINGS))
+ .field("window,omitempty", STRUCT_POINTER, new GoJson.Struct()
+ .field("before", STRUCT, INTERVAL)
+ .field("after", STRUCT, INTERVAL))
+ .field("outcome,omitempty", STRUCT_POINTER, new GoJson.Struct()
+ .field("state", STRING)
+ .field("exit_code", INT_POINTER))
+ .field("coverage,omitempty", STRING)
+ .field("gaps,omitempty", STRINGS)
+ .field("overlaps,omitempty", STRUCTS, new GoJson.Struct()
+ .field("capture", STRING)
+ .field("session", STRING)
+ .field("stream", STRING)
+ .field("tool,omitempty", STRING)
+ .field("tool_name,omitempty", STRING)
+ .field("state", STRING))
+ .field("changed_files", INT_POINTER)
+ .field("changes", STRUCTS, FILE_CHANGE);
+
+ /** The tool-use id of the call, so two producers observing one call share it. */
@Getter
private final String id;
@Getter
private final String capturedBy;
- /** The tool-use id the record joins to; empty on an unattributed record. */
@Getter
private final String tool;
- /** When the observation ended, RFC 3339 as written; the document sorts on it as text. */
@Getter
private final String time;
private final Map<String, Object> fields;
private ChangesRecord(final JsonObject json) {
- final Map<String, Object> m = new LinkedHashMap<>();
- m.put("schema", nullToEmpty(str(json, "schema")));
- id = nullToEmpty(str(json, "id"));
- m.put("id", id);
- capturedBy = nullToEmpty(str(json, "captured_by"));
- m.put("captured_by", capturedBy);
- m.put("session", nullToEmpty(str(json, "session")));
- m.put("stream", nullToEmpty(str(json, "stream")));
- tool = nullToEmpty(str(json, "tool"));
- putIfNotEmpty(m, "tool", tool);
- putIfNotEmpty(m, "tool_name", str(json, "tool_name"));
- time = nullToEmpty(str(json, "time"));
- m.put("time", time);
- m.put("basis", nullToEmpty(str(json, "basis")));
- final JsonObject root = object(json, "root");
- if (root != null) {
- final Map<String, Object> r = new LinkedHashMap<>();
- r.put("path", nullToEmpty(str(root, "path")));
- putIfNotEmpty(r, "id", str(root, "id"));
- m.put("root", r);
- }
- final JsonObject policy = object(json, "policy");
- if (policy != null) {
- final Map<String, Object> p = new LinkedHashMap<>();
- putIfNotEmpty(p, "exclusions", str(policy, "exclusions"));
- putIfNotEmpty(p, "read_only", str(policy, "read_only"));
- putIfNotEmpty(p, "expanded", strings(policy, "expanded"));
- m.put("policy", p);
- }
- final JsonObject window = object(json, "window");
- if (window != null) {
- final Map<String, Object> w = new LinkedHashMap<>();
- w.put("before", interval(object(window, "before")));
- w.put("after", interval(object(window, "after")));
- m.put("window", w);
- }
- final JsonObject outcome = object(json, "outcome");
- if (outcome != null) {
- final Map<String, Object> o = new LinkedHashMap<>();
- o.put("state", nullToEmpty(str(outcome, "state")));
- o.put("exit_code", integer(outcome, "exit_code"));
- m.put("outcome", o);
- }
- putIfNotEmpty(m, "coverage", str(json, "coverage"));
- putIfNotEmpty(m, "gaps", strings(json, "gaps"));
- final JsonArray overlaps = array(json, "overlaps");
- if (overlaps != null && overlaps.size() > 0) {
- final List<Object> list = new ArrayList<>();
- for (final JsonElement e : overlaps) {
- final JsonObject x = objectOf(e);
- final Map<String, Object> o = new LinkedHashMap<>();
- o.put("capture", nullToEmpty(str(x, "capture")));
- o.put("session", nullToEmpty(str(x, "session")));
- o.put("stream", nullToEmpty(str(x, "stream")));
- putIfNotEmpty(o, "tool", str(x, "tool"));
- putIfNotEmpty(o, "tool_name", str(x, "tool_name"));
- o.put("state", nullToEmpty(str(x, "state")));
- list.add(o);
- }
- m.put("overlaps", list);
- }
- m.put("changed_files", integer(json, "changed_files"));
- final JsonArray changes = array(json, "changes");
- m.put("changes", changes == null ? null : fileChanges(changes));
- fields = Collections.unmodifiableMap(m);
+ id = json.get("id").getAsString();
+ capturedBy = json.get("captured_by").getAsString();
+ tool = json.has("tool") ? json.get("tool").getAsString() : "";
+ time = json.get("time").getAsString();
+ fields = Collections.unmodifiableMap(GoJson.toMap(json));
}
/**
@@ -138,209 +130,12 @@ public final class ChangesRecord {
*/
@Nullable
public static ChangesRecord decode(@Nullable final String raw) {
- if (raw == null || raw.isEmpty() || raw.charAt(0) != '{') {
- return null;
- }
- final JsonElement parsed;
- try {
- parsed = JsonParser.parseString(raw);
- } catch (final RuntimeException e) {
- return null;
- }
- if (!parsed.isJsonObject()) {
- return null;
- }
- try {
- final ChangesRecord r = new ChangesRecord(parsed.getAsJsonObject());
- return SCHEMA.equals(r.fields.get("schema")) ? r : null;
- } catch (final Mismatch e) {
- return null;
- }
+ final JsonObject json = GoJson.decode(raw, RECORD);
+ return json != null && SCHEMA.equals(json.get("schema").getAsString()) ? new ChangesRecord(json) : null;
}
- /**
- * @return the record's fields, keys in the order the format lists them
- */
+ @Override
public Map<String, Object> fields() {
return fields;
}
-
- private static Map<String, Object> interval(@Nullable final JsonObject o) {
- final Map<String, Object> i = new LinkedHashMap<>();
- i.put("from", o == null ? "" : nullToEmpty(str(o, "from")));
- i.put("to", o == null ? "" : nullToEmpty(str(o, "to")));
- return i;
- }
-
- private static List<Object> fileChanges(final JsonArray changes) {
- final List<Object> out = new ArrayList<>();
- for (final JsonElement e : changes) {
- final JsonObject c = objectOf(e);
- final Map<String, Object> m = new LinkedHashMap<>();
- m.put("path", nullToEmpty(str(c, "path")));
- m.put("operation", nullToEmpty(str(c, "operation")));
- m.put("before", endpoint(object(c, "before")));
- m.put("after", endpoint(object(c, "after")));
- m.put("diff", nullToEmpty(str(c, "diff")));
- putIfNotEmpty(m, "attribution", str(c, "attribution"));
- putIfNotEmpty(m, "windows", strings(c, "windows"));
- m.put("additions", integer(c, "additions"));
- m.put("deletions", integer(c, "deletions"));
- final JsonArray hunks = array(c, "hunks");
- if (hunks != null && hunks.size() > 0) {
- final List<Object> list = new ArrayList<>();
- for (final JsonElement h : hunks) {
- final JsonObject x = objectOf(h);
- final Map<String, Object> hm = new LinkedHashMap<>();
- hm.put("old_start", integerOrZero(x, "old_start"));
- hm.put("old_lines", integerOrZero(x, "old_lines"));
- hm.put("new_start", integerOrZero(x, "new_start"));
- hm.put("new_lines", integerOrZero(x, "new_lines"));
- hm.put("lines", strings(x, "lines"));
- list.add(hm);
- }
- m.put("hunks", list);
- }
- out.add(m);
- }
- return out;
- }
-
- private static Map<String, Object> endpoint(@Nullable final JsonObject o) {
- final Map<String, Object> m = new LinkedHashMap<>();
- m.put("present", o != null && bool(o, "present"));
- m.put("bytes", o == null ? null : integer(o, "bytes"));
- if (o != null) {
- putIfNotEmpty(m, "sha256", str(o, "sha256"));
- if (bool(o, "no_newline_at_end")) {
- m.put("no_newline_at_end", true);
- }
- }
- return m;
- }
-
- // ---------------------------------------------------------------- reading as Go's json.Unmarshal reads
-
- /** A value of a type the field cannot hold; Go refuses the whole record. */
- private static final class Mismatch extends RuntimeException {
- private static final long serialVersionUID = 1L;
-
- Mismatch(final String what) {
- super(what);
- }
- }
-
- @Nullable
- private static String str(final JsonObject o, final String key) {
- final JsonElement e = o.get(key);
- if (e == null || e.isJsonNull()) {
- return null;
- }
- if (e.isJsonPrimitive() && e.getAsJsonPrimitive().isString()) {
- return e.getAsString();
- }
- throw new Mismatch(key + " is not a string");
- }
-
- @Nullable
- private static Long integer(final JsonObject o, final String key) {
- final JsonElement e = o.get(key);
- if (e == null || e.isJsonNull()) {
- return null;
- }
- if (e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber()) {
- // as written: Go decodes an integer field from an integer literal only, so 1.0 and 1e2 are refused
- final String literal = e.getAsString();
- if (INTEGER_LITERAL.matcher(literal).matches()) {
- try {
- return Long.parseLong(literal);
- } catch (final NumberFormatException ignored) {
- throw new Mismatch(key + " overflows");
- }
- }
- }
- throw new Mismatch(key + " is not an integer");
- }
-
- private static long integerOrZero(final JsonObject o, final String key) {
- final Long v = integer(o, key);
- return v == null ? 0L : v;
- }
-
- private static boolean bool(final JsonObject o, final String key) {
- final JsonElement e = o.get(key);
- if (e == null || e.isJsonNull()) {
- return false;
- }
- if (e.isJsonPrimitive() && e.getAsJsonPrimitive().isBoolean()) {
- return e.getAsBoolean();
- }
- throw new Mismatch(key + " is not a boolean");
- }
-
- @Nullable
- private static List<String> strings(final JsonObject o, final String key) {
- final JsonArray a = array(o, key);
- if (a == null) {
- return null;
- }
- final List<String> out = new ArrayList<>(a.size());
- for (final JsonElement e : a) {
- if (e.isJsonNull()) {
- out.add("");
- } else if (e.isJsonPrimitive() && ((JsonPrimitive) e).isString()) {
- out.add(e.getAsString());
- } else {
- throw new Mismatch(key + " holds a value that is not a string");
- }
- }
- return out;
- }
-
- @Nullable
- private static JsonObject object(final JsonObject o, final String key) {
- final JsonElement e = o.get(key);
- if (e == null || e.isJsonNull()) {
- return null;
- }
- if (e.isJsonObject()) {
- return e.getAsJsonObject();
- }
- throw new Mismatch(key + " is not an object");
- }
-
- private static JsonObject objectOf(final JsonElement e) {
- if (e.isJsonObject()) {
- return e.getAsJsonObject();
- }
- throw new Mismatch("a list element is not an object");
- }
-
- @Nullable
- private static JsonArray array(final JsonObject o, final String key) {
- final JsonElement e = o.get(key);
- if (e == null || e.isJsonNull()) {
- return null;
- }
- if (e.isJsonArray()) {
- return e.getAsJsonArray();
- }
- throw new Mismatch(key + " is not a list");
- }
-
- private static void putIfNotEmpty(final Map<String, Object> m, final String key, @Nullable final String value) {
- if (StringUtil.isNotEmpty(value)) {
- m.put(key, value);
- }
- }
-
- private static void putIfNotEmpty(final Map<String, Object> m, final String key, @Nullable final List<String> value) {
- if (value != null && !value.isEmpty()) {
- m.put(key, value);
- }
- }
-
- private static String nullToEmpty(@Nullable final String s) {
- return s == null ? "" : s;
- }
}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ExecutionRecord.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ExecutionRecord.java
new file mode 100644
index 0000000000..5097f6de2a
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ExecutionRecord.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import com.google.gson.JsonObject;
+import java.util.Collections;
+import java.util.Map;
+import javax.annotation.Nullable;
+import lombok.Getter;
+
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.INT;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.INT_POINTER;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.STRING;
+import static org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson.Kind.STRUCT_POINTER;
+
+/**
+ * One tool execution record, <code>execution/1</code>: what one observer saw a tool call do after the model asked
+ * for it, such as which MCP server ran it, how it ended and how long it took. It is read from a <code>data</code>
+ * part of an <code>execution</code> file, decoded and rendered as {@link GoJson} describes.
+ *
+ * <p>One call can have several records, one per observation, and each has an id of its own. All of them name the
+ * call by its tool-use id, which is the only join.
+ */
+public final class ExecutionRecord implements ToolCallRecord {
+ public static final String SCHEMA = "execution/1";
+
+ /** The Sessionizer's <code>execution.Record</code>, field for field and tag for tag. */
+ private static final GoJson.Struct CONTENT = new GoJson.Struct()
+ .field("state", STRING)
+ .field("bytes", INT)
+ .field("sha256,omitempty", STRING);
+ private static final GoJson.Struct RECORD = new GoJson.Struct()
+ .field("schema", STRING)
+ .field("id", STRING)
+ .field("observed_by", STRING)
+ .field("boundary", STRING)
+ .field("session", STRING)
+ .field("stream", STRING)
+ .field("tool", STRING)
+ .field("tool_name,omitempty", STRING)
+ .field("cwd,omitempty", STRING)
+ .field("protocol", STRING)
+ .field("server,omitempty", STRUCT_POINTER, new GoJson.Struct()
+ .field("name", STRING)
+ .field("source,omitempty", STRING))
+ .field("time", STRING)
+ .field("duration_ms,omitempty", INT_POINTER)
+ .field("outcome", STRING)
+ .field("arguments,omitempty", STRUCT_POINTER, CONTENT)
+ .field("result,omitempty", STRUCT_POINTER, CONTENT);
+
+ /** This observation's own identity; the document keeps a record once by it. */
+ @Getter
+ private final String id;
+ @Getter
+ private final String tool;
+ @Getter
+ private final String time;
+ private final Map<String, Object> fields;
+
+ private ExecutionRecord(final JsonObject json) {
+ id = json.get("id").getAsString();
+ tool = json.get("tool").getAsString();
+ time = json.get("time").getAsString();
+ fields = Collections.unmodifiableMap(GoJson.toMap(json));
+ }
+
+ /**
+ * @param raw the data of a part, as the Sessionizer wrote it
+ * @return the record, or null when the data is not an execution record: not an object, not this schema, or not
+ * the shape of one
+ */
+ @Nullable
+ public static ExecutionRecord decode(@Nullable final String raw) {
+ final JsonObject json = GoJson.decode(raw, RECORD);
+ return json != null && SCHEMA.equals(json.get("schema").getAsString()) ? new ExecutionRecord(json) : null;
+ }
+
+ @Override
+ public Map<String, Object> fields() {
+ return fields;
+ }
+}
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 a3a9e9b324..c7028ccbf8 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
@@ -29,6 +29,7 @@ import org.apache.skywalking.oap.server.library.util.StringUtil;
* <session>/streams/<stream>/transcript-<stamp>-<seq>.sd
* <session>/streams/<stream>/meta-<stamp>-<seq>.sd
* <session>/streams/<stream>/changes-<stamp>-<seq>.sd
+ * <session>/streams/<stream>/execution-<stamp>-<seq>.sd
* <session>/runs/<run>/journal-<stamp>-<seq>.sd
* <session>/runs/<run>/manifest-<stamp>-<seq>.sd
* <session>/runs/<run>/script-<stamp>-<seq>.sd
@@ -45,50 +46,70 @@ public final class FileNames {
* @return the file's relative path in the storage root
*/
public static String dataFile(final SessionDataFile.Header header) {
+ final Place place = place(header);
+ final String stamp = Times.fileStamp(header.getAt());
+ return header.getSession() + "/" + place.directory + "/" + place.prefix + "-" + (stamp == null ? "unknown" : stamp)
+ + "-" + String.format(Locale.ROOT, "%06d", header.getSeq()) + ".sd";
+ }
+
+ /**
+ * @param header the header line of a Session Data file
+ * @return the directory the file lands in under its session: <code>streams/<stream></code> for a stream's
+ * records, <code>runs/<run></code> for a workflow run's, and <code>provider_body</code> for the session's
+ * provider bodies
+ */
+ public static String directory(final SessionDataFile.Header header) {
+ return place(header).directory;
+ }
+
+ /**
+ * @param header the header line of a Session Data file
+ * @return the lane the file's records are in: its stream's or its workflow run's directory, or empty for the
+ * session's provider bodies, which belong to no stream or run
+ */
+ public static String lane(final SessionDataFile.Header header) {
+ return "provider_body".equals(header.getKind()) ? "" : place(header).directory;
+ }
+
+ /** Where a file of one kind lands: the prefix of its name and its directory under the session. */
+ private static final class Place {
final String prefix;
- final String dir;
+ final String directory;
+
+ Place(final String prefix, final String directory) {
+ this.prefix = prefix;
+ this.directory = directory;
+ }
+ }
+
+ private static Place place(final SessionDataFile.Header header) {
switch (header.getKind() == null ? "" : header.getKind()) {
case "transcript":
- prefix = "transcript";
- dir = "streams/" + header.getStream();
- break;
+ return new Place("transcript", "streams/" + header.getStream());
case "agent_meta":
- prefix = "meta";
- dir = "streams/" + header.getStream();
- break;
+ return new Place("meta", "streams/" + header.getStream());
case "changes":
// the plugin's workspace change records, beside the transcript of the stream the tool ran under
- prefix = "changes";
- dir = "streams/" + header.getStream();
- break;
+ return new Place("changes", "streams/" + header.getStream());
+ case "execution":
+ // what the plugin saw each tool call do after the model asked for it, beside the transcript of the
+ // stream the call ran under
+ return new Place("execution", "streams/" + header.getStream());
case "journal":
- prefix = "journal";
- dir = "runs/" + header.getBatch();
- break;
+ return new Place("journal", "runs/" + header.getBatch());
case "workflow_manifest":
- prefix = "manifest";
- dir = "runs/" + header.getBatch();
- break;
+ return new Place("manifest", "runs/" + header.getBatch());
case "workflow_script":
- prefix = "script";
- dir = "runs/" + header.getBatch();
- break;
+ return new Place("script", "runs/" + header.getBatch());
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;
+ return new Place("provider_body", "provider_body");
default:
- prefix = header.getKind() == null ? "file" : header.getKind();
- dir = StringUtil.isNotEmpty(header.getStream())
- ? "streams/" + header.getStream()
- : "runs/" + header.getBatch();
- break;
+ return new Place(header.getKind() == null ? "file" : header.getKind(),
+ StringUtil.isNotEmpty(header.getStream()) ? "streams/" + header.getStream()
+ : "runs/" + header.getBatch());
}
- final String stamp = Times.fileStamp(header.getAt());
- return header.getSession() + "/" + dir + "/" + prefix + "-" + (stamp == null ? "unknown" : stamp)
- + "-" + String.format(Locale.ROOT, "%06d", header.getSeq()) + ".sd";
}
/**
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/GoJson.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/GoJson.java
new file mode 100644
index 0000000000..fb936d54f1
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/GoJson.java
@@ -0,0 +1,815 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.internal.LazilyParsedNumber;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+
+/**
+ * Reads JSON the way the Sessionizer's Go code reads it, with <code>encoding/json</code>, and gives back what
+ * <code>json.Marshal</code> writes for the struct it was read into.
+ *
+ * <p>The text is scanned here rather than by Gson, whose reader accepts syntax Go refuses and refuses numbers Go
+ * accepts. The scan follows Go's scanner: its grammar for numbers, its escapes in strings, no control character
+ * written as it is, and no more than 10,000 levels of objects and arrays. A value nested that deep is read without
+ * recursion, so no text Go accepts can exhaust the stack.
+ *
+ * <p>Decoding into a struct follows <code>json.Unmarshal</code>. A key matches its field exactly, or else in another
+ * case, and a key the struct does not have is dropped. Keys are applied in order, so a key given twice keeps its later
+ * value, and an object given twice for one struct fills it twice. A null leaves a string, a number, a bool or a struct
+ * held by value as it was, and empties a pointer or a slice. A value of the wrong type refuses the whole text, as Go's
+ * error does, even when a later key would have replaced it.
+ *
+ * <p>Each record and attribute the Sessionizer's view decodes into a struct is read here, with that struct written
+ * out tag for tag, so the document accepts and refuses what the Sessionizer's does.
+ */
+public final class GoJson {
+ private static final Pattern INTEGER_LITERAL = Pattern.compile("-?\\d+");
+ private static final Pattern UNSIGNED_LITERAL = Pattern.compile("\\d+");
+ /** Go's scanner refuses a text whose objects and arrays nest deeper than this. */
+ private static final int MAX_DEPTH = 10000;
+
+ private GoJson() {
+ }
+
+ /** The type of one field of a Go struct, as far as a record uses one. */
+ public enum Kind {
+ /** <code>string</code> */
+ STRING,
+ /** <code>int</code> or <code>int64</code> */
+ INT,
+ /** <code>uint64</code>; a value past 2^63 is refused, which no count or position the Sessionizer writes reaches */
+ UINT,
+ /** <code>*int</code> or <code>*int64</code> */
+ INT_POINTER,
+ /** <code>bool</code> */
+ BOOL,
+ /** <code>[]string</code> */
+ STRINGS,
+ /** a struct held by value */
+ STRUCT,
+ /** a pointer to a struct */
+ STRUCT_POINTER,
+ /** a slice of structs */
+ STRUCTS
+ }
+
+ /** A Go struct: its fields in declaration order, each with its JSON tag. */
+ public static final class Struct {
+ private final List<Field> fields = new ArrayList<>();
+
+ /**
+ * @param tag the field's JSON tag as the Go source writes it: the name, and <code>,omitempty</code> when
+ * an empty value is left out
+ * @param kind the field's type
+ */
+ public Struct field(final String tag, final Kind kind) {
+ return field(tag, kind, null);
+ }
+
+ /**
+ * @param struct for a struct type, the struct
+ */
+ public Struct field(final String tag, final Kind kind, @Nullable final Struct struct) {
+ final int comma = tag.indexOf(',');
+ final String name = comma < 0 ? tag : tag.substring(0, comma);
+ fields.add(new Field(name, kind, struct, tag.endsWith(",omitempty")));
+ return this;
+ }
+
+ /** The field a key names: the one of exactly that name, or else one whose name matches it in another case. */
+ @Nullable
+ private Field find(final String key) {
+ for (final Field f : fields) {
+ if (f.name.equals(key)) {
+ return f;
+ }
+ }
+ for (final Field f : fields) {
+ if (f.name.equalsIgnoreCase(key)) {
+ return f;
+ }
+ }
+ return null;
+ }
+ }
+
+ private static final class Field {
+ final String name;
+ final Kind kind;
+ final Struct struct;
+ final boolean omitEmpty;
+
+ Field(final String name, final Kind kind, @Nullable final Struct struct, final boolean omitEmpty) {
+ this.name = name;
+ this.kind = kind;
+ this.struct = struct;
+ this.omitEmpty = omitEmpty;
+ }
+ }
+
+ /**
+ * @param text JSON text as it was written
+ * @return the one JSON value the text holds, a number kept with the digits it was written with; or null when Go
+ * refuses the text. A key given twice keeps its later value.
+ */
+ @Nullable
+ public static JsonElement parse(@Nullable final String text) {
+ if (text == null) {
+ return null;
+ }
+ try {
+ final Scanner s = new Scanner(text);
+ final JsonElement v = s.value(true);
+ s.end();
+ return v;
+ } catch (final Refused e) {
+ return null;
+ }
+ }
+
+ /**
+ * The members of a JSON object as written, as Go keeps them when it decodes into a map of
+ * <code>json.RawMessage</code>, or into a struct field of that type: a later key replaces an earlier one only then,
+ * so a reader that must pick the value Go picks reads the members, not a tree that has already merged them.
+ *
+ * @param text JSON text as it was written
+ * @return each key and the text of its value, in the order they appear, a key given twice listed twice; or null
+ * when the text is not one JSON object Go reads
+ */
+ @Nullable
+ public static List<String[]> members(@Nullable final String text) {
+ if (text == null) {
+ return null;
+ }
+ try {
+ final Scanner s = new Scanner(text);
+ final List<String[]> out = new ArrayList<>();
+ s.begin('{');
+ for (boolean first = true; s.next('}', first); first = false) {
+ final String key = s.key();
+ final int start = s.at();
+ s.value(false);
+ out.add(new String[] {key, text.substring(start, s.pos)});
+ }
+ s.end();
+ return out;
+ } catch (final Refused e) {
+ return null;
+ }
+ }
+
+ /**
+ * @param raw JSON text as it was written
+ * @param struct the Go struct it is decoded into
+ * @return what <code>json.Marshal</code> writes for the struct after <code>json.Unmarshal</code> of the text: every
+ * field under its own name, in declaration order, an empty one left out only when its tag says
+ * <code>omitempty</code>; or null when Go refuses the text: not JSON, or a value of the wrong type
+ */
+ @Nullable
+ public static JsonObject decode(@Nullable final String raw, final Struct struct) {
+ final JsonElement v = read(raw, new Field("", Kind.STRUCT, struct, false));
+ return v == null ? null : v.getAsJsonObject();
+ }
+
+ /**
+ * @param raw JSON text as it was written
+ * @param element the Go struct of each element
+ * @return the slice as <code>json.Marshal</code> writes it after <code>json.Unmarshal</code> of the text, empty
+ * for a JSON null; or null when Go refuses the text
+ */
+ @Nullable
+ public static JsonArray decodeSlice(@Nullable final String raw, final Struct element) {
+ final JsonElement v = read(raw, new Field("", Kind.STRUCTS, element, false));
+ if (v == null) {
+ return null;
+ }
+ return v.isJsonArray() ? v.getAsJsonArray() : new JsonArray();
+ }
+
+ /**
+ * @return a JSON object as Java values: an object as an ordered map, an array as a list, a whole number as a long
+ * or, past that, a big integer, any other number as a big decimal, so every number keeps the digits it was
+ * written with
+ */
+ public static Map<String, Object> toMap(final JsonObject json) {
+ final Map<String, Object> out = new LinkedHashMap<>();
+ for (final Map.Entry<String, JsonElement> e : json.entrySet()) {
+ out.put(e.getKey(), toValue(e.getValue()));
+ }
+ return out;
+ }
+
+ /**
+ * @return a JSON value as Java values, as {@link #toMap} converts one
+ */
+ @Nullable
+ public static Object toValue(@Nullable final JsonElement e) {
+ if (e == null || e.isJsonNull()) {
+ return null;
+ }
+ if (e.isJsonObject()) {
+ return toMap(e.getAsJsonObject());
+ }
+ if (e.isJsonArray()) {
+ final List<Object> list = new ArrayList<>();
+ for (final JsonElement x : e.getAsJsonArray()) {
+ list.add(toValue(x));
+ }
+ return list;
+ }
+ final JsonPrimitive p = e.getAsJsonPrimitive();
+ if (p.isBoolean()) {
+ return p.getAsBoolean();
+ }
+ if (p.isNumber()) {
+ final String literal = p.getAsString();
+ if (!INTEGER_LITERAL.matcher(literal).matches()) {
+ return new BigDecimal(literal);
+ }
+ final BigInteger n = new BigInteger(literal);
+ return n.bitLength() < Long.SIZE ? (Object) n.longValue() : n;
+ }
+ return p.getAsString();
+ }
+
+ /**
+ * @param text JSON text as it was written
+ * @return whether Go decodes the text into a value of <code>any</code>: it reads, and every number in it, a key
+ * given twice included, fits a double. A number past the range, such as 1e400, fails the whole decoding, even
+ * where a later key replaces it.
+ */
+ public static boolean decodesAsAny(@Nullable final String text) {
+ if (text == null) {
+ return false;
+ }
+ try {
+ final Scanner s = new Scanner(text);
+ s.value(false);
+ s.end();
+ return !s.numberPastDouble;
+ } catch (final Refused e) {
+ return false;
+ }
+ }
+
+ // ---------------------------------------------------------------- json.Unmarshal
+
+ /**
+ * @return the field as <code>json.Marshal</code> writes it after <code>json.Unmarshal</code> of the text, or null
+ * when Go refuses the text
+ */
+ @Nullable
+ private static JsonElement read(@Nullable final String raw, final Field top) {
+ if (raw == null) {
+ return null;
+ }
+ try {
+ final Scanner s = new Scanner(raw);
+ final JsonElement held = s.unmarshal(top, null);
+ s.end();
+ return s.failed ? null : marshal(top, held);
+ } catch (final Refused e) {
+ return null;
+ }
+ }
+
+ /** The text is not JSON Go reads. */
+ private static final class Refused extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ Refused() {
+ super(null, null, false, false);
+ }
+ }
+
+ /**
+ * A cursor over JSON text that reads it as Go's scanner and decoder do. Every method that reads a value leaves the
+ * cursor after it, and throws {@link Refused} at the first place Go's scanner stops.
+ */
+ private static final class Scanner {
+ private final String text;
+ private int pos;
+ /** How many objects and arrays are open at the cursor. */
+ private int depth;
+ /** A value had the wrong type: Go reads on to check the rest of the text, then refuses it. */
+ private boolean failed;
+ /** A number was past a double's range, which Go refuses when it decodes a number into <code>any</code>. */
+ private boolean numberPastDouble;
+
+ Scanner(final String text) {
+ this.text = text;
+ }
+
+ // ------------------------------------------------ structure
+
+ /** Passes over the white space Go allows between tokens. */
+ private void space() {
+ while (pos < text.length() && " \t\n\r".indexOf(text.charAt(pos)) >= 0) {
+ pos++;
+ }
+ }
+
+ /** @return where the next token starts, after any white space */
+ int at() {
+ space();
+ if (pos >= text.length()) {
+ throw new Refused();
+ }
+ return pos;
+ }
+
+ char peek() {
+ return text.charAt(at());
+ }
+
+ void expect(final char c) {
+ if (peek() != c) {
+ throw new Refused();
+ }
+ pos++;
+ }
+
+ /** Checks that nothing but white space follows the value. */
+ void end() {
+ space();
+ if (pos != text.length()) {
+ throw new Refused();
+ }
+ }
+
+ /** Opens an object or an array at the cursor. */
+ void begin(final char open) {
+ expect(open);
+ if (++depth > MAX_DEPTH) {
+ throw new Refused();
+ }
+ }
+
+ /**
+ * @param first whether nothing has been read since the container opened
+ * @return whether another member or element follows, having read the comma before it; false when the
+ * container closes here, having read the closing bracket
+ */
+ boolean next(final char close, final boolean first) {
+ final char c = peek();
+ if (c == close) {
+ pos++;
+ depth--;
+ return false;
+ }
+ if (!first) {
+ expect(',');
+ }
+ return true;
+ }
+
+ /** @return a member's key, having read the colon after it */
+ String key() {
+ final String key = string();
+ expect(':');
+ return key;
+ }
+
+ // ------------------------------------------------ values
+
+ /**
+ * Reads one value of any depth without recursion.
+ *
+ * @param keep whether to build the value, or only to check it and pass over it
+ * @return the value, or null when it is not kept
+ */
+ @Nullable
+ JsonElement value(final boolean keep) {
+ // the open containers, innermost first: each one's closing bracket, its tree when the value is kept, and
+ // for an object the key of the member being read
+ final Deque<Character> closes = new ArrayDeque<>();
+ final Deque<JsonElement> trees = new ArrayDeque<>();
+ final Deque<String> keys = new ArrayDeque<>();
+ while (true) {
+ JsonElement v = null;
+ final char c = peek();
+ if (c == '{' || c == '[') {
+ begin(c);
+ final char close = c == '{' ? '}' : ']';
+ if (keep) {
+ v = c == '{' ? new JsonObject() : new JsonArray();
+ }
+ if (next(close, true)) {
+ closes.push(close);
+ if (keep) {
+ trees.push(v);
+ }
+ if (close == '}') {
+ keys.push(key());
+ }
+ continue;
+ }
+ } else {
+ v = scalar(keep);
+ }
+ // a whole value: add it to its container, and close each container it completes
+ while (!closes.isEmpty()) {
+ final char close = closes.peek();
+ final String key = close == '}' ? keys.pop() : null;
+ if (keep && key != null) {
+ trees.peek().getAsJsonObject().add(key, v);
+ } else if (keep) {
+ trees.peek().getAsJsonArray().add(v);
+ }
+ if (next(close, false)) {
+ if (close == '}') {
+ keys.push(key());
+ }
+ break;
+ }
+ closes.pop();
+ v = keep ? trees.pop() : null;
+ }
+ if (closes.isEmpty()) {
+ return v;
+ }
+ }
+ }
+
+ @Nullable
+ private JsonElement scalar(final boolean keep) {
+ switch (peek()) {
+ case '"':
+ final String s = string();
+ return keep ? new JsonPrimitive(s) : null;
+ case 't':
+ literal("true");
+ return keep ? new JsonPrimitive(true) : null;
+ case 'f':
+ literal("false");
+ return keep ? new JsonPrimitive(false) : null;
+ case 'n':
+ literal("null");
+ return keep ? JsonNull.INSTANCE : null;
+ default:
+ final String n = number();
+ numberPastDouble |= Double.isInfinite(Double.parseDouble(n));
+ // the number Gson's own parser holds: the literal as written, read as a number only when asked
+ return keep ? new JsonPrimitive(new LazilyParsedNumber(n)) : null;
+ }
+ }
+
+ private void literal(final String word) {
+ if (!text.startsWith(word, at())) {
+ throw new Refused();
+ }
+ pos += word.length();
+ }
+
+ /** @return a number as written, by Go's grammar: <code>-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?</code> */
+ String number() {
+ final int start = at();
+ skip('-');
+ if (!skip('0')) {
+ // a first digit of 1 to 9, as a 0 is taken above, and any digits after it
+ digits();
+ }
+ if (skip('.')) {
+ digits();
+ }
+ if (skip('e') || skip('E')) {
+ if (!skip('+')) {
+ skip('-');
+ }
+ digits();
+ }
+ return text.substring(start, pos);
+ }
+
+ private boolean skip(final char c) {
+ if (pos < text.length() && text.charAt(pos) == c) {
+ pos++;
+ return true;
+ }
+ return false;
+ }
+
+ /** Reads one ASCII digit or more. */
+ private void digits() {
+ if (!isDigit(pos)) {
+ throw new Refused();
+ }
+ while (isDigit(pos)) {
+ pos++;
+ }
+ }
+
+ private boolean isDigit(final int i) {
+ return i < text.length() && text.charAt(i) >= '0' && text.charAt(i) <= '9';
+ }
+
+ /**
+ * @return a string's value as Go decodes it: the escapes a quote, a backslash, a slash, b, f, n, r, t and u with
+ * four hex digits, and nothing else; no control character written as it is; and a surrogate that is not half of
+ * a pair replaced by U+FFFD
+ */
+ String string() {
+ expect('"');
+ final StringBuilder out = new StringBuilder();
+ while (true) {
+ if (pos >= text.length()) {
+ throw new Refused();
+ }
+ final char c = text.charAt(pos++);
+ if (c == '"') {
+ return out.toString();
+ }
+ if (c < 0x20) {
+ throw new Refused();
+ }
+ if (c != '\\') {
+ if (Character.isHighSurrogate(c) && pos < text.length() && Character.isLowSurrogate(text.charAt(pos))) {
+ out.append(c).append(text.charAt(pos++));
+ } else {
+ out.append(Character.isSurrogate(c) ? '\ufffd' : c);
+ }
+ continue;
+ }
+ final char e = pos < text.length() ? text.charAt(pos++) : 0;
+ switch (e) {
+ case '"':
+ case '\\':
+ case '/':
+ out.append(e);
+ break;
+ case 'b':
+ out.append('\b');
+ break;
+ case 'f':
+ out.append('\f');
+ break;
+ case 'n':
+ out.append('\n');
+ break;
+ case 'r':
+ out.append('\r');
+ break;
+ case 't':
+ out.append('\t');
+ break;
+ case 'u':
+ final int u = hex4(pos);
+ if (u < 0) {
+ throw new Refused();
+ }
+ pos += 4;
+ if (!Character.isSurrogate((char) u)) {
+ out.append((char) u);
+ break;
+ }
+ // a high surrogate pairs with a low one written right after it as an escape; any other is
+ // U+FFFD, and what follows is read on its own
+ final int low = text.startsWith("\\u", pos) ? hex4(pos + 2) : -1;
+ if (Character.isHighSurrogate((char) u) && low >= 0 && Character.isLowSurrogate((char) low)) {
+ out.append((char) u).append((char) low);
+ pos += 6;
+ } else {
+ out.append('\ufffd');
+ }
+ break;
+ default:
+ throw new Refused();
+ }
+ }
+ }
+
+ /** @return the value of four ASCII hex digits at a place, or -1 when they are not */
+ private int hex4(final int from) {
+ if (from + 4 > text.length()) {
+ return -1;
+ }
+ int v = 0;
+ for (int i = from; i < from + 4; i++) {
+ final char c = text.charAt(i);
+ final int d = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10
+ : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1;
+ if (d < 0) {
+ return -1;
+ }
+ v = v * 16 + d;
+ }
+ return v;
+ }
+
+ // ------------------------------------------------ into a struct
+
+ /**
+ * Applies one JSON value to a field. What a field holds while the text is read is only what the text gave it:
+ * a struct holds the fields given, each once, and a field given nothing holds nothing.
+ *
+ * @param held what the field held before this value
+ * @return what the field holds after it, or null when it holds nothing
+ */
+ @Nullable
+ JsonElement unmarshal(final Field f, @Nullable final JsonElement held) {
+ final char c = peek();
+ if (c == 'n') {
+ literal("null");
+ switch (f.kind) {
+ case INT_POINTER:
+ case STRUCT_POINTER:
+ case STRINGS:
+ case STRUCTS:
+ return null;
+ default:
+ return held;
+ }
+ }
+ switch (f.kind) {
+ case STRING:
+ if (c == '"') {
+ return new JsonPrimitive(string());
+ }
+ break;
+ case INT:
+ case INT_POINTER:
+ case UINT:
+ if (c == '-' || c >= '0' && c <= '9') {
+ return integer(f.kind, held);
+ }
+ break;
+ case BOOL:
+ if (c == 't' || c == 'f') {
+ final boolean b = c == 't';
+ literal(b ? "true" : "false");
+ return new JsonPrimitive(b);
+ }
+ break;
+ case STRUCT:
+ case STRUCT_POINTER:
+ if (c == '{') {
+ return unmarshalStruct(f.struct, held == null ? new JsonObject() : held.getAsJsonObject());
+ }
+ break;
+ case STRINGS:
+ case STRUCTS:
+ if (c == '[') {
+ return unmarshalSlice(f, held == null ? new JsonArray() : held.getAsJsonArray());
+ }
+ break;
+ default:
+ break;
+ }
+ failed = true;
+ value(false);
+ return held;
+ }
+
+ /**
+ * Go decodes an integer from an integer literal only, so 1.0 and 1e2 are the wrong type, as is a sign on an
+ * unsigned one, and a value past the type's range.
+ */
+ @Nullable
+ private JsonElement integer(final Kind kind, @Nullable final JsonElement held) {
+ final String literal = number();
+ if ((kind == Kind.UINT ? UNSIGNED_LITERAL : INTEGER_LITERAL).matcher(literal).matches()) {
+ try {
+ return new JsonPrimitive(Long.parseLong(literal));
+ } catch (final NumberFormatException e) {
+ // past the range: the wrong type, as below
+ }
+ }
+ failed = true;
+ return held;
+ }
+
+ private JsonObject unmarshalStruct(final Struct struct, final JsonObject into) {
+ begin('{');
+ for (boolean first = true; next('}', first); first = false) {
+ final Field f = struct.find(key());
+ if (f == null) {
+ value(false);
+ continue;
+ }
+ final JsonElement v = unmarshal(f, into.get(f.name));
+ if (v == null) {
+ into.remove(f.name);
+ } else {
+ into.add(f.name, v);
+ }
+ }
+ return into;
+ }
+
+ /**
+ * An array into a slice: each element into the one already at its index, if any, as Go reuses the slice's
+ * backing array, and the slice cut to the array's length. A null element leaves its element as it was.
+ */
+ private JsonArray unmarshalSlice(final Field f, final JsonArray held) {
+ final boolean strings = f.kind == Kind.STRINGS;
+ final Field element = new Field("", strings ? Kind.STRING : Kind.STRUCT, f.struct, false);
+ final JsonArray out = new JsonArray();
+ begin('[');
+ for (int i = 0; next(']', i == 0); i++) {
+ final JsonElement v = unmarshal(element, i < held.size() ? held.get(i) : null);
+ // an element given nothing is there all the same, as its type's zero value
+ out.add(v != null ? v : strings ? new JsonPrimitive("") : new JsonObject());
+ }
+ return out;
+ }
+ }
+
+ // ---------------------------------------------------------------- json.Marshal
+
+ /**
+ * @param held what the field holds, or null for nothing
+ * @return the field as <code>json.Marshal</code> writes it: nothing held is the type's zero value, which is null for
+ * a pointer or a slice
+ */
+ private static JsonElement marshal(final Field f, @Nullable final JsonElement held) {
+ switch (f.kind) {
+ case STRING:
+ return held == null ? new JsonPrimitive("") : held;
+ case INT:
+ case UINT:
+ return held == null ? new JsonPrimitive(0L) : held;
+ case BOOL:
+ return held == null ? new JsonPrimitive(false) : held;
+ case STRUCT:
+ return marshalStruct(f.struct, held == null ? new JsonObject() : held.getAsJsonObject());
+ case STRUCT_POINTER:
+ return held == null ? JsonNull.INSTANCE : marshalStruct(f.struct, held.getAsJsonObject());
+ case STRUCTS:
+ if (held == null) {
+ return JsonNull.INSTANCE;
+ }
+ final JsonArray out = new JsonArray();
+ for (final JsonElement e : held.getAsJsonArray()) {
+ out.add(marshalStruct(f.struct, e.getAsJsonObject()));
+ }
+ return out;
+ default:
+ // a pointer to a number, or a slice of strings, holds its value as it is
+ return held == null ? JsonNull.INSTANCE : held;
+ }
+ }
+
+ private static JsonObject marshalStruct(final Struct struct, final JsonObject held) {
+ final JsonObject out = new JsonObject();
+ for (final Field f : struct.fields) {
+ final JsonElement v = marshal(f, held.get(f.name));
+ if (!f.omitEmpty || !isEmpty(f.kind, v)) {
+ out.add(f.name, v);
+ }
+ }
+ return out;
+ }
+
+ /** Whether <code>omitempty</code> leaves the value out: false, 0, "", a nil pointer, a nil or empty slice. */
+ private static boolean isEmpty(final Kind kind, final JsonElement v) {
+ switch (kind) {
+ case STRING:
+ return v.getAsString().isEmpty();
+ case INT:
+ case UINT:
+ return v.getAsLong() == 0;
+ case BOOL:
+ return !v.getAsBoolean();
+ case STRINGS:
+ case STRUCTS:
+ return v.isJsonNull() || v.getAsJsonArray().size() == 0;
+ case INT_POINTER:
+ case STRUCT_POINTER:
+ return v.isJsonNull();
+ default:
+ // a struct held by value is never empty
+ return false;
+ }
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/GoStrings.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/GoStrings.java
new file mode 100644
index 0000000000..24a1725057
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/GoStrings.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+
+import java.util.Comparator;
+
+/**
+ * The order Go sorts strings in, <code>strings.Compare</code> and the <code><</code> operator: by their UTF-8
+ * bytes, which is the order of their code points. Java's own order of strings is by UTF-16 unit, and puts a character
+ * past U+FFFF before U+E000. Every list the Sessionizer sorts by a string, such as ids and map keys, is sorted here
+ * with this order, so the two list them alike.
+ */
+public final class GoStrings {
+ /** Go's order of strings, as a comparator. */
+ public static final Comparator<String> ORDER = GoStrings::compare;
+
+ private GoStrings() {
+ }
+
+ /**
+ * @return negative, zero or positive as <code>a</code> sorts before, with or after <code>b</code>
+ */
+ public static int compare(final String a, final String b) {
+ int i = 0;
+ int j = 0;
+ while (i < a.length() && j < b.length()) {
+ final int ca = a.codePointAt(i);
+ final int cb = b.codePointAt(j);
+ if (ca != cb) {
+ return Integer.compare(ca, cb);
+ }
+ i += Character.charCount(ca);
+ j += Character.charCount(cb);
+ }
+ return Boolean.compare(i < a.length(), j < b.length());
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java
index df8676cf47..21c424ab01 100644
--- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java
@@ -36,8 +36,9 @@ import lombok.RequiredArgsConstructor;
public final class Ref {
private final long seq;
private final long row;
+ /** A Go <code>int</code>, 64 bits wide. */
@Nullable
- private final Integer block;
+ private final Long block;
@Nullable
public static Ref of(@Nullable final JsonObject json) {
@@ -47,7 +48,7 @@ public final class Ref {
return new Ref(
json.get("seq").getAsLong(),
json.get("row").getAsLong(),
- json.has("block") && !json.get("block").isJsonNull() ? json.get("block").getAsInt() : null
+ json.has("block") && !json.get("block").isJsonNull() ? json.get("block").getAsLong() : null
);
}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java
index be245619c0..8b2e32606c 100644
--- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java
@@ -18,9 +18,9 @@
package org.apache.skywalking.oap.server.ai.agent.conversation.format;
+import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
@@ -30,6 +30,7 @@ import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import lombok.Getter;
+import lombok.RequiredArgsConstructor;
import org.apache.skywalking.oap.server.library.util.StringUtil;
/**
@@ -38,6 +39,20 @@ import org.apache.skywalking.oap.server.library.util.StringUtil;
*/
@Getter
public final class SessionFlowRound {
+ /** The attribute a call node carries the provider bodies joined to it under. */
+ public static final String PROVIDER_BODIES_ATTR = "provider_bodies";
+ public static final String ROLE_REQUEST = "request";
+ public static final String ROLE_RESPONSE = "response";
+ /** The Sessionizer's <code>sessionflow.Ref</code>, field for field and tag for tag. */
+ public static final GoJson.Struct REF = new GoJson.Struct()
+ .field("seq", GoJson.Kind.UINT)
+ .field("row", GoJson.Kind.UINT)
+ .field("block,omitempty", GoJson.Kind.INT_POINTER);
+ /** The Sessionizer's <code>sessionflow.ProviderBody</code>, field for field and tag for tag. */
+ private static final GoJson.Struct PROVIDER_BODY = new GoJson.Struct()
+ .field("role", GoJson.Kind.STRING)
+ .field("ref", GoJson.Kind.STRUCT, REF);
+
private final Header header;
private final List<Node> nodes;
private final List<Relation> relations;
@@ -86,7 +101,12 @@ public final class SessionFlowRound {
if (line.isEmpty()) {
continue;
}
- final JsonObject json = JsonParser.parseString(line).getAsJsonObject();
+ // read as Go reads a frame: a line Go's decoder refuses is no frame, whatever Gson's lenient parser accepts
+ final JsonElement frame = GoJson.parse(line);
+ if (frame == null || !frame.isJsonObject()) {
+ throw new IllegalArgumentException("line " + lineNo + " is not a JSON object");
+ }
+ final JsonObject json = frame.getAsJsonObject();
final String t = SessionDataFile.string(json, "t");
if (t == null) {
throw new IllegalArgumentException("a Session Flow frame without a type");
@@ -103,10 +123,23 @@ public final class SessionFlowRound {
header.validate();
break;
case "node": {
- final Node n = new Node(json);
+ final String attrsText = attrsText(line);
+ // an attribute that names provider bodies but does not read as them is refused, not skipped
+ final List<ProviderBody> bodies;
+ try {
+ bodies = providerBodiesOf(attrsText);
+ } catch (final IllegalArgumentException e) {
+ throw new IllegalArgumentException("line " + lineNo + ": " + e.getMessage());
+ }
+ final Node n = new Node(json, attrsText, bodies);
claim(ids, lineNo, "node", n.getId());
checkRevision(lineNo, n.getRevision(), header);
- checkRefs(lineNo, n.getRef(), n.getRefs(), header);
+ checkRef(lineNo, n.getRef(), header);
+ checkRefs(lineNo, n.getRefs(), header);
+ // a provider body a call names is a reference like any other, so it must lie in the round's range
+ for (final ProviderBody y : bodies) {
+ checkRef(lineNo, y.getRef(), header);
+ }
nodes.add(n);
break;
}
@@ -119,7 +152,7 @@ public final class SessionFlowRound {
throw new IllegalArgumentException(
"line " + lineNo + ": relation " + rel.getId() + " is missing an endpoint or a type");
}
- checkRefs(lineNo, null, rel.getEvidence(), header);
+ checkRefs(lineNo, rel.getEvidence(), header);
relations.add(rel);
break;
}
@@ -197,21 +230,87 @@ public final class SessionFlowRound {
* A reference past the range the header declares it read describes evidence the round did not claim to
* have seen, and its input digest does not cover it.
*/
- private static void checkRefs(final int line, @Nullable final Ref one, final List<Ref> many, final Header header) {
- final List<Ref> all = new ArrayList<>();
- if (one != null) {
- all.add(one);
+ private static void checkRef(final int line, @Nullable final Ref r, final Header header) {
+ if (r == null) {
+ return;
+ }
+ if (r.getSeq() == 0 && r.getRow() == 0) {
+ throw new IllegalArgumentException("line " + line + ": a reference to seq 0 row 0 is not a position");
+ }
+ if (r.getSeq() > header.getThroughSeq()) {
+ throw new IllegalArgumentException("line " + line + ": reference to landed sequence " + r.getSeq()
+ + ", past the round's declared " + header.getThroughSeq());
+ }
+ }
+
+ private static void checkRefs(final int line, final List<Ref> refs, final Header header) {
+ for (final Ref r : refs) {
+ checkRef(line, r, header);
}
- all.addAll(many);
- for (final Ref r : all) {
- if (r.getSeq() == 0 && r.getRow() == 0) {
- throw new IllegalArgumentException("line " + line + ": a reference to seq 0 row 0 is not a position");
+ }
+
+ /**
+ * The text of a node's attrs as Go picks it: the value of the last key that names the field, in any case, as the
+ * Sessionizer's reader keeps it as written.
+ *
+ * @param line a frame that reads as one JSON object, so it reads member by member
+ */
+ @Nullable
+ private static String attrsText(final String line) {
+ String text = null;
+ for (final String[] m : GoJson.members(line)) {
+ if (m[0].equalsIgnoreCase("attrs")) {
+ text = m[1];
}
- if (r.getSeq() > header.getThroughSeq()) {
- throw new IllegalArgumentException("line " + line + ": reference to landed sequence " + r.getSeq()
- + ", past the round's declared " + header.getThroughSeq());
+ }
+ return text;
+ }
+
+ /**
+ * The provider bodies a node carries, read as the Sessionizer's <code>sessionflow.ProviderBodiesOf</code> reads
+ * them: the attributes as a map of values kept as written, so a key given twice keeps its later value and the
+ * earlier one is never decoded, then that value as a list of bodies. The join is made when the round is parsed,
+ * and the round carries it, so no reader repeats it.
+ *
+ * @param attrs a node's attributes as written, or null
+ * @return the bodies, none when the attributes are not an object or do not name them
+ * @throws IllegalArgumentException when the attribute is there but is not a list of bodies: a body whose role is
+ * not request or response, or whose seq or row is zero, names nothing
+ */
+ static List<ProviderBody> providerBodiesOf(@Nullable final String attrs) {
+ final List<String[]> members = attrs == null ? null : GoJson.members(attrs);
+ if (members == null) {
+ return Collections.emptyList();
+ }
+ String raw = null;
+ for (final String[] m : members) {
+ if (PROVIDER_BODIES_ATTR.equals(m[0])) {
+ raw = m[1];
}
}
+ if (raw == null) {
+ return Collections.emptyList();
+ }
+ final JsonArray decoded = GoJson.decodeSlice(raw, PROVIDER_BODY);
+ if (decoded == null) {
+ throw new IllegalArgumentException(PROVIDER_BODIES_ATTR + " is not a list of provider bodies");
+ }
+ final List<ProviderBody> out = new ArrayList<>();
+ for (final JsonElement e : decoded) {
+ final String role = e.getAsJsonObject().get("role").getAsString();
+ if (!ROLE_REQUEST.equals(role) && !ROLE_RESPONSE.equals(role)) {
+ throw new IllegalArgumentException("a provider body has the role \"" + role + "\"");
+ }
+ final Ref ref = Ref.of(e.getAsJsonObject().getAsJsonObject("ref"));
+ // both halves of the position, not just one: sequences and rows are counted from one, so a zero in either
+ // names no landed record
+ if (ref.getSeq() == 0 || ref.getRow() == 0) {
+ throw new IllegalArgumentException("a provider body is at seq " + ref.getSeq() + " row " + ref.getRow()
+ + ", which is no position");
+ }
+ out.add(new ProviderBody(role, ref));
+ }
+ return out;
}
public boolean isIntact() {
@@ -320,13 +419,21 @@ public final class SessionFlowRound {
@Nullable
private final Ref ref;
private final List<Ref> refs;
+ /** The attrs as written, for decoding as Go decodes them; null when the frame has none. */
@Nullable
+ private final String attrsText;
/** The attrs as written, any JSON value, for rendering; null when the frame has none. */
+ @Nullable
private final JsonElement rawAttrs;
- /** The attrs when they are an object, for lookups. */
+ /**
+ * The attrs for lookups, as the Sessionizer decodes them into a map: null when they are not an object, or hold
+ * a number past a double's range, which fails the whole decoding, so every lookup then finds nothing.
+ */
private final JsonObject attrs;
+ /** The provider bodies the node carries, read from its attributes as written. */
+ private final List<ProviderBody> providerBodies;
- Node(final JsonObject json) {
+ Node(final JsonObject json, @Nullable final String attrsText, final List<ProviderBody> providerBodies) {
super(json);
this.kind = SessionDataFile.string(json, "kind");
this.parent = SessionDataFile.string(json, "parent");
@@ -339,15 +446,18 @@ public final class SessionFlowRound {
}
}
this.refs = Collections.unmodifiableList(list);
- this.rawAttrs = json.get("attrs");
- this.attrs = rawAttrs != null && rawAttrs.isJsonObject() ? rawAttrs.getAsJsonObject() : null;
+ this.attrsText = attrsText;
+ this.rawAttrs = attrsText == null ? null : GoJson.parse(attrsText);
+ this.attrs = rawAttrs != null && rawAttrs.isJsonObject() && GoJson.decodesAsAny(attrsText)
+ ? rawAttrs.getAsJsonObject() : null;
+ this.providerBodies = Collections.unmodifiableList(providerBodies);
}
- @Nullable
/**
* @return the attr when it is a string, as the Sessionizer's <code>attrString</code>; null for a number,
* an object or nothing
*/
+ @Nullable
public String attr(final String key) {
if (attrs == null) {
return null;
@@ -375,6 +485,17 @@ public final class SessionFlowRound {
}
}
+ /**
+ * One landed provider body joined to a call: whether it is the request or the response, and the record it
+ * rebuilds from. The body itself is never in a round.
+ */
+ @Getter
+ @RequiredArgsConstructor
+ public static final class ProviderBody {
+ private final String role;
+ private final Ref ref;
+ }
+
@Getter
public static final class Relation extends Entity {
private final String type;
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java
index b057f68c66..f95e35dc15 100644
--- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Times.java
@@ -19,12 +19,11 @@
package org.apache.skywalking.oap.server.ai.agent.conversation.format;
import java.time.Instant;
-import java.time.OffsetDateTime;
+import java.time.LocalDateTime;
+import java.time.YearMonth;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeParseException;
import javax.annotation.Nullable;
-import org.apache.skywalking.oap.server.library.util.StringUtil;
/**
* Session Data and Session Flow carry RFC 3339 strings, because their bytes are digested. A view carries unix
@@ -35,7 +34,7 @@ public final class Times {
* The stamp in a landed file's name: <code>20260904T033423.840913000Z</code>, from the header's <code>at</code>.
*/
private static final DateTimeFormatter FILE_STAMP =
- DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss.nnnnnnnnn'Z'").withZone(ZoneOffset.UTC);
+ DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss.nnnnnnnnn'Z'").withZone(ZoneOffset.UTC);
private Times() {
}
@@ -59,28 +58,105 @@ public final class Times {
}
/**
- * RFC 3339 as Go's <code>time.RFC3339Nano</code> reads it: an offset is accepted, not only Z.
+ * Orders two record times by the instant they name, as the Sessionizer's <code>compareTimes</code> does. RFC 3339
+ * text does not sort as text when its fractions differ in length, and Go's <code>RFC3339Nano</code>, which the
+ * Sessionizer's plugin writes, drops trailing zeros: as text, .11 sorts before .1, and .5 before the whole second.
+ * Two spellings of one instant are the same time, and a time that does not parse sorts after every one
+ * that does; the caller decides between equal times by where each record was read, never by the text.
+ *
+ * @param a a time as written
+ * @param b a time as written
+ * @return negative, zero or positive as <code>a</code> is before, at or after <code>b</code>
+ */
+ public static int compare(final String a, final String b) {
+ final Instant ta = instant(a);
+ final Instant tb = instant(b);
+ if (ta != null && tb != null) {
+ return ta.compareTo(tb);
+ }
+ if ((ta == null) != (tb == null)) {
+ return ta != null ? -1 : 1;
+ }
+ return 0;
+ }
+
+ /**
+ * RFC 3339 as Go's <code>time.Parse(time.RFC3339Nano, s)</code> reads it, so every reader orders and times records
+ * the same way. Measured on Go 1.27, it takes a date and a time of exactly <code>2006-01-02T15:04:05</code>, each
+ * part in range and the month's days counted; a fraction after <code>.</code> or <code>,</code> of one digit or
+ * more, of any length, kept to the nanosecond; and <code>Z</code>, or an offset <code>+hh:mm</code> or
+ * <code>-hh:mm</code> whose hours go to 24 and whose minutes go to 60. Nothing else: no lowercase <code>t</code>
+ * or <code>z</code>, no <code>+0800</code>, no <code>+08</code>, nothing after the zone.
*/
@Nullable
- private static Instant instant(@Nullable final String rfc3339) {
- if (StringUtil.isEmpty(rfc3339)) {
+ private static Instant instant(@Nullable final String s) {
+ if (s == null || s.length() < 19 || s.charAt(4) != '-' || s.charAt(7) != '-' || s.charAt(10) != 'T'
+ || s.charAt(13) != ':' || s.charAt(16) != ':') {
return null;
}
- try {
- return OffsetDateTime.parse(rfc3339, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant();
- } catch (final DateTimeParseException e) {
+ final int year = digits(s, 0, 4);
+ final int month = digits(s, 5, 2);
+ final int day = digits(s, 8, 2);
+ final int hour = digits(s, 11, 2);
+ final int minute = digits(s, 14, 2);
+ final int second = digits(s, 17, 2);
+ if (year < 0 || month < 1 || month > 12 || day < 1 || day > YearMonth.of(year, month).lengthOfMonth()
+ || hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
return null;
}
+ int i = 19;
+ long nanos = 0;
+ if (i < s.length() && (s.charAt(i) == '.' || s.charAt(i) == ',')) {
+ int n = i + 1;
+ while (n < s.length() && s.charAt(n) >= '0' && s.charAt(n) <= '9') {
+ n++;
+ }
+ if (n == i + 1) {
+ return null;
+ }
+ // the first nine digits, as many nanoseconds as they say; the rest are dropped
+ for (int k = 0; k < 9; k++) {
+ final int at = i + 1 + k;
+ nanos = nanos * 10 + (at < n ? s.charAt(at) - '0' : 0);
+ }
+ i = n;
+ }
+ long offsetSeconds = 0;
+ final String zone = s.substring(i);
+ if (!"Z".equals(zone)) {
+ if (zone.length() != 6 || (zone.charAt(0) != '+' && zone.charAt(0) != '-') || zone.charAt(3) != ':') {
+ return null;
+ }
+ final int zh = digits(zone, 1, 2);
+ final int zm = digits(zone, 4, 2);
+ if (zh < 0 || zh > 24 || zm < 0 || zm > 60) {
+ return null;
+ }
+ offsetSeconds = (zh * 60L + zm) * 60L * (zone.charAt(0) == '-' ? -1 : 1);
+ }
+ final long local = LocalDateTime.of(year, month, day, hour, minute, second).toEpochSecond(ZoneOffset.UTC);
+ return Instant.ofEpochSecond(local - offsetSeconds, nanos);
+ }
+
+ /** The number the ASCII digits at a place spell, or -1 when one of them is not a digit. */
+ private static int digits(final String s, final int from, final int count) {
+ int v = 0;
+ for (int k = from; k < from + count; k++) {
+ final char c = s.charAt(k);
+ if (c < '0' || c > '9') {
+ return -1;
+ }
+ v = v * 10 + (c - '0');
+ }
+ return v;
}
/**
* @param rfc3339 a header's <code>at</code>
* @return the stamp used in the landed file's name, or null when absent or unparseable
*/
- public static String fileStamp(final String rfc3339) {
- if (StringUtil.isEmpty(rfc3339)) {
- return null;
- }
+ @Nullable
+ public static String fileStamp(@Nullable final String rfc3339) {
final Instant t = instant(rfc3339);
return t == null ? null : FILE_STAMP.format(t);
}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ToolCallRecord.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ToolCallRecord.java
new file mode 100644
index 0000000000..19c289b940
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/ToolCallRecord.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.format;
+
+import java.util.Map;
+
+/**
+ * A record about one tool call, which names the call by its tool-use id: a workspace change record or a tool
+ * execution record. The tool-use id is the only join between the record and the step of the call.
+ */
+public interface ToolCallRecord {
+ /**
+ * @return the record's own id
+ */
+ String getId();
+
+ /**
+ * @return the tool-use id of the call the record names; empty on a record that names none
+ */
+ String getTool();
+
+ /**
+ * @return when the observation ended, RFC 3339 as written
+ */
+ String getTime();
+
+ /**
+ * @return the record's fields as the Sessionizer prints them, keys in the order its struct lists them
+ */
+ Map<String, Object> fields();
+}
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 a42d18440e..ca72733d91 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
@@ -21,12 +21,8 @@ package org.apache.skywalking.oap.server.ai.agent.conversation.view;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import java.math.BigDecimal;
-import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
@@ -36,17 +32,22 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
-import java.util.regex.Pattern;
+import java.util.function.Function;
+import java.util.function.BiConsumer;
import javax.annotation.Nullable;
import lombok.Getter;
import org.apache.skywalking.oap.server.ai.agent.conversation.fold.ConversationFold;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.ChangesRecord;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.ExecutionRecord;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.FileNames;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.GoStrings;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.Ref;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.Times;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.ToolCallRecord;
import org.apache.skywalking.oap.server.library.util.StringUtil;
/**
@@ -61,29 +62,46 @@ public final class ConversationViewBuilder {
/** The readable text a node carries is clipped to this many bytes; the full size is in <code>bytes</code>. */
static final int PREVIEW_BYTES = 2000;
private static final int MAX_DEPTH = 12;
- private static final Pattern INTEGER_LITERAL = Pattern.compile("-?\\d+");
- /** A parent walk stops here; a well-formed fold is far shallower, a malformed one must not loop. */
- private static final int MAX_ANCESTORS = 64;
static final String STATE_VERIFIED = "verified";
static final String STATE_INCOMPLETE = "incomplete";
static final String STATE_MISMATCH = "mismatch";
- 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";
+ /** The typed values the Sessionizer's view reads out of records and attributes, as its Go structs declare them. */
+ private static final GoJson.Struct JOURNAL_ROW = new GoJson.Struct()
+ .field("type", GoJson.Kind.STRING)
+ .field("result", GoJson.Kind.STRUCT, new GoJson.Struct()
+ .field("surface", GoJson.Kind.STRING)
+ .field("summary", GoJson.Kind.STRING)
+ .field("verdict", GoJson.Kind.STRING)
+ .field("refuted_claims", GoJson.Kind.STRUCTS, new GoJson.Struct()
+ .field("claim", GoJson.Kind.STRING)));
+ private static final GoJson.Struct QUEUED_COMMAND = new GoJson.Struct()
+ .field("type", GoJson.Kind.STRING)
+ .field("prompt", GoJson.Kind.STRUCTS, new GoJson.Struct()
+ .field("text", GoJson.Kind.STRING));
+ private static final GoJson.Struct DURATION = new GoJson.Struct()
+ .field("durationMs", GoJson.Kind.INT);
+ private static final GoJson.Struct SESSION_ATTRS = new GoJson.Struct()
+ .field("provider_bodies_landed", GoJson.Kind.INT);
+ private static final GoJson.Struct USAGE_AT = new GoJson.Struct()
+ .field("usage_at", GoJson.Kind.STRUCT_POINTER, SessionFlowRound.REF);
private final ConversationFold fold;
private final List<RoundInput> rounds;
- private final Map<Long, SessionDataFile> files;
+ /** By seq, so every walk over the files is in landed order. */
+ private final TreeMap<Long, SessionDataFile> files;
private final List<String> problems;
/** Every timed record's moment in nanoseconds, the precision the Sessionizer computes intervals with. */
private final Map<Ref, Long> at = new HashMap<>();
+ /**
+ * Each file's lane by seq: the directory it lands in, its stream's or its workflow run's. A position orders records
+ * only inside one lane.
+ */
+ private final Map<Long, String> lanes = new HashMap<>();
/** Each step's workspace change ids, in the order the document lists the records; filled by {@link #workspaceChanges}. */
private final Map<String, List<String>> changesByStep = new HashMap<>();
- /** Each call step's joined provider bodies, its request then its response; filled by {@link #providerBodies}. */
+ /** Each step's tool execution record ids, in the order the document lists the records; filled by {@link #toolExecutions}. */
+ private final Map<String, List<String>> executionsByStep = new HashMap<>();
+ /** Each call step's joined provider bodies, its request then its response; filled by {@link #joinProviderBodies}. */
private final Map<String, List<Map<String, Object>>> providerBodiesByStep = new HashMap<>();
/**
@@ -99,9 +117,10 @@ public final class ConversationViewBuilder {
final List<String> problems) {
this.fold = fold;
this.rounds = rounds;
- this.files = files;
+ this.files = new TreeMap<>(files);
this.problems = problems;
for (final SessionDataFile f : files.values()) {
+ lanes.put(f.getHeader().getSeq(), FileNames.lane(f.getHeader()));
for (final SessionDataFile.Record r : f.getRecords()) {
if (r.getTimeNanos() != 0) {
at.put(new Ref(f.getHeader().getSeq(), r.getRow(), null), r.getTimeNanos());
@@ -118,15 +137,8 @@ public final class ConversationViewBuilder {
final Chain chain = chain();
// before the nodes are rendered: each step lists the ids of its records
final List<Map<String, Object>> workspaceChanges = workspaceChanges();
- final int providerBodies = providerBodies();
- int capturedPrompts = 0;
- for (final List<Map<String, Object>> bodies : providerBodiesByStep.values()) {
- for (final Map<String, Object> b : bodies) {
- if (ROLE_REQUEST.equals(b.get("role"))) {
- capturedPrompts++;
- }
- }
- }
+ final List<Map<String, Object>> toolExecutions = toolExecutions();
+ final int capturedPrompts = joinProviderBodies();
final Map<String, Object> doc = new LinkedHashMap<>();
doc.put("format", ViewYaml.FORMAT);
@@ -151,7 +163,7 @@ public final class ConversationViewBuilder {
summary.put("rounds", chain.rounds.size());
summary.put("unresolved", fold.openUnresolved().size());
summary.put("changes", workspaceChanges.size());
- summary.put("provider_bodies", providerBodies);
+ summary.put("provider_bodies", landedProviderBodies());
summary.put("captured_prompts", capturedPrompts);
final SessionFlowRound.Node sessionNode = fold.node(sessionNodeId());
summary.put("from", sessionNode == null ? 0L : Times.millis(sessionNode.attr("from_time")));
@@ -171,6 +183,7 @@ public final class ConversationViewBuilder {
doc.put("relations", relations());
doc.put("unresolved", unresolved());
doc.put("workspace_changes", workspaceChanges);
+ doc.put("tool_executions", toolExecutions);
return doc;
}
@@ -189,7 +202,7 @@ public final class ConversationViewBuilder {
others.add(s);
}
}
- Collections.sort(others);
+ others.sort(GoStrings.ORDER);
out.addAll(others);
return out;
}
@@ -375,10 +388,7 @@ public final class ConversationViewBuilder {
private List<Map<String, Object>> files() {
final List<Map<String, Object>> out = new ArrayList<>();
- final List<Long> seqs = new ArrayList<>(files.keySet());
- Collections.sort(seqs);
- for (final Long seq : seqs) {
- final SessionDataFile f = files.get(seq);
+ for (final SessionDataFile f : files.values()) {
final SessionDataFile.Header h = f.getHeader();
final Map<String, Object> m = new LinkedHashMap<>();
m.put("file", FileNames.dataFile(h));
@@ -407,6 +417,8 @@ public final class ConversationViewBuilder {
int tools;
long from;
long to;
+ /** When the talk began, in nanoseconds, which is what talks are ordered by. */
+ long began;
boolean child;
String segment = "";
String reply = "";
@@ -417,9 +429,9 @@ public final class ConversationViewBuilder {
private static final class Overview {
String title = "";
- Map<String, Object> kinds;
- Map<String, Object> relationTypes;
- Map<String, Object> quality;
+ Map<String, Integer> kinds;
+ Map<String, Integer> relationTypes;
+ Map<String, Integer> quality;
List<TalkRow> talks;
List<Map<String, Object>> streams;
List<Map<String, Object>> segments;
@@ -427,27 +439,26 @@ public final class ConversationViewBuilder {
private Overview overview() {
final Overview o = new Overview();
- final TreeMap<String, Object> kinds = new TreeMap<>();
+ o.kinds = new TreeMap<>(GoStrings.ORDER);
for (final SessionFlowRound.Node n : fold.getNodes().values()) {
- kinds.merge(nullToEmpty(n.getKind()), 1, (a, b) -> (Integer) a + (Integer) b);
+ o.kinds.merge(nullToEmpty(n.getKind()), 1, Integer::sum);
if ("session".equals(n.getKind())) {
o.title = nullToEmpty(n.attr("title"));
}
}
- final TreeMap<String, Object> quality = new TreeMap<>();
- final TreeMap<String, Object> rels = new TreeMap<>();
+ o.relationTypes = new TreeMap<>(GoStrings.ORDER);
+ o.quality = new TreeMap<>(GoStrings.ORDER);
for (final SessionFlowRound.Relation r : fold.getRelations().values()) {
- rels.merge(nullToEmpty(r.getType()), 1, (a, b) -> (Integer) a + (Integer) b);
- quality.merge(nullToEmpty(r.getQuality()), 1, (a, b) -> (Integer) a + (Integer) b);
+ o.relationTypes.merge(nullToEmpty(r.getType()), 1, Integer::sum);
+ o.quality.merge(nullToEmpty(r.getQuality()), 1, Integer::sum);
}
- o.kinds = kinds;
- o.relationTypes = rels;
- o.quality = quality;
- final Map<String, Boolean> childStream = new HashMap<>();
+ // a child agent's talk, and only that: an auxiliary stream is the same agent carrying a different prompt, not
+ // a child, as the Sessionizer reads it
+ final Set<String> childStreams = new HashSet<>();
for (final SessionFlowRound.Node st : streamNodes()) {
- if (!"main".equals(st.attr("role"))) {
- childStream.put(st.getStream(), true);
+ if ("child".equals(st.attr("role"))) {
+ childStreams.add(st.getStream());
}
}
final Map<String, String> inSegment = new HashMap<>();
@@ -460,16 +471,21 @@ public final class ConversationViewBuilder {
for (final SessionFlowRound.Node t : fold.nodesOfKind("talk")) {
final TalkRow row = new TalkRow();
row.node = t;
- row.child = childStream.containsKey(t.getStream());
+ row.child = childStreams.contains(t.getStream());
final long[] span = span(t);
row.from = span[0];
row.to = span[1];
+ row.began = beganAt(t);
row.segment = inSegment.getOrDefault(t.getId(), "");
walkCounts(t.getId(), row);
row.labelAt = labelRefs(t);
row.replyAt = replyRef(t);
talks.add(row);
}
+ // Across streams the order is time, not landed position: a position orders records within one stream only,
+ // and a child's file can land before its parent's. Every timed talk comes before every untimed one, timed
+ // talks by time, and the sort is stable, so position decides among equals, as the Sessionizer orders them.
+ talks.sort(Comparator.comparing((TalkRow t) -> t.began == 0).thenComparingLong(t -> t.began));
for (final TalkRow row : talks) {
for (final Ref r : row.labelAt) {
final String text = readableAt(r);
@@ -602,19 +618,28 @@ public final class ConversationViewBuilder {
}
final Map<String, String> parent = new HashMap<>();
final Map<String, List<Map<String, Object>>> openedBy = new HashMap<>();
+ // each stream's origins in the order their steps happened, so a stream with several candidate origins lists
+ // them in one order on every read, the Sessionizer's; each stream's on their own, since one order over every
+ // stream's origins is not the same: an untimed step holds back the rest of its lane
+ final Map<String, List<SessionFlowRound.Relation>> startsOf = new HashMap<>();
for (final SessionFlowRound.Relation r : fold.getRelations().values()) {
- if (!"starts".equals(r.getType())) {
- continue;
+ // an origin whose step is in no stream is not listed, so it takes no part in the order either
+ final SessionFlowRound.Node step = fold.node(r.getFrom());
+ if ("starts".equals(r.getType()) && step != null && StringUtil.isNotEmpty(step.getStream())) {
+ startsOf.computeIfAbsent(r.getTo(), k -> new ArrayList<>()).add(r);
}
- final SessionFlowRound.Node n = fold.node(r.getFrom());
- if (n != null && StringUtil.isNotEmpty(n.getStream())) {
- parent.put(r.getTo(), n.getStream());
+ }
+ final Function<SessionFlowRound.Relation, Order.Point> stepPoint = r -> pointOf(fold.node(r.getFrom()).getRef());
+ for (final Map.Entry<String, List<SessionFlowRound.Relation>> stream : startsOf.entrySet()) {
+ for (final SessionFlowRound.Relation r : Order.inOrder(stream.getValue(), stepPoint, RELATION_TIE)) {
+ final SessionFlowRound.Node n = fold.node(r.getFrom());
+ parent.put(stream.getKey(), n.getStream());
final Map<String, Object> origin = new LinkedHashMap<>();
origin.put("step", r.getFrom());
origin.put("stream", n.getStream());
origin.put("talk", talkOf(r.getFrom()));
origin.put("quality", nullToEmpty(r.getQuality()));
- openedBy.computeIfAbsent(r.getTo(), x -> new ArrayList<>()).add(origin);
+ openedBy.computeIfAbsent(stream.getKey(), x -> new ArrayList<>()).add(origin);
}
}
final Map<String, String> journalNames = journalNames();
@@ -632,7 +657,7 @@ public final class ConversationViewBuilder {
m.put("role", nullToEmpty(st.attr("role")));
m.put("label", label);
m.put("parent", parent.getOrDefault(st.getId(), ""));
- m.put("records", (int) st.attrNumber("records"));
+ m.put("records", (long) st.attrNumber("records"));
m.put("steps", steps.getOrDefault(nullToEmpty(st.getStream()), 0));
m.put("talk", firstTalk.getOrDefault(nullToEmpty(st.getStream()), ""));
m.put("named_by", namedBy);
@@ -647,10 +672,7 @@ public final class ConversationViewBuilder {
*/
private Map<String, String> journalNames() {
final Map<String, String> names = new HashMap<>();
- final List<Long> seqs = new ArrayList<>(files.keySet());
- Collections.sort(seqs);
- for (final Long seq : seqs) {
- final SessionDataFile f = files.get(seq);
+ for (final SessionDataFile f : files.values()) {
if (StringUtil.isEmpty(f.getHeader().getBatch())) {
continue;
}
@@ -659,96 +681,55 @@ public final class ConversationViewBuilder {
if (StringUtil.isEmpty(child) || names.containsKey(child)) {
continue;
}
- for (final String raw : candidates(rec)) {
- final JsonObject row;
- try {
- final JsonElement parsed = JsonParser.parseString(raw);
- if (!parsed.isJsonObject()) {
- continue;
- }
- row = parsed.getAsJsonObject();
- } catch (final RuntimeException e) {
- continue;
- }
- // the row must decode as Go's typed struct, or the candidate is skipped
- if (!isStringOrAbsent(row, "type") || !isObjectOrAbsent(row, "result")) {
- continue;
- }
- if (!"result".equals(string(row, "type"))) {
- continue;
- }
- final JsonObject result = row.has("result") && row.get("result").isJsonObject()
- ? row.getAsJsonObject("result") : new JsonObject();
- if (!isStringOrAbsent(result, "surface") || !isStringOrAbsent(result, "summary")
- || !isStringOrAbsent(result, "verdict") || !isClaimsOrAbsent(result)) {
- continue;
- }
- String name = string(result, "surface");
- if (StringUtil.isEmpty(name)) {
- name = string(result, "summary");
- }
- if (StringUtil.isEmpty(name) && StringUtil.isNotEmpty(string(result, "verdict"))) {
- name = string(result, "verdict");
- final JsonElement refuted = result.get("refuted_claims");
- if (refuted != null && refuted.isJsonArray() && refuted.getAsJsonArray().size() > 0) {
- final JsonElement first = refuted.getAsJsonArray().get(0);
- if (first.isJsonObject() && StringUtil.isNotEmpty(string(first.getAsJsonObject(), "claim"))) {
- name += " · " + string(first.getAsJsonObject(), "claim");
- }
- }
- }
- name = shortName(name);
- if (!name.isEmpty()) {
- names.put(child, name);
- }
- break;
+ final String name = resultName(rec);
+ if (StringUtil.isNotEmpty(name)) {
+ names.put(child, name);
}
}
}
return names;
}
- private static boolean isObjectOrAbsent(final JsonObject json, final String key) {
- final JsonElement e = json.get(key);
- return e == null || e.isJsonNull() || e.isJsonObject();
- }
-
/**
- * @return whether <code>refuted_claims</code> is absent or a list of objects whose <code>claim</code> is a
- * string, the shape Go decodes
+ * @param rec a record of a run's journal
+ * @return the name the record's first result row gives the child it started: its surface, else its summary, else
+ * its verdict and the first claim it refuted; empty when that row gives none; null when no candidate of the record
+ * is a result row
*/
- private static boolean isClaimsOrAbsent(final JsonObject result) {
- final JsonElement e = result.get("refuted_claims");
- if (e == null || e.isJsonNull()) {
- return true;
- }
- if (!e.isJsonArray()) {
- return false;
- }
- for (final JsonElement x : e.getAsJsonArray()) {
- if (!x.isJsonObject() || !isStringOrAbsent(x.getAsJsonObject(), "claim")) {
- return false;
+ @Nullable
+ static String resultName(final SessionDataFile.Record rec) {
+ for (final String raw : candidates(rec)) {
+ final JsonObject row = GoJson.decode(raw, JOURNAL_ROW);
+ if (row == null || !"result".equals(row.get("type").getAsString())) {
+ continue;
+ }
+ final JsonObject result = row.getAsJsonObject("result");
+ String name = result.get("surface").getAsString();
+ if (name.isEmpty()) {
+ name = result.get("summary").getAsString();
+ }
+ if (name.isEmpty() && !result.get("verdict").getAsString().isEmpty()) {
+ name = result.get("verdict").getAsString();
+ final JsonElement refuted = result.get("refuted_claims");
+ if (refuted.isJsonArray() && refuted.getAsJsonArray().size() > 0) {
+ final String claim = refuted.getAsJsonArray().get(0).getAsJsonObject().get("claim").getAsString();
+ if (!claim.isEmpty()) {
+ name += " · " + claim;
+ }
+ }
}
+ return shortName(name);
}
- return true;
+ return null;
}
- private static String shortName(@Nullable final String s) {
- if (s == null) {
- return "";
- }
+ /**
+ * A name cut to what a label holds: its words joined by one space, clipped to 160 bytes, and marked when clipped.
+ */
+ private static String shortName(final String s) {
final String joined = String.join(" ", fields(s));
- final int limit = 160;
- final byte[] bytes = joined.getBytes(StandardCharsets.UTF_8);
- if (bytes.length <= limit) {
- return joined;
- }
- // Go backs up to a rune boundary here, unlike clip, so no replacement character
- int cut = limit;
- while (cut > 0 && (bytes[cut] & 0xC0) == 0x80) {
- cut--;
- }
- return new String(bytes, 0, cut, StandardCharsets.UTF_8) + "…";
+ final String clipped = clipBytes(joined, 160);
+ return clipped.length() < joined.length() ? clipped + "…" : joined;
}
/**
@@ -856,9 +837,10 @@ public final class ConversationViewBuilder {
}
out.put("refs", refs);
}
- if (n.getRawAttrs() != null) {
+ final JsonElement attrs = withoutProviderBodies(n.getRawAttrs());
+ if (attrs != null) {
// as the Sessionizer prints the raw attrs: an empty object stays {}, an explicit null stays null
- out.put("attrs", jsonToValue(n.getRawAttrs()));
+ out.put("attrs", GoJson.toValue(attrs));
}
// text, state, bytes; then usage, flags, dropped; then the talk keys; then the tool keys
@@ -876,7 +858,7 @@ public final class ConversationViewBuilder {
if (dropped != null && dropped.size() > 0) {
final List<Object> drops = new ArrayList<>();
for (final JsonElement d : dropped) {
- drops.add(d.isJsonObject() ? jsonToMap(d.getAsJsonObject()) : d.toString());
+ drops.add(d.isJsonObject() ? GoJson.toMap(d.getAsJsonObject()) : d.toString());
}
content.put("dropped", drops);
}
@@ -893,7 +875,7 @@ public final class ConversationViewBuilder {
final JsonObject usage = usageAt(n);
if (usage != null) {
// as the Sessionizer prints the record's raw usage object, an empty one included
- content.put("usage", jsonToMap(usage));
+ content.put("usage", GoJson.toMap(usage));
}
// sessionview.Node lists text, state, bytes, then usage, flags, dropped
for (final String key : new String[] {"text", "state", "bytes", "usage", "flags", "dropped"}) {
@@ -941,6 +923,10 @@ public final class ConversationViewBuilder {
if (changes != null && !changes.isEmpty()) {
out.put("changes", new ArrayList<>(changes));
}
+ final List<String> executions = executionsByStep.get(n.getId());
+ if (executions != null && !executions.isEmpty()) {
+ out.put("executions", new ArrayList<>(executions));
+ }
final List<Map<String, Object>> bodies = providerBodiesByStep.get(n.getId());
if (bodies != null && !bodies.isEmpty()) {
out.put("provider_bodies", bodies);
@@ -954,16 +940,18 @@ public final class ConversationViewBuilder {
out.put("children", children);
}
}
- // every relation touching the node, by relation id and then direction, as the Sessionizer lists them
- final List<Map<String, Object>> edges = new ArrayList<>();
+ // every relation touching the node, in the order the relations happened, as the Sessionizer lists them
+ final List<Map<String, Object>> touching = new ArrayList<>();
for (final SessionFlowRound.Relation r : fold.relationsFrom(n.getId())) {
- edges.add(edge(r, r.getTo(), "out"));
+ touching.add(edge(r, r.getTo(), "out"));
}
for (final SessionFlowRound.Relation r : fold.relationsTo(n.getId())) {
- edges.add(edge(r, r.getFrom(), "in"));
+ touching.add(edge(r, r.getFrom(), "in"));
}
- edges.sort(Comparator.comparing((Map<String, Object> e) -> (String) e.get("id"))
- .thenComparing(e -> (String) e.get("dir")));
+ final List<Map<String, Object>> edges = Order.inOrder(
+ touching, e -> relationPoint(fold.getRelations().get((String) e.get("id"))),
+ Comparator.comparing((Map<String, Object> e) -> (String) e.get("id"), GoStrings.ORDER)
+ .thenComparing(e -> (String) e.get("dir")));
for (final Map<String, Object> e : edges) {
e.remove("id");
}
@@ -986,8 +974,11 @@ public final class ConversationViewBuilder {
}
SessionFlowRound.Node top = n;
boolean covered = false;
- SessionFlowRound.Node cur = n;
- for (int i = 0; cur != null && i < MAX_ANCESTORS; i++) {
+ // the whole parent chain, as the Sessionizer walks it; a malformed fold whose parents loop stops where it
+ // comes back round
+ final Set<String> walked = new HashSet<>();
+ for (SessionFlowRound.Node cur = n; cur != null && walked.add(cur.getId());
+ cur = StringUtil.isEmpty(cur.getParent()) ? null : fold.node(cur.getParent())) {
if ("talk".equals(cur.getKind())) {
covered = true;
break;
@@ -995,7 +986,6 @@ public final class ConversationViewBuilder {
if ("run".equals(cur.getKind()) || isStep(cur.getKind())) {
top = cur;
}
- cur = StringUtil.isEmpty(cur.getParent()) ? null : fold.node(cur.getParent());
}
if (!covered) {
roots.put(top.getId(), top);
@@ -1025,7 +1015,7 @@ public final class ConversationViewBuilder {
}
private static void fill(final Map<String, Object> content, final Map<String, Object> tool,
- final SessionDataFile.Record rec, @Nullable final Integer block) {
+ final SessionDataFile.Record rec, @Nullable final Long block) {
final SessionDataFile.Part p = partAt(rec, block);
if (p == null) {
final String text = clip(readable(rec));
@@ -1061,19 +1051,24 @@ public final class ConversationViewBuilder {
@Nullable
private JsonObject usageAt(final SessionFlowRound.Node n) {
- if (!"llm.call".equals(n.getKind()) || n.getAttrs() == null || !n.getAttrs().has("usage_at")
- || !n.getAttrs().get("usage_at").isJsonObject()) {
+ if (!"llm.call".equals(n.getKind())) {
+ return null;
+ }
+ // as the Sessionizer decodes the attrs into a struct: the key in any case, and a reference with nothing in it
+ // names no record
+ final JsonObject attrs = GoJson.decode(n.getAttrsText(), USAGE_AT);
+ if (attrs == null || !attrs.get("usage_at").isJsonObject()) {
return null;
}
- final SessionDataFile.Record at = record(Ref.of(n.getAttrs().getAsJsonObject("usage_at")));
+ final SessionDataFile.Record at = record(Ref.of(attrs.getAsJsonObject("usage_at")));
return at == null ? null : at.usage();
}
private static void fillResult(final Map<String, Object> tool, final SessionDataFile.Record rec,
- @Nullable final Integer block) {
+ @Nullable final Long block) {
SessionDataFile.Part p = null;
if (block != null && block < rec.getParts().size()) {
- p = rec.getParts().get(block);
+ p = rec.getParts().get(block.intValue());
} else {
for (final SessionDataFile.Part x : rec.getParts()) {
if ("result".equals(x.getKind())) {
@@ -1134,26 +1129,7 @@ public final class ConversationViewBuilder {
if (!"turn.duration".equals(n.getKind())) {
return;
}
- long durationMs = 0;
- for (final String raw : candidates(rec)) {
- try {
- final JsonElement e = JsonParser.parseString(raw);
- if (e.isJsonObject() && e.getAsJsonObject().has("durationMs")) {
- final JsonElement d = e.getAsJsonObject().get("durationMs");
- if (!d.isJsonPrimitive() || !d.getAsJsonPrimitive().isNumber()
- || !INTEGER_LITERAL.matcher(d.getAsString()).matches()) {
- // not the integer Go decodes into; the candidate is skipped
- continue;
- }
- durationMs = Long.parseLong(d.getAsString());
- if (durationMs != 0) {
- break;
- }
- }
- } catch (final RuntimeException ignored) {
- // not JSON; the next candidate may be
- }
- }
+ final long durationMs = reportedDuration(rec);
if (durationMs == 0) {
return;
}
@@ -1165,11 +1141,28 @@ public final class ConversationViewBuilder {
content.remove("text");
}
+ /**
+ * @param rec a turn duration record
+ * @return the milliseconds the runtime reported for the turn, from the first candidate that reports any; 0 when
+ * none does
+ */
+ static long reportedDuration(final SessionDataFile.Record rec) {
+ for (final String raw : candidates(rec)) {
+ final JsonObject probe = GoJson.decode(raw, DURATION);
+ if (probe != null && probe.get("durationMs").getAsLong() != 0) {
+ return probe.get("durationMs").getAsLong();
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * @return every relation of the fold, in the order they happened, each at the earliest record that supports it
+ */
private List<Map<String, Object>> relations() {
- final List<SessionFlowRound.Relation> sorted = new ArrayList<>(fold.getRelations().values());
- sorted.sort(Comparator.comparing(SessionFlowRound.Relation::getId));
final List<Map<String, Object>> out = new ArrayList<>();
- for (final SessionFlowRound.Relation r : sorted) {
+ final List<SessionFlowRound.Relation> all = new ArrayList<>(fold.getRelations().values());
+ for (final SessionFlowRound.Relation r : Order.inOrder(all, this::relationPoint, RELATION_TIE)) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.getId());
m.put("type", nullToEmpty(r.getType()));
@@ -1193,7 +1186,7 @@ public final class ConversationViewBuilder {
private List<Map<String, Object>> unresolved() {
final List<SessionFlowRound.Unresolved> sorted = new ArrayList<>(fold.getUnresolved().values());
- sorted.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId));
+ sorted.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId, GoStrings.ORDER));
final List<Map<String, Object>> out = new ArrayList<>();
for (final SessionFlowRound.Unresolved u : sorted) {
final Map<String, Object> m = new LinkedHashMap<>();
@@ -1207,505 +1200,239 @@ public final class ConversationViewBuilder {
return out;
}
- // ---------------------------------------------------------------- workspace changes
+ // ---------------------------------------------------------------- workspace changes and tool executions
/**
* Every workspace change record the session's files carry, each joined to its step. Two places hold them: a
* result record of an editing tool carries the runtime's own patch as a data part beside the raw result, and
- * a <code>changes</code> file the plugin's adapter landed holds one record per line. The join is the tool-use
- * id, which the record names and the step's call part carries; nothing is matched by time. Fills
+ * a <code>changes</code> file the plugin's adapter landed holds one record per line. Fills
* {@link #changesByStep} on the way, which {@link #step} reads, so this runs before the nodes are rendered.
*
- * @return the entries in the order the document lists them: by time, the runtime's record before the plugin's
- * for one call, then by id
+ * @return the entries in the order the document lists them: by the instant each names, the runtime's record
+ * before the plugin's for one call, then by where each was read
*/
private List<Map<String, Object>> workspaceChanges() {
- // which step carries which tool-use id, from the call part each tool step points at
- final Map<String, String> stepOf = new HashMap<>();
- for (final SessionFlowRound.Node n : fold.getNodes().values()) {
- if (!"tool".equals(n.getKind()) && !"agent.call".equals(n.getKind()) || n.getRef() == null) {
- continue;
- }
- final SessionDataFile.Record rec = record(n.getRef());
- final SessionDataFile.Part p = rec == null ? null : partAt(rec, n.getRef().getBlock());
- if (p != null && "call".equals(p.getKind()) && StringUtil.isNotEmpty(p.getId())) {
- stepOf.put(p.getId(), n.getId());
- }
- }
- final List<WorkspaceChange> entries = new ArrayList<>();
+ final Map<String, String> stepOf = stepsByToolUse();
+ final List<Joined<ChangesRecord>> entries = new ArrayList<>();
final Set<String> seen = new HashSet<>();
- // the runtime's own patches, on the result records the tool steps read
+ final BiConsumer<Ref, String> collect = (ref, data) -> {
+ final ChangesRecord r = ChangesRecord.decode(data);
+ // the id is the tool-use id, so two producers observing one call share it; who captured the record
+ // tells them apart, and a line landed twice by an interrupted pass is the same on both counts
+ if (r != null && seen.add(r.getCapturedBy() + "|" + r.getId())) {
+ entries.add(new Joined<>(r, stepOf.get(r.getTool()), ref));
+ }
+ };
+ // the runtime's own patches, on the result records a tool step reads after its call
for (final SessionFlowRound.Node n : fold.getNodes().values()) {
- if (!"tool".equals(n.getKind()) || n.getRefs().size() < 2) {
+ if (!"tool".equals(n.getKind())) {
continue;
}
for (int i = 1; i < n.getRefs().size(); i++) {
final Ref ref = n.getRefs().get(i);
- collectChanges(record(ref), ref.getSeq(), ref.getRow(), stepOf, seen, entries);
+ forEachDataPart(ref.getSeq(), record(ref), collect);
}
}
- // the plugin's records, from the files its adapter landed, in seq order
- final List<Long> seqs = new ArrayList<>(files.keySet());
- Collections.sort(seqs);
- for (final Long seq : seqs) {
- final SessionDataFile f = files.get(seq);
- if (!"changes".equals(f.getHeader().getKind())) {
- continue;
- }
+ // the plugin's records, from the files its adapter landed
+ for (final SessionDataFile f : filesOfKind("changes")) {
for (final SessionDataFile.Record rec : f.getRecords()) {
- collectChanges(rec, seq, rec.getRow(), stepOf, seen, entries);
+ forEachDataPart(f.getHeader().getSeq(), rec, collect);
}
}
entries.sort((a, b) -> {
- final int byTime = a.record.getTime().compareTo(b.record.getTime());
+ final int byTime = Times.compare(a.record.getTime(), b.record.getTime());
if (byTime != 0) {
return byTime;
}
- if (!a.record.getCapturedBy().equals(b.record.getCapturedBy())) {
- // the runtime's record is listed first, and a viewer showing one prefers it
- if (ChangesRecord.CAPTURED_BY_CLAUDE_CODE.equals(a.record.getCapturedBy())) {
- return -1;
- }
- return ChangesRecord.CAPTURED_BY_CLAUDE_CODE.equals(b.record.getCapturedBy()) ? 1 : 0;
- }
- return a.record.getId().compareTo(b.record.getId());
+ // the runtime's record is listed first, and a viewer showing one prefers it
+ final int byProducer = Boolean.compare(!ChangesRecord.CAPTURED_BY_CLAUDE_CODE.equals(a.record.getCapturedBy()),
+ !ChangesRecord.CAPTURED_BY_CLAUDE_CODE.equals(b.record.getCapturedBy()));
+ return byProducer != 0 ? byProducer : Order.comparePositions(pointOf(a.ref), pointOf(b.ref));
});
- final List<Map<String, Object>> out = new ArrayList<>(entries.size());
- for (final WorkspaceChange e : entries) {
- final Map<String, Object> m = new LinkedHashMap<>();
- m.put("step", e.step);
- m.put("ref", e.ref.toMap());
- m.putAll(e.record.fields());
- out.add(m);
- if (!e.step.isEmpty()) {
- changesByStep.computeIfAbsent(e.step, k -> new ArrayList<>()).add(e.record.getId());
- }
- }
- return out;
+ return listed(entries, changesByStep);
}
- private static void collectChanges(@Nullable final SessionDataFile.Record rec, final long seq, final long row,
- final Map<String, String> stepOf, final Set<String> seen,
- final List<WorkspaceChange> entries) {
- if (rec == null) {
- return;
- }
- final List<SessionDataFile.Part> parts = rec.getParts();
- for (int b = 0; b < parts.size(); b++) {
- final SessionDataFile.Part p = parts.get(b);
- if (!"data".equals(p.getKind())) {
- continue;
- }
- final ChangesRecord r = ChangesRecord.decode(p.data());
- if (r == null) {
- continue;
- }
- // the id is the tool-use id, so two producers observing one call share it; who captured the record
- // tells them apart, and a line landed twice by an interrupted pass is the same on both counts
- if (!seen.add(r.getCapturedBy() + "|" + r.getId())) {
- continue;
+ /**
+ * Every tool execution record the session's <code>execution</code> files carry, each joined to the step of the
+ * call it observed: what an observer around the call saw it do, such as which MCP server ran it, how it ended and
+ * how long it took. One call can have several records, one per observation, so a step lists them all. Fills
+ * {@link #executionsByStep} on the way, which {@link #step} reads, so this runs before the nodes are rendered.
+ *
+ * @return the entries in the order the document lists them: by the instant each names, then by where each was read
+ */
+ private List<Map<String, Object>> toolExecutions() {
+ final Map<String, String> stepOf = stepsByToolUse();
+ final List<Joined<ExecutionRecord>> entries = new ArrayList<>();
+ final Set<String> seen = new HashSet<>();
+ final BiConsumer<Ref, String> collect = (ref, data) -> {
+ final ExecutionRecord r = ExecutionRecord.decode(data);
+ // kept once by its own id: the same line landed twice, by a hook handed its event again or by an
+ // interrupted pass, is one record
+ if (r != null && seen.add(r.getId())) {
+ entries.add(new Joined<>(r, stepOf.get(r.getTool()), ref));
+ }
+ };
+ for (final SessionDataFile f : filesOfKind("execution")) {
+ for (final SessionDataFile.Record rec : f.getRecords()) {
+ forEachDataPart(f.getHeader().getSeq(), rec, collect);
}
- entries.add(new WorkspaceChange(nullToEmpty(stepOf.get(r.getTool())), new Ref(seq, row, b), r));
}
+ entries.sort((a, b) -> {
+ final int byTime = Times.compare(a.record.getTime(), b.record.getTime());
+ return byTime != 0 ? byTime : Order.comparePositions(pointOf(a.ref), pointOf(b.ref));
+ });
+ return listed(entries, executionsByStep);
}
- /** One change record with the step it joins to, or none, and the record it was read from. */
- private static final class WorkspaceChange {
+ /**
+ * A record about a tool call, the step of that call, and where the record was read. The join is the tool-use id
+ * the record names, which the step's call part carries; nothing is matched by name or by time. A record whose
+ * call is not a step of the document is kept with no step.
+ */
+ private static final class Joined<R extends ToolCallRecord> {
+ final R record;
final String step;
final Ref ref;
- final ChangesRecord record;
- WorkspaceChange(final String step, final Ref ref, final ChangesRecord record) {
- this.step = step;
- this.ref = ref;
+ Joined(final R record, @Nullable final String step, final Ref ref) {
this.record = record;
+ this.step = nullToEmpty(step);
+ this.ref = ref;
}
}
- // ---------------------------------------------------------------- provider bodies
-
/**
- * Joins the session's provider bodies to their calls, as the Sessionizer's view joins them. A body is a
- * <code>provider_body</code> 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.
- *
- * <p>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
+ * @param byStep filled with each step's record ids, in the order of the entries
+ * @return each entry as the document lists it: its step, its reference, then the record's own fields
*/
- private int providerBodies() {
- final List<Body> out = new ArrayList<>();
- final Set<String> seen = new HashSet<>();
- final List<Long> 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<Call> 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"));
- }
+ private static <R extends ToolCallRecord> List<Map<String, Object>> listed(
+ final List<Joined<R>> entries, final Map<String, List<String>> byStep) {
+ final List<Map<String, Object>> out = new ArrayList<>(entries.size());
+ for (final Joined<R> e : entries) {
+ final Map<String, Object> m = new LinkedHashMap<>();
+ m.put("step", e.step);
+ m.put("ref", e.ref.toMap());
+ m.putAll(e.record.fields());
+ out.add(m);
+ if (!e.step.isEmpty()) {
+ byStep.computeIfAbsent(e.step, k -> new ArrayList<>()).add(e.record.getId());
}
- 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));
+ return out;
+ }
- // responses, by message id
- final Map<String, List<Integer>> 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);
- }
+ /** Hands each data part of a record to a consumer, with the reference that names the part. */
+ private static void forEachDataPart(final long seq, @Nullable final SessionDataFile.Record rec,
+ final BiConsumer<Ref, String> consumer) {
+ if (rec == null) {
+ return;
}
- final Map<String, String> requestOf = new HashMap<>();
- final Map<String, Integer> responseOf = new HashMap<>();
- for (final Call k : calls) {
- if (k.msg.isEmpty()) {
- continue;
- }
- final List<Integer> 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;
- }
+ for (int b = 0; b < rec.getParts().size(); b++) {
+ final SessionDataFile.Part p = rec.getParts().get(b);
+ if ("data".equals(p.getKind())) {
+ consumer.accept(new Ref(seq, rec.getRow(), (long) b), p.data());
}
}
+ }
- // requests, by the request before them and their prompt
- final Map<List<String>, List<Integer>> 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<String, Boolean> gapped = new HashMap<>();
- for (final Call k : calls) {
- gapped.computeIfAbsent(k.stream, this::streamHasGap);
- }
- final Map<String, List<String>> callKey = new HashMap<>();
- final Map<List<String>, 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<String> key = Arrays.asList(prev, k.prompt);
- callKey.put(k.id, key);
- callsByKey.merge(key, 1, Integer::sum);
- }
- final Map<String, Integer> requestFor = new HashMap<>();
- for (final Call k : calls) {
- final List<String> key = callKey.get(k.id);
- if (key == null) {
- continue;
- }
- final List<Integer> 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());
+ private List<SessionDataFile> filesOfKind(final String kind) {
+ final List<SessionDataFile> out = new ArrayList<>();
+ for (final SessionDataFile f : files.values()) {
+ if (kind.equals(f.getHeader().getKind())) {
+ out.add(f);
}
}
- return out.size();
+ return out;
}
/**
- * @param rec a record of a <code>provider_body</code> file
- * @return its manifest: the last part, when it is a data part holding a <code>provider_body/1</code> object that
- * decodes as the Sessionizer's <code>providerbody.Manifest</code>, or null
+ * Which step carries which tool-use id, from the call part each tool or agent step points at. A change record and
+ * an execution record name their call by that id, and it is their only join.
*/
- @Nullable
- private static JsonObject manifest(final SessionDataFile.Record rec) {
- final List<SessionDataFile.Part> 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;
+ private Map<String, String> stepsByToolUse() {
+ final Map<String, String> stepOf = new HashMap<>();
+ for (final SessionFlowRound.Node n : fold.getNodes().values()) {
+ if (!"tool".equals(n.getKind()) && !"agent.call".equals(n.getKind()) || n.getRef() == null) {
+ continue;
}
- 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;
- }
+ final SessionDataFile.Record rec = record(n.getRef());
+ final SessionDataFile.Part p = rec == null ? null : partAt(rec, n.getRef().getBlock());
+ if (p != null && "call".equals(p.getKind()) && StringUtil.isNotEmpty(p.getId())) {
+ stepOf.put(p.getId(), n.getId());
}
}
- return PROVIDER_BODY_SCHEMA.equals(string(m, "schema")) ? m : null;
+ return stepOf;
}
- 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"};
+ // ---------------------------------------------------------------- provider bodies
/**
- * @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.
+ * A node's attributes without the provider bodies a call carries. The document gives them a field of their own,
+ * <code>provider_bodies</code> on the step, so leaving them in the attributes as well would say the same thing
+ * twice. Every other attribute travels as the round wrote it. When the bodies are taken out, what is left is
+ * written as Go writes a map, with its keys sorted, and nothing is left when nothing else was there.
*/
- 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;
- }
+ @Nullable
+ private static JsonElement withoutProviderBodies(@Nullable final JsonElement raw) {
+ if (raw == null || !raw.isJsonObject() || !raw.getAsJsonObject().has(SessionFlowRound.PROVIDER_BODIES_ATTR)) {
+ return raw;
}
- for (final String key : ints) {
- final JsonElement v = o.get(key);
- if (v != null && !v.isJsonNull() && integer(v, false) == null) {
- return false;
+ // by code point, which is the order of Go's sort on the UTF-8 bytes; Java's own String order is by UTF-16 unit
+ // and puts a character past U+FFFF before U+E000
+ final TreeMap<String, JsonElement> sorted = new TreeMap<>(GoStrings.ORDER);
+ for (final Map.Entry<String, JsonElement> e : raw.getAsJsonObject().entrySet()) {
+ if (!SessionFlowRound.PROVIDER_BODIES_ATTR.equals(e.getKey())) {
+ sorted.put(e.getKey(), e.getValue());
}
}
- 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()) {
+ if (sorted.isEmpty()) {
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;
+ final JsonObject out = new JsonObject();
+ sorted.forEach(out::add);
+ return out;
}
- 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 <code>{"ord":</code>, 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.
+ * Each call's provider bodies, as the round carries them. A body is the request or the response a runtime
+ * exchanged with its model provider. 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.
+ *
+ * <p>The join is not made here. The Sessionizer makes it once, when it parses the round, and the round carries it
+ * on each <code>llm.call</code>: a body joins by identifiers only its manifest holds, and a reader that made the
+ * join itself would open every landed body, the largest files a session holds, to read one line of each. A
+ * round whose bodies do not read is refused when it is read, so what reaches here is the shape or nothing.
+ *
+ * @return how many requests are joined, which is how many calls have their prompt captured
*/
- private boolean streamHasGap(final String stream) {
- final List<Long> 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()))) {
+ private int joinProviderBodies() {
+ int requests = 0;
+ for (final SessionFlowRound.Node n : fold.getNodes().values()) {
+ if (!"llm.call".equals(n.getKind())) {
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;
+ for (final SessionFlowRound.ProviderBody y : n.getProviderBodies()) {
+ final Map<String, Object> m = new LinkedHashMap<>();
+ m.put("role", y.getRole());
+ m.put("ref", y.getRef().toMap());
+ providerBodiesByStep.computeIfAbsent(n.getId(), k -> new ArrayList<>()).add(m);
+ if (SessionFlowRound.ROLE_REQUEST.equals(y.getRole())) {
+ requests++;
}
}
- 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<String, Object> toMap() {
- final Map<String, Object> m = new LinkedHashMap<>();
- m.put("role", role);
- m.put("ref", ref.toMap());
- return m;
}
+ return requests;
}
- /** 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;
- }
+ /**
+ * @return how many bodies the session holds as of the folded chain, joined or not, as its session node states it;
+ * 0 when the node does not, or states it as Go cannot decode into an int
+ */
+ private long landedProviderBodies() {
+ final SessionFlowRound.Node session = fold.node(sessionNodeId());
+ final JsonObject attrs = session == null ? null : GoJson.decode(session.getAttrsText(), SESSION_ATTRS);
+ return attrs == null ? 0 : attrs.get("provider_bodies_landed").getAsLong();
}
/**
@@ -1714,15 +1441,40 @@ public final class ConversationViewBuilder {
* @return the part named, or the only part when none is named, or null
*/
@Nullable
- private static SessionDataFile.Part partAt(final SessionDataFile.Record rec, @Nullable final Integer block) {
+ private static SessionDataFile.Part partAt(final SessionDataFile.Record rec, @Nullable final Long block) {
if (block != null && block < rec.getParts().size()) {
- return rec.getParts().get(block);
+ return rec.getParts().get(block.intValue());
}
return rec.getParts().size() == 1 ? rec.getParts().get(0) : null;
}
// ---------------------------------------------------------------- records and times
+ /** Relations one record supports are listed by id, as nothing else tells them apart. */
+ private static final Comparator<SessionFlowRound.Relation> RELATION_TIE =
+ Comparator.comparing(SessionFlowRound.Relation::getId, GoStrings.ORDER);
+
+ /** Where a reference sits: its file's lane, its position, and its record's time. */
+ private Order.Point pointOf(@Nullable final Ref r) {
+ if (r == null || r.getSeq() == 0) {
+ return Order.Point.NOWHERE;
+ }
+ return new Order.Point(lanes.getOrDefault(r.getSeq(), ""), r.getSeq(), r.getRow(),
+ r.getBlock() == null ? -1 : r.getBlock(), nanosAt(r));
+ }
+
+ /** Where a relation happened: at the earliest record that supports it. */
+ private Order.Point relationPoint(@Nullable final SessionFlowRound.Relation r) {
+ if (r == null) {
+ return Order.Point.NOWHERE;
+ }
+ final List<Order.Point> points = new ArrayList<>();
+ for (final Ref e : r.getEvidence()) {
+ points.add(pointOf(e));
+ }
+ return Order.earliest(points);
+ }
+
@Nullable
private SessionDataFile.Record record(@Nullable final Ref ref) {
if (ref == null) {
@@ -1755,6 +1507,21 @@ public final class ConversationViewBuilder {
return lohi;
}
+ /**
+ * The earliest record time under a node, in nanoseconds: talks are ordered at the precision the Sessionizer
+ * keeps, so two that began in the same millisecond keep their order. 0 when nothing under it is timed.
+ */
+ private long beganAt(final SessionFlowRound.Node x) {
+ long lo = nanosAt(x.getRef());
+ for (final SessionFlowRound.Node k : fold.children(x.getId())) {
+ final long t = beganAt(k);
+ if (t != 0 && (lo == 0 || t < lo)) {
+ lo = t;
+ }
+ }
+ return lo;
+ }
+
private void walkSpan(final SessionFlowRound.Node x, final long[] lohi) {
final long t = time(x);
if (t != 0) {
@@ -1784,41 +1551,34 @@ public final class ConversationViewBuilder {
return t;
}
for (final String raw : candidates(rec)) {
- try {
- final JsonElement e = JsonParser.parseString(raw);
- if (!e.isJsonObject() || !"queued_command".equals(string(e.getAsJsonObject(), "type"))) {
- continue;
- }
- final JsonElement prompt = e.getAsJsonObject().get("prompt");
- if (prompt == null || !prompt.isJsonArray()) {
- continue;
- }
- // the whole candidate is the shape Go decodes, or it is skipped: every element an object whose
- // text, when present, is a string
- final List<String> out = new ArrayList<>();
- boolean shaped = true;
- for (final JsonElement p : prompt.getAsJsonArray()) {
- if (!p.isJsonObject() || !isStringOrAbsent(p.getAsJsonObject(), "text")) {
- shaped = false;
- break;
- }
- if (StringUtil.isNotEmpty(string(p.getAsJsonObject(), "text"))) {
- out.add(string(p.getAsJsonObject(), "text"));
- }
- }
- if (shaped && !out.isEmpty()) {
- return trimSpace(String.join("\n", out));
- }
- } catch (final RuntimeException ignored) {
- // not JSON; the next candidate may be
+ final String prompt = queuedPrompt(raw);
+ if (prompt != null) {
+ return prompt;
}
}
return "";
}
- private static boolean isStringOrAbsent(final JsonObject json, final String key) {
- final JsonElement e = json.get(key);
- return e == null || e.isJsonNull() || e.isJsonPrimitive() && e.getAsJsonPrimitive().isString();
+ /**
+ * @param raw one candidate of a record
+ * @return the texts of the prompt a queued command envelope carries, one per line and trimmed, or null when the
+ * candidate is not one or carries no text
+ */
+ @Nullable
+ private static String queuedPrompt(final String raw) {
+ final JsonObject envelope = GoJson.decode(raw, QUEUED_COMMAND);
+ if (envelope == null || !"queued_command".equals(envelope.get("type").getAsString())
+ || !envelope.get("prompt").isJsonArray()) {
+ return null;
+ }
+ final List<String> out = new ArrayList<>();
+ for (final JsonElement p : envelope.getAsJsonArray("prompt")) {
+ final String text = p.getAsJsonObject().get("text").getAsString();
+ if (!text.isEmpty()) {
+ out.add(text);
+ }
+ }
+ return out.isEmpty() ? null : trimSpace(String.join("\n", out));
}
static List<String> candidates(final SessionDataFile.Record rec) {
@@ -1891,58 +1651,12 @@ public final class ConversationViewBuilder {
return new String(bytes, 0, cut, StandardCharsets.UTF_8);
}
- static Map<String, Object> jsonToMap(final JsonObject json) {
- final Map<String, Object> out = new LinkedHashMap<>();
- for (final Map.Entry<String, JsonElement> e : json.entrySet()) {
- out.put(e.getKey(), jsonToValue(e.getValue()));
- }
- return out;
- }
-
- private static Object jsonToValue(final JsonElement e) {
- if (e == null || e.isJsonNull()) {
- return null;
- }
- if (e.isJsonObject()) {
- return jsonToMap(e.getAsJsonObject());
- }
- if (e.isJsonArray()) {
- final List<Object> list = new ArrayList<>();
- for (final JsonElement x : e.getAsJsonArray()) {
- list.add(jsonToValue(x));
- }
- return list;
- }
- if (e.getAsJsonPrimitive().isBoolean()) {
- return e.getAsBoolean();
- }
- if (e.getAsJsonPrimitive().isNumber()) {
- // as written: Go prints the raw attrs, so 1.0 stays 1.0 and 1 stays 1
- final String literal = e.getAsString();
- if (INTEGER_LITERAL.matcher(literal).matches()) {
- try {
- return Long.parseLong(literal);
- } catch (final NumberFormatException ignored) {
- return new BigInteger(literal);
- }
- }
- return new BigDecimal(literal);
- }
- return e.getAsString();
- }
-
@Nullable
private static Long millisOrNull(@Nullable final String rfc3339) {
// absent is null; present but unreadable is 0, as the Sessionizer's millisPtr
return StringUtil.isEmpty(rfc3339) ? null : Long.valueOf(Times.millis(rfc3339));
}
- @Nullable
- private static String string(final JsonObject json, final String key) {
- final JsonElement e = json.get(key);
- return e == null || e.isJsonNull() || !e.isJsonPrimitive() ? null : e.getAsString();
- }
-
private static String nullToEmpty(@Nullable final String s) {
return s == null ? "" : s;
}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/Order.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/Order.java
new file mode 100644
index 0000000000..9f662840a8
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/Order.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.view;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * The order of what happened, as the Sessionizer lists it: by position inside one lane, a stream or a workflow run,
+ * and by time across lanes. A position means nothing across lanes, since a child's file can land before its parent's.
+ * An id decides only between items that one record supports.
+ */
+final class Order {
+ private Order() {
+ }
+
+ /**
+ * Where an item happened: the lane its record landed in, the record's position there, and the time the record
+ * carries, 0 when it carries none.
+ */
+ static final class Point {
+ static final Point NOWHERE = new Point("", 0, 0, -1, 0);
+
+ final String lane;
+ final long seq;
+ final long row;
+ /** -1 when the item stands on the whole record. */
+ final long block;
+ final long at;
+
+ Point(final String lane, final long seq, final long row, final long block, final long at) {
+ this.lane = lane;
+ this.seq = seq;
+ this.row = row;
+ this.block = block;
+ this.at = at;
+ }
+
+ boolean isLanded() {
+ return seq != 0;
+ }
+ }
+
+ /** Orders two points by position alone, which is meaningful inside one lane. */
+ static int comparePositions(final Point a, final Point b) {
+ if (a.seq != b.seq) {
+ return Long.compare(a.seq, b.seq);
+ }
+ if (a.row != b.row) {
+ return Long.compare(a.row, b.row);
+ }
+ return Long.compare(a.block, b.block);
+ }
+
+ /**
+ * @return the earliest of several points by the rule that orders items: the first position inside each lane, then
+ * the earliest time across lanes; nowhere when none is landed
+ */
+ static Point earliest(final List<Point> points) {
+ final Map<String, Point> first = new LinkedHashMap<>();
+ for (final Point p : points) {
+ if (p.isLanded()) {
+ first.merge(p.lane, p, (held, next) -> comparePositions(next, held) < 0 ? next : held);
+ }
+ }
+ Point out = Point.NOWHERE;
+ for (final Point p : first.values()) {
+ if (!out.isLanded() || earlier(p, out)) {
+ out = p;
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Sorts items the way they happened. It is a merge, not one comparison: each lane's items are put in position
+ * order, then the lanes are merged by taking, each time, the lane whose next item is earliest, a timed item
+ * before an untimed one. A single comparison that decides some pairs by position and others by time is not
+ * transitive, and a sort over it is undefined. Two lanes whose next items share a time are decided by position,
+ * which differs between lanes, so the result is the same on every read.
+ *
+ * @param tie decides between items at the very same position; items with no position at all come last, in this
+ * order
+ */
+ static <T> List<T> inOrder(final List<T> items, final Function<T, Point> pointOf, final Comparator<T> tie) {
+ final Map<String, List<T>> byLane = new LinkedHashMap<>();
+ final List<T> nowhere = new ArrayList<>();
+ for (final T it : items) {
+ final Point p = pointOf.apply(it);
+ if (p.isLanded()) {
+ byLane.computeIfAbsent(p.lane, k -> new ArrayList<>()).add(it);
+ } else {
+ nowhere.add(it);
+ }
+ }
+ final List<List<T>> lanes = new ArrayList<>(byLane.values());
+ for (final List<T> lane : lanes) {
+ lane.sort(Comparator.comparing(pointOf, Order::comparePositions).thenComparing(tie));
+ }
+ final List<T> out = new ArrayList<>(items.size());
+ final int[] next = new int[lanes.size()];
+ while (out.size() < items.size() - nowhere.size()) {
+ int best = -1;
+ Point bestAt = null;
+ for (int i = 0; i < lanes.size(); i++) {
+ if (next[i] >= lanes.get(i).size()) {
+ continue;
+ }
+ final Point p = pointOf.apply(lanes.get(i).get(next[i]));
+ if (best < 0 || earlier(p, bestAt)) {
+ best = i;
+ bestAt = p;
+ }
+ }
+ out.add(lanes.get(best).get(next[best]++));
+ }
+ nowhere.sort(tie);
+ out.addAll(nowhere);
+ return out;
+ }
+
+ /** Decides between the next items of two lanes: a timed one first, then the earlier time, then the position. */
+ private static boolean earlier(final Point a, final Point b) {
+ if ((a.at != 0) != (b.at != 0)) {
+ return a.at != 0;
+ }
+ if (a.at != b.at) {
+ return a.at < b.at;
+ }
+ return comparePositions(a, b) < 0;
+ }
+}
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 545eef7e99..9b805df734 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
@@ -26,6 +26,7 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
@@ -198,7 +199,7 @@ public class ConversationViewBuilderTest {
final Map<String, Object> edit = changes.get(1);
assertEquals("claude-code", edit.get("captured_by"));
assertEquals("runtime_reported", edit.get("basis"));
- assertEquals(Map.of("seq", 1L, "row", 8L, "block", 1), edit.get("ref"));
+ assertEquals(Map.of("seq", 1L, "row", 8L, "block", 1L), edit.get("ref"));
// and each step names its record, after its tool keys
final Map<String, Object> step = node(doc, "tool/s3-tool");
assertEquals(Collections.singletonList("s3-tool"), step.get("changes"));
@@ -236,6 +237,60 @@ public class ConversationViewBuilderTest {
assertNull(node(doc, "tool/tidier-s1-tool").get("changes"));
}
+ /**
+ * The files may come in any order: the document lists and reads them by seq, so it is still the Sessionizer's.
+ */
+ @Test
+ public void filesGivenInAnyOrderAreReadBySeq() throws Exception {
+ final String[][] sets = {
+ {"", Fixtures.ROUND_FILE},
+ {Fixtures.WORKSPACE_CHANGES_DIR, Fixtures.WORKSPACE_CHANGES_DIR + Fixtures.WORKSPACE_CHANGES_ROUND_FILE},
+ };
+ final List<Map<Long, SessionDataFile>> files = Arrays.asList(Fixtures.dataFiles(), Fixtures.workspaceChangesDataFiles());
+ for (int i = 0; i < sets.length; i++) {
+ final List<Long> seqs = new ArrayList<>(files.get(i).keySet());
+ Collections.sort(seqs, Collections.reverseOrder());
+ final Map<Long, SessionDataFile> reversed = new LinkedHashMap<>();
+ for (final Long seq : seqs) {
+ reversed.put(seq, files.get(i).get(seq));
+ }
+ final Map<String, Object> doc = view(Fixtures.bytes(sets[i][1]), reversed, Collections.emptyList());
+ final JsonElement expected = JsonParser.parseString(
+ new String(Fixtures.bytes(sets[i][0] + Fixtures.VIEW_EXAMPLE_JSON), StandardCharsets.UTF_8));
+ assertEquals(GSON.toJson(expected), GSON.toJson(GSON.toJsonTree(doc)), sets[i][1]);
+ }
+ }
+
+ /**
+ * Two producers' records of one instant list the runtime's first, whatever their ids say, and a record landed
+ * twice is listed once. The Sessionizer's document for the same edited files lists them in this order.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void theRuntimesChangeRecordLeadsAtOneInstantAndARecordLandedTwiceIsOne() throws Exception {
+ final Map<Long, SessionDataFile> files = Fixtures.workspaceChangesDataFiles();
+ final String plugin = new String(
+ Fixtures.bytes(Fixtures.WORKSPACE_CHANGES_DIR + Fixtures.WORKSPACE_CHANGES_DATA_FILES[1]), StandardCharsets.UTF_8);
+ // the header, the one record, and the end frame, which the edited file goes without, as the Sessionizer read it
+ final String[] lines = plugin.split("\n");
+ assertEquals(3, lines.length);
+ assertTrue(lines[1].contains("\"time\":\"2026-01-01T00:00:01.600Z\""));
+ final String atTheRuntimesInstant = lines[1].replace("\"time\":\"2026-01-01T00:00:01.600Z\"",
+ "\"time\":\"2026-01-01T00:00:02.900Z\"");
+ files.put(2L, SessionDataFile.parse((lines[0] + "\n" + atTheRuntimesInstant + "\n" + atTheRuntimesInstant + "\n")
+ .getBytes(StandardCharsets.UTF_8)));
+ final Map<String, Object> doc = view(
+ Fixtures.bytes(Fixtures.WORKSPACE_CHANGES_DIR + Fixtures.WORKSPACE_CHANGES_ROUND_FILE), files, Collections.emptyList());
+ final List<String> listed = new ArrayList<>();
+ for (final Map<String, Object> c : (List<Map<String, Object>>) doc.get("workspace_changes")) {
+ listed.add(c.get("id") + " " + c.get("captured_by") + " " + c.get("time") + " " + c.get("ref"));
+ }
+ // the record landed twice is kept at its first line
+ assertEquals(Arrays.asList("s3-tool claude-code 2026-01-01T00:00:02.900Z {seq=1, row=8, block=1}",
+ "s2-tool asz-plugin 2026-01-01T00:00:02.900Z {seq=2, row=1, block=0}",
+ "tidier-s1-tool asz-plugin 2026-01-01T00:00:06.400Z {seq=4, row=1, block=0}"), listed);
+ }
+
/**
* The Sessionizer's provider-bodies scenario: a main stream and a subagent, every call's request and response
* landed in one <code>provider_body</code> file. The document equals the Sessionizer's. Each call names its request
@@ -255,7 +310,7 @@ public class ConversationViewBuilderTest {
assertEquals(GSON.toJson(expected), GSON.toJson(actual));
final Map<String, Object> summary = (Map<String, Object>) doc.get("summary");
- assertEquals(14, summary.get("provider_bodies"));
+ assertEquals(14L, summary.get("provider_bodies"));
assertEquals(7, summary.get("captured_prompts"));
final Map<String, Object> first = node(doc, "call/s2-call-fdae022ac306");
assertEquals(Arrays.asList(
@@ -266,44 +321,25 @@ public class ConversationViewBuilderTest {
}
/**
- * 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.
+ * The session's <code>provider_body</code> file is missing. The round carries the join, so every call still names
+ * where its bodies landed, and the missing file is the chain's problem, as in the Sessionizer's document for the
+ * same files: 14 bodies named on the calls, 7 of them requests, and one problem.
*/
@Test
@SuppressWarnings("unchecked")
- public void aSessionWithoutItsProviderBodiesListsNone() throws Exception {
+ public void aSessionMissingItsBodyFileStillNamesWhatTheRoundJoined() throws Exception {
final byte[] round = Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE);
final Map<Long, SessionDataFile> files = Fixtures.providerBodiesDataFiles();
final Map<String, Object> whole = view(round, files, Collections.emptyList());
files.remove(4L);
final Map<String, Object> without = view(round, files, Collections.emptyList());
final Map<String, Object> summary = (Map<String, Object>) 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<Long, SessionDataFile> 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<String, Object> doc = view(
- Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList());
- final Map<String, Object> summary = (Map<String, Object>) 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());
+ assertEquals("incomplete", summary.get("state"));
+ assertEquals(Collections.singletonList("round 1: landed file seq 4 is missing"), summary.get("problems"));
+ assertEquals(14L, summary.get("provider_bodies"));
+ assertEquals(7, summary.get("captured_prompts"));
+ assertEquals(GSON.toJsonTree(whole.get("talks")), GSON.toJsonTree(without.get("talks")));
+ assertEquals(GSON.toJsonTree(whole.get("loose")), GSON.toJsonTree(without.get("loose")));
}
/**
@@ -324,7 +360,7 @@ public class ConversationViewBuilderTest {
assertEquals(GSON.toJson(expected), GSON.toJson(actual));
final Map<String, Object> summary = (Map<String, Object>) doc.get("summary");
- assertEquals(4, summary.get("provider_bodies"));
+ assertEquals(4L, summary.get("provider_bodies"));
assertEquals(2, summary.get("captured_prompts"));
assertNull(node(doc, "call/s3-synthetic-call").get("provider_bodies"));
assertEquals(Arrays.asList(
@@ -333,75 +369,463 @@ public class ConversationViewBuilderTest {
}
/**
- * 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.
+ * The Sessionizer's mcp-calls scenario: calls to MCP servers on the main stream and in a subagent, each with the
+ * record the plugin wrote after it, in an <code>execution</code> file of its stream. The document equals the
+ * Sessionizer's. Every record joins its step by the tool-use id; the record the plugin wrote twice is listed once,
+ * at its first line; the call whose name does not split into one server and one tool has no MCP attributes, and
+ * no record, as none was written for it. Each step names its record after its tool keys.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void executionsJoinTheirStepsAsTheSessionizerJoinsThem() throws Exception {
+ final Map<String, Object> doc = view(
+ Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_ROUND_FILE),
+ Fixtures.mcpCallsDataFiles(), Collections.emptyList());
+ final JsonElement expected = JsonParser.parseString(new String(
+ Fixtures.bytes(Fixtures.MCP_CALLS_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 List<Map<String, Object>> executions = (List<Map<String, Object>>) doc.get("tool_executions");
+ assertEquals(4, executions.size());
+ final Map<String, Integer> outcomes = new TreeMap<>();
+ for (final Map<String, Object> e : executions) {
+ assertTrue(((String) e.get("step")).startsWith("tool/"), e.get("id") + " is joined to a step");
+ outcomes.merge((String) e.get("outcome"), 1, Integer::sum);
+ }
+ assertEquals(Map.of("returned", 3, "failed", 1), outcomes);
+ // the line written twice is kept once, at the first of the two
+ assertEquals("s2-tool/client_hook", executions.get(0).get("id"));
+ assertEquals(Map.of("seq", 2L, "row", 1L, "block", 0L), executions.get(0).get("ref"));
+ assertEquals(Map.of("name", "status", "source", "user"), executions.get(0).get("server"));
+ assertEquals(380L, executions.get(0).get("duration_ms"));
+
+ final Map<String, Object> step = node(doc, "tool/s2-tool");
+ assertEquals(Collections.singletonList("s2-tool/client_hook"), step.get("executions"));
+ final List<String> keys = new ArrayList<>(step.keySet());
+ assertEquals(keys.indexOf("request_to_result_join") + 1, keys.indexOf("executions"));
+ final Map<String, Object> attrs = (Map<String, Object>) step.get("attrs");
+ assertEquals("status", attrs.get("mcp_server"));
+ assertEquals("lookup", attrs.get("mcp_tool"));
+ assertEquals(Collections.singletonList("auditor-s1-tool/client_hook"), node(doc, "tool/auditor-s1-tool").get("executions"));
+ final Map<String, Object> odd = node(doc, "tool/s5-tool");
+ assertNull(odd.get("executions"));
+ assertFalse(((Map<String, Object>) odd.get("attrs")).containsKey("mcp_server"));
+ }
+
+ /**
+ * Talks are ordered by when they began, at the nanosecond, as the Sessionizer orders them. Here the subagent's talk,
+ * which landed after the main one, began 0.8 milliseconds before it, within the same millisecond: ordered by the
+ * millisecond the two would tie, and the landed order would put the main talk first.
*/
@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<Long, SessionDataFile> files = Fixtures.providerBodiesDataFiles();
- files.put(4L, SessionDataFile.parse(broken.getBytes(StandardCharsets.UTF_8)));
- final Map<String, Object> doc = view(
- Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList());
- final Map<String, Object> summary = (Map<String, Object>) 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"));
+ public void talksBeginningInOneMillisecondKeepTheirOrder() throws Exception {
+ final Map<Long, SessionDataFile> files = Fixtures.mcpCallsDataFiles();
+ final String main = new String(Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_DATA_FILES[0]), StandardCharsets.UTF_8);
+ final String child = new String(Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_DATA_FILES[2]), StandardCharsets.UTF_8);
+ assertTrue(main.contains("\"time\":\"2026-01-01T00:00:00.000Z\"") && child.contains("\"time\":\"2026-01-01T00:00:09.500Z\""));
+ files.put(1L, SessionDataFile.parse(main.replace("\"time\":\"2026-01-01T00:00:00.000Z\"",
+ "\"time\":\"2026-01-01T00:00:00.000900Z\"").getBytes(StandardCharsets.UTF_8)));
+ files.put(3L, SessionDataFile.parse(child.replace("\"time\":\"2026-01-01T00:00:09.500Z\"",
+ "\"time\":\"2026-01-01T00:00:00.000100Z\"").getBytes(StandardCharsets.UTF_8)));
+ final Map<String, Object> doc = view(
+ Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_ROUND_FILE), files, Collections.emptyList());
+ final List<Map<String, Object>> talks = (List<Map<String, Object>>) doc.get("talks");
+ assertEquals("talk/a39a255c7ba239348", talks.get(0).get("id"));
+ assertEquals("talk/main/s1-cycle", talks.get(1).get("id"));
+ assertEquals(talks.get(0).get("from"), talks.get(1).get("from"));
+ }
+
+ /**
+ * Two records of one instant are listed in the order they were read, never by id and never by how the time is
+ * spelled: here the ids and the text of the times sort the other way. The Sessionizer's document for the same
+ * edited files lists them so.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void recordsOfOneTimeAreInTheOrderTheyWereRead() throws Exception {
+ final Map<Long, SessionDataFile> changes = Fixtures.workspaceChangesDataFiles();
+ final String plugin = new String(
+ Fixtures.bytes(Fixtures.WORKSPACE_CHANGES_DIR + Fixtures.WORKSPACE_CHANGES_DATA_FILES[1]), StandardCharsets.UTF_8);
+ assertTrue(plugin.contains("\"time\":\"2026-01-01T00:00:01.600Z\"") && plugin.contains("\"id\":\"s2-tool\""));
+ changes.put(2L, SessionDataFile.parse(plugin.replace("\"time\":\"2026-01-01T00:00:01.600Z\"", "\"time\":\"2026-01-01T00:00:06.4Z\"")
+ .replace("\"id\":\"s2-tool\"", "\"id\":\"zz-s2-tool\"")
+ .getBytes(StandardCharsets.UTF_8)));
+ final List<String> listed = new ArrayList<>();
+ for (final Map<String, Object> c : (List<Map<String, Object>>) view(
+ Fixtures.bytes(Fixtures.WORKSPACE_CHANGES_DIR + Fixtures.WORKSPACE_CHANGES_ROUND_FILE), changes,
+ Collections.emptyList()).get("workspace_changes")) {
+ listed.add((String) c.get("id"));
}
+ assertEquals(Arrays.asList("s3-tool", "zz-s2-tool", "tidier-s1-tool"), listed);
+
+ final Map<Long, SessionDataFile> executions = Fixtures.mcpCallsDataFiles();
+ final String main = new String(Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_DATA_FILES[1]), StandardCharsets.UTF_8);
+ assertTrue(main.contains("\"time\":\"2026-01-01T00:00:06.200Z\""));
+ executions.put(2L, SessionDataFile.parse(main.replace("\"time\":\"2026-01-01T00:00:06.200Z\"", "\"time\":\"2026-01-01T00:00:06.2Z\"")
+ .getBytes(StandardCharsets.UTF_8)));
+ final String child = new String(Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_DATA_FILES[3]), StandardCharsets.UTF_8);
+ assertTrue(child.contains("\"time\":\"2026-01-01T00:00:10.900Z\""));
+ executions.put(4L, SessionDataFile.parse(child.replace("\"time\":\"2026-01-01T00:00:10.900Z\"", "\"time\":\"2026-01-01T00:00:06.200Z\"")
+ .getBytes(StandardCharsets.UTF_8)));
+ final List<String> observed = new ArrayList<>();
+ for (final Map<String, Object> e : (List<Map<String, Object>>) view(
+ Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_ROUND_FILE), executions,
+ Collections.emptyList()).get("tool_executions")) {
+ observed.add((String) e.get("id"));
+ }
+ assertEquals(Arrays.asList("s2-tool/client_hook", "s3-tool/client_hook", "s4-tool/client_hook",
+ "auditor-s1-tool/client_hook"), observed);
}
/**
- * 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 <code>{"ord":</code> 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.
+ * A talk nothing under which has a time comes after every talk that has one, though its stream landed first. The
+ * Sessionizer's document for the same edited files orders them so.
*/
@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"},
+ public void anUntimedTalkComesAfterEveryTimedOne() throws Exception {
+ final Map<Long, SessionDataFile> files = Fixtures.mcpCallsDataFiles();
+ final String main = new String(Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_DATA_FILES[0]), StandardCharsets.UTF_8);
+ final String untimed = main.replaceAll(",\"time\":\"[^\"]*\"", "");
+ assertFalse(untimed.contains("\"time\""));
+ files.put(1L, SessionDataFile.parse(untimed.getBytes(StandardCharsets.UTF_8)));
+ final Map<String, Object> doc = view(
+ Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_ROUND_FILE), files, Collections.emptyList());
+ final List<Map<String, Object>> talks = (List<Map<String, Object>>) doc.get("talks");
+ assertEquals("talk/a39a255c7ba239348", talks.get(0).get("id"));
+ assertEquals("talk/main/s1-cycle", talks.get(1).get("id"));
+ assertNull(talks.get(1).get("from"));
+ }
+
+ /**
+ * What a round carries is read as the Sessionizer reads it, even when it is not what the Sessionizer writes. A
+ * session's body count written as 1.5 does not decode into Go's integer, so the document counts 0 bodies while the
+ * calls still name theirs. With the provider bodies taken out of a call's attributes, the rest are listed by code
+ * point, as Go sorts a map's keys: U+E000 before U+10000, where Java's own order of strings puts it after. The
+ * Sessionizer's document for the same round, re-signed, says 0 bodies, 7 captured prompts, and that key order.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void aRoundIsReadAsTheSessionizerReadsIt() throws Exception {
+ String round = new String(
+ Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), StandardCharsets.UTF_8);
+ final String bodies = "\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1}}";
+ assertTrue(round.contains(bodies) && round.contains("\"provider_bodies_landed\":14"));
+ round = round.replace(bodies, "\"\\ue000\":1,\"\\ud800\\udc00\":2," + bodies)
+ .replace("\"provider_bodies_landed\":14", "\"provider_bodies_landed\":1.5");
+ final Map<String, Object> doc = view(resign(round), Fixtures.providerBodiesDataFiles(), Collections.emptyList());
+ final Map<String, Object> summary = (Map<String, Object>) doc.get("summary");
+ assertEquals("verified", summary.get("state"));
+ assertEquals(0L, summary.get("provider_bodies"));
+ assertEquals(7, summary.get("captured_prompts"));
+ final Map<String, Object> attrs = (Map<String, Object>) node(doc, "call/s2-call-fdae022ac306").get("attrs");
+ assertEquals(Arrays.asList("fragments", "usage", "usage_at", "usage_from", "\ue000", "\ud800\udc00"),
+ new ArrayList<>(attrs.keySet()));
+ // the count as Go decodes the session's attrs into an int: a key in any case, the later of two, a null leaving
+ // the value as it was, and a string refused
+ final String original = new String(
+ Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), StandardCharsets.UTF_8);
+ final String[][] counts = {
+ {"\"PROVIDER_BODIES_LANDED\":14", "14"},
+ {"\"provider_bodies_landed\":14,\"Provider_Bodies_Landed\":3", "3"},
+ {"\"Provider_Bodies_Landed\":3,\"provider_bodies_landed\":14", "14"},
+ {"\"provider_bodies_landed\":14,\"provider_bodies_landed\":null", "14"},
+ {"\"provider_bodies_landed\":\"14\"", "0"},
+ };
+ for (final String[] c : counts) {
+ final Map<String, Object> changed = view(resign(original.replace("\"provider_bodies_landed\":14", c[0])),
+ Fixtures.providerBodiesDataFiles(), Collections.emptyList());
+ assertEquals(Long.parseLong(c[1]), ((Map<String, Object>) changed.get("summary")).get("provider_bodies"), c[0]);
+ }
+ }
+
+ /**
+ * A stream more than one step could have started lists every one as an origin, in the order those steps happened,
+ * by their position in the stream, whatever the relation ids say and whatever order the round wrote the relations
+ * in. The ids are tried both ways round. Relations one record supports are listed by id, in Go's order of strings:
+ * U+E000 before U+10000, which Java's own order reverses. The Sessionizer's document for the same round, re-signed,
+ * lists them so.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void aStreamsOriginsAreInTheOrderTheirStepsHappened() throws Exception {
+ String round = new String(
+ Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), StandardCharsets.UTF_8);
+ final String starts = "{\"t\":\"relation\",\"id\":\"rel/starts/tool_s5-tool/stream_a02ba01b82410e9e3\"";
+ final String counts = "\"counts\":{\"nodes\":40,\"relations\":9,\"unresolved\":0}";
+ assertTrue(round.contains(starts) && round.contains(counts));
+ final String more = "{\"t\":\"relation\",\"id\":\"rel/starts/zz-late/stream_a02ba01b82410e9e3\",\"revision\":1,"
+ + "\"type\":\"starts\",\"from\":\"tool/s2-tool\",\"to\":\"stream/a02ba01b82410e9e3\",\"quality\":\"exact_unique\","
+ + "\"via\":\"parent tool result\",\"evidence\":[{\"seq\":1,\"row\":9}]}\n"
+ + "{\"t\":\"relation\",\"id\":\"rel/starts/aa-early/stream_a02ba01b82410e9e3\",\"revision\":1,"
+ + "\"type\":\"starts\",\"from\":\"call/s2-call-fdae022ac306\",\"to\":\"stream/a02ba01b82410e9e3\","
+ + "\"quality\":\"exact_unique\",\"via\":\"parent tool result\",\"evidence\":[{\"seq\":1,\"row\":9}]}\n";
+ round = round.replace(starts, more + starts).replace(counts, counts.replace("\"relations\":9", "\"relations\":11"));
+ final String[] renamed = {
+ round,
+ round.replace("rel/starts/aa-early/stream_a02ba01b82410e9e3", "rel/starts/zz-late-2")
+ .replace("rel/starts/zz-late/stream_a02ba01b82410e9e3", "rel/starts/aa-early-2"),
+ round.replace("rel/starts/aa-early/stream_a02ba01b82410e9e3", "rel/starts/\ue000")
+ .replace("rel/starts/zz-late/stream_a02ba01b82410e9e3", "rel/starts/\ud800\udc00"),
};
- for (final String[] c : cases) {
- assertFalse(c[0].equals(main));
- final Map<Long, SessionDataFile> files = Fixtures.providerBodiesDataFiles();
- files.put(1L, SessionDataFile.parse(c[0].getBytes(StandardCharsets.UTF_8)));
- final Map<String, Object> doc = view(
- Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList());
- final Map<String, Object> summary = (Map<String, Object>) doc.get("summary");
- assertEquals(14, summary.get("provider_bodies"));
- assertEquals(Integer.parseInt(c[1]), summary.get("captured_prompts"));
+ for (final String r : renamed) {
+ final Map<String, Object> doc = view(resign(r), Fixtures.providerBodiesDataFiles(), Collections.emptyList());
+ assertEquals("verified", ((Map<String, Object>) doc.get("summary")).get("state"));
+ final List<String> origins = new ArrayList<>();
+ for (final Map<String, Object> st : (List<Map<String, Object>>) doc.get("streams")) {
+ if ("stream/a02ba01b82410e9e3".equals(st.get("id"))) {
+ for (final Map<String, Object> o : (List<Map<String, Object>>) st.get("opened_by")) {
+ origins.add((String) o.get("step"));
+ }
+ }
+ }
+ // rows 3, 4 and 8 of the main transcript
+ assertEquals(Arrays.asList("call/s2-call-fdae022ac306", "tool/s2-tool", "tool/s5-tool"), origins);
+ }
+ final List<String> relations = new ArrayList<>();
+ for (final Map<String, Object> rel : (List<Map<String, Object>>) view(
+ resign(renamed[2]), Fixtures.providerBodiesDataFiles(), Collections.emptyList()).get("relations")) {
+ relations.add((String) rel.get("id"));
}
+ assertEquals(relations.indexOf("rel/starts/\ue000") + 1, relations.indexOf("rel/starts/\ud800\udc00"));
}
- 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 the round's bytes with its commit digest made the digest of every line before it, as a round the
+ * Sessionizer wrote after the change would carry
+ */
+ /**
+ * Each stream's origins are ordered on their own. One order over every stream's origins, split by stream after,
+ * is not the same: an untimed step holds back the rest of its lane, so a step that started another stream moved
+ * this one's origins. Here A1, untimed, starts X; A2 at time 10 and B1 at time 50 start S; and A0, untimed and in
+ * no stream, starts S too: it is not listed, and it must not move the origins that are. The Sessionizer's
+ * TestEachStreamsOriginsAreOrderedOnTheirOwn lists S's origins as A2 then B1 for the same fold.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void eachStreamsOriginsAreOrderedOnTheirOwn() throws Exception {
+ final byte[] round = round(
+ frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"),
+ frameNode("stream/B", "stream", "B", 2, 1, ""),
+ frameNode("stream/S", "stream", "S", 2, 1, ""),
+ frameNode("stream/X", "stream", "X", 2, 1, ""),
+ frameNode("tool/a0", "tool", "", 1, 1, ""),
+ frameNode("tool/a1", "tool", "A", 1, 1, ""),
+ frameNode("tool/a2", "tool", "A", 1, 2, ""),
+ frameNode("tool/b1", "tool", "B", 2, 1, ""),
+ frameStarts("rel/0", "tool/a0", "stream/S"),
+ frameStarts("rel/1", "tool/a1", "stream/X"),
+ frameStarts("rel/2", "tool/a2", "stream/S"),
+ frameStarts("rel/3", "tool/b1", "stream/S"));
+ final Map<Long, SessionDataFile> files = new TreeMap<>();
+ files.put(1L, dataFile(1, "transcript", "A", "", "", "2026-01-01T00:00:10Z"));
+ files.put(2L, dataFile(2, "transcript", "B", "", "2026-01-01T00:00:50Z"));
+ final Map<String, Object> doc = view(round, files, Collections.emptyList());
+ final List<String> origins = new ArrayList<>();
+ for (final Map<String, Object> st : (List<Map<String, Object>>) doc.get("streams")) {
+ if ("stream/S".equals(st.get("id"))) {
+ for (final Map<String, Object> o : (List<Map<String, Object>>) st.get("opened_by")) {
+ origins.add((String) o.get("step"));
+ }
+ }
+ }
+ assertEquals(Arrays.asList("tool/a2", "tool/b1"), origins);
+ }
+
+ /**
+ * A file is in the lane of the directory it lands in, as the Sessionizer reads the files by their place: a journal
+ * is in its run's lane, even when its header also names a stream. Here r1 is supported by a transcript record at
+ * 00:30 and r2 by a record of that journal at 00:10, so r2 comes first; in one lane, r1 would, by position.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void aJournalIsInItsRunsLane() throws Exception {
+ final byte[] round = round(
+ frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"),
+ frameNode("tool/a1", "tool", "A", 1, 1, ""),
+ frameRelation("rel/1", "tool/a1", "stream/A", 1, 1),
+ frameRelation("rel/2", "tool/a1", "stream/A", 2, 1));
+ final Map<Long, SessionDataFile> files = new TreeMap<>();
+ files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:30Z"));
+ files.put(2L, dataFile(2, "journal", "A", "R", "2026-01-01T00:00:10Z"));
+ assertEquals(Arrays.asList("rel/2", "rel/1"), relationIds(view(round, files, Collections.emptyList())));
+ }
+
+ /**
+ * The session's provider bodies are in no stream or run, and neither is a file that did not land: the Sessionizer
+ * reads both in the one lane of the session, so position decides between them, not time.
+ */
+ @Test
+ public void theSessionsProviderBodiesAreInNoLane() throws Exception {
+ final byte[] round = round(
+ frameNode("stream/A", "stream", "A", 3, 1, ",\"attrs\":{\"role\":\"main\"}"),
+ frameNode("tool/a1", "tool", "A", 3, 1, ""),
+ frameRelation("rel/1", "tool/a1", "stream/A", 1, 1),
+ frameRelation("rel/2", "tool/a1", "stream/A", 2, 1));
+ final Map<Long, SessionDataFile> files = new TreeMap<>();
+ files.put(2L, dataFile(2, "provider_body", "", "", "2026-01-01T00:00:10Z"));
+ files.put(3L, dataFile(3, "transcript", "A", "", "2026-01-01T00:00:30Z"));
+ assertEquals(Arrays.asList("rel/1", "rel/2"), relationIds(view(round, files, Collections.emptyList())));
+ }
+
+ /**
+ * A call's usage is read from the record its attrs name, as the Sessionizer decodes them into a struct: the key in
+ * any case, and a reference with nothing in it names no record rather than failing the document.
+ */
+ @Test
+ public void aCallsUsageIsReadAsTheSessionizerReadsItsAttrs() throws Exception {
+ final String usage = "{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"at\":\"2026-01-01T00:00:00Z\",\"kind\":\"transcript\","
+ + "\"adapter\":\"mock/0.2.0\",\"dialect\":\"mock/1\",\"src\":\"x\",\"session\":\"s\",\"stream\":\"A\"}\n"
+ + "{\"ord\":1,\"off\":0,\"sha\":\"0\",\"bytes\":1,\"time\":\"2026-01-01T00:00:01Z\",\"parts\":[]}\n"
+ + "{\"ord\":2,\"off\":0,\"sha\":\"0\",\"bytes\":1,\"time\":\"2026-01-01T00:00:02Z\",\"usage\":{\"in\":5},\"parts\":[]}\n";
+ final Map<Long, SessionDataFile> files = new TreeMap<>();
+ files.put(1L, SessionDataFile.parse(usage.getBytes(StandardCharsets.UTF_8)));
+ final Object[][] cases = {
+ {",\"attrs\":{\"USAGE_AT\":{\"seq\":1,\"row\":2}}", Map.of("in", 5L)},
+ {",\"attrs\":{\"usage_at\":{}}", null},
+ {",\"attrs\":{\"usage_at\":{\"seq\":\"1\",\"row\":2}}", null},
+ };
+ for (final Object[] c : cases) {
+ final Map<String, Object> doc = view(round(
+ frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"),
+ frameNode("call/c1", "llm.call", "A", 1, 1, (String) c[0])), files, Collections.emptyList());
+ assertEquals(c[1], node(doc, "call/c1").get("usage"), (String) c[0]);
+ }
+ }
+
+ /**
+ * A run is loose only when no talk is above it however deep it sits, as the Sessionizer walks the whole parent
+ * chain. A chain whose parents loop, which the Sessionizer would walk for ever, stops where it comes back round.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void aRunDeepUnderATalkIsNotLoose() throws Exception {
+ final List<String> frames = new ArrayList<>();
+ frames.add(frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"));
+ frames.add(frameNode("talk/a", "talk", "A", 1, 1, ""));
+ for (int i = 1; i <= 70; i++) {
+ frames.add(frameNode("run/" + i, "run", "A", 1, 1, ",\"parent\":\"" + (i == 1 ? "talk/a" : "run/" + (i - 1)) + "\""));
+ }
+ frames.add(frameNode("run/loop-1", "run", "A", 1, 1, ",\"parent\":\"run/loop-2\""));
+ frames.add(frameNode("run/loop-2", "run", "A", 1, 1, ",\"parent\":\"run/loop-1\""));
+ final Map<Long, SessionDataFile> files = new TreeMap<>();
+ files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:01Z"));
+ final List<String> loose = new ArrayList<>();
+ for (final Map<String, Object> n : (List<Map<String, Object>>) view(round(frames.toArray(new String[0])), files,
+ Collections.emptyList()).get("loose")) {
+ loose.add((String) n.get("id"));
+ }
+ // only the two runs that loop, which the Sessionizer never finishes walking; none of the seventy under the talk
+ assertEquals(Arrays.asList("run/loop-1", "run/loop-2"), loose);
+ }
+
+ /**
+ * A node's attrs are looked up as the Sessionizer decodes them into a map: a stream's record count past 32 bits is
+ * kept, and a number past a double's range fails the whole decoding, so the session's title is then empty.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void attrsAreLookedUpAsTheSessionizerDecodesThem() throws Exception {
+ final Map<Long, SessionDataFile> files = new TreeMap<>();
+ files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:01Z"));
+ // the number past the range fails the decoding even where a later key replaces it
+ for (final String other : new String[] {"1e400", "1e400,\"other\":0"}) {
+ final Map<String, Object> doc = view(round(
+ frameNode("session/s", "session", "", 1, 1, ",\"attrs\":{\"title\":\"hello\",\"other\":" + other + "}"),
+ frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\",\"records\":2147483648}")),
+ files, Collections.emptyList());
+ assertEquals("", ((Map<String, Object>) doc.get("summary")).get("title"), other);
+ assertEquals(2147483648L, ((List<Map<String, Object>>) doc.get("streams")).get(0).get("records"));
+ }
+ }
+
+ /** A round of these node and relation frames, with its header and commit, signed. */
+ private static byte[] round(final String... frames) {
+ long nodes = 0;
+ for (final String f : frames) {
+ nodes += f.startsWith("{\"t\":\"node\"") ? 1 : 0;
+ }
+ return resign("{\"t\":\"header\",\"schema\":\"sf/1\",\"conversation\":\"c\",\"session\":\"s\",\"round\":1,"
+ + "\"from_seq\":1,\"through_seq\":3,\"input_digest\":\"x\",\"parser\":\"p\",\"policy\":\"p\"}\n"
+ + String.join("\n", frames) + "\n"
+ + "{\"t\":\"commit\",\"digest\":\"0\",\"counts\":{\"nodes\":" + nodes + ",\"relations\":" + (frames.length - nodes)
+ + ",\"unresolved\":0}}\n");
+ }
+
+ private static String frameRelation(final String id, final String from, final String to, final long seq, final long row) {
+ return "{\"t\":\"relation\",\"id\":\"" + id + "\",\"revision\":1,\"type\":\"reports\",\"from\":\"" + from
+ + "\",\"to\":\"" + to + "\",\"quality\":\"exact_unique\",\"evidence\":[{\"seq\":" + seq + ",\"row\":" + row + "}]}";
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<String> relationIds(final Map<String, Object> doc) {
+ final List<String> out = new ArrayList<>();
+ for (final Map<String, Object> r : (List<Map<String, Object>>) doc.get("relations")) {
+ out.add((String) r.get("id"));
+ }
+ return out;
+ }
+
+ private static String frameNode(final String id, final String kind, final String stream, final long seq, final long row,
+ final String more) {
+ return "{\"t\":\"node\",\"id\":\"" + id + "\",\"revision\":1,\"kind\":\"" + kind + "\",\"stream\":\"" + stream
+ + "\",\"ref\":{\"seq\":" + seq + ",\"row\":" + row + "}" + more + "}";
+ }
+
+ private static String frameStarts(final String id, final String from, final String to) {
+ return "{\"t\":\"relation\",\"id\":\"" + id + "\",\"revision\":1,\"type\":\"starts\",\"from\":\"" + from
+ + "\",\"to\":\"" + to + "\",\"quality\":\"exact_unique\"}";
+ }
+
+ /**
+ * A landed file of one kind, its stream and its run, with one record per time given, an empty time meaning a record
+ * that carries none.
+ */
+ private static SessionDataFile dataFile(final long seq, final String kind, final String stream, final String run,
+ final String... times) {
+ final StringBuilder body = new StringBuilder("{\"h\":1,\"schema\":\"sd/1\",\"seq\":" + seq
+ + ",\"at\":\"2026-01-01T00:00:00Z\",\"kind\":\"" + kind + "\",\"adapter\":\"mock/0.2.0\",\"dialect\":\"mock/1\","
+ + "\"src\":\"x\",\"session\":\"s\",\"stream\":\"" + stream + "\""
+ + (run.isEmpty() ? "" : ",\"batch\":\"" + run + "\"") + "}\n");
+ for (int i = 0; i < times.length; i++) {
+ body.append("{\"ord\":").append(i + 1).append(",\"off\":0,\"sha\":\"0\",\"bytes\":1")
+ .append(times[i].isEmpty() ? "" : ",\"time\":\"" + times[i] + "\"").append(",\"parts\":[]}\n");
+ }
+ return SessionDataFile.parse(body.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static byte[] resign(final String round) {
+ final String body = round.substring(0, round.lastIndexOf("{\"t\":\"commit\""));
+ final String commit = round.substring(body.length()).replaceFirst(
+ "\"digest\":\"[0-9a-f]+\"", "\"digest\":\"" + Digests.sha256Hex(body.getBytes(StandardCharsets.UTF_8)) + "\"");
+ return (body + commit).getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * A LangChain conversation the Sessionizer landed from what a real LangSmith client sent: a main stream, a nested
+ * agent's child stream, and an auxiliary stream that summarises. The document equals the Sessionizer's. The
+ * auxiliary stream is the same agent carrying another prompt, not a child, so only the nested agent's talk is a
+ * child's.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void aLangChainConversationIsTheSessionizersDocument() throws Exception {
+ final Map<String, Object> doc = view(
+ Fixtures.bytes(Fixtures.LANGCHAIN_SUBAGENT_DIR + Fixtures.LANGCHAIN_SUBAGENT_ROUND_FILE),
+ Fixtures.langchainSubagentDataFiles(), Collections.emptyList());
+ final JsonElement expected = JsonParser.parseString(new String(
+ Fixtures.bytes(Fixtures.LANGCHAIN_SUBAGENT_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<String, Boolean> child = new TreeMap<>();
+ for (final Map<String, Object> t : (List<Map<String, Object>>) doc.get("talks")) {
+ child.put((String) t.get("stream"), Boolean.TRUE.equals(t.get("child")));
}
- return e;
+ assertEquals(Map.of("main", false, "delegate-to-analyst-6e581483db74", true, "summarise-d6aa2d533d9e", false), child);
}
/**
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ExecutionRecordTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ExecutionRecordTest.java
new file mode 100644
index 0000000000..412c56ddf0
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ExecutionRecordTest.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import com.google.gson.Gson;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.ExecutionRecord;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class ExecutionRecordTest {
+ private static final Gson GSON = new Gson();
+
+ /**
+ * Each line decoded and rendered as the Sessionizer's <code>execution.Decode</code> and <code>json.Marshal</code>
+ * do. The right-hand side of every case is what Go printed for the same line: the keys in the struct's order, a key
+ * the struct does not have dropped, an empty optional left out, a server given as <code>{}</code> kept with an
+ * empty name, a duration of 0 kept, and a size that was not given written as 0.
+ */
+ @Test
+ public void aRecordIsRenderedAsTheSessionizerRendersIt() {
+ final String[][] cases = {
+ {
+ "{\"outcome\":\"returned\",\"time\":\"2026-01-01T00:00:01.5Z\",\"schema\":\"execution/1\",\"id\":\"t1/client_hook\","
+ + "\"tool\":\"t1\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\","
+ + "\"tool_name\":\"mcp__status__lookup\",\"cwd\":\"/w\",\"protocol\":\"mcp\","
+ + "\"server\":{\"source\":\"user\",\"name\":\"status\",\"extra\":1},\"duration_ms\":0,"
+ + "\"arguments\":{\"state\":\"size_only\",\"bytes\":21,\"sha256\":\"ab\"},\"a_later_key\":true}",
+ "{\"schema\":\"execution/1\",\"id\":\"t1/client_hook\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\","
+ + "\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"tool_name\":\"mcp__status__lookup\",\"cwd\":\"/w\","
+ + "\"protocol\":\"mcp\",\"server\":{\"name\":\"status\",\"source\":\"user\"},\"time\":\"2026-01-01T00:00:01.5Z\","
+ + "\"duration_ms\":0,\"outcome\":\"returned\",\"arguments\":{\"state\":\"size_only\",\"bytes\":21,\"sha256\":\"ab\"}}",
+ },
+ {
+ "{\"schema\":\"execution/1\",\"id\":\"t2/client_hook\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\","
+ + "\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t2\",\"tool_name\":\"\",\"protocol\":\"mcp\",\"server\":{},"
+ + "\"time\":\"t\",\"outcome\":\"failed\",\"arguments\":null,\"result\":{\"state\":\"size_only\"}}",
+ "{\"schema\":\"execution/1\",\"id\":\"t2/client_hook\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\","
+ + "\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t2\",\"protocol\":\"mcp\",\"server\":{\"name\":\"\"},\"time\":\"t\","
+ + "\"outcome\":\"failed\",\"result\":{\"state\":\"size_only\",\"bytes\":0}}",
+ },
+ {
+ "{\"schema\":\"execution/1\",\"id\":\"t3\",\"server\":null,\"duration_ms\":null}",
+ "{\"schema\":\"execution/1\",\"id\":\"t3\",\"observed_by\":\"\",\"boundary\":\"\",\"session\":\"\",\"stream\":\"\","
+ + "\"tool\":\"\",\"protocol\":\"\",\"time\":\"\",\"outcome\":\"\"}",
+ },
+ {
+ "{\"schema\":\"execution/1\",\"id\":\"t9\",\"duration_ms\":-3,\"arguments\":{\"bytes\":-1}}",
+ "{\"schema\":\"execution/1\",\"id\":\"t9\",\"observed_by\":\"\",\"boundary\":\"\",\"session\":\"\",\"stream\":\"\","
+ + "\"tool\":\"\",\"protocol\":\"\",\"time\":\"\",\"duration_ms\":-3,\"outcome\":\"\","
+ + "\"arguments\":{\"state\":\"\",\"bytes\":-1}}",
+ },
+ };
+ for (final String[] c : cases) {
+ final ExecutionRecord r = ExecutionRecord.decode(c[0]);
+ assertNotNull(r, c[0]);
+ assertEquals(c[1], GSON.toJson(r.fields()));
+ }
+ final ExecutionRecord first = ExecutionRecord.decode(cases[0][0]);
+ assertEquals("t1/client_hook", first.getId());
+ assertEquals("t1", first.getTool());
+ assertEquals("2026-01-01T00:00:01.5Z", first.getTime());
+ }
+
+ /**
+ * What Go refuses to decode into the struct is no record: a fraction or a string where an integer goes, a number
+ * where a string goes, another schema, and a value that is not an object.
+ */
+ @Test
+ public void whatGoRefusesIsNoRecord() {
+ for (final String raw : new String[] {
+ "{\"schema\":\"execution/1\",\"id\":\"t4\",\"duration_ms\":1.5}",
+ "{\"schema\":\"execution/1\",\"id\":\"t5\",\"duration_ms\":\"380\"}",
+ "{\"schema\":\"execution/1\",\"id\":\"t6\",\"arguments\":{\"bytes\":1e2}}",
+ "{\"schema\":\"execution/1\",\"id\":\"t7\",\"server\":{\"name\":7}}",
+ "{\"schema\":\"changes/1\",\"id\":\"t8\"}",
+ "[{\"schema\":\"execution/1\"}]",
+ "",
+ }) {
+ assertNull(ExecutionRecord.decode(raw), raw);
+ }
+ assertNull(ExecutionRecord.decode(null));
+ }
+}
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 a9f26655fb..566c6c0d8b 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
@@ -42,6 +42,14 @@ import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlow
* with a subagent whose calls' request and response bodies landed in one <code>provider_body</code> file. Under
* <code>provider-bodies-errors/</code>, the same for <code>tests/scenarios/provider-bodies-errors.yaml</code>: an API
* error the runtime wrote as a synthetic call, between two calls it did send.
+ *
+ * <p>Under <code>mcp-calls/</code>, the same for <code>tests/scenarios/mcp-calls.yaml</code>: calls to MCP servers on
+ * the main stream and in a subagent, each with the execution record the plugin wrote after it in an
+ * <code>execution</code> file of its stream, one of them written twice.
+ *
+ * <p>Under <code>langchain-subagent/</code>, one LangChain conversation as the Sessionizer lands it from what a real
+ * LangSmith client sent, its <code>subagent</code> capture, parsed: a main stream, a nested agent's child stream, and
+ * an auxiliary stream that summarises, with the provider bodies of every call.
*/
public final class Fixtures {
public static final String SESSION = "00000001-0000-4000-8000-000000000001";
@@ -74,14 +82,33 @@ public final class Fixtures {
"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_ROUND_FILE = "r000001-e292bd0f6de6.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";
+ public static final String PROVIDER_BODIES_ERRORS_ROUND_FILE = "r000001-76f9606aacf3.sf";
+
+ public static final String MCP_CALLS_DIR = "mcp-calls/";
+ public static final String[] MCP_CALLS_DATA_FILES = {
+ "transcript-20260101T000000.000000000Z-000001.sd",
+ "execution-20260101T000000.000000000Z-000002.sd",
+ "transcript-20260101T000000.000000000Z-000003.sd",
+ "execution-20260101T000000.000000000Z-000004.sd",
+ "meta-20260101T000000.000000000Z-000005.sd",
+ };
+ public static final String MCP_CALLS_ROUND_FILE = "r000001-0465bcdcdb72.sf";
+
+ public static final String LANGCHAIN_SUBAGENT_DIR = "langchain-subagent/";
+ public static final String[] LANGCHAIN_SUBAGENT_DATA_FILES = {
+ "transcript-20260920T110158.000000000Z-000001.sd",
+ "transcript-20260920T110158.000000000Z-000002.sd",
+ "transcript-20260920T110158.000000000Z-000003.sd",
+ "provider_body-20260920T110158.000000000Z-000004.sd",
+ };
+ public static final String LANGCHAIN_SUBAGENT_ROUND_FILE = "r000001-3fd311176e49.sf";
private Fixtures() {
}
@@ -111,6 +138,14 @@ public final class Fixtures {
return dataFiles(PROVIDER_BODIES_ERRORS_DIR, PROVIDER_BODIES_ERRORS_DATA_FILES);
}
+ public static Map<Long, SessionDataFile> mcpCallsDataFiles() throws IOException {
+ return dataFiles(MCP_CALLS_DIR, MCP_CALLS_DATA_FILES);
+ }
+
+ public static Map<Long, SessionDataFile> langchainSubagentDataFiles() throws IOException {
+ return dataFiles(LANGCHAIN_SUBAGENT_DIR, LANGCHAIN_SUBAGENT_DATA_FILES);
+ }
+
private static Map<Long, SessionDataFile> dataFiles(final String dir, final String[] names) throws IOException {
final Map<Long, SessionDataFile> 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/GoDecodingTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/GoDecodingTest.java
new file mode 100644
index 0000000000..e1f036a7ea
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/GoDecodingTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.function.Function;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.ChangesRecord;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.ExecutionRecord;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.GoJson;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * A record is read as the Sessionizer's Go code reads it, whatever its producer wrote. Each case pairs a line with
+ * what Go's <code>json.Unmarshal</code> into the Sessionizer's struct and <code>json.Marshal</code> back gave for
+ * it, or null where Go refused the line: keys in another case, a key given twice, an object given twice, a null over
+ * a value, a value of the wrong type, an escape Go does not know, a control character in a string, a lone surrogate,
+ * and text after the object. The document must hold what Go holds, key for key and in key order.
+ */
+public class GoDecodingTest {
+ private static final Gson GSON = new GsonBuilder().serializeNulls().create();
+
+ private static final String[][] EXECUTIONS = {
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}"},
+ {"{\"Schema\":\"execution/1\",\"ID\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}"},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"Server\":{\"Name\":\"a\",\"SOURCE\":\"user\"},\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"server\":{\"n [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"id\":null,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"ret [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"duration_ms\":\"bad\",\"duration_ms\":1,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"duration_ms\":1,\"duration_ms\":null,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00 [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"server\":{\"name\":\"a\",\"source\":\"x\"},\"server\":{\"name\":\"b\"},\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\ [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"server\":{\"name\":\"a\"},\"server\":null,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01- [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"arguments\":{\"bytes\":1},\"arguments\":{\"state\":\"s\"},\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"t [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"tool\":\"a\",\"TOOL\":\"b\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\" [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\",\"TOOL\":\"b\",\"tool\":\"a\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"a\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\", [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"arguments\":{\"bytes\":1.0},\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"arguments\":{\"bytes\":\"1\"},\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"server\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"server\":[],\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"outcome\":1,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"time\":null,\"arguments\":null,\"duration_ms\":null,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\": [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"unknown\":{\"a\":[1,{\"b\":null}]},\"server\":{\"name\":\"a\",\"extra\":[1]},\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"pro [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"unknown\":{\"a\":[184467440737095516160,{\"b\":null}]},\"server\":{\"name\":\"a\",\"extra\":[1]},\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\ [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\",}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}x", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"} ", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}"},
+ {"{\"schema\":\"execution/1\",\"id\":\"xA\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"xA\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}"},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\\'\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\ty\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\\ud800y\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\ufffdy\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\" [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\ud83d\ude00\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\ud83d\ude00\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"out [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"<&>\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"\\u003c\\u0026\\u003e\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outco [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"duration_ms\":-0,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"duration [...]
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"duration_ms\":9223372036854775808,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"duration_ms\":01,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"duration_ms\":1E2,\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", null},
+ {"{\"schema\":\"execution/1\",\"id\":\"x\",\"result\":{},\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"r [...]
+ {"{\"\u017fchema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}", "{\"schema\":\"execution/1\",\"id\":\"x\",\"observed_by\":\"asz-plugin\",\"boundary\":\"client_hook\",\"session\":\"s\",\"stream\":\"main\",\"tool\":\"t1\",\"protocol\":\"mcp\",\"time\":\"2026-01-01T00:00:01Z\",\"outcome\":\"returned\"}"}
+ };
+
+ private static final String[][] CHANGES = {
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"captured_by\":\"asz-plugin\",\"session\":\"s\",\"stream\":\"main\",\"time\":\"t\",\"basis\":\"b\",\"changed_files\":1,\"changes\":[{\"path\":\"a\",\"operation\":\"modify\",\"before\":{\"present\":true,\"bytes\":1},\"after\":{\"present\":true},\"diff\":\"available\",\"hunks\":[{\"old_start\":1,\"lines\":[\"+a\"]}]}]}", "{\"schema\":\"changes/1\",\"id\":\"t\",\"captured_by\":\"asz-plugin\",\"session\":\"s\",\"stream\":\"main\",\"time\" [...]
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"CHANGES\":[{\"Path\":\"a\",\"Before\":{\"Present\":true}}],\"changes\":[{\"operation\":\"create\"}]}", "{\"schema\":\"changes/1\",\"id\":\"t\",\"captured_by\":\"\",\"session\":\"\",\"stream\":\"\",\"time\":\"\",\"basis\":\"\",\"changed_files\":null,\"changes\":[{\"path\":\"a\",\"operation\":\"create\",\"before\":{\"present\":true,\"bytes\":null},\"after\":{\"present\":false,\"bytes\":null},\"diff\":\"\",\"additions\":null,\"deletions [...]
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"changes\":[{\"path\":\"a\",\"hunks\":[{\"lines\":[\"x\",\"y\"]}]}],\"changes\":[{\"hunks\":[{\"lines\":[null]}]},{\"path\":\"b\"}]}", "{\"schema\":\"changes/1\",\"id\":\"t\",\"captured_by\":\"\",\"session\":\"\",\"stream\":\"\",\"time\":\"\",\"basis\":\"\",\"changed_files\":null,\"changes\":[{\"path\":\"a\",\"operation\":\"\",\"before\":{\"present\":false,\"bytes\":null},\"after\":{\"present\":false,\"bytes\":null},\"diff\":\"\",\"ad [...]
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"window\":{\"before\":{\"from\":\"a\"}},\"window\":{\"before\":{\"to\":\"b\"}},\"window\":{\"after\":null}}", "{\"schema\":\"changes/1\",\"id\":\"t\",\"captured_by\":\"\",\"session\":\"\",\"stream\":\"\",\"time\":\"\",\"basis\":\"\",\"window\":{\"before\":{\"from\":\"a\",\"to\":\"b\"},\"after\":{\"from\":\"\",\"to\":\"\"}},\"changed_files\":null,\"changes\":null}"},
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"gaps\":[\"a\"],\"gaps\":null,\"policy\":{\"expanded\":[]},\"outcome\":{\"exit_code\":null,\"state\":\"x\"}}", "{\"schema\":\"changes/1\",\"id\":\"t\",\"captured_by\":\"\",\"session\":\"\",\"stream\":\"\",\"time\":\"\",\"basis\":\"\",\"policy\":{},\"outcome\":{\"state\":\"x\",\"exit_code\":null},\"changed_files\":null,\"changes\":null}"},
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"changes\":[{\"path\":\"a\",\"before\":{\"present\":\"yes\"}}]}", null},
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"changes\":[{\"path\":\"a\",\"additions\":2.5}]}", null},
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"overlaps\":[null,{\"capture\":\"c\"}],\"changed_files\":null}", "{\"schema\":\"changes/1\",\"id\":\"t\",\"captured_by\":\"\",\"session\":\"\",\"stream\":\"\",\"time\":\"\",\"basis\":\"\",\"overlaps\":[{\"capture\":\"\",\"session\":\"\",\"stream\":\"\",\"state\":\"\"},{\"capture\":\"c\",\"session\":\"\",\"stream\":\"\",\"state\":\"\"}],\"changed_files\":null,\"changes\":null}"},
+ {"{\"schema\":\"changes/1\",\"id\":\"t\",\"changes\":[],\"changed_files\":0}", "{\"schema\":\"changes/1\",\"id\":\"t\",\"captured_by\":\"\",\"session\":\"\",\"stream\":\"\",\"time\":\"\",\"basis\":\"\",\"changed_files\":0,\"changes\":[]}"}
+ };
+
+ @Test
+ public void anExecutionRecordIsReadAsGoReadsIt() {
+ check(EXECUTIONS, raw -> {
+ final ExecutionRecord r = ExecutionRecord.decode(raw);
+ return r == null ? null : r.fields();
+ });
+ }
+
+ @Test
+ public void aChangeRecordIsReadAsGoReadsIt() {
+ check(CHANGES, raw -> {
+ final ChangesRecord r = ChangesRecord.decode(raw);
+ return r == null ? null : r.fields();
+ });
+ }
+
+ /**
+ * A text is read as Go's <code>encoding/json</code> reads it. Each case in the fixture pairs a text with whether
+ * Go accepts it and, when it does, the value Go decodes, written back with numbers as written: the syntax Gson
+ * alone accepts is refused, and every number Go's grammar allows is read, however long. Go's scanner takes 10,000
+ * levels of objects and arrays and refuses the next, and a text nested far deeper is refused, not a stack overflow.
+ */
+ @Test
+ public void aTextIsReadAsGoReadsIt() throws Exception {
+ final JsonArray cases = JsonParser.parseString(
+ new String(Fixtures.bytes("go-json-syntax.json"), StandardCharsets.UTF_8)).getAsJsonArray();
+ assertEquals(82, cases.size());
+ for (final JsonElement e : cases) {
+ final JsonObject c = e.getAsJsonObject();
+ final String text = c.get("text").getAsString();
+ final JsonElement read = GoJson.parse(text);
+ if (!c.get("valid").getAsBoolean()) {
+ assertNull(read, text);
+ continue;
+ }
+ assertNotNull(read, text);
+ assertEquals(GoJson.toValue(GoJson.parse(c.get("canonical").getAsString())), GoJson.toValue(read), text);
+ }
+ assertNotNull(GoJson.parse("[".repeat(10000) + "0" + "]".repeat(10000)));
+ assertNull(GoJson.parse("[".repeat(10001) + "0" + "]".repeat(10001)));
+ assertNull(GoJson.parse("[".repeat(50000) + "0" + "]".repeat(50000)));
+ assertNull(GoJson.members("{\"a\":" + "[".repeat(10001) + "0" + "]".repeat(10001) + "}"));
+ // every number keeps the digits it was written with, checked here without the scanner on both sides
+ for (final String literal : new String[] {"184467440737095516160", "123456789012345678901234567890", "1e400", "-1.0e+10"}) {
+ assertEquals(literal, GoJson.parse("[" + literal + "]").getAsJsonArray().get(0).getAsString());
+ }
+ }
+
+ private static void check(final String[][] cases, final Function<String, Map<String, Object>> decode) {
+ for (final String[] c : cases) {
+ final Map<String, Object> fields = decode.apply(c[0]);
+ if (c[1] == null) {
+ assertNull(fields, c[0]);
+ continue;
+ }
+ final JsonElement want = JsonParser.parseString(c[1]);
+ final JsonElement got = GSON.toJsonTree(fields);
+ assertEquals(want, got, c[0]);
+ assertEquals(order(want), order(got), c[0]);
+ }
+ }
+
+ /** The keys of every object, depth first, so two trees with one order of keys give one list. */
+ private static String order(final JsonElement e) {
+ final StringBuilder sb = new StringBuilder();
+ if (e.isJsonObject()) {
+ final JsonObject o = e.getAsJsonObject();
+ sb.append(new ArrayList<>(o.keySet()));
+ for (final String k : o.keySet()) {
+ sb.append(order(o.get(k)));
+ }
+ } else if (e.isJsonArray()) {
+ for (final JsonElement x : e.getAsJsonArray()) {
+ sb.append(order(x));
+ }
+ }
+ return sb.toString();
+ }
+}
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 fac7716a53..338a5eb129 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
@@ -24,6 +24,7 @@ import java.util.List;
import java.util.Map;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.Digests;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.FileNames;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.Ref;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound;
import org.apache.skywalking.oap.server.ai.agent.conversation.format.Times;
@@ -32,6 +33,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class SessionFormatsTest {
@@ -115,6 +117,18 @@ public class SessionFormatsTest {
assertFalse(r.isIntact());
}
+ /**
+ * A file's stamp writes the year as the Sessionizer does, four digits of the year itself: year zero is 0000, not the
+ * first year of an era.
+ */
+ @Test
+ public void aFilesStampWritesYearZeroAsTheSessionizerDoes() {
+ final SessionDataFile f = SessionDataFile.parse(("{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"at\":\"0000-01-01T00:00:00Z\","
+ + "\"kind\":\"transcript\",\"adapter\":\"mock/0.2.0\",\"dialect\":\"mock/1\",\"src\":\"x\",\"session\":\"s\","
+ + "\"stream\":\"main\"}\n").getBytes(StandardCharsets.UTF_8));
+ assertEquals("s/streams/main/transcript-00000101T000000.000000000Z-000001.sd", FileNames.dataFile(f.getHeader()));
+ }
+
@Test
public void fileNamesFollowTheStorageRootLayout() throws Exception {
final Map<Long, SessionDataFile> files = Fixtures.dataFiles();
@@ -166,4 +180,167 @@ public class SessionFormatsTest {
assertEquals("null", parts.get(2).data());
assertEquals("\"plain\"", parts.get(3).data());
}
+
+ /**
+ * A call's provider bodies are references like any other, so a round whose bodies do not read, or name a record
+ * past the round's range, is refused, and a body's own block is kept. Every verdict here is the one the
+ * Sessionizer's <code>sessionflow.Read</code> gave for the same round with the same line changed.
+ */
+ @Test
+ public void aRoundWhoseProviderBodiesDoNotReadIsRefused() throws Exception {
+ final String round = new String(
+ Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), StandardCharsets.UTF_8);
+ final String from = "\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1}},";
+ assertTrue(round.contains(from));
+ final Object[][] cases = {
+ {from, true},
+ {"\"provider_bodies\":[{\"role\":\"prompt\",\"ref\":{\"seq\":4,\"row\":1}},", false},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":0,\"row\":1}},", false},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":0}},", false},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":5,\"row\":1}},", false},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4.0,\"row\":1}},", false},
+ {"\"provider_bodies\":[{\"role\":7,\"ref\":{\"seq\":4,\"row\":1}},", false},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1,\"block\":2}},", true},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":-4,\"row\":1}},", false},
+ {"\"provider_bodies\":[null,", false},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1,\"block\":2147483648}},", true},
+ };
+ for (final Object[] c : cases) {
+ final int at = round.indexOf(from);
+ final byte[] changed = (round.substring(0, at) + c[0] + round.substring(at + from.length()))
+ .getBytes(StandardCharsets.UTF_8);
+ if ((Boolean) c[1]) {
+ SessionFlowRound.parse(changed);
+ } else {
+ assertThrows(IllegalArgumentException.class, () -> SessionFlowRound.parse(changed), (String) c[0]);
+ }
+ }
+ // a block past 32 bits is kept whole, as Go's int keeps it
+ assertEquals(Long.valueOf(2147483648L), call(round.replace(from, (String) cases[10][0])).getProviderBodies().get(0)
+ .getRef().getBlock());
+ assertEquals(2147483648L, call(round.replace(from, (String) cases[10][0])).getProviderBodies().get(0).getRef()
+ .toMap().get("block"));
+ final String withBlock = round.replace(from, (String) cases[7][0]);
+ final SessionFlowRound.Node call = SessionFlowRound.parse(withBlock.getBytes(StandardCharsets.UTF_8)).getNodes()
+ .stream().filter(n -> "call/s2-call-fdae022ac306".equals(n.getId())).findFirst().orElseThrow();
+ assertEquals(Long.valueOf(2), call.getProviderBodies().get(0).getRef().getBlock());
+ // what Go settles from the text as written: a key in another case, a key given twice, an object given twice
+ final Object[][] asWritten = {
+ {"\"provider_bodies\":[{\"role\":7,\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1}},", null},
+ {"\"provider_bodies\":[{\"Role\":\"request\",\"REF\":{\"Seq\":4,\"row\":1}},", "4/1"},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"seq\":5,\"row\":1}},", null},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":\"4\",\"seq\":4,\"row\":1}},", null},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":-4,\"seq\":4,\"row\":1}},", null},
+ // syntax only Gson's lenient parser takes: Go refuses the line, and with it the round
+ {"\"x\":0;\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1}},", null},
+ {"x:0,\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1}},", null},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":-1,\"row\":1}},", null},
+ {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1},\"ref\":{\"row\":2}},", "4/2"},
+ {"\"provider_bodies\":\"x\",\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1}},", "4/1"},
+ };
+ for (final Object[] c : asWritten) {
+ final String changed = round.replace(from, (String) c[0]);
+ if (c[1] == null) {
+ assertThrows(IllegalArgumentException.class,
+ () -> SessionFlowRound.parse(changed.getBytes(StandardCharsets.UTF_8)), (String) c[0]);
+ } else {
+ final Ref first = call(changed).getProviderBodies().get(0).getRef();
+ assertEquals(c[1], first.getSeq() + "/" + first.getRow(), (String) c[0]);
+ }
+ }
+ // the node's attrs under a key in another case, and given twice: Go takes the last, in any case
+ for (final String key : new String[] {"\"Attrs\":", "\"attrs\":{\"x\":1},\"attrs\":"}) {
+ final String changed = round.replaceFirst(
+ "(\"id\":\"call/s2-call-fdae022ac306\"[^\\n]*?)\"attrs\":", "$1" + key.replace("$", "\\$"));
+ assertFalse(changed.equals(round));
+ assertEquals(2, call(changed).getProviderBodies().size(), key);
+ }
+ // the attribute as a whole: null is no bodies, anything else that is not a list is refused
+ SessionFlowRound.parse(round.replaceAll("\"provider_bodies\":\\[[^\\]]*\\],?", "\"provider_bodies\":null,")
+ .getBytes(StandardCharsets.UTF_8));
+ assertThrows(IllegalArgumentException.class, () -> SessionFlowRound.parse(
+ round.replaceAll("\"provider_bodies\":\\[[^\\]]*\\],?", "\"provider_bodies\":\"x\",").getBytes(StandardCharsets.UTF_8)));
+ }
+
+ private static SessionFlowRound.Node call(final String round) {
+ return SessionFlowRound.parse(round.getBytes(StandardCharsets.UTF_8)).getNodes()
+ .stream().filter(n -> "call/s2-call-fdae022ac306".equals(n.getId())).findFirst().orElseThrow();
+ }
+
+ /**
+ * A record time is read as Go's <code>time.Parse(time.RFC3339Nano, s)</code> reads it, so the OAP orders and
+ * times records as the Sessionizer does. Every value on the right is what Go 1.27 gave for the text on the left,
+ * as <code>UnixNano</code>, or its refusal: a fraction of any length cut to the nanosecond, a comma before it, an
+ * offset of 24 hours, no lowercase letters and no compact offset.
+ */
+ @Test
+ public void recordTimesAreReadAsGoReadsThem() {
+ final String[][] cases = {
+ {"2026-01-01T00:00:00Z", "1767225600000000000"},
+ {"2026-01-01T00:00:00.1Z", "1767225600100000000"},
+ {"2026-01-01T00:00:00.123456789Z", "1767225600123456789"},
+ {"2026-01-01T00:00:00.0000000001Z", "1767225600000000000"},
+ {"2026-01-01T00:00:00.1234567891234Z", "1767225600123456789"},
+ {"2026-01-01T00:00:00+08:00", "1767196800000000000"},
+ {"2026-01-01T00:00:00.5-05:30", "1767245400500000000"},
+ {"2026-01-01t00:00:00z", "refused"},
+ {"2026-01-01T00:00:00", "refused"},
+ {"2026-01-01 00:00:00Z", "refused"},
+ {"2026-01-01T24:00:00Z", "refused"},
+ {"2026-02-30T00:00:00Z", "refused"},
+ {"2026-01-01T00:00:60Z", "refused"},
+ {"2026-01-01T00:00:00.Z", "refused"},
+ {"2026-01-01T00:00:00,5Z", "1767225600500000000"},
+ {"2026-1-01T00:00:00Z", "refused"},
+ {"2026-01-01T00:00:00+0800", "refused"},
+ {"2026-01-01T00:00:00+08", "refused"},
+ {"2026-01-01T00:00:00+24:00", "1767139200000000000"},
+ {"2026-01-01T00:00:00-00:00", "1767225600000000000"},
+ {"", "refused"},
+ {"2026-01-01T00:00:00.999999999999Z", "1767225600999999999"},
+ {"2026-01-01T00:00:00,Z", "refused"},
+ {"2026-01-01T00:00:00,123Z", "1767225600123000000"},
+ {"2026-01-01T00:00:00+23:59", "1767139260000000000"},
+ {"2026-01-01T00:00:00+23:60", "1767139200000000000"},
+ {"2026-01-01T00:00:00+24:59", "1767135660000000000"},
+ {"2026-01-01T00:00:00+25:00", "refused"},
+ {"2026-01-01T00:00:00+99:00", "refused"},
+ {"2026-01-01T00:00:00.5+24:00", "1767139200500000000"},
+ {"2026-01-01T00:00:00,5+08:00", "1767196800500000000"},
+ {"2026-01-01T00:00:00.1234567890123456789012Z", "1767225600123456789"},
+ {"2026-01-01T00:00:00Z ", "refused"},
+ {" 2026-01-01T00:00:00Z", "refused"},
+ {"0000-01-01T00:00:00Z", "-6826986978871345152"},
+ {"9999-12-31T23:59:59Z", "-4852116232933722624"},
+ {"2026-01-01T00:00:00.5", "refused"},
+ {"2026-01-01T00:00:00.-5Z", "refused"},
+ {"2026-01-01T00:00:00..5Z", "refused"},
+ {"2024-02-29T00:00:00Z", "1709164800000000000"},
+ {"2025-02-29T00:00:00Z", "refused"},
+ {"2026-01-01T00:00:00.5z", "refused"},
+ {"2026-01-01T00:00:00-24:00", "1767312000000000000"},
+ {"2026-01-01T00:00:00+00:00:00", "refused"},
+ {"2026-01-01T00:00:00+24:60", "1767135600000000000"},
+ {"2026-01-01T00:00:00+00:60", "1767222000000000000"},
+ {"2026-01-01T00:00:00+00:61", "refused"},
+ {"2026-01-01T00:00:00,1234567891234Z", "1767225600123456789"},
+ {"2026-01-01T00:00:00.5-24:59", "1767315540500000000"},
+ {"2026-01-01T00:00:00+2:00", "refused"},
+ {"2026-01-01T00:00:00+02:0", "refused"},
+ {"2026-01-01T00:00:00+0a:00", "refused"},
+ {"2026-01-01T00:00:00+02-00", "refused"},
+ {"2026-01-01T00:00:00Z07:00", "refused"}
+ };
+ for (final String[] c : cases) {
+ final long want = "refused".equals(c[1]) ? 0L : Long.parseLong(c[1]);
+ assertEquals(want, Times.nanos(c[0]), c[0]);
+ }
+ // a time Go refuses sorts after every time it reads, by its text in Go's order of strings
+ assertTrue(Times.compare("2026-01-01T00:00:00.0000000001Z", "2026-01-01T00:00:01Z") < 0);
+ // two spellings of one instant are one time, and two times that do not parse are equal: the caller decides
+ assertEquals(0, Times.compare("2026-01-01T00:00:01Z", "2026-01-01T00:00:01.0Z"));
+ assertEquals(0, Times.compare("\ue000", "\ud800\udc00"));
+ assertTrue(Times.compare("2026-01-01T00:00:00.11Z", "2026-01-01T00:00:00.1Z") > 0);
+ assertTrue(Times.compare("2026-01-01t00:00:00z", "2026-01-01T00:00:01Z") > 0);
+ }
}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/OrderTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/OrderTest.java
new file mode 100644
index 0000000000..3418d765a3
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/OrderTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.view;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class OrderTest {
+ private static final class Item {
+ final String name;
+ final Order.Point point;
+
+ Item(final String name, final Order.Point point) {
+ this.name = name;
+ this.point = point;
+ }
+ }
+
+ private static Item at(final String name, final String lane, final long seq, final long row, final long time) {
+ return new Item(name, new Order.Point(lane, seq, row, -1, time));
+ }
+
+ /**
+ * A position orders records inside one lane, and time orders lanes against each other: a child's file lands after
+ * its parent's, so by sequence all of its records would follow, though it ran in the middle. Inside a lane the
+ * position holds even where the times disagree, a timed item comes before an untimed one when lanes are merged,
+ * and an item with no position at all comes last. The same items and the same order as the Sessionizer's
+ * TestInOrderMergesLanesByTime.
+ */
+ @Test
+ public void lanesAreMergedByTime() {
+ final List<Item> items = new ArrayList<>(Arrays.asList(
+ at("child-2", "stream/c1", 5, 2, 25),
+ at("main-3", "stream/main", 1, 3, 30),
+ at("main-1", "stream/main", 1, 1, 10),
+ new Item("nowhere", Order.Point.NOWHERE),
+ at("child-1", "stream/c1", 5, 1, 20),
+ // an earlier time than main-1, but after it in the stream
+ at("main-2", "stream/main", 1, 2, 5),
+ // untimed: after every timed lane head
+ at("run-1", "run/r1", 9, 1, 0),
+ // two lane heads of one time: the earlier position first
+ at("tie-b", "stream/b", 7, 1, 40),
+ at("tie-a", "stream/a", 6, 1, 40)));
+ for (int i = 0; i < 20; i++) {
+ final List<String> names = new ArrayList<>();
+ for (final Item x : Order.inOrder(items, x -> x.point, Comparator.comparing(x -> x.name))) {
+ names.add(x.name);
+ }
+ assertEquals(Arrays.asList("main-1", "main-2", "child-1", "child-2", "main-3", "tie-a", "tie-b", "run-1", "nowhere"),
+ names);
+ // the input order must not matter
+ Collections.rotate(items, 3);
+ }
+ }
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/RecordReadingTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/RecordReadingTest.java
new file mode 100644
index 0000000000..13ac86f6f6
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/view/RecordReadingTest.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.ai.agent.conversation.view;
+
+import java.nio.charset.StandardCharsets;
+import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The records the document reads a typed value out of are read as the Sessionizer's view reads them into its Go
+ * structs: a queued command's prompt, a turn's reported duration, and the result row that names a workflow's child.
+ * Each case is a record's data parts, and every expected value is what the Sessionizer gave for the same parts: keys
+ * in another case, a key given twice, a null over a value, a value of the wrong type, an escape Go does not know, and
+ * which candidate decides when a record has more than one.
+ */
+public class RecordReadingTest {
+ private static final String HEADER = "{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"at\":\"2026-01-01T00:00:00Z\","
+ + "\"kind\":\"transcript\",\"adapter\":\"mock/0.2.0\",\"dialect\":\"mock/1\",\"src\":\"s/streams/main\","
+ + "\"session\":\"s\",\"stream\":\"main\"}";
+
+ /** The data parts; then what the Sessionizer read: the readable text, the duration, the child's name or null. */
+ private static final Object[][] CASES = {
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":[{\"text\":\"hi\"}]}"}, "hi", 0L, null},
+ {new String[] {"{\"Type\":\"queued_command\",\"PROMPT\":[{\"Text\":\"hi\"}]}"}, "hi", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":[{\"text\":\"a\"},null,{\"text\":\"b\"}]}"}, "a\nb", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":[{\"text\":7}]}", "{\"type\":\"queued_command\",\"prompt\":[{\"text\":\"next\"}]}"}, "next", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":\"x\"}"}, "", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":[{\"text\":\"a\\'b\"}]}"}, "", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"type\":\"other\",\"prompt\":[{\"text\":\"a\"}]}"}, "", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":[{\"text\":\"a\"}],\"prompt\":null}"}, "", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":[{\"text\":\"a\"},{\"text\":\"b\"}],\"prompt\":[null]}"}, "a", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":[{\"text\":\" \"}]}", "{\"type\":\"queued_command\",\"prompt\":[{\"text\":\"b\"}]}"}, "", 0L, null},
+ {new String[] {"{\"type\":\"queued_command\",\"prompt\":[]}", "{\"type\":\"queued_command\",\"prompt\":[{\"text\":\" b \"}]}"}, "b", 0L, null},
+ {new String[] {"{\"durationMs\":1500}"}, "", 1500L, null},
+ {new String[] {"{\"DurationMS\":1500}"}, "", 1500L, null},
+ {new String[] {"{\"durationMs\":1.5}"}, "", 0L, null},
+ {new String[] {"{\"durationMs\":\"1500\"}"}, "", 0L, null},
+ {new String[] {"{\"durationMs\":1500,\"durationMs\":null}"}, "", 1500L, null},
+ {new String[] {"{\"durationMs\":0}", "{\"durationMs\":7}"}, "", 7L, null},
+ {new String[] {"{\"durationMs\":-7}"}, "", -7L, null},
+ {new String[] {"{\"durationMs\":1e3}"}, "", 0L, null},
+ {new String[] {"{\"durationMs\":9223372036854775808}"}, "", 0L, null},
+ {new String[] {"{\"type\":\"result\",\"result\":{\"surface\":\"S\",\"summary\":\"M\"}}"}, "", 0L, "S"},
+ {new String[] {"{\"TYPE\":\"result\",\"RESULT\":{\"SURFACE\":\"S\"}}"}, "", 0L, "S"},
+ {new String[] {"{\"type\":\"Result\",\"result\":{\"surface\":\"S\"}}"}, "", 0L, null},
+ {new String[] {"{\"type\":\"result\",\"result\":{\"verdict\":\"V\",\"refuted_claims\":[{\"claim\":\"C\"}]}}"}, "", 0L, "V · C"},
+ {new String[] {"{\"type\":\"result\",\"result\":{\"verdict\":\"V\",\"refuted_claims\":[null,{\"claim\":\"C\"}]}}"}, "", 0L, "V"},
+ {new String[] {"{\"type\":\"result\",\"result\":null}", "{\"type\":\"result\",\"result\":{\"summary\":\"second\"}}"}, "", 0L, ""},
+ {new String[] {"{\"type\":\"result\",\"result\":{\"surface\":7}}", "{\"type\":\"result\",\"result\":{\"summary\":\"second\"}}"}, "", 0L, "second"},
+ {new String[] {"{\"type\":\"result\",\"result\":{\"summary\":\"a\"},\"result\":{\"verdict\":\"V\"}}"}, "", 0L, "a"},
+ {new String[] {"{\"type\":\"result\",\"result\":{\"summary\":\" two\\u00a0words\\n\"}}"}, "", 0L, "two words"},
+ };
+
+ @Test
+ public void aRecordIsReadAsTheSessionizerReadsIt() {
+ for (final Object[] c : CASES) {
+ final String[] parts = (String[]) c[0];
+ final SessionDataFile.Record rec = record(parts);
+ final String what = String.join(" + ", parts);
+ assertEquals(c[1], ConversationViewBuilder.readable(rec), what);
+ assertEquals(c[2], ConversationViewBuilder.reportedDuration(rec), what);
+ assertEquals(c[3], ConversationViewBuilder.resultName(rec), what);
+ }
+ }
+
+ /**
+ * A child's name is its words joined by one space and cut at 160 bytes, never inside a character, and marked when
+ * cut. Each expected name is the one the Sessionizer gave for the same summary.
+ */
+ @Test
+ public void aLongNameIsCutAsTheSessionizerCutsIt() {
+ final String[][] cases = {
+ {"a".repeat(159) + "é tail", "a".repeat(159) + "…"},
+ {"a".repeat(160), "a".repeat(160)},
+ {" " + "b".repeat(80) + "\\n\\n" + "c".repeat(80) + " ", "b".repeat(80) + " " + "c".repeat(79) + "…"},
+ };
+ for (final String[] c : cases) {
+ final String raw = "{\"type\":\"result\",\"result\":{\"summary\":\"" + c[0] + "\"}}";
+ assertEquals(c[1], ConversationViewBuilder.resultName(record(raw)), c[0]);
+ }
+ }
+
+ private static SessionDataFile.Record record(final String... data) {
+ final StringBuilder line = new StringBuilder("{\"ord\":1,\"off\":0,\"sha\":\"0\",\"bytes\":1,\"parts\":[");
+ for (int i = 0; i < data.length; i++) {
+ line.append(i == 0 ? "" : ",").append("{\"k\":\"data\",\"data\":").append(data[i]).append('}');
+ }
+ line.append("]}");
+ final String file = HEADER + "\n" + line + "\n";
+ return SessionDataFile.parse(file.getBytes(StandardCharsets.UTF_8)).getRecords().get(0);
+ }
+}
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 9471d8697d..d8cdaf546b 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
@@ -708,51 +708,6 @@
}
]
},
- {
- "id": "talk/main/s8-cycle-compact",
- "kind": "talk",
- "parent": "epoch/main/s8-boundary",
- "stream": "main",
- "at": 1767225610700,
- "ref": {
- "seq": 1,
- "row": 16
- },
- "attrs": {
- "loops": 1,
- "runs": 1,
- "trigger": "external"
- },
- "runs": 1,
- "from": 1767225610700,
- "to": 1767225610700,
- "segment": "segment/at_1_2",
- "children": [
- {
- "id": "run/talk_main_s8-cycle-compact/s8-cycle-compact",
- "kind": "run",
- "parent": "talk/main/s8-cycle-compact",
- "stream": "main",
- "at": 1767225610700,
- "ref": {
- "seq": 1,
- "row": 16
- },
- "attrs": {
- "trigger": "external"
- }
- }
- ],
- "edges": [
- {
- "type": "in_segment",
- "other": "segment/at_1_2",
- "dir": "out",
- "quality": "exact_unique",
- "via": "activity window"
- }
- ]
- },
{
"id": "talk/a0a10ef0666c4dc7e",
"kind": "talk",
@@ -889,6 +844,51 @@
"via": "inside the window of the talk that delegated it"
}
]
+ },
+ {
+ "id": "talk/main/s8-cycle-compact",
+ "kind": "talk",
+ "parent": "epoch/main/s8-boundary",
+ "stream": "main",
+ "at": 1767225610700,
+ "ref": {
+ "seq": 1,
+ "row": 16
+ },
+ "attrs": {
+ "loops": 1,
+ "runs": 1,
+ "trigger": "external"
+ },
+ "runs": 1,
+ "from": 1767225610700,
+ "to": 1767225610700,
+ "segment": "segment/at_1_2",
+ "children": [
+ {
+ "id": "run/talk_main_s8-cycle-compact/s8-cycle-compact",
+ "kind": "run",
+ "parent": "talk/main/s8-cycle-compact",
+ "stream": "main",
+ "at": 1767225610700,
+ "ref": {
+ "seq": 1,
+ "row": 16
+ },
+ "attrs": {
+ "trigger": "external"
+ }
+ }
+ ],
+ "edges": [
+ {
+ "type": "in_segment",
+ "other": "segment/at_1_2",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "activity window"
+ }
+ ]
}
],
"loose": [
@@ -947,30 +947,30 @@
],
"relations": [
{
- "id": "rel/ends_with/stream_a0a10ef0666c4dc7e/output_a0a10ef0666c4dc7e",
- "type": "ends_with",
- "from": "stream/a0a10ef0666c4dc7e",
- "to": "output/a0a10ef0666c4dc7e",
+ "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": "the last response in the child stream, and what it returned",
+ "via": "activity window",
"evidence": [
{
- "seq": 3,
+ "seq": 1,
"row": 2
}
]
},
{
- "id": "rel/follows/epoch_main_s8-boundary/epoch_main_0",
- "type": "follows",
- "from": "epoch/main/s8-boundary",
- "to": "epoch/main/0",
+ "id": "rel/starts/tool_s5-tool/stream_a0a10ef0666c4dc7e",
+ "type": "starts",
+ "from": "tool/s5-tool",
+ "to": "stream/a0a10ef0666c4dc7e",
"quality": "exact_unique",
- "via": "explicit context reset",
+ "via": "parent tool result",
"evidence": [
{
"seq": 1,
- "row": 15
+ "row": 11
}
]
},
@@ -989,58 +989,58 @@
]
},
{
- "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",
+ "id": "rel/ends_with/stream_a0a10ef0666c4dc7e/output_a0a10ef0666c4dc7e",
+ "type": "ends_with",
+ "from": "stream/a0a10ef0666c4dc7e",
+ "to": "output/a0a10ef0666c4dc7e",
"quality": "exact_unique",
- "via": "activity window",
+ "via": "the last response in the child stream, and what it returned",
"evidence": [
{
- "seq": 1,
+ "seq": 3,
"row": 2
}
]
},
{
- "id": "rel/in_segment/talk_main_s8-cycle-compact/segment_at_1_2",
- "type": "in_segment",
- "from": "talk/main/s8-cycle-compact",
- "to": "segment/at_1_2",
+ "id": "rel/reports/notify_1_12/stream_a0a10ef0666c4dc7e",
+ "type": "reports",
+ "from": "notify/1/12",
+ "to": "stream/a0a10ef0666c4dc7e",
"quality": "exact_unique",
- "via": "activity window",
+ "via": "task id on the notification",
"evidence": [
{
"seq": 1,
- "row": 16
+ "row": 12
}
]
},
{
- "id": "rel/reports/notify_1_12/stream_a0a10ef0666c4dc7e",
- "type": "reports",
- "from": "notify/1/12",
- "to": "stream/a0a10ef0666c4dc7e",
+ "id": "rel/follows/epoch_main_s8-boundary/epoch_main_0",
+ "type": "follows",
+ "from": "epoch/main/s8-boundary",
+ "to": "epoch/main/0",
"quality": "exact_unique",
- "via": "task id on the notification",
+ "via": "explicit context reset",
"evidence": [
{
"seq": 1,
- "row": 12
+ "row": 15
}
]
},
{
- "id": "rel/starts/tool_s5-tool/stream_a0a10ef0666c4dc7e",
- "type": "starts",
- "from": "tool/s5-tool",
- "to": "stream/a0a10ef0666c4dc7e",
+ "id": "rel/in_segment/talk_main_s8-cycle-compact/segment_at_1_2",
+ "type": "in_segment",
+ "from": "talk/main/s8-cycle-compact",
+ "to": "segment/at_1_2",
"quality": "exact_unique",
- "via": "parent tool result",
+ "via": "activity window",
"evidence": [
{
"seq": 1,
- "row": 11
+ "row": 16
}
]
},
@@ -1138,5 +1138,6 @@
}
]
}
- ]
+ ],
+ "tool_executions": []
}
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 e8e09609a1..ed936411ad 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
@@ -542,39 +542,6 @@ talks:
dir: out
quality: exact_unique
via: activity window
- - id: talk/main/s8-cycle-compact
- kind: talk
- parent: epoch/main/s8-boundary
- stream: main
- at: 1767225610700
- ref:
- seq: 1
- row: 16
- attrs:
- loops: 1
- runs: 1
- trigger: external
- runs: 1
- from: 1767225610700
- to: 1767225610700
- segment: segment/at_1_2
- children:
- - id: run/talk_main_s8-cycle-compact/s8-cycle-compact
- kind: run
- parent: talk/main/s8-cycle-compact
- stream: main
- at: 1767225610700
- ref:
- seq: 1
- row: 16
- attrs:
- trigger: external
- edges:
- - type: in_segment
- other: segment/at_1_2
- dir: out
- quality: exact_unique
- via: activity window
- id: talk/a0a10ef0666c4dc7e
kind: talk
parent: epoch/a0a10ef0666c4dc7e/0
@@ -674,6 +641,39 @@ talks:
dir: out
quality: strong_inference
via: inside the window of the talk that delegated it
+ - id: talk/main/s8-cycle-compact
+ kind: talk
+ parent: epoch/main/s8-boundary
+ stream: main
+ at: 1767225610700
+ ref:
+ seq: 1
+ row: 16
+ attrs:
+ loops: 1
+ runs: 1
+ trigger: external
+ runs: 1
+ from: 1767225610700
+ to: 1767225610700
+ segment: segment/at_1_2
+ children:
+ - id: run/talk_main_s8-cycle-compact/s8-cycle-compact
+ kind: run
+ parent: talk/main/s8-cycle-compact
+ stream: main
+ at: 1767225610700
+ ref:
+ seq: 1
+ row: 16
+ attrs:
+ trigger: external
+ edges:
+ - type: in_segment
+ other: segment/at_1_2
+ dir: out
+ quality: exact_unique
+ via: activity window
loose:
- id: boundary/1/15
kind: epoch.boundary
@@ -714,24 +714,24 @@ loose:
quality: exact_unique
via: containment parent
relations:
- - id: rel/ends_with/stream_a0a10ef0666c4dc7e/output_a0a10ef0666c4dc7e
- type: ends_with
- from: stream/a0a10ef0666c4dc7e
- to: output/a0a10ef0666c4dc7e
+ - 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: the last response in the child stream, and what it returned
+ via: activity window
evidence:
- - seq: 3
+ - seq: 1
row: 2
- - id: rel/follows/epoch_main_s8-boundary/epoch_main_0
- type: follows
- from: epoch/main/s8-boundary
- to: epoch/main/0
+ - id: rel/starts/tool_s5-tool/stream_a0a10ef0666c4dc7e
+ type: starts
+ from: tool/s5-tool
+ to: stream/a0a10ef0666c4dc7e
quality: exact_unique
- via: explicit context reset
+ via: parent tool result
evidence:
- seq: 1
- row: 15
+ row: 11
- id: rel/in_segment/talk_a0a10ef0666c4dc7e/segment_at_1_2
type: in_segment
from: talk/a0a10ef0666c4dc7e
@@ -741,24 +741,15 @@ relations:
evidence:
- seq: 3
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
+ - id: rel/ends_with/stream_a0a10ef0666c4dc7e/output_a0a10ef0666c4dc7e
+ type: ends_with
+ from: stream/a0a10ef0666c4dc7e
+ to: output/a0a10ef0666c4dc7e
quality: exact_unique
- via: activity window
+ via: the last response in the child stream, and what it returned
evidence:
- - seq: 1
+ - seq: 3
row: 2
- - id: rel/in_segment/talk_main_s8-cycle-compact/segment_at_1_2
- type: in_segment
- from: talk/main/s8-cycle-compact
- to: segment/at_1_2
- quality: exact_unique
- via: activity window
- evidence:
- - seq: 1
- row: 16
- id: rel/reports/notify_1_12/stream_a0a10ef0666c4dc7e
type: reports
from: notify/1/12
@@ -768,15 +759,24 @@ relations:
evidence:
- seq: 1
row: 12
- - id: rel/starts/tool_s5-tool/stream_a0a10ef0666c4dc7e
- type: starts
- from: tool/s5-tool
- to: stream/a0a10ef0666c4dc7e
+ - id: rel/follows/epoch_main_s8-boundary/epoch_main_0
+ type: follows
+ from: epoch/main/s8-boundary
+ to: epoch/main/0
quality: exact_unique
- via: parent tool result
+ via: explicit context reset
evidence:
- seq: 1
- row: 11
+ row: 15
+ - id: rel/in_segment/talk_main_s8-cycle-compact/segment_at_1_2
+ type: in_segment
+ from: talk/main/s8-cycle-compact
+ to: segment/at_1_2
+ quality: exact_unique
+ via: activity window
+ evidence:
+ - seq: 1
+ row: 16
- id: rel/summarizes/summary_1_16/boundary_1_15
type: summarizes
from: summary/1/16
@@ -846,4 +846,5 @@ workspace_changes:
- ' '
- -const Version = "0.2.0"
- +const Version = "0.3.0"
+tool_executions: []
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/go-json-syntax.json b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/go-json-syntax.json
new file mode 100644
index 0000000000..19f2932eeb
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/go-json-syntax.json
@@ -0,0 +1,371 @@
+[
+ {
+ "text": "{\"x\":184467440737095516160}",
+ "valid": true,
+ "canonical": "{\"x\":184467440737095516160}"
+ },
+ {
+ "text": "{\"x\":18446744073709551616}",
+ "valid": true,
+ "canonical": "{\"x\":18446744073709551616}"
+ },
+ {
+ "text": "[1e400]",
+ "valid": true,
+ "canonical": "[1e400]"
+ },
+ {
+ "text": "[-0]",
+ "valid": true,
+ "canonical": "[-0]"
+ },
+ {
+ "text": "[0]",
+ "valid": true,
+ "canonical": "[0]"
+ },
+ {
+ "text": "[1E+2]",
+ "valid": true,
+ "canonical": "[1E+2]"
+ },
+ {
+ "text": "[1e-2]",
+ "valid": true,
+ "canonical": "[1e-2]"
+ },
+ {
+ "text": "[1.5e3]",
+ "valid": true,
+ "canonical": "[1.5e3]"
+ },
+ {
+ "text": "{\"a\":1;\"b\":2}",
+ "valid": false
+ },
+ {
+ "text": "{a:1}",
+ "valid": false
+ },
+ {
+ "text": "{'a':1}",
+ "valid": false
+ },
+ {
+ "text": "[1,]",
+ "valid": false
+ },
+ {
+ "text": "[01]",
+ "valid": false
+ },
+ {
+ "text": "[-]",
+ "valid": false
+ },
+ {
+ "text": "[1.]",
+ "valid": false
+ },
+ {
+ "text": "[.5]",
+ "valid": false
+ },
+ {
+ "text": "[1e]",
+ "valid": false
+ },
+ {
+ "text": "[+1]",
+ "valid": false
+ },
+ {
+ "text": "[- 1]",
+ "valid": false
+ },
+ {
+ "text": "[0x1]",
+ "valid": false
+ },
+ {
+ "text": "[\"\\u00e9\"]",
+ "valid": true,
+ "canonical": "[\"\u00e9\"]"
+ },
+ {
+ "text": "[\"\\x\"]",
+ "valid": false
+ },
+ {
+ "text": "[\"\\'\"]",
+ "valid": false
+ },
+ {
+ "text": "[\"a\tb\"]",
+ "valid": false
+ },
+ {
+ "text": "[\"a\u007fb\"]",
+ "valid": true,
+ "canonical": "[\"a\u007fb\"]"
+ },
+ {
+ "text": "[NaN]",
+ "valid": false
+ },
+ {
+ "text": "[Infinity]",
+ "valid": false
+ },
+ {
+ "text": "[TRUE]",
+ "valid": false
+ },
+ {
+ "text": "[true]",
+ "valid": true,
+ "canonical": "[true]"
+ },
+ {
+ "text": "[nul]",
+ "valid": false
+ },
+ {
+ "text": "[null]",
+ "valid": true,
+ "canonical": "[null]"
+ },
+ {
+ "text": "{\"a\":1}x",
+ "valid": false
+ },
+ {
+ "text": " {\"a\":1}\n\t",
+ "valid": true,
+ "canonical": "{\"a\":1}"
+ },
+ {
+ "text": "",
+ "valid": false
+ },
+ {
+ "text": " ",
+ "valid": false
+ },
+ {
+ "text": "{\"a\":1,}",
+ "valid": false
+ },
+ {
+ "text": "{,\"a\":1}",
+ "valid": false
+ },
+ {
+ "text": "{\"a\" 1}",
+ "valid": false
+ },
+ {
+ "text": "{\"a\":}",
+ "valid": false
+ },
+ {
+ "text": "[\"\\u0000\"]",
+ "valid": true,
+ "canonical": "[\"\\u0000\"]"
+ },
+ {
+ "text": "[\"\\uD800\"]",
+ "valid": true,
+ "canonical": "[\"\ufffd\"]"
+ },
+ {
+ "text": "[\"\\uD83D\\uDE00\"]",
+ "valid": true,
+ "canonical": "[\"\ud83d\ude00\"]"
+ },
+ {
+ "text": "[\"\\uDE00\\uD83D\"]",
+ "valid": true,
+ "canonical": "[\"\ufffd\ufffd\"]"
+ },
+ {
+ "text": "[\"\\uD83Dx\"]",
+ "valid": true,
+ "canonical": "[\"\ufffdx\"]"
+ },
+ {
+ "text": "[\"\\uD83D\\u0041\"]",
+ "valid": true,
+ "canonical": "[\"\ufffdA\"]"
+ },
+ {
+ "text": "[\"\\u12\"]",
+ "valid": false
+ },
+ {
+ "text": "[\"\\u12G4\"]",
+ "valid": false
+ },
+ {
+ "text": "[\"a\\/b\"]",
+ "valid": true,
+ "canonical": "[\"a/b\"]"
+ },
+ {
+ "text": "[\"\\b\\f\\n\\r\\t\\\"\\\\\"]",
+ "valid": true,
+ "canonical": "[\"\\b\\f\\n\\r\\t\\\"\\\\\"]"
+ },
+ {
+ "text": "\"top\"",
+ "valid": true,
+ "canonical": "\"top\""
+ },
+ {
+ "text": "7",
+ "valid": true,
+ "canonical": "7"
+ },
+ {
+ "text": "null",
+ "valid": true,
+ "canonical": "null"
+ },
+ {
+ "text": "[]",
+ "valid": true,
+ "canonical": "[]"
+ },
+ {
+ "text": "{}",
+ "valid": true,
+ "canonical": "{}"
+ },
+ {
+ "text": "[[],[{}]]",
+ "valid": true,
+ "canonical": "[[],[{}]]"
+ },
+ {
+ "text": "{\"a\":{\"b\":[1,{\"c\":null}]}}",
+ "valid": true,
+ "canonical": "{\"a\":{\"b\":[1,{\"c\":null}]}}"
+ },
+ {
+ "text": "{\"a\":1,\"a\":2}",
+ "valid": true,
+ "canonical": "{\"a\":2}"
+ },
+ {
+ "text": "[1 2]",
+ "valid": false
+ },
+ {
+ "text": "{\"a\":1 \"b\":2}",
+ "valid": false
+ },
+ {
+ "text": "{\"\":1}",
+ "valid": true,
+ "canonical": "{\"\":1}"
+ },
+ {
+ "text": "[1]]",
+ "valid": false
+ },
+ {
+ "text": "[[1]",
+ "valid": false
+ },
+ {
+ "text": "{\"a\":[}",
+ "valid": false
+ },
+ {
+ "text": "[\"\\u0041\"]",
+ "valid": true,
+ "canonical": "[\"A\"]"
+ },
+ {
+ "text": "[\"\\uff10\"]",
+ "valid": true,
+ "canonical": "[\"\uff10\"]"
+ },
+ {
+ "text": "[\"\\uFF10\"]",
+ "valid": true,
+ "canonical": "[\"\uff10\"]"
+ },
+ {
+ "text": "[123456789012345678901234567890]",
+ "valid": true,
+ "canonical": "[123456789012345678901234567890]"
+ },
+ {
+ "text": "[-1.0e+10]",
+ "valid": true,
+ "canonical": "[-1.0e+10]"
+ },
+ {
+ "text": "[1e01]",
+ "valid": true,
+ "canonical": "[1e01]"
+ },
+ {
+ "text": "[00]",
+ "valid": false
+ },
+ {
+ "text": "[-01]",
+ "valid": false
+ },
+ {
+ "text": "[1.0.0]",
+ "valid": false
+ },
+ {
+ "text": "[\"\\uD800\\uD800\"]",
+ "valid": true,
+ "canonical": "[\"\ufffd\ufffd\"]"
+ },
+ {
+ "text": "[\"\\uDBFF\\uDFFF\"]",
+ "valid": true,
+ "canonical": "[\"\udbff\udfff\"]"
+ },
+ {
+ "text": "{\"a\\u0062\":1}",
+ "valid": true,
+ "canonical": "{\"ab\":1}"
+ },
+ {
+ "text": "[true false]",
+ "valid": false
+ },
+ {
+ "text": "[truex]",
+ "valid": false
+ },
+ {
+ "text": "[\"\\u\"]",
+ "valid": false
+ },
+ {
+ "text": "[\"\\u\uff10000\"]",
+ "valid": false
+ },
+ {
+ "text": "[\"\u00e9\"]",
+ "valid": true,
+ "canonical": "[\"\u00e9\"]"
+ },
+ {
+ "text": "[\"\ud83d\ude00\"]",
+ "valid": true,
+ "canonical": "[\"\ud83d\ude00\"]"
+ },
+ {
+ "text": "[\u00a01]",
+ "valid": false
+ }
+]
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/asz-view-example.json b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/asz-view-example.json
new file mode 100644
index 0000000000..14590ff5a7
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/asz-view-example.json
@@ -0,0 +1,1064 @@
+{
+ "format": "asz.view",
+ "version": "1.0",
+ "conversation": "ls-asz-harness-thread-subagent-4e1037625675",
+ "sessions": [
+ "ls-asz-harness-thread-subagent-4e1037625675"
+ ],
+ "head": {
+ "round": 1,
+ "digest": "3fd311176e49c289fc90f98be080c73ec33acfbbef97dce888b569af9bcd6c7e"
+ },
+ "parser": "v1",
+ "policy": "v1+idle=10m0s",
+ "summary": {
+ "title": "Is prod-1 healthy?",
+ "state": "verified",
+ "problems": [],
+ "talks": 3,
+ "steps": 14,
+ "streams": 3,
+ "segments": 1,
+ "rounds": 1,
+ "unresolved": 0,
+ "changes": 0,
+ "provider_bodies": 12,
+ "captured_prompts": 6,
+ "from": 1789901215524,
+ "to": 1789901215562,
+ "kinds": {
+ "agent.call": 1,
+ "agent.output": 1,
+ "epoch": 3,
+ "llm.call": 6,
+ "message.assistant": 3,
+ "message.external": 1,
+ "run": 3,
+ "segment": 1,
+ "session": 1,
+ "stream": 3,
+ "talk": 3,
+ "tool": 2
+ },
+ "relation_types": {
+ "ends_with": 1,
+ "in_segment": 3,
+ "result_of": 2,
+ "starts": 2
+ },
+ "quality": {
+ "exact_unique": 6,
+ "strong_inference": 2
+ }
+ },
+ "rounds": [
+ {
+ "round": 1,
+ "digest": "3fd311176e49c289fc90f98be080c73ec33acfbbef97dce888b569af9bcd6c7e",
+ "previous": null,
+ "from_seq": 1,
+ "through_seq": 4,
+ "input_digest": "f3644169bcc6e832fae6eaa18af2747851d804b425e16bf1c245ede0945ad525",
+ "from_time": 1789901215524,
+ "through_time": 1789901215562,
+ "verified": true
+ }
+ ],
+ "files": [
+ {
+ "file": "ls-asz-harness-thread-subagent-4e1037625675/streams/delegate-to-analyst-6e581483db74/transcript-20260920T110158.000000000Z-000001.sd",
+ "format": "sd",
+ "kind": "transcript",
+ "seq": 1,
+ "round": null,
+ "stream": "delegate-to-analyst-6e581483db74",
+ "run": null,
+ "lines": 17,
+ "bytes": 17348,
+ "digest": "9543c811671b35488339792b5a639feb81fd9240b035d973b77329f04d64e140",
+ "from_time": 1789901215550,
+ "through_time": 1789901215555
+ },
+ {
+ "file": "ls-asz-harness-thread-subagent-4e1037625675/streams/main/transcript-20260920T110158.000000000Z-000002.sd",
+ "format": "sd",
+ "kind": "transcript",
+ "seq": 2,
+ "round": null,
+ "stream": "main",
+ "run": null,
+ "lines": 27,
+ "bytes": 22437,
+ "digest": "d368f0bb0f0fbb7579fa7c0240ae9f7a498e7510f4d53e705a4322eab8239b20",
+ "from_time": 1789901215524,
+ "through_time": 1789901215562
+ },
+ {
+ "file": "ls-asz-harness-thread-subagent-4e1037625675/streams/summarise-d6aa2d533d9e/transcript-20260920T110158.000000000Z-000003.sd",
+ "format": "sd",
+ "kind": "transcript",
+ "seq": 3,
+ "round": null,
+ "stream": "summarise-d6aa2d533d9e",
+ "run": null,
+ "lines": 3,
+ "bytes": 828,
+ "digest": "9ffc294dc8a62771c3b589324eb3e8749dea903c0069c2598c57a245219617c8",
+ "from_time": 1789901215559,
+ "through_time": 1789901215559
+ },
+ {
+ "file": "ls-asz-harness-thread-subagent-4e1037625675/provider_body/provider_body-20260920T110158.000000000Z-000004.sd",
+ "format": "sd",
+ "kind": "provider_body",
+ "seq": 4,
+ "round": null,
+ "stream": null,
+ "run": null,
+ "lines": 14,
+ "bytes": 24376,
+ "digest": "9f2bad635245b486b0920268fb646d55587753204f4062584993779dce02733e",
+ "from_time": null,
+ "through_time": null
+ },
+ {
+ "file": "_conversations/ls-asz-harness-thread-subagent-4e1037625675/rounds/r000001-3fd311176e49.sf",
+ "format": "sf",
+ "kind": "round",
+ "seq": null,
+ "round": 1,
+ "stream": null,
+ "run": null,
+ "lines": 38,
+ "bytes": 12042,
+ "digest": "5fdbf9f7cb00c5ba634c9da349e37eb7a835ff2aedc4fb75fe97ade911bbdbf2",
+ "from_time": 1789901215524,
+ "through_time": 1789901215562
+ }
+ ],
+ "streams": [
+ {
+ "id": "stream/main",
+ "name": "main",
+ "role": "main",
+ "label": "",
+ "parent": "",
+ "records": 25,
+ "steps": 7,
+ "talk": "talk/main/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "named_by": "",
+ "opened_by": []
+ },
+ {
+ "id": "stream/delegate-to-analyst-6e581483db74",
+ "name": "delegate-to-analyst-6e581483db74",
+ "role": "child",
+ "label": "",
+ "parent": "main",
+ "records": 15,
+ "steps": 5,
+ "talk": "talk/delegate-to-analyst-6e581483db74",
+ "named_by": "",
+ "opened_by": [
+ {
+ "step": "tool/call_delegate_to_analyst_23",
+ "stream": "main",
+ "talk": "talk/main/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "quality": "exact_unique"
+ }
+ ]
+ },
+ {
+ "id": "stream/summarise-d6aa2d533d9e",
+ "name": "summarise-d6aa2d533d9e",
+ "role": "auxiliary",
+ "label": "",
+ "parent": "main",
+ "records": 1,
+ "steps": 2,
+ "talk": "talk/summarise-d6aa2d533d9e",
+ "named_by": "",
+ "opened_by": [
+ {
+ "step": "tool/call_summarise_26",
+ "stream": "main",
+ "talk": "talk/main/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "quality": "exact_unique"
+ }
+ ]
+ }
+ ],
+ "segments": [
+ {
+ "id": "segment/at_2_2",
+ "state": "open",
+ "committable": false,
+ "talks": 3,
+ "from": 1789901215524,
+ "to": 1789901215561
+ }
+ ],
+ "talks": [
+ {
+ "id": "talk/main/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "kind": "talk",
+ "parent": "epoch/main/0",
+ "stream": "main",
+ "at": 1789901215524,
+ "ref": {
+ "seq": 2,
+ "row": 2
+ },
+ "attrs": {
+ "loops": 1,
+ "runs": 1,
+ "trigger": "external"
+ },
+ "label": "Is prod-1 healthy?",
+ "reply": "answer 28",
+ "runs": 1,
+ "steps": 7,
+ "tools": 2,
+ "from": 1789901215524,
+ "to": 1789901215561,
+ "segment": "segment/at_2_2",
+ "children": [
+ {
+ "id": "run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "kind": "run",
+ "parent": "talk/main/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "stream": "main",
+ "at": 1789901215524,
+ "ref": {
+ "seq": 2,
+ "row": 2
+ },
+ "attrs": {
+ "trigger": "external"
+ },
+ "children": [
+ {
+ "id": "input/2/2",
+ "kind": "message.external",
+ "parent": "run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "stream": "main",
+ "at": 1789901215524,
+ "ref": {
+ "seq": 2,
+ "row": 2
+ },
+ "text": "Is prod-1 healthy?",
+ "state": "available",
+ "bytes": 18,
+ "flags": [
+ "external_input"
+ ]
+ },
+ {
+ "id": "call/01a0be6d-1735-76d3-b90b-cd6a57657ebe",
+ "kind": "llm.call",
+ "parent": "run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "stream": "main",
+ "at": 1789901215548,
+ "ref": {
+ "seq": 2,
+ "row": 8
+ },
+ "refs": [
+ {
+ "seq": 2,
+ "row": 8
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 8,
+ "seq": 2
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 123,
+ "out": 10,
+ "cache_read": 5
+ },
+ "provider_bodies": [
+ {
+ "role": "request",
+ "ref": {
+ "seq": 4,
+ "row": 1
+ }
+ },
+ {
+ "role": "response",
+ "ref": {
+ "seq": 4,
+ "row": 2
+ }
+ }
+ ],
+ "children": [
+ {
+ "id": "tool/call_delegate_to_analyst_23",
+ "kind": "agent.call",
+ "parent": "call/01a0be6d-1735-76d3-b90b-cd6a57657ebe",
+ "stream": "main",
+ "at": 1789901215548,
+ "ref": {
+ "seq": 2,
+ "row": 8,
+ "block": 0
+ },
+ "refs": [
+ {
+ "seq": 2,
+ "row": 8,
+ "block": 0
+ },
+ {
+ "seq": 2,
+ "row": 11,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "name": "delegate_to_analyst",
+ "result": "available",
+ "result_join": "exact_unique",
+ "timing": "unavailable"
+ },
+ "text": "{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"}",
+ "state": "available",
+ "bytes": 69,
+ "flags": [
+ "finished"
+ ],
+ "name": "delegate_to_analyst",
+ "result": "answer 25",
+ "result_state": "available",
+ "result_bytes": 9,
+ "request_to_result_ms": 7,
+ "request_to_result_join": "exact_unique",
+ "edges": [
+ {
+ "type": "starts",
+ "other": "stream/delegate-to-analyst-6e581483db74",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "parent tool result"
+ },
+ {
+ "type": "result_of",
+ "other": "output/delegate-to-analyst-6e581483db74",
+ "dir": "in",
+ "quality": "exact_unique",
+ "via": "the result the call that started this stream received"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "call/01a0be6d-1744-73e1-b54f-bcfda53a8067",
+ "kind": "llm.call",
+ "parent": "run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "stream": "main",
+ "at": 1789901215557,
+ "ref": {
+ "seq": 2,
+ "row": 16
+ },
+ "refs": [
+ {
+ "seq": 2,
+ "row": 16
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 16,
+ "seq": 2
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 126,
+ "out": 10,
+ "cache_read": 5
+ },
+ "provider_bodies": [
+ {
+ "role": "request",
+ "ref": {
+ "seq": 4,
+ "row": 7
+ }
+ },
+ {
+ "role": "response",
+ "ref": {
+ "seq": 4,
+ "row": 8
+ }
+ }
+ ],
+ "children": [
+ {
+ "id": "tool/call_summarise_26",
+ "kind": "tool",
+ "parent": "call/01a0be6d-1744-73e1-b54f-bcfda53a8067",
+ "stream": "main",
+ "at": 1789901215557,
+ "ref": {
+ "seq": 2,
+ "row": 16,
+ "block": 0
+ },
+ "refs": [
+ {
+ "seq": 2,
+ "row": 16,
+ "block": 0
+ },
+ {
+ "seq": 2,
+ "row": 19,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "name": "summarise",
+ "result": "available",
+ "result_join": "exact_unique",
+ "timing": "unavailable"
+ },
+ "text": "{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"}",
+ "state": "available",
+ "bytes": 69,
+ "flags": [
+ "finished"
+ ],
+ "name": "summarise",
+ "result": "answer 27",
+ "result_state": "available",
+ "result_bytes": 9,
+ "request_to_result_ms": 2,
+ "request_to_result_join": "exact_unique",
+ "edges": [
+ {
+ "type": "result_of",
+ "other": "call/01a0be6d-1746-7170-b6d0-d6ba40ae33b2",
+ "dir": "in",
+ "quality": "exact_unique",
+ "via": "the result the tool that started this stream received"
+ },
+ {
+ "type": "starts",
+ "other": "stream/summarise-d6aa2d533d9e",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "parent tool result"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "call/01a0be6d-1748-76f1-8883-e83b7c887dc1",
+ "kind": "llm.call",
+ "parent": "run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "stream": "main",
+ "at": 1789901215561,
+ "ref": {
+ "seq": 2,
+ "row": 24
+ },
+ "refs": [
+ {
+ "seq": 2,
+ "row": 24
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 24,
+ "seq": 2
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 128,
+ "out": 10,
+ "cache_read": 5
+ },
+ "provider_bodies": [
+ {
+ "role": "request",
+ "ref": {
+ "seq": 4,
+ "row": 11
+ }
+ },
+ {
+ "role": "response",
+ "ref": {
+ "seq": 4,
+ "row": 12
+ }
+ }
+ ],
+ "children": [
+ {
+ "id": "msg/2/24:0",
+ "kind": "message.assistant",
+ "parent": "call/01a0be6d-1748-76f1-8883-e83b7c887dc1",
+ "stream": "main",
+ "at": 1789901215561,
+ "ref": {
+ "seq": 2,
+ "row": 24,
+ "block": 0
+ },
+ "text": "answer 28",
+ "state": "available",
+ "bytes": 9,
+ "flags": [
+ "finished"
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "edges": [
+ {
+ "type": "in_segment",
+ "other": "segment/at_2_2",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "activity window"
+ }
+ ]
+ },
+ {
+ "id": "talk/delegate-to-analyst-6e581483db74",
+ "kind": "talk",
+ "parent": "epoch/delegate-to-analyst-6e581483db74/0",
+ "stream": "delegate-to-analyst-6e581483db74",
+ "at": 1789901215555,
+ "ref": {
+ "seq": 1,
+ "row": 1
+ },
+ "attrs": {
+ "loops": 1,
+ "runs": 1,
+ "trigger": "unknown"
+ },
+ "reply": "answer 25",
+ "runs": 1,
+ "steps": 5,
+ "tools": 1,
+ "from": 1789901215551,
+ "to": 1789901215555,
+ "child": true,
+ "segment": "segment/at_2_2",
+ "children": [
+ {
+ "id": "run/talk_delegate-to-analyst-6e581483db74/01a0be6d-173d-7c81-a464-6e581483db74",
+ "kind": "run",
+ "parent": "talk/delegate-to-analyst-6e581483db74",
+ "stream": "delegate-to-analyst-6e581483db74",
+ "at": 1789901215555,
+ "ref": {
+ "seq": 1,
+ "row": 1
+ },
+ "attrs": {
+ "trigger": "external"
+ },
+ "children": [
+ {
+ "id": "call/01a0be6d-173e-7082-be42-8daec07f7b13",
+ "kind": "llm.call",
+ "parent": "run/talk_delegate-to-analyst-6e581483db74/01a0be6d-173d-7c81-a464-6e581483db74",
+ "stream": "delegate-to-analyst-6e581483db74",
+ "at": 1789901215551,
+ "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": 124,
+ "out": 10,
+ "cache_read": 5
+ },
+ "provider_bodies": [
+ {
+ "role": "request",
+ "ref": {
+ "seq": 4,
+ "row": 3
+ }
+ },
+ {
+ "role": "response",
+ "ref": {
+ "seq": 4,
+ "row": 4
+ }
+ }
+ ],
+ "children": [
+ {
+ "id": "tool/call_lookup_status_24",
+ "kind": "tool",
+ "parent": "call/01a0be6d-173e-7082-be42-8daec07f7b13",
+ "stream": "delegate-to-analyst-6e581483db74",
+ "at": 1789901215551,
+ "ref": {
+ "seq": 1,
+ "row": 6,
+ "block": 0
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 6,
+ "block": 0
+ },
+ {
+ "seq": 1,
+ "row": 9,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "name": "lookup_status",
+ "result": "available",
+ "result_join": "exact_unique",
+ "timing": "unavailable"
+ },
+ "text": "{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"}",
+ "state": "available",
+ "bytes": 69,
+ "flags": [
+ "finished"
+ ],
+ "name": "lookup_status",
+ "result": "cluster prod-1: 3/3 pods ready",
+ "result_state": "available",
+ "result_bytes": 30,
+ "request_to_result_join": "exact_unique"
+ }
+ ]
+ },
+ {
+ "id": "call/01a0be6d-1741-7073-b4de-f8b2848b3db2",
+ "kind": "llm.call",
+ "parent": "run/talk_delegate-to-analyst-6e581483db74/01a0be6d-173d-7c81-a464-6e581483db74",
+ "stream": "delegate-to-analyst-6e581483db74",
+ "at": 1789901215554,
+ "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": 125,
+ "out": 10,
+ "cache_read": 5
+ },
+ "provider_bodies": [
+ {
+ "role": "request",
+ "ref": {
+ "seq": 4,
+ "row": 5
+ }
+ },
+ {
+ "role": "response",
+ "ref": {
+ "seq": 4,
+ "row": 6
+ }
+ }
+ ],
+ "children": [
+ {
+ "id": "msg/1/14:0",
+ "kind": "message.assistant",
+ "parent": "call/01a0be6d-1741-7073-b4de-f8b2848b3db2",
+ "stream": "delegate-to-analyst-6e581483db74",
+ "at": 1789901215554,
+ "ref": {
+ "seq": 1,
+ "row": 14,
+ "block": 0
+ },
+ "text": "answer 25",
+ "state": "available",
+ "bytes": 9,
+ "flags": [
+ "finished"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "output/delegate-to-analyst-6e581483db74",
+ "kind": "agent.output",
+ "parent": "run/talk_delegate-to-analyst-6e581483db74/01a0be6d-173d-7c81-a464-6e581483db74",
+ "stream": "delegate-to-analyst-6e581483db74",
+ "at": 1789901215554,
+ "ref": {
+ "seq": 1,
+ "row": 14
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 14
+ },
+ {
+ "seq": 2,
+ "row": 11,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "returned_value": "observed_replayable"
+ },
+ "text": "answer 25",
+ "state": "available",
+ "bytes": 9,
+ "flags": [
+ "finished"
+ ],
+ "result": "answer 25",
+ "result_state": "available",
+ "result_bytes": 9,
+ "edges": [
+ {
+ "type": "ends_with",
+ "other": "stream/delegate-to-analyst-6e581483db74",
+ "dir": "in",
+ "quality": "exact_unique",
+ "via": "the last response in the child stream, and what it returned"
+ },
+ {
+ "type": "result_of",
+ "other": "tool/call_delegate_to_analyst_23",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "the result the call that started this stream received"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "edges": [
+ {
+ "type": "in_segment",
+ "other": "segment/at_2_2",
+ "dir": "out",
+ "quality": "strong_inference",
+ "via": "inside the window of the talk that delegated it"
+ }
+ ]
+ },
+ {
+ "id": "talk/summarise-d6aa2d533d9e",
+ "kind": "talk",
+ "parent": "epoch/summarise-d6aa2d533d9e/0",
+ "stream": "summarise-d6aa2d533d9e",
+ "at": 1789901215559,
+ "ref": {
+ "seq": 3,
+ "row": 1
+ },
+ "attrs": {
+ "loops": 1,
+ "runs": 1,
+ "trigger": "unknown"
+ },
+ "reply": "answer 27",
+ "runs": 1,
+ "steps": 2,
+ "from": 1789901215559,
+ "to": 1789901215559,
+ "segment": "segment/at_2_2",
+ "children": [
+ {
+ "id": "run/talk_summarise-d6aa2d533d9e/01a0be6d-1746-7170-b6d0-d6aa2d533d9e",
+ "kind": "run",
+ "parent": "talk/summarise-d6aa2d533d9e",
+ "stream": "summarise-d6aa2d533d9e",
+ "at": 1789901215559,
+ "ref": {
+ "seq": 3,
+ "row": 1
+ },
+ "attrs": {
+ "trigger": "external"
+ },
+ "children": [
+ {
+ "id": "call/01a0be6d-1746-7170-b6d0-d6ba40ae33b2",
+ "kind": "llm.call",
+ "parent": "run/talk_summarise-d6aa2d533d9e/01a0be6d-1746-7170-b6d0-d6aa2d533d9e",
+ "stream": "summarise-d6aa2d533d9e",
+ "at": 1789901215559,
+ "ref": {
+ "seq": 3,
+ "row": 1
+ },
+ "refs": [
+ {
+ "seq": 3,
+ "row": 1
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 1,
+ "seq": 3
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 127,
+ "out": 10,
+ "cache_read": 5
+ },
+ "provider_bodies": [
+ {
+ "role": "request",
+ "ref": {
+ "seq": 4,
+ "row": 9
+ }
+ },
+ {
+ "role": "response",
+ "ref": {
+ "seq": 4,
+ "row": 10
+ }
+ }
+ ],
+ "children": [
+ {
+ "id": "msg/3/1:0",
+ "kind": "message.assistant",
+ "parent": "call/01a0be6d-1746-7170-b6d0-d6ba40ae33b2",
+ "stream": "summarise-d6aa2d533d9e",
+ "at": 1789901215559,
+ "ref": {
+ "seq": 3,
+ "row": 1,
+ "block": 0
+ },
+ "text": "answer 27",
+ "state": "available",
+ "bytes": 9,
+ "flags": [
+ "finished"
+ ]
+ }
+ ],
+ "edges": [
+ {
+ "type": "result_of",
+ "other": "tool/call_summarise_26",
+ "dir": "out",
+ "quality": "exact_unique",
+ "via": "the result the tool that started this stream received"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "edges": [
+ {
+ "type": "in_segment",
+ "other": "segment/at_2_2",
+ "dir": "out",
+ "quality": "strong_inference",
+ "via": "inside the window of the talk that delegated it"
+ }
+ ]
+ }
+ ],
+ "loose": [],
+ "relations": [
+ {
+ "id": "rel/in_segment/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/segment_at_2_2",
+ "type": "in_segment",
+ "from": "talk/main/01a0be6d-1724-7870-b615-7535de6b6e91",
+ "to": "segment/at_2_2",
+ "quality": "exact_unique",
+ "via": "activity window",
+ "evidence": [
+ {
+ "seq": 2,
+ "row": 2
+ }
+ ]
+ },
+ {
+ "id": "rel/in_segment/talk_delegate-to-analyst-6e581483db74/segment_at_2_2",
+ "type": "in_segment",
+ "from": "talk/delegate-to-analyst-6e581483db74",
+ "to": "segment/at_2_2",
+ "quality": "strong_inference",
+ "via": "inside the window of the talk that delegated it",
+ "evidence": [
+ {
+ "seq": 1,
+ "row": 1
+ }
+ ]
+ },
+ {
+ "id": "rel/ends_with/stream_delegate-to-analyst-6e581483db74/output_delegate-to-analyst-6e581483db74",
+ "type": "ends_with",
+ "from": "stream/delegate-to-analyst-6e581483db74",
+ "to": "output/delegate-to-analyst-6e581483db74",
+ "quality": "exact_unique",
+ "via": "the last response in the child stream, and what it returned",
+ "evidence": [
+ {
+ "seq": 1,
+ "row": 14
+ },
+ {
+ "seq": 2,
+ "row": 11,
+ "block": 0
+ }
+ ]
+ },
+ {
+ "id": "rel/starts/tool_call_delegate_to_analyst_23/stream_delegate-to-analyst-6e581483db74",
+ "type": "starts",
+ "from": "tool/call_delegate_to_analyst_23",
+ "to": "stream/delegate-to-analyst-6e581483db74",
+ "quality": "exact_unique",
+ "via": "parent tool result",
+ "evidence": [
+ {
+ "seq": 2,
+ "row": 11
+ }
+ ]
+ },
+ {
+ "id": "rel/result_of/output_delegate-to-analyst-6e581483db74/tool_call_delegate_to_analyst_23",
+ "type": "result_of",
+ "from": "output/delegate-to-analyst-6e581483db74",
+ "to": "tool/call_delegate_to_analyst_23",
+ "quality": "exact_unique",
+ "via": "the result the call that started this stream received",
+ "evidence": [
+ {
+ "seq": 2,
+ "row": 11,
+ "block": 0
+ }
+ ]
+ },
+ {
+ "id": "rel/in_segment/talk_summarise-d6aa2d533d9e/segment_at_2_2",
+ "type": "in_segment",
+ "from": "talk/summarise-d6aa2d533d9e",
+ "to": "segment/at_2_2",
+ "quality": "strong_inference",
+ "via": "inside the window of the talk that delegated it",
+ "evidence": [
+ {
+ "seq": 3,
+ "row": 1
+ }
+ ]
+ },
+ {
+ "id": "rel/result_of/call_01a0be6d-1746-7170-b6d0-d6ba40ae33b2/tool_call_summarise_26",
+ "type": "result_of",
+ "from": "call/01a0be6d-1746-7170-b6d0-d6ba40ae33b2",
+ "to": "tool/call_summarise_26",
+ "quality": "exact_unique",
+ "via": "the result the tool that started this stream received",
+ "evidence": [
+ {
+ "seq": 3,
+ "row": 1
+ },
+ {
+ "seq": 2,
+ "row": 19,
+ "block": 0
+ }
+ ]
+ },
+ {
+ "id": "rel/starts/tool_call_summarise_26/stream_summarise-d6aa2d533d9e",
+ "type": "starts",
+ "from": "tool/call_summarise_26",
+ "to": "stream/summarise-d6aa2d533d9e",
+ "quality": "exact_unique",
+ "via": "parent tool result",
+ "evidence": [
+ {
+ "seq": 2,
+ "row": 19
+ }
+ ]
+ }
+ ],
+ "unresolved": [],
+ "workspace_changes": [],
+ "tool_executions": []
+}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/provider_body-20260920T110158.000000000Z-000004.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/provider_body-20260920T110158.000000000Z-000004.sd
new file mode 100644
index 0000000000..ffde8c3aac
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/provider_body-20260920T110158.000000000Z-000004.sd
@@ -0,0 +1,14 @@
+{"h":1,"schema":"sd/1","seq":4,"at":"2026-09-20T11:01:58Z","kind":"provider_body","adapter":"langsmith-ingest/0.1.0","dialect":"langsmith/1","src":".","session":"ls-asz-harness-thread-subagent-4e1037625675"}
+{"ord":1,"off":0,"sha":"8efec506fb0c","bytes":201,"id":"01a0be6d-1735-76d3-b90b-cd6a57657ebe:request","call":"01a0be6d-1735-76d3-b90b-cd6a57657ebe","run":"01a0be6d-1724-7870-b615-7535de6b6e91","model":"every-tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"8efec506fb0ccd6f544de92522c4c0f9f450a297e171d0be8f2fc537b582a735","bytes":201,"depth":0,"chain":"2661b2353be27731","model":"every-tool","ses [...]
+{"ord":1,"off":0,"sha":"b54bca9a6c72","bytes":1466,"id":"01a0be6d-1735-76d3-b90b-cd6a57657ebe:response","call":"01a0be6d-1735-76d3-b90b-cd6a57657ebe","model":"every-tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"b54bca9a6c7292722781dc089d2e63e5d507368de8ebc66a0857cfa8ae2d8fe4","bytes":1466,"depth":0,"model":"every-tool","session":"ls-asz-harness-thread-subagent-4e1037625675","call":"01a0be6d [...]
+{"ord":1,"off":0,"sha":"86970b504905","bytes":195,"id":"01a0be6d-173e-7082-be42-8daec07f7b13:request","call":"01a0be6d-173e-7082-be42-8daec07f7b13","run":"01a0be6d-173d-7c81-a464-6e581483db74","model":"tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"86970b50490572901745c95cdd4628b18a6fa1233a7d2c42bf2ed669ce325f52","bytes":195,"depth":0,"chain":"795eb0de95eae4ed","model":"tool","session":"ls-as [...]
+{"ord":1,"off":0,"sha":"38ce668105db","bytes":1442,"id":"01a0be6d-173e-7082-be42-8daec07f7b13:response","call":"01a0be6d-173e-7082-be42-8daec07f7b13","model":"tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"38ce668105db392972151df28f6a9f36868e7c84238e50665ac6939eb3d626e9","bytes":1442,"depth":0,"model":"tool","session":"ls-asz-harness-thread-subagent-4e1037625675","call":"01a0be6d-173e-7082-b [...]
+{"ord":1,"off":0,"sha":"485959d62d6a","bytes":1424,"id":"01a0be6d-1741-7073-b4de-f8b2848b3db2:request","call":"01a0be6d-1741-7073-b4de-f8b2848b3db2","run":"01a0be6d-173d-7c81-a464-6e581483db74","model":"tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"485959d62d6ada7732b1f85d6d0a4bde9aca5548034ff6f6479b95ae9b626a2f","bytes":1424,"depth":0,"chain":"f4d6ae3618b15b90","model":"tool","session":"ls- [...]
+{"ord":1,"off":0,"sha":"00e3ff974510","bytes":1299,"id":"01a0be6d-1741-7073-b4de-f8b2848b3db2:response","call":"01a0be6d-1741-7073-b4de-f8b2848b3db2","model":"tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"00e3ff974510df283b1b60fce3c947ab5df075e8c1a9bf8e6c7a5927aeb15477","bytes":1299,"depth":0,"model":"tool","session":"ls-asz-harness-thread-subagent-4e1037625675","call":"01a0be6d-1741-7073-b [...]
+{"ord":1,"off":0,"sha":"6fa8aaa1c538","bytes":1422,"id":"01a0be6d-1744-73e1-b54f-bcfda53a8067:request","call":"01a0be6d-1744-73e1-b54f-bcfda53a8067","run":"01a0be6d-1724-7870-b615-7535de6b6e91","model":"every-tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"6fa8aaa1c5385b726cfd0c83c6021400c659016c8fcaf80c751d39bdcf1dcab0","bytes":1422,"depth":0,"chain":"fd651ad9d2fc0d6c","model":"every-tool","s [...]
+{"ord":1,"off":0,"sha":"caf5f8efe4ad","bytes":1446,"id":"01a0be6d-1744-73e1-b54f-bcfda53a8067:response","call":"01a0be6d-1744-73e1-b54f-bcfda53a8067","model":"every-tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"caf5f8efe4ad7b82e645b1c920666b70fd9c4f80f543146fc7a4d691f1673641","bytes":1446,"depth":0,"model":"every-tool","session":"ls-asz-harness-thread-subagent-4e1037625675","call":"01a0be6d [...]
+{"ord":1,"off":0,"sha":"2e8fdbe7012b","bytes":168,"id":"01a0be6d-1746-7170-b6d0-d6ba40ae33b2:request","call":"01a0be6d-1746-7170-b6d0-d6ba40ae33b2","run":"01a0be6d-1746-7170-b6d0-d6aa2d533d9e","model":"plain","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"2e8fdbe7012b31be8ef1e172fe10b9292e2d466b6ca706ceebbbc9350d81b603","bytes":168,"depth":0,"chain":"b5b1c3bb32fb68c5","model":"plain","session":"ls- [...]
+{"ord":1,"off":0,"sha":"e38a922e0ce0","bytes":1301,"id":"01a0be6d-1746-7170-b6d0-d6ba40ae33b2:response","call":"01a0be6d-1746-7170-b6d0-d6ba40ae33b2","model":"plain","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"e38a922e0ce02e00d9f618cc395efb4e7833762b9e24da78d254f3ff3229a7e7","bytes":1301,"depth":0,"model":"plain","session":"ls-asz-harness-thread-subagent-4e1037625675","call":"01a0be6d-1746-7170 [...]
+{"ord":1,"off":0,"sha":"43913e678328","bytes":2603,"id":"01a0be6d-1748-76f1-8883-e83b7c887dc1:request","call":"01a0be6d-1748-76f1-8883-e83b7c887dc1","run":"01a0be6d-1724-7870-b615-7535de6b6e91","model":"every-tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"43913e6783283612a073edacd860df0a32566d90f47b433924404aec12dfa085","bytes":2603,"depth":0,"chain":"dcf7705876030b7b","model":"every-tool","s [...]
+{"ord":1,"off":0,"sha":"97a73a065c5b","bytes":1311,"id":"01a0be6d-1748-76f1-8883-e83b7c887dc1:response","call":"01a0be6d-1748-76f1-8883-e83b7c887dc1","model":"every-tool","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"runs-20260920T100000.001000000Z-000001.request","sha256":"97a73a065c5b62743829aa23accb74e6d29cfa2e44bf16629bed4ceb79e1ccd7","bytes":1311,"depth":0,"model":"every-tool","session":"ls-asz-harness-thread-subagent-4e1037625675","call":"01a0be6d [...]
+{"t":"end","records":12,"digest":"caa45d9dcbd36af9d4ea17315384b4f084e5353e66510d6ed2c6a8074dd11bc5"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/r000001-3fd311176e49.sf b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/r000001-3fd311176e49.sf
new file mode 100644
index 0000000000..ced1844eef
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/r000001-3fd311176e49.sf
@@ -0,0 +1,38 @@
+{"t":"header","schema":"sf/1","conversation":"ls-asz-harness-thread-subagent-4e1037625675","session":"ls-asz-harness-thread-subagent-4e1037625675","round":1,"from_seq":1,"through_seq":4,"input_digest":"f3644169bcc6e832fae6eaa18af2747851d804b425e16bf1c245ede0945ad525","parser":"v1","policy":"v1+idle=10m0s","from_time":"2026-09-20T10:46:55.524236Z","through_time":"2026-09-20T10:46:55.562022Z","session_from_time":"2026-09-20T10:46:55.524236Z","session_through_time":"2026-09-20T10:46:55.5620 [...]
+{"t":"node","id":"call/01a0be6d-1735-76d3-b90b-cd6a57657ebe","revision":1,"kind":"llm.call","parent":"run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91","stream":"main","ref":{"seq":2,"row":8},"refs":[{"seq":2,"row":8}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":1}},{"role":"response","ref":{"seq":4,"row":2}}],"usage":"observed_replayable","usage_at":{"row":8,"seq":2},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/01a0be6d-173e-7082-be42-8daec07f7b13","revision":1,"kind":"llm.call","parent":"run/talk_delegate-to-analyst-6e581483db74/01a0be6d-173d-7c81-a464-6e581483db74","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":6},"refs":[{"seq":1,"row":6}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":3}},{"role":"response","ref":{"seq":4,"row":4}}],"usage":"observed_replayable","usage_at":{"row":6,"seq":1},"usage_from":"last_fragmen [...]
+{"t":"node","id":"call/01a0be6d-1741-7073-b4de-f8b2848b3db2","revision":1,"kind":"llm.call","parent":"run/talk_delegate-to-analyst-6e581483db74/01a0be6d-173d-7c81-a464-6e581483db74","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":14},"refs":[{"seq":1,"row":14}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":5}},{"role":"response","ref":{"seq":4,"row":6}}],"usage":"observed_replayable","usage_at":{"row":14,"seq":1},"usage_from":"last_frag [...]
+{"t":"node","id":"call/01a0be6d-1744-73e1-b54f-bcfda53a8067","revision":1,"kind":"llm.call","parent":"run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91","stream":"main","ref":{"seq":2,"row":16},"refs":[{"seq":2,"row":16}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":7}},{"role":"response","ref":{"seq":4,"row":8}}],"usage":"observed_replayable","usage_at":{"row":16,"seq":2},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/01a0be6d-1746-7170-b6d0-d6ba40ae33b2","revision":1,"kind":"llm.call","parent":"run/talk_summarise-d6aa2d533d9e/01a0be6d-1746-7170-b6d0-d6aa2d533d9e","stream":"summarise-d6aa2d533d9e","ref":{"seq":3,"row":1},"refs":[{"seq":3,"row":1}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":9}},{"role":"response","ref":{"seq":4,"row":10}}],"usage":"observed_replayable","usage_at":{"row":1,"seq":3},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/01a0be6d-1748-76f1-8883-e83b7c887dc1","revision":1,"kind":"llm.call","parent":"run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91","stream":"main","ref":{"seq":2,"row":24},"refs":[{"seq":2,"row":24}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":11}},{"role":"response","ref":{"seq":4,"row":12}}],"usage":"observed_replayable","usage_at":{"row":24,"seq":2},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"epoch/delegate-to-analyst-6e581483db74/0","revision":1,"kind":"epoch","parent":"stream/delegate-to-analyst-6e581483db74","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":1},"attrs":{"records":15,"reset":"none"}}
+{"t":"node","id":"epoch/main/0","revision":1,"kind":"epoch","parent":"stream/main","stream":"main","ref":{"seq":2,"row":1},"attrs":{"records":25,"reset":"none"}}
+{"t":"node","id":"epoch/summarise-d6aa2d533d9e/0","revision":1,"kind":"epoch","parent":"stream/summarise-d6aa2d533d9e","stream":"summarise-d6aa2d533d9e","ref":{"seq":3,"row":1},"attrs":{"records":1,"reset":"none"}}
+{"t":"node","id":"input/2/2","revision":1,"kind":"message.external","parent":"run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91","stream":"main","ref":{"seq":2,"row":2}}
+{"t":"node","id":"msg/1/14:0","revision":1,"kind":"message.assistant","parent":"call/01a0be6d-1741-7073-b4de-f8b2848b3db2","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":14,"block":0}}
+{"t":"node","id":"msg/2/24:0","revision":1,"kind":"message.assistant","parent":"call/01a0be6d-1748-76f1-8883-e83b7c887dc1","stream":"main","ref":{"seq":2,"row":24,"block":0}}
+{"t":"node","id":"msg/3/1:0","revision":1,"kind":"message.assistant","parent":"call/01a0be6d-1746-7170-b6d0-d6ba40ae33b2","stream":"summarise-d6aa2d533d9e","ref":{"seq":3,"row":1,"block":0}}
+{"t":"node","id":"output/delegate-to-analyst-6e581483db74","revision":1,"kind":"agent.output","parent":"run/talk_delegate-to-analyst-6e581483db74/01a0be6d-173d-7c81-a464-6e581483db74","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":14},"refs":[{"seq":1,"row":14},{"seq":2,"row":11,"block":0}],"attrs":{"returned_value":"observed_replayable"}}
+{"t":"node","id":"run/talk_delegate-to-analyst-6e581483db74/01a0be6d-173d-7c81-a464-6e581483db74","revision":1,"kind":"run","parent":"talk/delegate-to-analyst-6e581483db74","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":1},"attrs":{"trigger":"external"}}
+{"t":"node","id":"run/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/01a0be6d-1724-7870-b615-7535de6b6e91","revision":1,"kind":"run","parent":"talk/main/01a0be6d-1724-7870-b615-7535de6b6e91","stream":"main","ref":{"seq":2,"row":2},"attrs":{"trigger":"external"}}
+{"t":"node","id":"run/talk_summarise-d6aa2d533d9e/01a0be6d-1746-7170-b6d0-d6aa2d533d9e","revision":1,"kind":"run","parent":"talk/summarise-d6aa2d533d9e","stream":"summarise-d6aa2d533d9e","ref":{"seq":3,"row":1},"attrs":{"trigger":"external"}}
+{"t":"node","id":"segment/at_2_2","revision":1,"kind":"segment","parent":"session/ls-asz-harness-thread-subagent-4e1037625675","ref":{"seq":2,"row":2},"attrs":{"committable":false,"gates_unmet":["activity_boundary","lateness_watermark"],"state":"open","talks":1}}
+{"t":"node","id":"session/ls-asz-harness-thread-subagent-4e1037625675","revision":1,"kind":"session","attrs":{"conversation":"ls-asz-harness-thread-subagent-4e1037625675","from_time":"2026-09-20T10:46:55.524236Z","provider_bodies_landed":12,"through_time":"2026-09-20T10:46:55.562022Z","title":"Is prod-1 healthy?","title_from":"observed_replayable"}}
+{"t":"node","id":"stream/delegate-to-analyst-6e581483db74","revision":1,"kind":"stream","parent":"session/ls-asz-harness-thread-subagent-4e1037625675","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":1},"attrs":{"records":15,"role":"child"}}
+{"t":"node","id":"stream/main","revision":1,"kind":"stream","parent":"session/ls-asz-harness-thread-subagent-4e1037625675","stream":"main","ref":{"seq":2,"row":1},"attrs":{"records":25,"role":"main"}}
+{"t":"node","id":"stream/summarise-d6aa2d533d9e","revision":1,"kind":"stream","parent":"session/ls-asz-harness-thread-subagent-4e1037625675","stream":"summarise-d6aa2d533d9e","ref":{"seq":3,"row":1},"attrs":{"records":1,"role":"auxiliary"}}
+{"t":"node","id":"talk/delegate-to-analyst-6e581483db74","revision":1,"kind":"talk","parent":"epoch/delegate-to-analyst-6e581483db74/0","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":1},"attrs":{"loops":1,"runs":1,"trigger":"unknown"}}
+{"t":"node","id":"talk/main/01a0be6d-1724-7870-b615-7535de6b6e91","revision":1,"kind":"talk","parent":"epoch/main/0","stream":"main","ref":{"seq":2,"row":2},"attrs":{"loops":1,"runs":1,"trigger":"external"}}
+{"t":"node","id":"talk/summarise-d6aa2d533d9e","revision":1,"kind":"talk","parent":"epoch/summarise-d6aa2d533d9e/0","stream":"summarise-d6aa2d533d9e","ref":{"seq":3,"row":1},"attrs":{"loops":1,"runs":1,"trigger":"unknown"}}
+{"t":"node","id":"tool/call_delegate_to_analyst_23","revision":1,"kind":"agent.call","parent":"call/01a0be6d-1735-76d3-b90b-cd6a57657ebe","stream":"main","ref":{"seq":2,"row":8,"block":0},"refs":[{"seq":2,"row":8,"block":0},{"seq":2,"row":11,"block":0}],"attrs":{"name":"delegate_to_analyst","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/call_lookup_status_24","revision":1,"kind":"tool","parent":"call/01a0be6d-173e-7082-be42-8daec07f7b13","stream":"delegate-to-analyst-6e581483db74","ref":{"seq":1,"row":6,"block":0},"refs":[{"seq":1,"row":6,"block":0},{"seq":1,"row":9,"block":0}],"attrs":{"name":"lookup_status","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/call_summarise_26","revision":1,"kind":"tool","parent":"call/01a0be6d-1744-73e1-b54f-bcfda53a8067","stream":"main","ref":{"seq":2,"row":16,"block":0},"refs":[{"seq":2,"row":16,"block":0},{"seq":2,"row":19,"block":0}],"attrs":{"name":"summarise","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"relation","id":"rel/ends_with/stream_delegate-to-analyst-6e581483db74/output_delegate-to-analyst-6e581483db74","revision":1,"type":"ends_with","from":"stream/delegate-to-analyst-6e581483db74","to":"output/delegate-to-analyst-6e581483db74","quality":"exact_unique","via":"the last response in the child stream, and what it returned","evidence":[{"seq":1,"row":14},{"seq":2,"row":11,"block":0}]}
+{"t":"relation","id":"rel/in_segment/talk_delegate-to-analyst-6e581483db74/segment_at_2_2","revision":1,"type":"in_segment","from":"talk/delegate-to-analyst-6e581483db74","to":"segment/at_2_2","quality":"strong_inference","via":"inside the window of the talk that delegated it","evidence":[{"seq":1,"row":1}]}
+{"t":"relation","id":"rel/in_segment/talk_main_01a0be6d-1724-7870-b615-7535de6b6e91/segment_at_2_2","revision":1,"type":"in_segment","from":"talk/main/01a0be6d-1724-7870-b615-7535de6b6e91","to":"segment/at_2_2","quality":"exact_unique","via":"activity window","evidence":[{"seq":2,"row":2}]}
+{"t":"relation","id":"rel/in_segment/talk_summarise-d6aa2d533d9e/segment_at_2_2","revision":1,"type":"in_segment","from":"talk/summarise-d6aa2d533d9e","to":"segment/at_2_2","quality":"strong_inference","via":"inside the window of the talk that delegated it","evidence":[{"seq":3,"row":1}]}
+{"t":"relation","id":"rel/result_of/call_01a0be6d-1746-7170-b6d0-d6ba40ae33b2/tool_call_summarise_26","revision":1,"type":"result_of","from":"call/01a0be6d-1746-7170-b6d0-d6ba40ae33b2","to":"tool/call_summarise_26","quality":"exact_unique","via":"the result the tool that started this stream received","evidence":[{"seq":3,"row":1},{"seq":2,"row":19,"block":0}]}
+{"t":"relation","id":"rel/result_of/output_delegate-to-analyst-6e581483db74/tool_call_delegate_to_analyst_23","revision":1,"type":"result_of","from":"output/delegate-to-analyst-6e581483db74","to":"tool/call_delegate_to_analyst_23","quality":"exact_unique","via":"the result the call that started this stream received","evidence":[{"seq":2,"row":11,"block":0}]}
+{"t":"relation","id":"rel/starts/tool_call_delegate_to_analyst_23/stream_delegate-to-analyst-6e581483db74","revision":1,"type":"starts","from":"tool/call_delegate_to_analyst_23","to":"stream/delegate-to-analyst-6e581483db74","quality":"exact_unique","via":"parent tool result","evidence":[{"seq":2,"row":11}]}
+{"t":"relation","id":"rel/starts/tool_call_summarise_26/stream_summarise-d6aa2d533d9e","revision":1,"type":"starts","from":"tool/call_summarise_26","to":"stream/summarise-d6aa2d533d9e","quality":"exact_unique","via":"parent tool result","evidence":[{"seq":2,"row":19}]}
+{"t":"commit","digest":"3fd311176e49c289fc90f98be080c73ec33acfbbef97dce888b569af9bcd6c7e","counts":{"nodes":28,"relations":8,"unresolved":0}}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000001.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000001.sd
new file mode 100644
index 0000000000..c9c7676c38
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000001.sd
@@ -0,0 +1,17 @@
+{"h":1,"schema":"sd/1","seq":1,"at":"2026-09-20T11:01:58Z","kind":"transcript","adapter":"langsmith-ingest/0.1.0","dialect":"langsmith/1","src":"runs-20260920T100000.001000000Z-000001.request","session":"ls-asz-harness-thread-subagent-4e1037625675","stream":"delegate-to-analyst-6e581483db74"}
+{"ord":1,"off":0,"sha":"01ee3e1e666c","bytes":3451,"id":"01a0be6d-173d-7c81-a464-6e60edad7ecb","parent":"01a0be6d-173d-7c81-a464-6e581483db74","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"analyst","time":"2026-09-20T10:46:55.555295+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T104655549592Z [...]
+{"ord":2,"off":3452,"sha":"e020f3ee6487","bytes":2600,"id":"01a0be6d-173e-7082-be42-8d6353660dd3","parent":"01a0be6d-173d-7c81-a464-6e60edad7ecb","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"agent","time":"2026-09-20T10:46:55.552155+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T104655549592 [...]
+{"ord":3,"off":6053,"sha":"425b02acbe21","bytes":2662,"id":"01a0be6d-173e-7082-be42-8d7dcfbce29a","parent":"01a0be6d-173e-7082-be42-8d6353660dd3","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"call_model","time":"2026-09-20T10:46:55.551956+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T1046555 [...]
+{"ord":4,"off":8716,"sha":"4965b106d3fe","bytes":2706,"id":"01a0be6d-173e-7082-be42-8d88c69aeb62","parent":"01a0be6d-173e-7082-be42-8d7dcfbce29a","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"RunnableSequence","time":"2026-09-20T10:46:55.551917+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T1 [...]
+{"ord":5,"off":11423,"sha":"501803c8728b","bytes":2043,"id":"01a0be6d-173e-7082-be42-8d99011a6188","parent":"01a0be6d-173e-7082-be42-8d88c69aeb62","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"Prompt","time":"2026-09-20T10:46:55.550422+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T1046555495 [...]
+{"ord":6,"off":13467,"sha":"59c793941e29","bytes":4518,"id":"01a0be6d-173e-7082-be42-8daec07f7b13","parent":"01a0be6d-173e-7082-be42-8d88c69aeb62","call":"01a0be6d-173e-7082-be42-8daec07f7b13","run":"01a0be6d-173d-7c81-a464-6e581483db74","from":"agent","time":"2026-09-20T10:46:55.551863+00:00","flags":["finished"],"usage":{"in":124,"out":10,"cache_read":5},"model":"tool","parts":[{"k":"call","data":{"cluster":"prod-1","question":"check prod-1","text":"some findings"},"id":"call_lookup_st [...]
+{"ord":7,"off":17986,"sha":"7c1f49fa2058","bytes":3983,"id":"01a0be6d-1740-7423-8bc2-d6afca6c09cc","parent":"01a0be6d-173e-7082-be42-8d6353660dd3","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"should_continue","time":"2026-09-20T10:46:55.552086+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T1 [...]
+{"ord":8,"off":21970,"sha":"8ae5f29a2352","bytes":2990,"id":"01a0be6d-1740-7423-8bc2-d6bec1b04246","parent":"01a0be6d-173d-7c81-a464-6e60edad7ecb","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"tools","time":"2026-09-20T10:46:55.552944+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T10465554959 [...]
+{"ord":9,"off":24961,"sha":"dfcb20eb0e61","bytes":1938,"id":"01a0be6d-1740-7423-8bc2-d6cec4ffad2c","parent":"01a0be6d-1740-7423-8bc2-d6bec1b04246","run":"01a0be6d-173d-7c81-a464-6e581483db74","tool":"call_lookup_status_24","label":"lookup_status","from":"external","time":"2026-09-20T10:46:55.552840+00:00","flags":["finished"],"parts":[{"k":"result","text":"cluster prod-1: 3/3 pods ready","of":"call_lookup_status_24","state":"available","bytes":30}]}
+{"ord":10,"off":26900,"sha":"df1076580c23","bytes":3547,"id":"01a0be6d-1741-7073-b4de-f8795804378e","parent":"01a0be6d-173d-7c81-a464-6e60edad7ecb","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"agent","time":"2026-09-20T10:46:55.555049+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T1046555495 [...]
+{"ord":11,"off":30448,"sha":"bf3f15d87d61","bytes":3609,"id":"01a0be6d-1741-7073-b4de-f88ab5fb02c1","parent":"01a0be6d-1741-7073-b4de-f8795804378e","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"call_model","time":"2026-09-20T10:46:55.554896+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T10465 [...]
+{"ord":12,"off":34058,"sha":"842d97804bf8","bytes":3653,"id":"01a0be6d-1741-7073-b4de-f890dd143145","parent":"01a0be6d-1741-7073-b4de-f88ab5fb02c1","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"RunnableSequence","time":"2026-09-20T10:46:55.554857+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920 [...]
+{"ord":13,"off":37712,"sha":"65883e114e41","bytes":4229,"id":"01a0be6d-1741-7073-b4de-f8a2091504a9","parent":"01a0be6d-1741-7073-b4de-f890dd143145","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"Prompt","time":"2026-09-20T10:46:55.553413+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T104655549 [...]
+{"ord":14,"off":41942,"sha":"23f2fdc2f8f6","bytes":5584,"id":"01a0be6d-1741-7073-b4de-f8b2848b3db2","parent":"01a0be6d-1741-7073-b4de-f890dd143145","call":"01a0be6d-1741-7073-b4de-f8b2848b3db2","run":"01a0be6d-173d-7c81-a464-6e581483db74","from":"agent","time":"2026-09-20T10:46:55.554811+00:00","flags":["finished"],"usage":{"in":125,"out":10,"cache_read":5},"model":"tool","parts":[{"k":"text","text":"answer 25","state":"available","bytes":9}]}
+{"ord":15,"off":47527,"sha":"0753a175d892","bytes":3620,"id":"01a0be6d-1742-75f2-ab3b-5db86201f2f9","parent":"01a0be6d-1741-7073-b4de-f8795804378e","run":"01a0be6d-173d-7c81-a464-6e581483db74","label":"should_continue","time":"2026-09-20T10:46:55.555018+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725.20260920T104655549392Z01a0be6d-173d-7c81-a464-6e581483db74.20260920T [...]
+{"t":"end","records":15,"digest":"033d55009d45cf6274d529c081bdad077b55b26b745510415867b32c77fab7aa"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000002.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000002.sd
new file mode 100644
index 0000000000..febc08f11d
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000002.sd
@@ -0,0 +1,27 @@
+{"h":1,"schema":"sd/1","seq":2,"at":"2026-09-20T11:01:58Z","kind":"transcript","adapter":"langsmith-ingest/0.1.0","dialect":"langsmith/1","src":"runs-20260920T100000.001000000Z-000001.request","session":"ls-asz-harness-thread-subagent-4e1037625675","stream":"main"}
+{"ord":1,"off":0,"sha":"f9bd598bdc60","bytes":18,"label":"Is prod-1 healthy?","from":"runtime"}
+{"ord":2,"off":19,"sha":"f7071ab0e062","bytes":4001,"id":"01a0be6d-1724-7870-b615-7535de6b6e91:input","run":"01a0be6d-1724-7870-b615-7535de6b6e91","from":"external","time":"2026-09-20T10:46:55.524236+00:00","trigger":"external","flags":["external_input"],"parts":[{"k":"text","text":"Is prod-1 healthy?","state":"available","bytes":18}]}
+{"ord":3,"off":4021,"sha":"f7071ab0e062","bytes":4001,"id":"01a0be6d-1724-7870-b615-7535de6b6e91","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"LangGraph","time":"2026-09-20T10:46:55.562022+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91","id":"01a0be6d-1724-7870-b615-7535de6b6e91","metadata":{"ls_integration":"langgraph"},"name":"LangGraph","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state" [...]
+{"ord":4,"off":8023,"sha":"bade21a71d09","bytes":2329,"id":"01a0be6d-1735-76d3-b90b-cd250112a122","parent":"01a0be6d-1724-7870-b615-7535de6b6e91","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"agent","time":"2026-09-20T10:46:55.548673+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655541172Z01a0be6d-1735-76d3-b90b-cd250112a122","id":"01a0be6d-1735-76d3-b90b-cd250112a122","metadata":{"langgraph_checkpoint_n [...]
+{"ord":5,"off":10353,"sha":"a2fe285e3093","bytes":2391,"id":"01a0be6d-1735-76d3-b90b-cd3af992fb8c","parent":"01a0be6d-1735-76d3-b90b-cd250112a122","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"call_model","time":"2026-09-20T10:46:55.548411+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655541172Z01a0be6d-1735-76d3-b90b-cd250112a122.20260920T104655541404Z01a0be6d-1735-76d3-b90b-cd3af992fb8c","id":"01a0be6d [...]
+{"ord":6,"off":12745,"sha":"f5c875cdfd51","bytes":2513,"id":"01a0be6d-1735-76d3-b90b-cd409b826c50","parent":"01a0be6d-1735-76d3-b90b-cd3af992fb8c","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"RunnableSequence","time":"2026-09-20T10:46:55.548351+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655541172Z01a0be6d-1735-76d3-b90b-cd250112a122.20260920T104655541404Z01a0be6d-1735-76d3-b90b-cd3af992fb8c.20260920T [...]
+{"ord":7,"off":15259,"sha":"18a8b0819f29","bytes":1838,"id":"01a0be6d-1735-76d3-b90b-cd5b7c6cee79","parent":"01a0be6d-1735-76d3-b90b-cd409b826c50","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"Prompt","time":"2026-09-20T10:46:55.541676+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655541172Z01a0be6d-1735-76d3-b90b-cd250112a122.20260920T104655541404Z01a0be6d-1735-76d3-b90b-cd3af992fb8c.20260920T1046555415 [...]
+{"ord":8,"off":17098,"sha":"f10b382d6c55","bytes":4670,"id":"01a0be6d-1735-76d3-b90b-cd6a57657ebe","parent":"01a0be6d-1735-76d3-b90b-cd409b826c50","call":"01a0be6d-1735-76d3-b90b-cd6a57657ebe","run":"01a0be6d-1724-7870-b615-7535de6b6e91","from":"agent","time":"2026-09-20T10:46:55.548267+00:00","flags":["finished"],"usage":{"in":123,"out":10,"cache_read":5},"model":"every-tool","parts":[{"k":"call","data":{"cluster":"prod-1","question":"check prod-1","text":"some findings"},"id":"call_del [...]
+{"ord":9,"off":21769,"sha":"c35ef9b7397a","bytes":3735,"id":"01a0be6d-173c-76d0-b51e-e1793319669f","parent":"01a0be6d-1735-76d3-b90b-cd250112a122","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"should_continue","time":"2026-09-20T10:46:55.548587+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655541172Z01a0be6d-1735-76d3-b90b-cd250112a122.20260920T104655548497Z01a0be6d-173c-76d0-b51e-e1793319669f","id":"01a [...]
+{"ord":10,"off":25505,"sha":"50f35fd45809","bytes":2722,"id":"01a0be6d-173d-7c81-a464-6e4e67a1d725","parent":"01a0be6d-1724-7870-b615-7535de6b6e91","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"tools","time":"2026-09-20T10:46:55.555461+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655549107Z01a0be6d-173d-7c81-a464-6e4e67a1d725","id":"01a0be6d-173d-7c81-a464-6e4e67a1d725","metadata":{"langgraph_checkpoint [...]
+{"ord":11,"off":28228,"sha":"49388183cf28","bytes":1721,"id":"01a0be6d-173d-7c81-a464-6e581483db74","parent":"01a0be6d-173d-7c81-a464-6e4e67a1d725","run":"01a0be6d-1724-7870-b615-7535de6b6e91","tool":"call_delegate_to_analyst_23","child":"delegate-to-analyst-6e581483db74","label":"delegate_to_analyst","from":"external","time":"2026-09-20T10:46:55.555369+00:00","flags":["finished"],"parts":[{"k":"result","text":"answer 25","of":"call_delegate_to_analyst_23","state":"available","bytes":9}]}
+{"ord":12,"off":29950,"sha":"b14f942414fc","bytes":3394,"id":"01a0be6d-1743-7352-8769-f28c498055dd","parent":"01a0be6d-1724-7870-b615-7535de6b6e91","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"agent","time":"2026-09-20T10:46:55.557600+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655555654Z01a0be6d-1743-7352-8769-f28c498055dd","id":"01a0be6d-1743-7352-8769-f28c498055dd","metadata":{"langgraph_checkpoint [...]
+{"ord":13,"off":33345,"sha":"46ca99bb5ff5","bytes":3456,"id":"01a0be6d-1743-7352-8769-f29ecc02bfcb","parent":"01a0be6d-1743-7352-8769-f28c498055dd","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"call_model","time":"2026-09-20T10:46:55.557375+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655555654Z01a0be6d-1743-7352-8769-f28c498055dd.20260920T104655555722Z01a0be6d-1743-7352-8769-f29ecc02bfcb","id":"01a0be6 [...]
+{"ord":14,"off":36802,"sha":"3c3db6e8bb1f","bytes":3578,"id":"01a0be6d-1743-7352-8769-f2ae98ebfffb","parent":"01a0be6d-1743-7352-8769-f29ecc02bfcb","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"RunnableSequence","time":"2026-09-20T10:46:55.557334+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655555654Z01a0be6d-1743-7352-8769-f28c498055dd.20260920T104655555722Z01a0be6d-1743-7352-8769-f29ecc02bfcb.20260920 [...]
+{"ord":15,"off":40381,"sha":"8920d62a558e","bytes":4008,"id":"01a0be6d-1743-7352-8769-f2bab1d4df0e","parent":"01a0be6d-1743-7352-8769-f2ae98ebfffb","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"Prompt","time":"2026-09-20T10:46:55.555945+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655555654Z01a0be6d-1743-7352-8769-f28c498055dd.20260920T104655555722Z01a0be6d-1743-7352-8769-f29ecc02bfcb.20260920T104655555 [...]
+{"ord":16,"off":44390,"sha":"67fb7089f7e7","bytes":5871,"id":"01a0be6d-1744-73e1-b54f-bcfda53a8067","parent":"01a0be6d-1743-7352-8769-f2ae98ebfffb","call":"01a0be6d-1744-73e1-b54f-bcfda53a8067","run":"01a0be6d-1724-7870-b615-7535de6b6e91","from":"agent","time":"2026-09-20T10:46:55.557284+00:00","flags":["finished"],"usage":{"in":126,"out":10,"cache_read":5},"model":"every-tool","parts":[{"k":"call","data":{"cluster":"prod-1","question":"check prod-1","text":"some findings"},"id":"call_su [...]
+{"ord":17,"off":50262,"sha":"f66dcc76c4bd","bytes":5817,"id":"01a0be6d-1745-7c41-90ad-0cf9fd1c93c5","parent":"01a0be6d-1743-7352-8769-f28c498055dd","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"should_continue","time":"2026-09-20T10:46:55.557508+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655555654Z01a0be6d-1743-7352-8769-f28c498055dd.20260920T104655557439Z01a0be6d-1745-7c41-90ad-0cf9fd1c93c5","id":"01 [...]
+{"ord":18,"off":56080,"sha":"944b5a26ed58","bytes":3747,"id":"01a0be6d-1745-7c41-90ad-0d09833f47ab","parent":"01a0be6d-1724-7870-b615-7535de6b6e91","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"tools","time":"2026-09-20T10:46:55.559505+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655557875Z01a0be6d-1745-7c41-90ad-0d09833f47ab","id":"01a0be6d-1745-7c41-90ad-0d09833f47ab","metadata":{"langgraph_checkpoint [...]
+{"ord":19,"off":59828,"sha":"99b991a298be","bytes":1681,"id":"01a0be6d-1746-7170-b6d0-d6aa2d533d9e","parent":"01a0be6d-1745-7c41-90ad-0d09833f47ab","run":"01a0be6d-1724-7870-b615-7535de6b6e91","tool":"call_summarise_26","child":"summarise-d6aa2d533d9e","label":"summarise","from":"external","time":"2026-09-20T10:46:55.559411+00:00","flags":["finished","auxiliary"],"parts":[{"k":"result","text":"answer 27","of":"call_summarise_26","state":"available","bytes":9}]}
+{"ord":20,"off":61510,"sha":"823678107cf2","bytes":4301,"id":"01a0be6d-1747-7010-9bc9-e3dea29ef4b0","parent":"01a0be6d-1724-7870-b615-7535de6b6e91","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"agent","time":"2026-09-20T10:46:55.561728+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655559788Z01a0be6d-1747-7010-9bc9-e3dea29ef4b0","id":"01a0be6d-1747-7010-9bc9-e3dea29ef4b0","metadata":{"langgraph_checkpoint [...]
+{"ord":21,"off":65812,"sha":"595bfb9b84ae","bytes":4363,"id":"01a0be6d-1747-7010-9bc9-e3efaa900eb0","parent":"01a0be6d-1747-7010-9bc9-e3dea29ef4b0","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"call_model","time":"2026-09-20T10:46:55.561565+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655559788Z01a0be6d-1747-7010-9bc9-e3dea29ef4b0.20260920T104655559862Z01a0be6d-1747-7010-9bc9-e3efaa900eb0","id":"01a0be6 [...]
+{"ord":22,"off":70176,"sha":"f05435011f2e","bytes":4485,"id":"01a0be6d-1747-7010-9bc9-e3f8caa0b7aa","parent":"01a0be6d-1747-7010-9bc9-e3efaa900eb0","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"RunnableSequence","time":"2026-09-20T10:46:55.561514+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655559788Z01a0be6d-1747-7010-9bc9-e3dea29ef4b0.20260920T104655559862Z01a0be6d-1747-7010-9bc9-e3efaa900eb0.20260920 [...]
+{"ord":23,"off":74662,"sha":"c406c3077a32","bytes":6098,"id":"01a0be6d-1748-76f1-8883-e82db9f6a6cb","parent":"01a0be6d-1747-7010-9bc9-e3f8caa0b7aa","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"Prompt","time":"2026-09-20T10:46:55.560120+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655559788Z01a0be6d-1747-7010-9bc9-e3dea29ef4b0.20260920T104655559862Z01a0be6d-1747-7010-9bc9-e3efaa900eb0.20260920T104655559 [...]
+{"ord":24,"off":80761,"sha":"7f6aa20632d9","bytes":6897,"id":"01a0be6d-1748-76f1-8883-e83b7c887dc1","parent":"01a0be6d-1747-7010-9bc9-e3f8caa0b7aa","call":"01a0be6d-1748-76f1-8883-e83b7c887dc1","run":"01a0be6d-1724-7870-b615-7535de6b6e91","from":"agent","time":"2026-09-20T10:46:55.561461+00:00","flags":["finished"],"usage":{"in":128,"out":10,"cache_read":5},"model":"every-tool","parts":[{"k":"text","text":"answer 28","state":"available","bytes":9}]}
+{"ord":25,"off":87659,"sha":"b0ef80de5004","bytes":4374,"id":"01a0be6d-1749-7172-a50d-bb301148784c","parent":"01a0be6d-1747-7010-9bc9-e3dea29ef4b0","run":"01a0be6d-1724-7870-b615-7535de6b6e91","label":"should_continue","time":"2026-09-20T10:46:55.561694+00:00","parts":[{"k":"data","data":{"dotted_order":"20260920T104655524236Z01a0be6d-1724-7870-b615-7535de6b6e91.20260920T104655559788Z01a0be6d-1747-7010-9bc9-e3dea29ef4b0.20260920T104655561628Z01a0be6d-1749-7172-a50d-bb301148784c","id":"01 [...]
+{"t":"end","records":25,"digest":"57d636efcf0db2d9a4902de7d5a520fd984ef844744243ce2aba5e13e0175125"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000003.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000003.sd
new file mode 100644
index 0000000000..d9666ce325
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/langchain-subagent/transcript-20260920T110158.000000000Z-000003.sd
@@ -0,0 +1,3 @@
+{"h":1,"schema":"sd/1","seq":3,"at":"2026-09-20T11:01:58Z","kind":"transcript","adapter":"langsmith-ingest/0.1.0","dialect":"langsmith/1","src":"runs-20260920T100000.001000000Z-000001.request","session":"ls-asz-harness-thread-subagent-4e1037625675","stream":"summarise-d6aa2d533d9e"}
+{"ord":1,"off":0,"sha":"9e17c257d324","bytes":3755,"id":"01a0be6d-1746-7170-b6d0-d6ba40ae33b2","parent":"01a0be6d-1746-7170-b6d0-d6aa2d533d9e","call":"01a0be6d-1746-7170-b6d0-d6ba40ae33b2","run":"01a0be6d-1746-7170-b6d0-d6aa2d533d9e","from":"agent","time":"2026-09-20T10:46:55.559361+00:00","flags":["finished"],"usage":{"in":127,"out":10,"cache_read":5},"model":"plain","parts":[{"k":"text","text":"answer 27","state":"available","bytes":9}]}
+{"t":"end","records":1,"digest":"5d1168aad740f0e46156874f1f225edb15457a7bf219c1b0be863dd5cddd7499"}
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/mcp-calls/asz-view-example.json
similarity index 54%
copy from oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json
copy to oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/asz-view-example.json
index 4754730ff1..2c953f4fd4 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/mcp-calls/asz-view-example.json
@@ -1,38 +1,38 @@
{
"format": "asz.view",
"version": "1.0",
- "conversation": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3",
+ "conversation": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
"sessions": [
- "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3"
+ "720d2f9e-dca7-49be-8c5e-89b667b39a01"
],
"head": {
"round": 1,
- "digest": "475193a5f44b74117ee7f527bb8e5f29277ef6686dfb8d0e9a07d2e69f34c0ee"
+ "digest": "0465bcdcdb729b5e5022b97e10f03c2589937c735debe88956af173df7f612d1"
},
"parser": "v1",
"policy": "v1+idle=10m0s",
"summary": {
- "title": "workspace changes beside the steps",
+ "title": "calls to MCP servers",
"state": "verified",
"problems": [],
"talks": 2,
- "steps": 19,
+ "steps": 25,
"streams": 2,
"segments": 1,
"rounds": 1,
"unresolved": 0,
- "changes": 3,
+ "changes": 0,
"provider_bodies": 0,
"captured_prompts": 0,
"from": 1767225600000,
- "to": 1767225609400,
+ "to": 1767225613900,
"kinds": {
"agent.call": 1,
"agent.launch_ack": 1,
"agent.output": 1,
"epoch": 2,
- "llm.call": 6,
- "message.assistant": 5,
+ "llm.call": 8,
+ "message.assistant": 7,
"message.external": 1,
"run": 3,
"runtime.notification": 1,
@@ -40,7 +40,7 @@
"session": 1,
"stream": 2,
"talk": 2,
- "tool": 3
+ "tool": 5
},
"relation_types": {
"ends_with": 1,
@@ -56,100 +56,100 @@
"rounds": [
{
"round": 1,
- "digest": "475193a5f44b74117ee7f527bb8e5f29277ef6686dfb8d0e9a07d2e69f34c0ee",
+ "digest": "0465bcdcdb729b5e5022b97e10f03c2589937c735debe88956af173df7f612d1",
"previous": null,
"from_seq": 1,
"through_seq": 5,
- "input_digest": "1b2cfd7d2a04f4131f249a4d76622cdc0cf3dc251a15f77bce7ecf9537c771c5",
+ "input_digest": "d89701ec46c206724a1881d3571defaab92ce201520b19f84362aa85c9f47627",
"from_time": 1767225600000,
- "through_time": 1767225609400,
+ "through_time": 1767225613900,
"verified": true
}
],
"files": [
{
- "file": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3/streams/main/transcript-20260101T000000.000000000Z-000001.sd",
+ "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/main/transcript-20260101T000000.000000000Z-000001.sd",
"format": "sd",
"kind": "transcript",
"seq": 1,
"round": null,
"stream": "main",
"run": null,
- "lines": 14,
- "bytes": 5470,
- "digest": "52ed2a09cafa93b7c061767a987b184d9d4405385566ca5869109fedf4b28397",
+ "lines": 20,
+ "bytes": 6910,
+ "digest": "76e8539b8ad8ea080bd5daec3c187554d68c8a648533974d40cfff535081e0a0",
"from_time": 1767225600000,
- "through_time": 1767225609400
+ "through_time": 1767225613900
},
{
- "file": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3/streams/main/changes-20260101T000000.000000000Z-000002.sd",
+ "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/main/execution-20260101T000000.000000000Z-000002.sd",
"format": "sd",
- "kind": "changes",
+ "kind": "execution",
"seq": 2,
"round": null,
"stream": "main",
"run": null,
- "lines": 3,
- "bytes": 2034,
- "digest": "def121178eb59d8c7496e643e0f9044e3d496f759ef473d1a854b118a7313a40",
- "from_time": 1767225601600,
- "through_time": 1767225601600
+ "lines": 6,
+ "bytes": 3507,
+ "digest": "9a6de86e0baa955694fc14b3f1764820a93b7bb30fedf92854a1ea0cbdae4c94",
+ "from_time": 1767225601500,
+ "through_time": 1767225606200
},
{
- "file": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3/streams/a16c61ba3c4753923/transcript-20260101T000000.000000000Z-000003.sd",
+ "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/transcript-20260101T000000.000000000Z-000003.sd",
"format": "sd",
"kind": "transcript",
"seq": 3,
"round": null,
- "stream": "a16c61ba3c4753923",
+ "stream": "a39a255c7ba239348",
"run": null,
"lines": 7,
- "bytes": 2051,
- "digest": "423a9738186796d2fdf6e6cb4a116a454479361d3820446b63535bb57d3f2d34",
- "from_time": 1767225605000,
- "through_time": 1767225607400
+ "bytes": 2147,
+ "digest": "3211096e8b6f85c6b6459be1d3d855a2930d49f4065f4d1d4315a7fc357ee5d4",
+ "from_time": 1767225609500,
+ "through_time": 1767225611900
},
{
- "file": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3/streams/a16c61ba3c4753923/changes-20260101T000000.000000000Z-000004.sd",
+ "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/execution-20260101T000000.000000000Z-000004.sd",
"format": "sd",
- "kind": "changes",
+ "kind": "execution",
"seq": 4,
"round": null,
- "stream": "a16c61ba3c4753923",
+ "stream": "a39a255c7ba239348",
"run": null,
"lines": 3,
- "bytes": 1597,
- "digest": "0efef2a756848481b85ac4450bed3c9432f2d8de98398b2eae8ffac52548962a",
- "from_time": 1767225606400,
- "through_time": 1767225606400
+ "bytes": 1253,
+ "digest": "aea3dc04001736aba88914ff0eacea3731ebbf62915d58941b09110d6099f066",
+ "from_time": 1767225610900,
+ "through_time": 1767225610900
},
{
- "file": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3/streams/a16c61ba3c4753923/meta-20260101T000000.000000000Z-000005.sd",
+ "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/meta-20260101T000000.000000000Z-000005.sd",
"format": "sd",
"kind": "agent_meta",
"seq": 5,
"round": null,
- "stream": "a16c61ba3c4753923",
+ "stream": "a39a255c7ba239348",
"run": null,
"lines": 3,
- "bytes": 662,
- "digest": "7e430e402adabc5f885031e03ef7a106e9cb14bc0e583e64786217f25faf4ce0",
+ "bytes": 664,
+ "digest": "eec87b3a2a776af6ebcbc9209d820cbe05dff81c067b8ca70f829e59e5e08781",
"from_time": null,
"through_time": null
},
{
- "file": "_conversations/3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3/rounds/r000001-475193a5f44b.sf",
+ "file": "_conversations/720d2f9e-dca7-49be-8c5e-89b667b39a01/rounds/r000001-0465bcdcdb72.sf",
"format": "sf",
"kind": "round",
"seq": null,
"round": 1,
"stream": null,
"run": null,
- "lines": 37,
- "bytes": 9123,
- "digest": "f1485e853a379a1cf8e7fcee2f327f2ed67192c39352ebfa61e5f31ec028f535",
+ "lines": 43,
+ "bytes": 10874,
+ "digest": "2ea12f2b3a05408df1eefc01e2273a681e6f26a37ff6ec92bc950b5bd8a7b8a6",
"from_time": 1767225600000,
- "through_time": 1767225609400
+ "through_time": 1767225613900
}
],
"streams": [
@@ -159,25 +159,25 @@
"role": "main",
"label": "",
"parent": "",
- "records": 12,
- "steps": 13,
+ "records": 18,
+ "steps": 19,
"talk": "talk/main/s1-cycle",
"named_by": "",
"opened_by": []
},
{
- "id": "stream/a16c61ba3c4753923",
- "name": "a16c61ba3c4753923",
+ "id": "stream/a39a255c7ba239348",
+ "name": "a39a255c7ba239348",
"role": "child",
- "label": "tidier",
+ "label": "auditor",
"parent": "main",
"records": 6,
"steps": 6,
- "talk": "talk/a16c61ba3c4753923",
+ "talk": "talk/a39a255c7ba239348",
"named_by": "",
"opened_by": [
{
- "step": "tool/s4-tool",
+ "step": "tool/s6-tool",
"stream": "main",
"talk": "talk/main/s1-cycle",
"quality": "exact_unique"
@@ -192,7 +192,7 @@
"committable": false,
"talks": 2,
"from": 1767225600000,
- "to": 1767225609400
+ "to": 1767225613900
}
],
"talks": [
@@ -211,13 +211,13 @@
"runs": 2,
"trigger": "external"
},
- "label": "raise the timeout and add a configuration file",
- "reply": "Done.",
+ "label": "check the checkout service and then the payment service",
+ "reply": "Checkout is healthy and payment is not.",
"runs": 2,
- "steps": 13,
- "tools": 3,
+ "steps": 19,
+ "tools": 5,
"from": 1767225600000,
- "to": 1767225609400,
+ "to": 1767225613900,
"segment": "segment/at_1_2",
"children": [
{
@@ -244,9 +244,9 @@
"seq": 1,
"row": 2
},
- "text": "raise the timeout and add a configuration file",
+ "text": "check the checkout service and then the payment service",
"state": "available",
- "bytes": 46,
+ "bytes": 55,
"flags": [
"external_input"
]
@@ -298,9 +298,9 @@
"row": 3,
"block": 0
},
- "text": "Changing both.",
+ "text": "Asking the status server about checkout.",
"state": "available",
- "bytes": 14
+ "bytes": 40
},
{
"id": "tool/s2-tool",
@@ -326,24 +326,27 @@
}
],
"attrs": {
- "name": "Bash",
+ "mcp_server": "status",
+ "mcp_tool": "lookup",
+ "name": "mcp__status__lookup",
"result": "available",
"result_join": "exact_unique",
"timing": "unavailable"
},
- "text": "{\"command\":\"sed -i 's/= 10/= 30/' server.go \u0026\u0026 printf 'timeout: 30\\\\n' \u003e config.yaml\"}",
+ "text": "{\"service\":\"checkout\"}",
"state": "available",
- "bytes": 86,
+ "bytes": 22,
"flags": [
"finished"
],
- "name": "Bash",
- "result": "{\"stderr\":\"\",\"stdout\":\"\"}",
+ "name": "mcp__status__lookup",
+ "result": "checkout: 3 of 3 ready",
"result_state": "available",
- "request_to_result_ms": 500,
+ "result_bytes": 22,
+ "request_to_result_ms": 400,
"request_to_result_join": "exact_unique",
- "changes": [
- "s2-tool"
+ "executions": [
+ "s2-tool/client_hook"
]
}
]
@@ -353,7 +356,7 @@
"kind": "llm.call",
"parent": "run/talk_main_s1-cycle/s1-cycle",
"stream": "main",
- "at": 1767225602600,
+ "at": 1767225602500,
"ref": {
"seq": 1,
"row": 6
@@ -389,22 +392,22 @@
"kind": "message.assistant",
"parent": "call/s3-call",
"stream": "main",
- "at": 1767225602600,
+ "at": 1767225602500,
"ref": {
"seq": 1,
"row": 6,
"block": 0
},
- "text": "Naming the unit.",
+ "text": "And about payment.",
"state": "available",
- "bytes": 16
+ "bytes": 18
},
{
"id": "tool/s3-tool",
"kind": "tool",
"parent": "call/s3-call",
"stream": "main",
- "at": 1767225602700,
+ "at": 1767225602600,
"ref": {
"seq": 1,
"row": 7,
@@ -423,25 +426,28 @@
}
],
"attrs": {
- "name": "Edit",
+ "mcp_server": "status",
+ "mcp_tool": "lookup",
+ "name": "mcp__status__lookup",
"result": "available",
"result_join": "exact_unique",
"timing": "unavailable"
},
- "text": "{\"file_path\":\"/Users/dev/scenario/server.go\",\"new_string\":\"var timeoutSeconds = 30\",\"old_string\":\"var timeout = 30\"}",
+ "text": "{\"service\":\"payment\"}",
"state": "available",
- "bytes": 116,
+ "bytes": 21,
"flags": [
"finished"
],
- "name": "Edit",
- "result": "The file has been updated.",
+ "name": "mcp__status__lookup",
+ "failed": true,
+ "result": "the status server could not reach payment",
"result_state": "available",
- "result_bytes": 26,
- "request_to_result_ms": 200,
+ "result_bytes": 41,
+ "request_to_result_ms": 2000,
"request_to_result_join": "exact_unique",
- "changes": [
- "s3-tool"
+ "executions": [
+ "s3-tool/client_hook"
]
}
]
@@ -451,7 +457,7 @@
"kind": "llm.call",
"parent": "run/talk_main_s1-cycle/s1-cycle",
"stream": "main",
- "at": 1767225603900,
+ "at": 1767225605600,
"ref": {
"seq": 1,
"row": 9
@@ -460,13 +466,17 @@
{
"seq": 1,
"row": 9
+ },
+ {
+ "seq": 1,
+ "row": 10
}
],
"attrs": {
- "fragments": 1,
+ "fragments": 2,
"usage": "observed_replayable",
"usage_at": {
- "row": 9,
+ "row": 10,
"seq": 1
},
"usage_from": "last_fragment_in_line_order"
@@ -479,25 +489,216 @@
},
"children": [
{
- "id": "tool/s4-tool",
- "kind": "agent.call",
+ "id": "msg/1/9:0",
+ "kind": "message.assistant",
"parent": "call/s4-call",
"stream": "main",
- "at": 1767225603900,
+ "at": 1767225605600,
"ref": {
"seq": 1,
"row": 9,
"block": 0
},
+ "text": "Asking again, through a hook that names the region.",
+ "state": "available",
+ "bytes": 51
+ },
+ {
+ "id": "tool/s4-tool",
+ "kind": "tool",
+ "parent": "call/s4-call",
+ "stream": "main",
+ "at": 1767225605700,
+ "ref": {
+ "seq": 1,
+ "row": 10,
+ "block": 0
+ },
"refs": [
{
"seq": 1,
- "row": 9,
+ "row": 10,
"block": 0
},
{
"seq": 1,
- "row": 10,
+ "row": 11,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "mcp_server": "status",
+ "mcp_tool": "lookup",
+ "name": "mcp__status__lookup",
+ "result": "available",
+ "result_join": "exact_unique",
+ "timing": "unavailable"
+ },
+ "text": "{\"service\":\"payment\"}",
+ "state": "available",
+ "bytes": 21,
+ "flags": [
+ "finished"
+ ],
+ "name": "mcp__status__lookup",
+ "result": "payment: 1 of 3 ready",
+ "result_state": "available",
+ "result_bytes": 21,
+ "request_to_result_ms": 500,
+ "request_to_result_join": "exact_unique",
+ "executions": [
+ "s4-tool/client_hook"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "call/s5-call",
+ "kind": "llm.call",
+ "parent": "run/talk_main_s1-cycle/s1-cycle",
+ "stream": "main",
+ "at": 1767225607200,
+ "ref": {
+ "seq": 1,
+ "row": 12
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 12
+ },
+ {
+ "seq": 1,
+ "row": 13
+ }
+ ],
+ "attrs": {
+ "fragments": 2,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 13,
+ "seq": 1
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 2,
+ "out": 40,
+ "cache_read": 900,
+ "cache_write": 100
+ },
+ "children": [
+ {
+ "id": "msg/1/12:0",
+ "kind": "message.assistant",
+ "parent": "call/s5-call",
+ "stream": "main",
+ "at": 1767225607200,
+ "ref": {
+ "seq": 1,
+ "row": 12,
+ "block": 0
+ },
+ "text": "A tool whose name the runtime cannot split.",
+ "state": "available",
+ "bytes": 43
+ },
+ {
+ "id": "tool/s5-tool",
+ "kind": "tool",
+ "parent": "call/s5-call",
+ "stream": "main",
+ "at": 1767225607300,
+ "ref": {
+ "seq": 1,
+ "row": 13,
+ "block": 0
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 13,
+ "block": 0
+ },
+ {
+ "seq": 1,
+ "row": 14,
+ "block": 0
+ }
+ ],
+ "attrs": {
+ "name": "mcp__odd__name__twice",
+ "result": "available",
+ "result_join": "exact_unique",
+ "timing": "unavailable"
+ },
+ "text": "{}",
+ "state": "available",
+ "bytes": 2,
+ "flags": [
+ "finished"
+ ],
+ "name": "mcp__odd__name__twice",
+ "result": "ok",
+ "result_state": "available",
+ "result_bytes": 2,
+ "request_to_result_ms": 100,
+ "request_to_result_join": "exact_unique"
+ }
+ ]
+ },
+ {
+ "id": "call/s6-call",
+ "kind": "llm.call",
+ "parent": "run/talk_main_s1-cycle/s1-cycle",
+ "stream": "main",
+ "at": 1767225608400,
+ "ref": {
+ "seq": 1,
+ "row": 15
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 15
+ }
+ ],
+ "attrs": {
+ "fragments": 1,
+ "usage": "observed_replayable",
+ "usage_at": {
+ "row": 15,
+ "seq": 1
+ },
+ "usage_from": "last_fragment_in_line_order"
+ },
+ "usage": {
+ "in": 2,
+ "out": 40,
+ "cache_read": 900,
+ "cache_write": 100
+ },
+ "children": [
+ {
+ "id": "tool/s6-tool",
+ "kind": "agent.call",
+ "parent": "call/s6-call",
+ "stream": "main",
+ "at": 1767225608400,
+ "ref": {
+ "seq": 1,
+ "row": 15,
+ "block": 0
+ },
+ "refs": [
+ {
+ "seq": 1,
+ "row": 15,
+ "block": 0
+ },
+ {
+ "seq": 1,
+ "row": 16,
"block": 0
}
],
@@ -507,9 +708,9 @@
"result_join": "exact_unique",
"timing": "unavailable"
},
- "text": "{\"description\":\"tidier\",\"prompt\":\"remove the stale notes\"}",
+ "text": "{\"description\":\"auditor\",\"prompt\":\"read the release notes\"}",
"state": "available",
- "bytes": 58,
+ "bytes": 59,
"flags": [
"finished"
],
@@ -522,7 +723,7 @@
"edges": [
{
"type": "starts",
- "other": "stream/a16c61ba3c4753923",
+ "other": "stream/a39a255c7ba239348",
"dir": "out",
"quality": "exact_unique",
"via": "parent tool result"
@@ -532,14 +733,14 @@
]
},
{
- "id": "ack/1/10",
+ "id": "ack/1/16",
"kind": "agent.launch_ack",
"parent": "run/talk_main_s1-cycle/s1-cycle",
"stream": "main",
- "at": 1767225604000,
+ "at": 1767225608500,
"ref": {
"seq": 1,
- "row": 10
+ "row": 16
},
"text": "launched",
"state": "available",
@@ -551,36 +752,36 @@
]
},
{
- "id": "run/talk_main_s1-cycle/s4-cycle-notification",
+ "id": "run/talk_main_s1-cycle/s6-cycle-notification",
"kind": "run",
"parent": "talk/main/s1-cycle",
"stream": "main",
- "at": 1767225608400,
+ "at": 1767225612900,
"ref": {
"seq": 1,
- "row": 11
+ "row": 17
},
"attrs": {
"trigger": "notification"
},
"children": [
{
- "id": "notify/1/11",
+ "id": "notify/1/17",
"kind": "runtime.notification",
- "parent": "run/talk_main_s1-cycle/s4-cycle-notification",
+ "parent": "run/talk_main_s1-cycle/s6-cycle-notification",
"stream": "main",
- "at": 1767225608400,
+ "at": 1767225612900,
"ref": {
"seq": 1,
- "row": 11
+ "row": 17
},
- "text": "\u003ctask-notification\u003e\n\u003ctask-id\u003ea16c61ba3c4753923\u003c/task-id\u003e\n\u003ctool-use-id\u003es4-tool\u003c/tool-use-id\u003e\n\u003cstatus\u003ecompleted\u003c/status\u003e\n\u003c/task-notification\u003e",
+ "text": "\u003ctask-notification\u003e\n\u003ctask-id\u003ea39a255c7ba239348\u003c/task-id\u003e\n\u003ctool-use-id\u003es6-tool\u003c/tool-use-id\u003e\n\u003cstatus\u003ecompleted\u003c/status\u003e\n\u003c/task-notification\u003e",
"state": "available",
"bytes": 139,
"edges": [
{
"type": "reports",
- "other": "stream/a16c61ba3c4753923",
+ "other": "stream/a39a255c7ba239348",
"dir": "out",
"quality": "exact_unique",
"via": "task id on the notification"
@@ -588,26 +789,26 @@
]
},
{
- "id": "call/s5-call",
+ "id": "call/s7-call",
"kind": "llm.call",
- "parent": "run/talk_main_s1-cycle/s4-cycle-notification",
+ "parent": "run/talk_main_s1-cycle/s6-cycle-notification",
"stream": "main",
- "at": 1767225609400,
+ "at": 1767225613900,
"ref": {
"seq": 1,
- "row": 12
+ "row": 18
},
"refs": [
{
"seq": 1,
- "row": 12
+ "row": 18
}
],
"attrs": {
"fragments": 1,
"usage": "observed_replayable",
"usage_at": {
- "row": 12,
+ "row": 18,
"seq": 1
},
"usage_from": "last_fragment_in_line_order"
@@ -620,19 +821,19 @@
},
"children": [
{
- "id": "msg/1/12:0",
+ "id": "msg/1/18:0",
"kind": "message.assistant",
- "parent": "call/s5-call",
+ "parent": "call/s7-call",
"stream": "main",
- "at": 1767225609400,
+ "at": 1767225613900,
"ref": {
"seq": 1,
- "row": 12,
+ "row": 18,
"block": 0
},
- "text": "Done.",
+ "text": "Checkout is healthy and payment is not.",
"state": "available",
- "bytes": 5,
+ "bytes": 39,
"flags": [
"finished"
]
@@ -653,11 +854,11 @@
]
},
{
- "id": "talk/a16c61ba3c4753923",
+ "id": "talk/a39a255c7ba239348",
"kind": "talk",
- "parent": "epoch/a16c61ba3c4753923/0",
- "stream": "a16c61ba3c4753923",
- "at": 1767225605000,
+ "parent": "epoch/a39a255c7ba239348/0",
+ "stream": "a39a255c7ba239348",
+ "at": 1767225609500,
"ref": {
"seq": 3,
"row": 1
@@ -667,21 +868,21 @@
"runs": 1,
"trigger": "unknown"
},
- "reply": "Removed.",
+ "reply": "Read.",
"runs": 1,
"steps": 6,
"tools": 1,
- "from": 1767225605000,
- "to": 1767225607400,
+ "from": 1767225609500,
+ "to": 1767225611900,
"child": true,
"segment": "segment/at_1_2",
"children": [
{
- "id": "run/talk_a16c61ba3c4753923/a16c61ba3c4753923-cycle",
+ "id": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle",
"kind": "run",
- "parent": "talk/a16c61ba3c4753923",
- "stream": "a16c61ba3c4753923",
- "at": 1767225605000,
+ "parent": "talk/a39a255c7ba239348",
+ "stream": "a39a255c7ba239348",
+ "at": 1767225609500,
"ref": {
"seq": 3,
"row": 1
@@ -691,11 +892,11 @@
},
"children": [
{
- "id": "call/tidier-s1-call",
+ "id": "call/auditor-s1-call",
"kind": "llm.call",
- "parent": "run/talk_a16c61ba3c4753923/a16c61ba3c4753923-cycle",
- "stream": "a16c61ba3c4753923",
- "at": 1767225606000,
+ "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle",
+ "stream": "a39a255c7ba239348",
+ "at": 1767225610500,
"ref": {
"seq": 3,
"row": 2
@@ -729,24 +930,24 @@
{
"id": "msg/3/2:0",
"kind": "message.assistant",
- "parent": "call/tidier-s1-call",
- "stream": "a16c61ba3c4753923",
- "at": 1767225606000,
+ "parent": "call/auditor-s1-call",
+ "stream": "a39a255c7ba239348",
+ "at": 1767225610500,
"ref": {
"seq": 3,
"row": 2,
"block": 0
},
- "text": "Removing them.",
+ "text": "Reading them.",
"state": "available",
- "bytes": 14
+ "bytes": 13
},
{
- "id": "tool/tidier-s1-tool",
+ "id": "tool/auditor-s1-tool",
"kind": "tool",
- "parent": "call/tidier-s1-call",
- "stream": "a16c61ba3c4753923",
- "at": 1767225606100,
+ "parent": "call/auditor-s1-call",
+ "stream": "a39a255c7ba239348",
+ "at": 1767225610600,
"ref": {
"seq": 3,
"row": 3,
@@ -765,34 +966,37 @@
}
],
"attrs": {
- "name": "Bash",
+ "mcp_server": "docs",
+ "mcp_tool": "read",
+ "name": "mcp__docs__read",
"result": "available",
"result_join": "exact_unique",
"timing": "unavailable"
},
- "text": "{\"command\":\"rm notes.txt\"}",
+ "text": "{\"page\":\"release-notes\"}",
"state": "available",
- "bytes": 26,
+ "bytes": 24,
"flags": [
"finished"
],
- "name": "Bash",
- "result": "{\"stderr\":\"\",\"stdout\":\"\"}",
+ "name": "mcp__docs__read",
+ "result": "0.6.0: ...",
"result_state": "available",
+ "result_bytes": 10,
"request_to_result_ms": 300,
"request_to_result_join": "exact_unique",
- "changes": [
- "tidier-s1-tool"
+ "executions": [
+ "auditor-s1-tool/client_hook"
]
}
]
},
{
- "id": "call/tidier-s2-call",
+ "id": "call/auditor-s2-call",
"kind": "llm.call",
- "parent": "run/talk_a16c61ba3c4753923/a16c61ba3c4753923-cycle",
- "stream": "a16c61ba3c4753923",
- "at": 1767225607400,
+ "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle",
+ "stream": "a39a255c7ba239348",
+ "at": 1767225611900,
"ref": {
"seq": 3,
"row": 5
@@ -822,17 +1026,17 @@
{
"id": "msg/3/5:0",
"kind": "message.assistant",
- "parent": "call/tidier-s2-call",
- "stream": "a16c61ba3c4753923",
- "at": 1767225607400,
+ "parent": "call/auditor-s2-call",
+ "stream": "a39a255c7ba239348",
+ "at": 1767225611900,
"ref": {
"seq": 3,
"row": 5,
"block": 0
},
- "text": "Removed.",
+ "text": "Read.",
"state": "available",
- "bytes": 8,
+ "bytes": 5,
"flags": [
"finished"
]
@@ -840,11 +1044,11 @@
]
},
{
- "id": "output/a16c61ba3c4753923",
+ "id": "output/a39a255c7ba239348",
"kind": "agent.output",
- "parent": "run/talk_a16c61ba3c4753923/a16c61ba3c4753923-cycle",
- "stream": "a16c61ba3c4753923",
- "at": 1767225607400,
+ "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle",
+ "stream": "a39a255c7ba239348",
+ "at": 1767225611900,
"ref": {
"seq": 3,
"row": 5
@@ -858,16 +1062,16 @@
"attrs": {
"returned_value": "unavailable"
},
- "text": "Removed.",
+ "text": "Read.",
"state": "available",
- "bytes": 8,
+ "bytes": 5,
"flags": [
"finished"
],
"edges": [
{
"type": "ends_with",
- "other": "stream/a16c61ba3c4753923",
+ "other": "stream/a39a255c7ba239348",
"dir": "in",
"quality": "exact_unique",
"via": "the last response in the child stream, and what it returned"
@@ -891,78 +1095,79 @@
"loose": [],
"relations": [
{
- "id": "rel/ends_with/stream_a16c61ba3c4753923/output_a16c61ba3c4753923",
- "type": "ends_with",
- "from": "stream/a16c61ba3c4753923",
- "to": "output/a16c61ba3c4753923",
+ "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": "the last response in the child stream, and what it returned",
+ "via": "activity window",
"evidence": [
{
- "seq": 3,
- "row": 5
+ "seq": 1,
+ "row": 2
}
]
},
{
- "id": "rel/in_segment/talk_a16c61ba3c4753923/segment_at_1_2",
- "type": "in_segment",
- "from": "talk/a16c61ba3c4753923",
- "to": "segment/at_1_2",
- "quality": "strong_inference",
- "via": "inside the window of the talk that delegated it",
+ "id": "rel/starts/tool_s6-tool/stream_a39a255c7ba239348",
+ "type": "starts",
+ "from": "tool/s6-tool",
+ "to": "stream/a39a255c7ba239348",
+ "quality": "exact_unique",
+ "via": "parent tool result",
"evidence": [
{
- "seq": 3,
- "row": 1
+ "seq": 1,
+ "row": 16
}
]
},
{
- "id": "rel/in_segment/talk_main_s1-cycle/segment_at_1_2",
+ "id": "rel/in_segment/talk_a39a255c7ba239348/segment_at_1_2",
"type": "in_segment",
- "from": "talk/main/s1-cycle",
+ "from": "talk/a39a255c7ba239348",
"to": "segment/at_1_2",
- "quality": "exact_unique",
- "via": "activity window",
+ "quality": "strong_inference",
+ "via": "inside the window of the talk that delegated it",
"evidence": [
{
- "seq": 1,
- "row": 2
+ "seq": 3,
+ "row": 1
}
]
},
{
- "id": "rel/reports/notify_1_11/stream_a16c61ba3c4753923",
- "type": "reports",
- "from": "notify/1/11",
- "to": "stream/a16c61ba3c4753923",
+ "id": "rel/ends_with/stream_a39a255c7ba239348/output_a39a255c7ba239348",
+ "type": "ends_with",
+ "from": "stream/a39a255c7ba239348",
+ "to": "output/a39a255c7ba239348",
"quality": "exact_unique",
- "via": "task id on the notification",
+ "via": "the last response in the child stream, and what it returned",
"evidence": [
{
- "seq": 1,
- "row": 11
+ "seq": 3,
+ "row": 5
}
]
},
{
- "id": "rel/starts/tool_s4-tool/stream_a16c61ba3c4753923",
- "type": "starts",
- "from": "tool/s4-tool",
- "to": "stream/a16c61ba3c4753923",
+ "id": "rel/reports/notify_1_17/stream_a39a255c7ba239348",
+ "type": "reports",
+ "from": "notify/1/17",
+ "to": "stream/a39a255c7ba239348",
"quality": "exact_unique",
- "via": "parent tool result",
+ "via": "task id on the notification",
"evidence": [
{
"seq": 1,
- "row": 10
+ "row": 17
}
]
}
],
"unresolved": [],
- "workspace_changes": [
+ "workspace_changes": [],
+ "tool_executions": [
{
"step": "tool/s2-tool",
"ref": {
@@ -970,232 +1175,133 @@
"row": 1,
"block": 0
},
- "schema": "changes/1",
- "id": "s2-tool",
- "captured_by": "asz-plugin",
- "session": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3",
+ "schema": "execution/1",
+ "id": "s2-tool/client_hook",
+ "observed_by": "asz-plugin",
+ "boundary": "client_hook",
+ "session": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
"stream": "main",
"tool": "s2-tool",
- "tool_name": "Bash",
- "time": "2026-01-01T00:00:01.600Z",
- "basis": "tool_window",
- "root": {
- "path": "/Users/dev/scenario"
- },
- "policy": {
- "exclusions": "standard-v1",
- "read_only": "readonly-v1"
+ "tool_name": "mcp__status__lookup",
+ "cwd": "/Users/dev/scenario",
+ "protocol": "mcp",
+ "server": {
+ "name": "status",
+ "source": "user"
},
- "window": {
- "before": {
- "from": "2026-01-01T00:00:00.600Z",
- "to": "2026-01-01T00:00:00.600Z"
- },
- "after": {
- "from": "2026-01-01T00:00:01.600Z",
- "to": "2026-01-01T00:00:01.600Z"
- }
+ "time": "2026-01-01T00:00:01.500Z",
+ "duration_ms": 380,
+ "outcome": "returned",
+ "arguments": {
+ "state": "size_only",
+ "bytes": 22,
+ "sha256": "ea3a64398a82e2ee89a19c80733d2be7a2496e0a0a64050fa307a20f7be8879b"
},
- "outcome": {
- "state": "returned",
- "exit_code": 0
- },
- "coverage": "complete",
- "changed_files": 2,
- "changes": [
- {
- "path": "server.go",
- "operation": "modify",
- "before": {
- "present": true,
- "bytes": 31,
- "sha256": "3189e1da38c06459ea27645112a044b0852adfe54f7cb30918c12974b46e4906"
- },
- "after": {
- "present": true,
- "bytes": 31,
- "sha256": "ec2dae1adf80381d4d3e6e1f4048c3b4a0c3b667ae8e77fff58c7250f2b3ce00"
- },
- "diff": "available",
- "attribution": "only_this_window",
- "windows": [
- "s2-tool"
- ],
- "additions": 1,
- "deletions": 1,
- "hunks": [
- {
- "old_start": 1,
- "old_lines": 3,
- "new_start": 1,
- "new_lines": 3,
- "lines": [
- " package main",
- " ",
- "-var timeout = 10",
- "+var timeout = 30"
- ]
- }
- ]
- },
- {
- "path": "config.yaml",
- "operation": "create",
- "before": {
- "present": false,
- "bytes": null
- },
- "after": {
- "present": true,
- "bytes": 12,
- "sha256": "6e4206a968e12622b1c42b272ee6faa88501e8522f3391be751753ff7d03e008"
- },
- "diff": "available",
- "attribution": "only_this_window",
- "windows": [
- "s2-tool"
- ],
- "additions": 1,
- "deletions": 0,
- "hunks": [
- {
- "old_start": 0,
- "old_lines": 0,
- "new_start": 1,
- "new_lines": 1,
- "lines": [
- "+timeout: 30"
- ]
- }
- ]
- }
- ]
+ "result": {
+ "state": "size_only",
+ "bytes": 49,
+ "sha256": "b8af7b751371023596105f146d227c1f580834e3a84903b6d822a166de343be9"
+ }
},
{
"step": "tool/s3-tool",
"ref": {
- "seq": 1,
- "row": 8,
- "block": 1
+ "seq": 2,
+ "row": 3,
+ "block": 0
},
- "schema": "changes/1",
- "id": "s3-tool",
- "captured_by": "claude-code",
- "session": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3",
+ "schema": "execution/1",
+ "id": "s3-tool/client_hook",
+ "observed_by": "asz-plugin",
+ "boundary": "client_hook",
+ "session": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
"stream": "main",
"tool": "s3-tool",
- "time": "2026-01-01T00:00:02.900Z",
- "basis": "runtime_reported",
- "root": {
- "path": "/Users/dev/scenario"
+ "tool_name": "mcp__status__lookup",
+ "cwd": "/Users/dev/scenario",
+ "protocol": "mcp",
+ "server": {
+ "name": "status",
+ "source": "user"
},
- "changed_files": 1,
- "changes": [
- {
- "path": "server.go",
- "operation": "modify",
- "before": {
- "present": true,
- "bytes": 31,
- "sha256": "ec2dae1adf80381d4d3e6e1f4048c3b4a0c3b667ae8e77fff58c7250f2b3ce00"
- },
- "after": {
- "present": true,
- "bytes": 38,
- "sha256": "e09685d08cfce37f21707e337394d2397df80966b2949fe9de14b8f8791817c5"
- },
- "diff": "available",
- "additions": 1,
- "deletions": 1,
- "hunks": [
- {
- "old_start": 1,
- "old_lines": 3,
- "new_start": 1,
- "new_lines": 3,
- "lines": [
- " package main",
- " ",
- "-var timeout = 30",
- "+var timeoutSeconds = 30"
- ]
- }
- ]
- }
- ]
+ "time": "2026-01-01T00:00:04.600Z",
+ "duration_ms": 2000,
+ "outcome": "failed",
+ "arguments": {
+ "state": "size_only",
+ "bytes": 21,
+ "sha256": "e0f7631369273f52e3e7c14ca01c6bbf3f8416a36ed125c8d45fc14156596c36"
+ }
},
{
- "step": "tool/tidier-s1-tool",
+ "step": "tool/s4-tool",
"ref": {
- "seq": 4,
- "row": 1,
+ "seq": 2,
+ "row": 4,
"block": 0
},
- "schema": "changes/1",
- "id": "tidier-s1-tool",
- "captured_by": "asz-plugin",
- "session": "3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3",
- "stream": "a16c61ba3c4753923",
- "tool": "tidier-s1-tool",
- "tool_name": "Bash",
- "time": "2026-01-01T00:00:06.400Z",
- "basis": "tool_window",
- "root": {
- "path": "/Users/dev/scenario"
+ "schema": "execution/1",
+ "id": "s4-tool/client_hook",
+ "observed_by": "asz-plugin",
+ "boundary": "client_hook",
+ "session": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
+ "stream": "main",
+ "tool": "s4-tool",
+ "tool_name": "mcp__status__lookup",
+ "cwd": "/Users/dev/scenario",
+ "protocol": "mcp",
+ "server": {
+ "name": "status",
+ "source": "user"
},
- "policy": {
- "exclusions": "standard-v1",
- "read_only": "readonly-v1"
+ "time": "2026-01-01T00:00:06.200Z",
+ "duration_ms": 450,
+ "outcome": "returned",
+ "arguments": {
+ "state": "size_only",
+ "bytes": 42,
+ "sha256": "ea25b35f703111d67a809bd52709c167eda433619f18d87eb668eeab5dcc0851"
},
- "window": {
- "before": {
- "from": "2026-01-01T00:00:05.400Z",
- "to": "2026-01-01T00:00:05.400Z"
- },
- "after": {
- "from": "2026-01-01T00:00:06.400Z",
- "to": "2026-01-01T00:00:06.400Z"
- }
+ "result": {
+ "state": "size_only",
+ "bytes": 48,
+ "sha256": "0397134b1598c5571ad554ef3963b3ad77adaca182426b6850b6b89be698fc32"
+ }
+ },
+ {
+ "step": "tool/auditor-s1-tool",
+ "ref": {
+ "seq": 4,
+ "row": 1,
+ "block": 0
},
- "outcome": {
- "state": "returned",
- "exit_code": 0
+ "schema": "execution/1",
+ "id": "auditor-s1-tool/client_hook",
+ "observed_by": "asz-plugin",
+ "boundary": "client_hook",
+ "session": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
+ "stream": "a39a255c7ba239348",
+ "tool": "auditor-s1-tool",
+ "tool_name": "mcp__docs__read",
+ "cwd": "/Users/dev/scenario",
+ "protocol": "mcp",
+ "server": {
+ "name": "docs",
+ "source": "project"
},
- "coverage": "complete",
- "changed_files": 1,
- "changes": [
- {
- "path": "notes.txt",
- "operation": "delete",
- "before": {
- "present": true,
- "bytes": 10,
- "sha256": "54d048ab0699bcf1fff66455775c49a538241b098d9aad8b9ae35eba96528201"
- },
- "after": {
- "present": false,
- "bytes": null
- },
- "diff": "available",
- "attribution": "only_this_window",
- "windows": [
- "tidier-s1-tool"
- ],
- "additions": 0,
- "deletions": 1,
- "hunks": [
- {
- "old_start": 1,
- "old_lines": 1,
- "new_start": 0,
- "new_lines": 0,
- "lines": [
- "-old notes"
- ]
- }
- ]
- }
- ]
+ "time": "2026-01-01T00:00:10.900Z",
+ "duration_ms": 280,
+ "outcome": "returned",
+ "arguments": {
+ "state": "size_only",
+ "bytes": 24,
+ "sha256": "5cb51817b71b5f57c1159e9cedfe7a555f4e89d469548438ee4b25324cac71a0"
+ },
+ "result": {
+ "state": "size_only",
+ "bytes": 37,
+ "sha256": "1dc1201945582b1373186efab341595fbbb3ab3316889ea91ea5c2968456e164"
+ }
}
]
}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/execution-20260101T000000.000000000Z-000002.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/execution-20260101T000000.000000000Z-000002.sd
new file mode 100644
index 0000000000..c5bfbb6986
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/execution-20260101T000000.000000000Z-000002.sd
@@ -0,0 +1,6 @@
+{"h":1,"schema":"sd/1","seq":2,"at":"2026-01-01T00:00:00Z","kind":"execution","adapter":"mock/0.2.0","dialect":"mock/1","src":"-Users-dev-scenario/720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/main.execution","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"main"}
+{"ord":1,"off":0,"sha":"f325e3c19038","bytes":796,"id":"s2-tool/client_hook","tool":"s2-tool","time":"2026-01-01T00:00:01.500Z","parts":[{"k":"data","data":{"schema":"execution/1","id":"s2-tool/client_hook","observed_by":"asz-plugin","boundary":"client_hook","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"main","tool":"s2-tool","tool_name":"mcp__status__lookup","cwd":"/Users/dev/scenario","protocol":"mcp","server":{"name":"status","source":"user"},"time":"2026-01-01T00:00:01.5 [...]
+{"ord":2,"off":797,"sha":"339489085043","bytes":798,"id":"s2-tool/client_hook","tool":"s2-tool","time":"2026-01-01T00:00:01.500Z","parts":[{"k":"data","data":{"schema":"execution/1","id":"s2-tool/client_hook","observed_by":"asz-plugin","boundary":"client_hook","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"main","tool":"s2-tool","tool_name":"mcp__status__lookup","cwd":"/Users/dev/scenario","protocol":"mcp","server":{"name":"status","source":"user"},"time":"2026-01-01T00:00:01 [...]
+{"ord":3,"off":1596,"sha":"79176d5ac68f","bytes":680,"id":"s3-tool/client_hook","tool":"s3-tool","time":"2026-01-01T00:00:04.600Z","parts":[{"k":"data","data":{"schema":"execution/1","id":"s3-tool/client_hook","observed_by":"asz-plugin","boundary":"client_hook","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"main","tool":"s3-tool","tool_name":"mcp__status__lookup","cwd":"/Users/dev/scenario","protocol":"mcp","server":{"name":"status","source":"user"},"time":"2026-01-01T00:00:0 [...]
+{"ord":4,"off":2277,"sha":"f57fa920bd5e","bytes":799,"id":"s4-tool/client_hook","tool":"s4-tool","time":"2026-01-01T00:00:06.200Z","parts":[{"k":"data","data":{"schema":"execution/1","id":"s4-tool/client_hook","observed_by":"asz-plugin","boundary":"client_hook","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"main","tool":"s4-tool","tool_name":"mcp__status__lookup","cwd":"/Users/dev/scenario","protocol":"mcp","server":{"name":"status","source":"user"},"time":"2026-01-01T00:00:0 [...]
+{"t":"end","records":4,"digest":"27ac70a9258c2d27f717c25420b0a9e9c9bd1359bd697e2b88c67980c1334c5f"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/execution-20260101T000000.000000000Z-000004.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/execution-20260101T000000.000000000Z-000004.sd
new file mode 100644
index 0000000000..69ff6d0ff1
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/execution-20260101T000000.000000000Z-000004.sd
@@ -0,0 +1,3 @@
+{"h":1,"schema":"sd/1","seq":4,"at":"2026-01-01T00:00:00Z","kind":"execution","adapter":"mock/0.2.0","dialect":"mock/1","src":"-Users-dev-scenario/720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348.execution","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"a39a255c7ba239348"}
+{"ord":1,"off":0,"sha":"a6cf06b3c1ee","bytes":838,"id":"auditor-s1-tool/client_hook","tool":"auditor-s1-tool","time":"2026-01-01T00:00:10.900Z","parts":[{"k":"data","data":{"schema":"execution/1","id":"auditor-s1-tool/client_hook","observed_by":"asz-plugin","boundary":"client_hook","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"a39a255c7ba239348","tool":"auditor-s1-tool","tool_name":"mcp__docs__read","cwd":"/Users/dev/scenario","protocol":"mcp","server":{"name":"docs","source [...]
+{"t":"end","records":1,"digest":"d4caa802215b055ab13392b649bebde41fe27c89158192481ed17c9949ddbb23"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/meta-20260101T000000.000000000Z-000005.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/meta-20260101T000000.000000000Z-000005.sd
new file mode 100644
index 0000000000..f6ddef79c9
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/meta-20260101T000000.000000000Z-000005.sd
@@ -0,0 +1,3 @@
+{"h":1,"schema":"sd/1","seq":5,"at":"2026-01-01T00:00:00Z","kind":"agent_meta","adapter":"mock/0.2.0","dialect":"mock/1","src":"-Users-dev-scenario/720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348.meta","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"a39a255c7ba239348"}
+{"ord":1,"off":0,"sha":"7c2a0613d0e3","bytes":253,"child":"a39a255c7ba239348","label":"auditor","from":"runtime","parts":[{"k":"data","data":{"agentType":"general-purpose","description":"auditor","spawnDepth":1,"toolUseId":"s6-tool"},"state":"available","bytes":92}]}
+{"t":"end","records":1,"digest":"ef8a5275d50244e9612dad7b431bce18287017914a6ed87dbb40097b0af20c32"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/r000001-0465bcdcdb72.sf b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/r000001-0465bcdcdb72.sf
new file mode 100644
index 0000000000..40cb36859d
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/r000001-0465bcdcdb72.sf
@@ -0,0 +1,43 @@
+{"t":"header","schema":"sf/1","conversation":"720d2f9e-dca7-49be-8c5e-89b667b39a01","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","round":1,"from_seq":1,"through_seq":5,"input_digest":"d89701ec46c206724a1881d3571defaab92ce201520b19f84362aa85c9f47627","parser":"v1","policy":"v1+idle=10m0s","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:13.9Z","session_from_time":"2026-01-01T00:00:00Z","session_through_time":"2026-01-01T00:00:13.9Z","title":"calls to MCP servers","t [...]
+{"t":"node","id":"ack/1/16","revision":1,"kind":"agent.launch_ack","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":16}}
+{"t":"node","id":"call/auditor-s1-call","revision":1,"kind":"llm.call","parent":"run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle","stream":"a39a255c7ba239348","ref":{"seq":3,"row":2},"refs":[{"seq":3,"row":2},{"seq":3,"row":3}],"attrs":{"fragments":2,"usage":"observed_replayable","usage_at":{"row":3,"seq":3},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/auditor-s2-call","revision":1,"kind":"llm.call","parent":"run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle","stream":"a39a255c7ba239348","ref":{"seq":3,"row":5},"refs":[{"seq":3,"row":5}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":5,"seq":3},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s2-call","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","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},{"seq":1,"row":7}],"attrs":{"fragments":2,"usage":"observed_replayable","usage_at":{"row":7,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s4-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":9},"refs":[{"seq":1,"row":9},{"seq":1,"row":10}],"attrs":{"fragments":2,"usage":"observed_replayable","usage_at":{"row":10,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s5-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":12},"refs":[{"seq":1,"row":12},{"seq":1,"row":13}],"attrs":{"fragments":2,"usage":"observed_replayable","usage_at":{"row":13,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s6-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":15},"refs":[{"seq":1,"row":15}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":15,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"call/s7-call","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s6-cycle-notification","stream":"main","ref":{"seq":1,"row":18},"refs":[{"seq":1,"row":18}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":18,"seq":1},"usage_from":"last_fragment_in_line_order"}}
+{"t":"node","id":"epoch/a39a255c7ba239348/0","revision":1,"kind":"epoch","parent":"stream/a39a255c7ba239348","stream":"a39a255c7ba239348","ref":{"seq":3,"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":18,"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/12:0","revision":1,"kind":"message.assistant","parent":"call/s5-call","stream":"main","ref":{"seq":1,"row":12,"block":0}}
+{"t":"node","id":"msg/1/18:0","revision":1,"kind":"message.assistant","parent":"call/s7-call","stream":"main","ref":{"seq":1,"row":18,"block":0}}
+{"t":"node","id":"msg/1/3:0","revision":1,"kind":"message.assistant","parent":"call/s2-call","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","stream":"main","ref":{"seq":1,"row":6,"block":0}}
+{"t":"node","id":"msg/1/9:0","revision":1,"kind":"message.assistant","parent":"call/s4-call","stream":"main","ref":{"seq":1,"row":9,"block":0}}
+{"t":"node","id":"msg/3/2:0","revision":1,"kind":"message.assistant","parent":"call/auditor-s1-call","stream":"a39a255c7ba239348","ref":{"seq":3,"row":2,"block":0}}
+{"t":"node","id":"msg/3/5:0","revision":1,"kind":"message.assistant","parent":"call/auditor-s2-call","stream":"a39a255c7ba239348","ref":{"seq":3,"row":5,"block":0}}
+{"t":"node","id":"notify/1/17","revision":1,"kind":"runtime.notification","parent":"run/talk_main_s1-cycle/s6-cycle-notification","stream":"main","ref":{"seq":1,"row":17}}
+{"t":"node","id":"output/a39a255c7ba239348","revision":1,"kind":"agent.output","parent":"run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle","stream":"a39a255c7ba239348","ref":{"seq":3,"row":5},"refs":[{"seq":3,"row":5}],"attrs":{"returned_value":"unavailable"}}
+{"t":"node","id":"run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle","revision":1,"kind":"run","parent":"talk/a39a255c7ba239348","stream":"a39a255c7ba239348","ref":{"seq":3,"row":1},"attrs":{"trigger":"external"}}
+{"t":"node","id":"run/talk_main_s1-cycle/s1-cycle","revision":1,"kind":"run","parent":"talk/main/s1-cycle","stream":"main","ref":{"seq":1,"row":2},"attrs":{"trigger":"external"}}
+{"t":"node","id":"run/talk_main_s1-cycle/s6-cycle-notification","revision":1,"kind":"run","parent":"talk/main/s1-cycle","stream":"main","ref":{"seq":1,"row":17},"attrs":{"trigger":"notification"}}
+{"t":"node","id":"segment/at_1_2","revision":1,"kind":"segment","parent":"session/720d2f9e-dca7-49be-8c5e-89b667b39a01","ref":{"seq":1,"row":2},"attrs":{"committable":false,"gates_unmet":["activity_boundary","lateness_watermark"],"state":"open","talks":1}}
+{"t":"node","id":"session/720d2f9e-dca7-49be-8c5e-89b667b39a01","revision":1,"kind":"session","attrs":{"conversation":"720d2f9e-dca7-49be-8c5e-89b667b39a01","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:13.9Z","title":"calls to MCP servers","title_from":"observed_replayable"}}
+{"t":"node","id":"stream/a39a255c7ba239348","revision":1,"kind":"stream","parent":"session/720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"a39a255c7ba239348","ref":{"seq":3,"row":1},"attrs":{"label":"auditor","records":6,"role":"child"}}
+{"t":"node","id":"stream/main","revision":1,"kind":"stream","parent":"session/720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":18,"role":"main"}}
+{"t":"node","id":"talk/a39a255c7ba239348","revision":1,"kind":"talk","parent":"epoch/a39a255c7ba239348/0","stream":"a39a255c7ba239348","ref":{"seq":3,"row":1},"attrs":{"loops":1,"runs":1,"trigger":"unknown"}}
+{"t":"node","id":"talk/main/s1-cycle","revision":1,"kind":"talk","parent":"epoch/main/0","stream":"main","ref":{"seq":1,"row":2},"attrs":{"loops":2,"runs":2,"trigger":"external"}}
+{"t":"node","id":"tool/auditor-s1-tool","revision":1,"kind":"tool","parent":"call/auditor-s1-call","stream":"a39a255c7ba239348","ref":{"seq":3,"row":3,"block":0},"refs":[{"seq":3,"row":3,"block":0},{"seq":3,"row":4,"block":0}],"attrs":{"mcp_server":"docs","mcp_tool":"read","name":"mcp__docs__read","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/s2-tool","revision":1,"kind":"tool","parent":"call/s2-call","stream":"main","ref":{"seq":1,"row":4,"block":0},"refs":[{"seq":1,"row":4,"block":0},{"seq":1,"row":5,"block":0}],"attrs":{"mcp_server":"status","mcp_tool":"lookup","name":"mcp__status__lookup","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/s3-tool","revision":1,"kind":"tool","parent":"call/s3-call","stream":"main","ref":{"seq":1,"row":7,"block":0},"refs":[{"seq":1,"row":7,"block":0},{"seq":1,"row":8,"block":0}],"attrs":{"mcp_server":"status","mcp_tool":"lookup","name":"mcp__status__lookup","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/s4-tool","revision":1,"kind":"tool","parent":"call/s4-call","stream":"main","ref":{"seq":1,"row":10,"block":0},"refs":[{"seq":1,"row":10,"block":0},{"seq":1,"row":11,"block":0}],"attrs":{"mcp_server":"status","mcp_tool":"lookup","name":"mcp__status__lookup","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/s5-tool","revision":1,"kind":"tool","parent":"call/s5-call","stream":"main","ref":{"seq":1,"row":13,"block":0},"refs":[{"seq":1,"row":13,"block":0},{"seq":1,"row":14,"block":0}],"attrs":{"name":"mcp__odd__name__twice","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"node","id":"tool/s6-tool","revision":1,"kind":"agent.call","parent":"call/s6-call","stream":"main","ref":{"seq":1,"row":15,"block":0},"refs":[{"seq":1,"row":15,"block":0},{"seq":1,"row":16,"block":0}],"attrs":{"name":"Agent","result":"available","result_join":"exact_unique","timing":"unavailable"}}
+{"t":"relation","id":"rel/ends_with/stream_a39a255c7ba239348/output_a39a255c7ba239348","revision":1,"type":"ends_with","from":"stream/a39a255c7ba239348","to":"output/a39a255c7ba239348","quality":"exact_unique","via":"the last response in the child stream, and what it returned","evidence":[{"seq":3,"row":5}]}
+{"t":"relation","id":"rel/in_segment/talk_a39a255c7ba239348/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/a39a255c7ba239348","to":"segment/at_1_2","quality":"strong_inference","via":"inside the window of the talk that delegated it","evidence":[{"seq":3,"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/reports/notify_1_17/stream_a39a255c7ba239348","revision":1,"type":"reports","from":"notify/1/17","to":"stream/a39a255c7ba239348","quality":"exact_unique","via":"task id on the notification","evidence":[{"seq":1,"row":17}]}
+{"t":"relation","id":"rel/starts/tool_s6-tool/stream_a39a255c7ba239348","revision":1,"type":"starts","from":"tool/s6-tool","to":"stream/a39a255c7ba239348","quality":"exact_unique","via":"parent tool result","evidence":[{"seq":1,"row":16}]}
+{"t":"commit","digest":"0465bcdcdb729b5e5022b97e10f03c2589937c735debe88956af173df7f612d1","counts":{"nodes":36,"relations":5,"unresolved":0}}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/transcript-20260101T000000.000000000Z-000001.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/transcript-20260101T000000.000000000Z-000001.sd
new file mode 100644
index 0000000000..a951ba4ba9
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/transcript-20260101T000000.000000000Z-000001.sd
@@ -0,0 +1,20 @@
+{"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/720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/main","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"main"}
+{"ord":1,"off":0,"sha":"bc787c13c908","bytes":198,"label":"calls to MCP servers","from":"runtime","parts":[{"k":"data","data":{"aiTitle":"calls to MCP servers","type":"ai-title"},"state":"available","bytes":52}]}
+{"ord":2,"off":199,"sha":"f6c5c7765053","bytes":290,"id":"s1-input","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:00.000Z","trigger":"external","flags":["external_input"],"parts":[{"k":"text","text":"check the checkout service and then the payment service","state":"available","bytes":55}]}
+{"ord":3,"off":490,"sha":"622dd1f2c490","bytes":331,"id":"s2-call-f1","parent":"s1-input","call":"s2-call","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":"Asking the status server about checkout.","state":"available","bytes":40}]}
+{"ord":4,"off":822,"sha":"6f7396cf846b","bytes":419,"id":"s2-call-f2","parent":"s2-call-f1","call":"s2-call","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":{"service":"checkout"},"id":"s2-tool","name":"mcp__status__lookup","server":"status","server_tool":"lookup","state":"available","bytes":22}]}
+{"ord":5,"off":1242,"sha":"83a81b8ba86a","bytes":305,"id":"s2-result","parent":"s2-call-f2","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:01.500Z","parts":[{"k":"result","text":"checkout: 3 of 3 ready","data":{"stderr":"","stdout":"checkout: 3 of 3 ready"},"of":"s2-tool","state":"available","bytes":22}]}
+{"ord":6,"off":1548,"sha":"e9e71c54c9e1","bytes":311,"id":"s3-call-f1","parent":"s2-result","call":"s3-call","from":"agent","time":"2026-01-01T00:00:02.500Z","usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"And about payment.","state":"available","bytes":18}]}
+{"ord":7,"off":1860,"sha":"5a2253dd1f84","bytes":419,"id":"s3-call-f2","parent":"s3-call-f1","call":"s3-call","from":"agent","time":"2026-01-01T00:00:02.600Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"call","data":{"service":"payment"},"id":"s3-tool","name":"mcp__status__lookup","server":"status","server_tool":"lookup","state":"available","bytes":21}]}
+{"ord":8,"off":2280,"sha":"f2a6b6ebdcd8","bytes":357,"id":"s3-result","parent":"s3-call-f2","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:04.600Z","parts":[{"k":"result","text":"the status server could not reach payment","data":{"stderr":"","stdout":"the status server could not reach payment"},"of":"s3-tool","failed":true,"state":"available","bytes":41}]}
+{"ord":9,"off":2638,"sha":"357277cc70b2","bytes":344,"id":"s4-call-f1","parent":"s3-result","call":"s4-call","from":"agent","time":"2026-01-01T00:00:05.600Z","usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"Asking again, through a hook that names the region.","state":"available","bytes":51}]}
+{"ord":10,"off":2983,"sha":"beccee0b37c1","bytes":420,"id":"s4-call-f2","parent":"s4-call-f1","call":"s4-call","from":"agent","time":"2026-01-01T00:00:05.700Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"call","data":{"service":"payment"},"id":"s4-tool","name":"mcp__status__lookup","server":"status","server_tool":"lookup","state":"available","bytes":21}]}
+{"ord":11,"off":3404,"sha":"07889e9b69a1","bytes":304,"id":"s4-result","parent":"s4-call-f2","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:06.200Z","parts":[{"k":"result","text":"payment: 1 of 3 ready","data":{"stderr":"","stdout":"payment: 1 of 3 ready"},"of":"s4-tool","state":"available","bytes":21}]}
+{"ord":12,"off":3709,"sha":"626b9594a017","bytes":337,"id":"s5-call-f1","parent":"s4-result","call":"s5-call","from":"agent","time":"2026-01-01T00:00:07.200Z","usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"A tool whose name the runtime cannot split.","state":"available","bytes":43}]}
+{"ord":13,"off":4047,"sha":"b5f77b510a28","bytes":361,"id":"s5-call-f2","parent":"s5-call-f1","call":"s5-call","from":"agent","time":"2026-01-01T00:00:07.300Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"call","data":{},"id":"s5-tool","name":"mcp__odd__name__twice","state":"available","bytes":2}]}
+{"ord":14,"off":4409,"sha":"8e6e89a4ac1a","bytes":265,"id":"s5-result","parent":"s5-call-f2","run":"s1-cycle","from":"external","time":"2026-01-01T00:00:07.400Z","parts":[{"k":"result","text":"ok","data":{"stderr":"","stdout":"ok"},"of":"s5-tool","state":"available","bytes":2}]}
+{"ord":15,"off":4675,"sha":"3680825e77a7","bytes":402,"id":"s6-call-f1","parent":"s5-result","call":"s6-call","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":"call","data":{"description":"auditor","prompt":"read the release notes"},"id":"s6-tool","name":"Agent","state":"available","bytes":59}]}
+{"ord":16,"off":5078,"sha":"8153ed6c39fb","bytes":422,"id":"s6-ack","parent":"s6-call-f1","run":"s1-cycle","child":"a39a255c7ba239348","from":"external","time":"2026-01-01T00:00:08.500Z","flags":["launch_ack"],"parts":[{"k":"result","text":"launched","data":{"agentId":"a39a255c7ba239348","description":"auditor","isAsync":true,"prompt":"read the release notes","status":"async_launched"},"of":"s6-tool","state":"available","bytes":8}]}
+{"ord":17,"off":5501,"sha":"ca5e9cc50542","bytes":435,"id":"s6-notice","parent":"s6-ack","run":"s6-cycle-notification","tool":"s6-tool","child":"a39a255c7ba239348","from":"external","time":"2026-01-01T00:00:12.900Z","trigger":"notification","parts":[{"k":"text","text":"<task-notification>\n<task-id>a39a255c7ba239348</task-id>\n<tool-use-id>s6-tool</tool-use-id>\n<status>completed</status>\n</task-notification>","state":"available","bytes":139}]}
+{"ord":18,"off":5937,"sha":"584ff751658b","bytes":354,"id":"s7-call-f1","parent":"s6-notice","call":"s7-call","from":"agent","time":"2026-01-01T00:00:13.900Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"Checkout is healthy and payment is not.","state":"available","bytes":39}]}
+{"t":"end","records":18,"digest":"5ae7cf3849b2409b0603df305c1a5e1bdc055ec782e0dfddfe5fe1fb324e6a17"}
diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/transcript-20260101T000000.000000000Z-000003.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/transcript-20260101T000000.000000000Z-000003.sd
new file mode 100644
index 0000000000..cc9271f090
--- /dev/null
+++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/transcript-20260101T000000.000000000Z-000003.sd
@@ -0,0 +1,7 @@
+{"h":1,"schema":"sd/1","seq":3,"at":"2026-01-01T00:00:00Z","kind":"transcript","adapter":"mock/0.2.0","dialect":"mock/1","src":"-Users-dev-scenario/720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348","session":"720d2f9e-dca7-49be-8c5e-89b667b39a01","stream":"a39a255c7ba239348"}
+{"ord":1,"off":0,"sha":"83216466d38a","bytes":238,"id":"a39a255c7ba239348-prompt","run":"a39a255c7ba239348-cycle","from":"external","time":"2026-01-01T00:00:09.500Z","parts":[{"k":"text","text":"read the release notes","state":"available","bytes":22}]}
+{"ord":2,"off":239,"sha":"adff5e05072c","bytes":336,"id":"auditor-s1-call-f1","parent":"a39a255c7ba239348-prompt","call":"auditor-s1-call","from":"agent","time":"2026-01-01T00:00:10.500Z","usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"Reading them.","state":"available","bytes":13}]}
+{"ord":3,"off":576,"sha":"f401f63bb2a5","bytes":445,"id":"auditor-s1-call-f2","parent":"auditor-s1-call-f1","call":"auditor-s1-call","from":"agent","time":"2026-01-01T00:00:10.600Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"call","data":{"page":"release-notes"},"id":"auditor-s1-tool","name":"mcp__docs__read","server":"docs","server_tool":"read","state":"available","bytes":24}]}
+{"ord":4,"off":1022,"sha":"0417ea84e81b","bytes":320,"id":"auditor-s1-result","parent":"auditor-s1-call-f2","run":"a39a255c7ba239348-cycle","from":"external","time":"2026-01-01T00:00:10.900Z","parts":[{"k":"result","text":"0.6.0: ...","data":{"stderr":"","stdout":"0.6.0: ..."},"of":"auditor-s1-tool","state":"available","bytes":10}]}
+{"ord":5,"off":1343,"sha":"0dd7507aba33","bytes":342,"id":"auditor-s2-call-f1","parent":"auditor-s1-result","call":"auditor-s2-call","from":"agent","time":"2026-01-01T00:00:11.900Z","flags":["finished"],"usage":{"in":2,"out":40,"cache_read":900,"cache_write":100},"model":"claude-opus-5","parts":[{"k":"text","text":"Read.","state":"available","bytes":5}]}
+{"t":"end","records":5,"digest":"ce97f48a0339c009d2f11afb280a87206db0fb813222061ca731466636dbf6ed"}
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
index 577d9458b9..e985dec958 100644
--- 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
@@ -7,7 +7,7 @@
],
"head": {
"round": 1,
- "digest": "0a33a0c2d269a8b66804075c0eb46e0546187e9a9f45d8eca10486bbcb040699"
+ "digest": "76f9606aacf31460a8b0446391c9c94b94ccb458772356c595629899cd6efadd"
},
"parser": "v1",
"policy": "v1+idle=10m0s",
@@ -48,11 +48,11 @@
"rounds": [
{
"round": 1,
- "digest": "0a33a0c2d269a8b66804075c0eb46e0546187e9a9f45d8eca10486bbcb040699",
+ "digest": "76f9606aacf31460a8b0446391c9c94b94ccb458772356c595629899cd6efadd",
"previous": null,
"from_seq": 1,
"through_seq": 2,
- "input_digest": "950a569a9d180b4b09b44f954d6cbfe8e1a85a717f285edd7f4f437440bb2778",
+ "input_digest": "fa6cbbf0240ea542877c410da5e6c494904cc8cce39621304e1712b01d97d9f2",
"from_time": 1767225600000,
"through_time": 1767225603000,
"verified": true
@@ -82,13 +82,13 @@
"stream": null,
"run": null,
"lines": 6,
- "bytes": 12454,
- "digest": "bb4e2514f2da98c009ce6fcf716c82b091582929800c98c76de6ec18b0434ec4",
+ "bytes": 12926,
+ "digest": "7be8ce0d6c7396c55930498a3f9fd7d6771b2f9004baf62ce0e6277db23161ad",
"from_time": null,
"through_time": null
},
{
- "file": "_conversations/f41b7a16-49fa-44ea-8ef6-bb39db57ab1d/rounds/r000001-0a33a0c2d269.sf",
+ "file": "_conversations/f41b7a16-49fa-44ea-8ef6-bb39db57ab1d/rounds/r000001-76f9606aacf3.sf",
"format": "sf",
"kind": "round",
"seq": null,
@@ -96,8 +96,8 @@
"stream": null,
"run": null,
"lines": 16,
- "bytes": 3840,
- "digest": "078b6f3f50c11dbbc522d8246fd12c561276a95e66ce7102eb9bead4ce26ae23",
+ "bytes": 4081,
+ "digest": "14fec4e63a8c6bde9b33810b556c5d014e8c9dcd6c00634ab2dd8662788210fd",
"from_time": 1767225600000,
"through_time": 1767225603000
}
@@ -392,5 +392,6 @@
}
],
"unresolved": [],
- "workspace_changes": []
+ "workspace_changes": [],
+ "tool_executions": []
}
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
index 3e7280dcd0..f1e0b9ba56 100644
--- 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
@@ -1,6 +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":"<system-reminder>\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. [...]
-{"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\":\"Readin [...]
+{"ord":1,"off":0,"sha":"66feb4cf894f","bytes":373,"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":"66feb4cf894f3c2745f616f86bebc6760871b574254ece238dea11774784a768","bytes":373,"depth":0,"model":"claude-opus-5","call":"s2-call-f7d240c7da38","request":"s2-req-f7d240c7da38","segments":[{"lit":"{\"content\":[{\"text\":\"Readin [...]
{"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"," [...]
-{"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 [...]
-{"t":"end","records":4,"digest":"d8bbc389639d63bea5e577738050699841ea1c8550abadd12fdbc7d5b81e1008"}
+{"ord":1,"off":0,"sha":"9ca8e894ff1a","bytes":389,"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":"9ca8e894ff1a50d2826e77ea4575459066e3b26ef5e7e23df8c96c4a2e6982bc","bytes":389,"depth":0,"model":"claude-opus-5","call":"s4-call-f7d240c7da38","request":"s4-req-f7d240c7da38","segments":[{"lit":"{\"content\":[{\"text\":\"It is [...]
+{"t":"end","records":4,"digest":"c39c9aebe5317f6d7a61741a8a5ac73947e5d21710e5b2bd8eed0689e5c38922"}
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-76f9606aacf3.sf
similarity index 79%
rename from oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/r000001-0a33a0c2d269.sf
rename to oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies-errors/r000001-76f9606aacf3.sf
index f977786c3b..ab3557256c 100644
--- 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-76f9606aacf3.sf
@@ -1,7 +1,7 @@
-{"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", [...]
-{"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":"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":"fa6cbbf0240ea542877c410da5e6c494904cc8cce39621304e1712b01d97d9f2","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", [...]
+{"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,"provider_bodies":[{"role":"request","ref":{"seq":2,"row":1}},{"role":"response","ref":{"seq":2,"row":2}}],"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":"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,"provider_bodies":[{"role":"request","ref":{"seq":2,"row":3}},{"role":"response","ref":{"seq":2,"row":4}}],"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}}
@@ -9,8 +9,8 @@
{"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":"session/f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","revision":1,"kind":"session","attrs":{"conversation":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","from_time":"2026-01-01T00:00:00Z","provider_bodies_landed":4,"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}}
+{"t":"commit","digest":"76f9606aacf31460a8b0446391c9c94b94ccb458772356c595629899cd6efadd","counts":{"nodes":13,"relations":1,"unresolved":0}}
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
index c4d468eeb4..33aefd54d6 100644
--- 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
@@ -7,7 +7,7 @@
],
"head": {
"round": 1,
- "digest": "ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920"
+ "digest": "e292bd0f6de64aa79b2c5c03d04d6affed8efd317d2cb31fee346fc06fa394aa"
},
"parser": "v1",
"policy": "v1+idle=10m0s",
@@ -58,11 +58,11 @@
"rounds": [
{
"round": 1,
- "digest": "ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920",
+ "digest": "e292bd0f6de64aa79b2c5c03d04d6affed8efd317d2cb31fee346fc06fa394aa",
"previous": null,
"from_seq": 1,
"through_seq": 4,
- "input_digest": "bb774e3f497f8618d3e944070b5d074c0ab6589f0c089db4c2371a99dc4528e3",
+ "input_digest": "8a4c1af7839a7c893ecc33e9c0631639b3e17e16eb41441495d7095762d42160",
"from_time": 1767225600000,
"through_time": 1767225608400,
"verified": true
@@ -120,13 +120,13 @@
"stream": null,
"run": null,
"lines": 16,
- "bytes": 27874,
- "digest": "a8cfc9e0f6e06ab3ce944fd1db40468d6502e7e1d7549c01292f9326a83fd331",
+ "bytes": 29526,
+ "digest": "9b324f02fcc81de83faad5667ee9e6f17a9dc194f5a01666576d01caed6ab8df",
"from_time": null,
"through_time": null
},
{
- "file": "_conversations/6b7a6063-8714-4f6b-87cd-6c2da3a5094d/rounds/r000001-ff8eaba03b03.sf",
+ "file": "_conversations/6b7a6063-8714-4f6b-87cd-6c2da3a5094d/rounds/r000001-e292bd0f6de6.sf",
"format": "sf",
"kind": "round",
"seq": null,
@@ -134,8 +134,8 @@
"stream": null,
"run": null,
"lines": 51,
- "bytes": 12009,
- "digest": "2a0088c80cdf2abd2d9ff4517ccba7ec070035f904019d25f1c24eeec8346f10",
+ "bytes": 12791,
+ "digest": "dddf2a17aac5687ff39924e86293168553a1d04810b1a981b667bc2f6732c59d",
"from_time": 1767225600000,
"through_time": 1767225608400
}
@@ -685,186 +685,6 @@
}
]
},
- {
- "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",
@@ -1129,6 +949,186 @@
"via": "inside the window of the talk that delegated it"
}
]
+ },
+ {
+ "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"
+ }
+ ]
}
],
"loose": [
@@ -1187,30 +1187,44 @@
],
"relations": [
{
- "id": "rel/ends_with/stream_a02ba01b82410e9e3/output_a02ba01b82410e9e3",
- "type": "ends_with",
- "from": "stream/a02ba01b82410e9e3",
- "to": "output/a02ba01b82410e9e3",
+ "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": "the last response in the child stream, and what it returned",
+ "via": "activity window",
"evidence": [
{
- "seq": 2,
- "row": 5
+ "seq": 1,
+ "row": 2
}
]
},
{
- "id": "rel/follows/epoch_main_s7-boundary/epoch_main_0",
- "type": "follows",
- "from": "epoch/main/s7-boundary",
- "to": "epoch/main/0",
+ "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": "explicit context reset",
+ "via": "activity window",
"evidence": [
{
"seq": 1,
- "row": 11
+ "row": 7
+ }
+ ]
+ },
+ {
+ "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
}
]
},
@@ -1229,40 +1243,40 @@
]
},
{
- "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",
+ "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": "activity window",
+ "via": "explicit context reset",
"evidence": [
{
"seq": 1,
- "row": 2
+ "row": 11
}
]
},
{
- "id": "rel/in_segment/talk_main_s4-cycle/segment_at_1_2",
+ "id": "rel/in_segment/talk_main_s7-cycle-compact/segment_at_1_2",
"type": "in_segment",
- "from": "talk/main/s4-cycle",
+ "from": "talk/main/s7-cycle-compact",
"to": "segment/at_1_2",
"quality": "exact_unique",
"via": "activity window",
"evidence": [
{
"seq": 1,
- "row": 7
+ "row": 12
}
]
},
{
- "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",
+ "id": "rel/summarizes/summary_1_12/boundary_1_11",
+ "type": "summarizes",
+ "from": "summary/1/12",
+ "to": "boundary/1/11",
"quality": "exact_unique",
- "via": "activity window",
+ "via": "containment parent",
"evidence": [
{
"seq": 1,
@@ -1285,34 +1299,21 @@
]
},
{
- "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",
+ "id": "rel/ends_with/stream_a02ba01b82410e9e3/output_a02ba01b82410e9e3",
+ "type": "ends_with",
+ "from": "stream/a02ba01b82410e9e3",
+ "to": "output/a02ba01b82410e9e3",
"quality": "exact_unique",
- "via": "containment parent",
+ "via": "the last response in the child stream, and what it returned",
"evidence": [
{
- "seq": 1,
- "row": 12
+ "seq": 2,
+ "row": 5
}
... 1796 lines suppressed ...