diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md index 063b1127c0b6..1963a424811c 100644 --- a/docs/en/changes/changes.md +++ b/docs/en/changes/changes.md @@ -12,7 +12,7 @@ * Add BanyanDB trace tail sampling metrics to the BanyanDB self-observability layer, in a new `otel-rules/banyandb/banyandb-trace-sampling.yaml` rule file. It covers the whole `banyandb_trace_pipeline_*` / `banyandb_trace_tst_pipeline_*` catalog a sampler plugin chain emits — pipeline reconciliation, per-plugin `Decide` execution rate and latency, chain batching, the trace-level evaluated / retained / dropped / immature outcomes, every fail-open guard and bounded-retention counter, drop-set capacity and finalization state, the plugin telemetry-host safety bounds, and the first-party `sw-trace-sampler` / `zipkin-trace-sampler` decision and row metrics. The plugin chain is optional, and the metrics follow it: on a cluster with no sampler configured the wire families are never registered, so every metric here stays absent rather than reading zero. Modeled at Service scope with `group` kept as a metric label rather than at Endpoint scope, so one cluster-wide page can render per-group series and cluster totals alike — OAP does no cross-scope rollup, so an Endpoint-scope metric could not have been aggregated back up to the cluster. * Fix a second `CounterWindow` key collision in the v2 MAL engine, this time ACROSS rules. `rate()` / `increase()` / `irate()` resolve their lower bound from a process-wide window keyed on the counter's own name plus its post-`.sum(...)` label set, with nothing identifying the rule doing the evaluation. Two rules that read one wire family, tell their streams apart with `tagEqual(...)`, and then `.sum(...)` away the label they filtered on therefore collapse onto one window slot and difference against each other's values. The queue is ordered by (timestamp, value), so the smaller counter wins the lower-bound lookup and still reads correctly while its partner is inflated by the gap between them — which is why this went unnoticed. A collision needs the discriminating label to be DROPPED by the `.sum(...)`: where it survives, the rules' label values differ and the window keeps them apart. Auditing the shipped rules on that basis gives 10 colliding keys over ~25 rules — `meter_activemq_cluster_gc_parallel_young_collection_count` reported ~9000/min of young-gen collections from a completely idle broker (differencing against the old-gen counter); MySQL `commands_*` / `tps` rate against each other; so do the GenAI gateway input/output token rates, four Envoy `cluster_*` counters, APISIX matched/unmatched instance bandwidth, and BanyanDB's own `network_recv` / `network_sent`, which drop the `kind` label that separates bytes-received from bytes-sent on one interface. Measured against two live scrapes of the demo cluster's FODC proxy, that last pair was wrong on every interface: `network_sent` read a flat 0 B/s and `network_recv` read large negative values (down to -778 MB/s) from differencing against the sent counter, where both now match the byte delta exactly. No rule changes were needed for any of these -- each rule already reduces to the labels it should; only the window key was wrong. The window is now keyed by (owning rule, counter name, labels). This is the complement of the within-rule collision fixed earlier by keying on the counter's own name: neither name alone is sufficient, because the two collisions are independent. `RunningContext.metricName` — written on every rule evaluation and read by nobody since that earlier fix — is what supplies the rule identity, so no code generation or MAL syntax changes. Note the whole-rule-set comparison suite could not have caught this: it resets the shared window before every rule, the one condition under which the collision cannot appear. * Fix `meter_rabbitmq_node_outgoing_messages_total` double-counting one of its terms. The rule summed six delivery-rate terms but `rabbitmq_global_messages_delivered_get_auto_ack_total` appeared twice, so auto-ack `basic.get` deliveries were counted once more than the other four delivery paths and the reported outgoing rate ran high whenever polling consumers were in use. The duplicate term is removed, leaving the five distinct families (redelivered, consume auto/manual ack, get auto/manual ack). -* Add AI agent conversations landed by the AI Sessionizer: the `AI_AGENT` layer, the bundled `lal/ai-agent.yaml` rule with the `ConversationFile` output builder that verifies and stores Session Data and Session Flow files, the `ai_agent_session_data` and `ai_agent_session_flow` models in a new BanyanDB group `recordsAIAgent`, the `ai-agent-conversation` module that folds a conversation into one `asz.view` document, the `listConversations` GraphQL query (with optional `conversation` and `title` conditions), and two streamed HTTP routes beside `/graphql`: `GET /ai-agent/conversations/{conversation}/v1/view`, which serves the document, and `GET /ai-agent/conversations/{conversation}/v1/files`, which serves up to 32 Session Data files of a session, each chosen by its landed seq, as `application/vnd.skywalking.asz.files+ndjson`, a line naming each file followed by the file's bytes, so a page loads the files a step points at only when a reader opens it; there is no read of every file. Both routes require the service and the sender's instance, as a list row names them, so every read is a full series lookup. The view and the files route leave BanyanDB's stage selection unspecified by default and select only the cold stage when the caller explicitly sets `coldStage` to true. A round from before the list attributes existed lands and lists with zero counts, and the view shows as much as landed: the chain resumes after a missing, unreadable or refused round, and the absent rounds and files are named once as ranges. A file over `maxFileBytes`, 15 MiB by default, is rejected at ingest and counted under the reason `size`, because one file over BanyanDB's 16 MiB gRPC message limit fails the bulk write it travels in and every record behind it; on MySQL the body column is `LONGTEXT`, since a body that size outgrows `MEDIUMTEXT` as Base64. A conversation's rounds and files are read `readWindow` at a time, 16 by default, and each of those reads is capped at `maxResponseBytes`, 100 MiB by default, as a per-call option on the BanyanDB client in place of its 50 MB default, so the module's reads are bounded by its own settings and nothing else's read changes. The document also carries the session's workspace changes: the Sessionizer's Claude Code plugin lands a Session Data file of kind `changes` beside a stream's transcript, `streams//changes--.sd`, with one change record per observed tool call, stored like any other file, and the runtime's own patch for its editing tools travels as the second data part of the call's result record; `workspace_changes` lists every record joined to its step by tool-use id with the record's fields as `changes/1` lists them, `summary.changes` counts them, and a tool step names its records under `changes`. The document also names the session's provider bodies: the request and response bodies a runtime exchanged with its model provider land as Session Data files of kind `provider_body`, `/provider_body/provider_body--.sd`, stored like any other file; an `llm.call` step lists where its request and response landed under `provider_bodies`, joined by the bodies' own ids, and `summary.provider_bodies` and `summary.captured_prompts` count them. A round's header counts, `changes`, `lines_added`, `lines_removed`, `llm_calls`, `subagents` and `bash_runs`, land on `ai_agent_session_flow` and reach the conversation list as `ConversationRow` fields, absent rather than zero when the round did not carry them. The layer also takes the agent runtime's own metrics, in the `otel-rules/ai-agent/` rule set enabled by default: Claude Code's `claude_code.token.usage`, as its own exporter sends it or as the Sessionizer derives it from the landed transcripts, per service and per sender, by type, model and query source with `session.id` summed away, plus the exporter's cost, active time, sessions, lines of code, commits, pull requests and edit decisions when they arrive. +* Add AI agent conversations landed by the AI Sessionizer: the `AI_AGENT` layer, the bundled `lal/ai-agent.yaml` rule with the `ConversationFile` output builder that verifies and stores Session Data and Session Flow files, the `ai_agent_session_data` and `ai_agent_session_flow` models in a new BanyanDB group `recordsAIAgent`, the `ai-agent-conversation` module that folds a conversation into one `asz.view` document, the `listConversations` GraphQL query (with optional `conversation` and `title` conditions), and two streamed HTTP routes beside `/graphql`: `GET /ai-agent/conversations/{conversation}/v1/view`, which serves the document, and `GET /ai-agent/conversations/{conversation}/v1/files`, which serves up to 32 Session Data files of a session, each chosen by its landed seq, as `application/vnd.skywalking.asz.files+ndjson`, a line naming each file followed by the file's bytes, so a page loads the files a step points at only when a reader opens it; there is no read of every file. Both routes require the service and the sender's instance, as a list row names them, so every read is a full series lookup. The view and the files route leave BanyanDB's stage selection unspecified by default and select only the cold stage when the caller explicitly sets `coldStage` to true. A round from before the list attributes existed lands and lists with zero counts, and the view shows as much as landed: the chain resumes after a missing, unreadable or refused round, and the absent rounds and files are named once as ranges. A file over `maxFileBytes`, 15 MiB by default, is rejected at ingest and counted under the reason `size`, because one file over BanyanDB's 16 MiB gRPC message limit fails the bulk write it travels in and every record behind it; on MySQL the body column is `LONGTEXT`, since a body that size outgrows `MEDIUMTEXT` as Base64. A conversation's rounds and files are read `readWindow` at a time, 16 by default, and each of those reads is capped at `maxResponseBytes`, 100 MiB by default, as a per-call option on the BanyanDB client in place of its 50 MB default, so the module's reads are bounded by its own settings and nothing else's read changes. The document also carries the session's workspace changes: the Sessionizer's Claude Code plugin lands a Session Data file of kind `changes` beside a stream's transcript, `streams//changes--.sd`, with one change record per observed tool call, stored like any other file, and the runtime's own patch for its editing tools travels as the second data part of the call's result record; `workspace_changes` lists every record joined to its step by tool-use id with the record's fields as `changes/1` lists them, `summary.changes` counts them, and a tool step names its records under `changes`. The document also names the session's provider bodies: the request and response bodies a runtime exchanged with its model provider land as Session Data files of kind `provider_body`, `/provider_body/provider_body--.sd`, stored like any other file; an `llm.call` step lists where its request and response landed under `provider_bodies`, as the round carries the join the Sessionizer made when it parsed the round, and `summary.provider_bodies` and `summary.captured_prompts` count them. A round's header counts, `changes`, `lines_added`, `lines_removed`, `llm_calls`, `subagents` and `bash_runs`, land on `ai_agent_session_flow` and reach the conversation list as `ConversationRow` fields, absent rather than zero when the round did not carry them. The layer also takes the agent runtime's metrics, in the `otel-rules/ai-agent/` rule set enabled by default: `agent.token.usage`, which the Sessionizer derives from the landed transcripts, per service and per sender, by type, model and query source with `session.id` summed away, plus the cost, active time, sessions, lines of code, commits, pull requests and edit decisions that Claude Code's own exporter sends when it is pointed at the OAP directly; its own token metric is not read, so a call is never counted twice. * Support querying Zipkin traces from the BanyanDB cold stage through the Zipkin HTTP query API. `/api/v2/traces` takes an optional `coldStage` parameter, and `/api/v2/trace/{traceId}` and `/api/v2/traceMany` take optional `coldStage`, `endTs` and `lookback` parameters; the two by-id lookups had no time range at all before. All of them are SkyWalking additions to the Zipkin API and default to the previous behavior, so existing Zipkin clients such as the Lens UI keep working unchanged, and storages other than BanyanDB ignore `coldStage`. The admin debugging endpoints `/debugging/query/zipkin/api/v2/traces` and `/debugging/query/zipkin/api/v2/trace` accept the same parameters. The BanyanDB stages e2e now seeds Zipkin traces into the generated cold data and verifies the three endpoints against it. * BanyanDB: a query without a time range now covers everything the group's hot/warm stages retain instead of only the last 24 hours. The hard-coded day was a fallback added with the trace model, which requires a time range; it made `queryTrace(traceId)` without a `duration`, the Zipkin `/api/v2/trace/{traceId}` and `/api/v2/traceMany` lookups, and the TraceQL by-id lookup return "not found" for any trace older than a day even though it was still retained. The unbounded range is now bound instead, and BanyanDB's own retention limits the scan. `queryTraces` now rejects a condition with neither `queryDuration` nor `traceId` instead of silently searching the last day; a `traceId` lookup without `queryDuration` searches everything the hot/warm stages retain and reports a 0-to-now `retrievedTimeRange`. * Fix the JDBC storages, MySQL, PostgreSQL and H2, reading only the first day's table of a time range shorter than a day that crosses midnight. `TableHelper` walked the range in 24-hour steps from its first instant, so a range that began late on one day and ended early on the next never reached the second day; every time-ranged read, metrics, traces, logs, alarms, records and the AI agent conversation list, was missing the newer day's rows for the length of the range after each midnight, the last thirty minutes read at 00:10 among them. The range is now walked by calendar day from the start of its first day. @@ -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 matching traces, so Grafana's dependent dropdowns narrow down; the `status` and `kind` dropdowns always list their full enum. The `/otlp` datasource also accepts Tempo's `span:name`, `span:kind`, `span:status` and `span:duration` spellings. `/api/metrics/query_range` and `/api/metrics/query` answer `501 Not Implemented` with an error body and accept Tempo's `q` parameter, instead of `200` with a string Grafana cannot parse. * 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 SkyWalking, `resource.instance` or `name` without a service on SkyWalking). The deprecated `tags` search parameter is parsed as logfmt on every datasource and goes through the same mapping and refusals. `kind = server` and `status = error` accept the bare keyword as Tempo does. Every search-result span carries a `status` attribute (`error`, `ok`, `unset`) next to `service.name` and `span.kind`, so a trace list can show failures. A trace the storage matched but none of whose spans satisfies every condition is left out instead of listed with every span, and the `/otlp` datasource compares names the way the receiver indexed them and keeps the `resource.` and `span.` scopes apart in its tag index, so `span.env` no longer matches a resource attribute. `duration >` and `<` are strict at microsecond precision. (#14093) * 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 hostmetrics source (`tcp_alloc`, `sockets_used`, `udp_inuse`, `filefd_allocated`) stay node-exporter only. The new `process-hostmetrics-linux` and `process-hostmetrics-windows` rules, enabled by default, report each process name as an instance of its host (`mp_process_linux_*` / `mp_process_windows_*`: process count, threads, CPU, resident memory, open handles and oldest process uptime). Reference Collector configurations for both systems are under `docs/en/setup/backend/`; they set the `job_name` and normalized `process_name` labels the rules route and group on, and sum same-named processes before export. +* AI agent conversations adopt AI Sessionizer `130601c`. Calls to MCP servers: the `execution` kind the Sessionizer's Claude Code plugin writes, `streams//execution--.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 new `otel-rules/ai-agent/mcp_endpoint.yaml` gives one endpoint per MCP server and tool, `/`, under the agent's service, with `meter_ai_agent_mcp_calls`, `meter_ai_agent_mcp_calls_by_outcome` and `meter_ai_agent_mcp_duration` from the Sessionizer's `agent.mcp.calls` and `agent.mcp.duration`. The token rules read `agent.token.usage`, the Sessionizer's name for the metric. The document follows the Sessionizer's: an `llm.call` takes its provider bodies from the round's `provider_bodies` attribute and the session its count from `provider_bodies_landed`, and a round whose bodies do not read or point past its range is refused; talks are in the order they began across streams; only a `child` stream's talk is a child's, not an `auxiliary` one's; a stream's `opened_by`, the relations and a step's edges are in the order they happened, by record position inside one stream or workflow run and by time across them, never by id; change and execution records of one instant are in the order they were read; a record is read by the fields its format lists, and one of another shape is skipped; a round with a frame field of another type, or a negative sequence, row or round number, does not read; record times compare as instants. #### 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 5de27bc5b8e0..31dd5b1b563b 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//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//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//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//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//script-…` | the program a workflow ran | nothing; it is kept because it is part of the session | +| `changes` | `streams//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//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,25 @@ 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. A round does not read when a line +of it is not a JSON object, holds more than 256 objects and lists open at once, or has a field of another type than +Session Flow gives it, such as a count written as a string. It does not read either when a sequence, a row or the round +number in it is negative. No round the Sessionizer writes does. In a Session Data file, the records end before a line +that is not a JSON object or nests that deep. No landed line measured nests deeper than sixteen levels. The document is +built on every call and nothing is cached. | Parameter or header | Meaning | |---|---| @@ -117,11 +156,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, "detail": "..."}` | The route is on the core HTTP server beside `/graphql`, so it has the same host, port, context path and TLS -settings, and serves HTTP/1.1 and HTTP/2 alike. The body is streamed: it is written to the response as it is -rendered, never held whole in memory, and a slow client holds back the render. The route runs under its own +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 +170,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 +185,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 +200,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, - `/streams//changes--.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. + `/streams//changes--.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 +242,61 @@ 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, in its order, a data part of another shape not being a record; 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, + `/streams//execution--.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, in its order, a data part of + another shape not being a record; records of one instant are in the order they were read; +- 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____`, + 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, `/provider_body/provider_body--.sd`, one record per body. It lands, is verified and is stored @@ -227,20 +307,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 seq or row below one, 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 +346,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 Sessionizer, so a window is a few tens of megabytes. | +| `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 default by the Sessionizer, so a window is usually a few tens of megabytes. | | `maxResponseBytes` | the most bytes one storage query may answer with. **BanyanDB alone accepts it**, carried as a call option on the shared client in place of the 50 MB it holds every other read to, so nothing else's read changes; Elasticsearch and JDBC ignore it and bound a read by hits and by rows. 100 MiB by default, above sixteen files at the 2 MiB cut with room for files landed whole. For a root whose files land whole, raise it or lower `readWindow`; a read over the limit fails as a storage error. | -| `maxFileBytes` | the largest file stored, in bytes; a larger one is rejected at ingest and counted under the reason `size`. 15 MiB by default, under BanyanDB's 16 MiB gRPC message limit. The Sessionizer cuts files at 2 MiB, so only a record landed whole comes near it; a test lowers this to prove the rejection without pushing a file that size. | +| `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 prove the rejection without pushing a file that size. | ### Turning the feature off The GraphQL query module requires this module, so the `-` selector cannot remove it; `SW_AI_AGENT_CONVERSATION=none` -selects the `none` provider instead, which answers `listConversations` 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 +364,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 +386,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 +416,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 `/` 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 8fcd6e44da90..e808136c3b7f 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,12 +27,13 @@ import java.util.Map; import javax.annotation.Nullable; import lombok.Getter; +import org.apache.skywalking.oap.server.ai.agent.conversation.format.CodePointOrder; import org.apache.skywalking.oap.server.ai.agent.conversation.format.Ref; import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound; /** - * The fold of a conversation's rounds, as the Sessionizer's sessionflow.View folds them: entities - * keyed by id, a higher revision replaces a lower one, a tombstone removes, absence means unchanged. + * The fold of a conversation's rounds, as Session Flow defines it: entities keyed by id, a higher revision + * replaces a lower one, a tombstone removes, absence means unchanged. * *

Rounds must fold in order and each must name the previous round's commit digest. A gap in the stored * rounds is bridged by {@link #applyAfterGap}: what the absent rounds carried stays as it was, and the document @@ -58,9 +59,9 @@ public final class ConversationFold { private Map> to; /** - * Fold one round on top of the current head, or refuse it the way the Sessionizer's Apply - * does: a round out of order, naming another head, or from another conversation, session, parser or policy - * is not merged, and the reason is returned in the Sessionizer's words for the document to carry. + * Fold one round on top of the current head, or refuse it as Session Flow's fold does: a round out of order, + * naming another head, or from another conversation, session, parser or policy is not merged, and the reason + * is returned in the Sessionizer's words for the document to carry. * * @param r the next round of the chain * @return null when the round was folded, else why it was refused @@ -227,13 +228,12 @@ public List openUnresolved() { out.add(u); } } - out.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId)); + out.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId, CodePointOrder.ORDER)); return out; } /** - * Record order: by the record a node stands on, then by id; a positioned node before one without a - * reference. + * The nodes in record order, as {@link #compare} gives it. * * @param nodes the nodes * @return a sorted copy @@ -244,27 +244,30 @@ public static List inOrder(final List POSITION = Comparator.comparingLong(Ref::getSeq) + .thenComparingLong(Ref::getRow) + .thenComparing(Ref::getBlock, Comparator.nullsFirst(Comparator.naturalOrder())); + private void index() { if (kids != null) { return; 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 e2173513f1df..4d33b51a62c9 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,108 @@ 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.Schema.Kind.BOOLEAN; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.INTEGER; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.NULLABLE_INTEGER; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.NULLABLE_OBJECT; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.OBJECT; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.OBJECTS; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.STRING; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.STRINGS; /** * One workspace change record, changes/1: which files one tool call changed, as a git-style change - * log. It is read from a data part and rendered with its keys in the order the Sessionizer's - * changes.Record lists them, so the document carries the record as the Sessionizer prints it and not - * as its producer spelled it. - * - *

Decoding follows Go's json.Unmarshal 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 json.Marshal: a - * string, list or nested object marked omitempty 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 data part, read by its {@link Schema}, 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 fields of a changes/1 record, in the format's order. */ + private static final Schema INTERVAL = new Schema() + .field("from", STRING) + .field("to", STRING); + private static final Schema ENDPOINT = new Schema() + .field("present", BOOLEAN) + .field("bytes", NULLABLE_INTEGER) + .optional("sha256", STRING) + .optional("no_newline_at_end", BOOLEAN); + private static final Schema HUNK = new Schema() + .field("old_start", INTEGER) + .field("old_lines", INTEGER) + .field("new_start", INTEGER) + .field("new_lines", INTEGER) + .field("lines", STRINGS); + private static final Schema FILE_CHANGE = new Schema() + .field("path", STRING) + .field("operation", STRING) + .field("before", OBJECT, ENDPOINT) + .field("after", OBJECT, ENDPOINT) + .field("diff", STRING) + .optional("attribution", STRING) + .optional("windows", STRINGS) + .field("additions", NULLABLE_INTEGER) + .field("deletions", NULLABLE_INTEGER) + .optional("hunks", OBJECTS, HUNK); + private static final Schema RECORD = new Schema() + .field("schema", STRING) + .field("id", STRING) + .field("captured_by", STRING) + .field("session", STRING) + .field("stream", STRING) + .optional("tool", STRING) + .optional("tool_name", STRING) + .field("time", STRING) + .field("basis", STRING) + .optional("root", NULLABLE_OBJECT, new Schema() + .field("path", STRING) + .optional("id", STRING)) + .optional("policy", NULLABLE_OBJECT, new Schema() + .optional("exclusions", STRING) + .optional("read_only", STRING) + .optional("expanded", STRINGS)) + .optional("window", NULLABLE_OBJECT, new Schema() + .field("before", OBJECT, INTERVAL) + .field("after", OBJECT, INTERVAL)) + .optional("outcome", NULLABLE_OBJECT, new Schema() + .field("state", STRING) + .field("exit_code", NULLABLE_INTEGER)) + .optional("coverage", STRING) + .optional("gaps", STRINGS) + .optional("overlaps", OBJECTS, new Schema() + .field("capture", STRING) + .field("session", STRING) + .field("stream", STRING) + .optional("tool", STRING) + .optional("tool_name", STRING) + .field("state", STRING)) + .field("changed_files", NULLABLE_INTEGER) + .field("changes", OBJECTS, 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 fields; - private ChangesRecord(final JsonObject json) { - final Map 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 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 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 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 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 list = new ArrayList<>(); - for (final JsonElement e : overlaps) { - final JsonObject x = objectOf(e); - final Map 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); + private ChangesRecord(final Map fields) { + id = (String) fields.get("id"); + capturedBy = (String) fields.get("captured_by"); + tool = (String) fields.getOrDefault("tool", ""); + time = (String) fields.get("time"); + this.fields = Collections.unmodifiableMap(fields); } /** @@ -138,209 +129,12 @@ private ChangesRecord(final JsonObject json) { */ @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 Map fields = RECORD.read(Schema.parse(raw)); + return fields != null && SCHEMA.equals(fields.get("schema")) ? new ChangesRecord(fields) : null; } - /** - * @return the record's fields, keys in the order the format lists them - */ + @Override public Map fields() { return fields; } - - private static Map interval(@Nullable final JsonObject o) { - final Map 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 fileChanges(final JsonArray changes) { - final List out = new ArrayList<>(); - for (final JsonElement e : changes) { - final JsonObject c = objectOf(e); - final Map 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 list = new ArrayList<>(); - for (final JsonElement h : hunks) { - final JsonObject x = objectOf(h); - final Map 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 endpoint(@Nullable final JsonObject o) { - final Map 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 strings(final JsonObject o, final String key) { - final JsonArray a = array(o, key); - if (a == null) { - return null; - } - final List 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 m, final String key, @Nullable final String value) { - if (StringUtil.isNotEmpty(value)) { - m.put(key, value); - } - } - - private static void putIfNotEmpty(final Map m, final String key, @Nullable final List 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/CodePointOrder.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/CodePointOrder.java new file mode 100644 index 000000000000..6bff6bdad3ee --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/CodePointOrder.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.apache.skywalking.oap.server.ai.agent.conversation.format; + +import java.util.Comparator; + +/** + * The order of strings by their code points, which is the order of their UTF-8 bytes. Java's own order of strings is + * by UTF-16 unit, and puts a character past U+FFFF before U+E000. Where the formats order by a string, such as an id + * breaking a tie, or the keys of a map, they order by code point, so every reader lists them alike. + */ +public final class CodePointOrder { + /** The order, as a comparator. */ + public static final Comparator ORDER = CodePointOrder::compare; + + private CodePointOrder() { + } + + /** + * @return negative, zero or positive as a sorts before, with or after b + */ + 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/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 000000000000..68173724d83e --- /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,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.apache.skywalking.oap.server.ai.agent.conversation.format; + +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.Schema.Kind.INTEGER; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.NULLABLE_INTEGER; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.NULLABLE_OBJECT; +import static org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema.Kind.STRING; + +/** + * One tool execution record, execution/1: 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 data + * part of an execution file, read by its {@link Schema}. + * + *

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 fields of an execution/1 record, in the format's order. */ + private static final Schema CONTENT = new Schema() + .field("state", STRING) + .field("bytes", INTEGER) + .optional("sha256", STRING); + private static final Schema RECORD = new Schema() + .field("schema", STRING) + .field("id", STRING) + .field("observed_by", STRING) + .field("boundary", STRING) + .field("session", STRING) + .field("stream", STRING) + .field("tool", STRING) + .optional("tool_name", STRING) + .optional("cwd", STRING) + .field("protocol", STRING) + .optional("server", NULLABLE_OBJECT, new Schema() + .field("name", STRING) + .optional("source", STRING)) + .field("time", STRING) + .optional("duration_ms", NULLABLE_INTEGER) + .field("outcome", STRING) + .optional("arguments", NULLABLE_OBJECT, CONTENT) + .optional("result", NULLABLE_OBJECT, 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 fields; + + private ExecutionRecord(final Map fields) { + id = (String) fields.get("id"); + tool = (String) fields.get("tool"); + time = (String) fields.get("time"); + this.fields = Collections.unmodifiableMap(fields); + } + + /** + * @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 Map fields = RECORD.read(Schema.parse(raw)); + return fields != null && SCHEMA.equals(fields.get("schema")) ? new ExecutionRecord(fields) : null; + } + + @Override + public Map 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 a3a9e9b324dc..c7028ccbf8e2 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 @@ * <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 @@ private 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: streams/<stream> for a stream's + * records, runs/<run> for a workflow run's, and provider_body 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/JsonText.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/JsonText.java new file mode 100644 index 000000000000..7a2699024d60 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/JsonText.java @@ -0,0 +1,289 @@ +/* + * 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.util.ArrayDeque; +import java.util.Deque; +import javax.annotation.Nullable; + +/** + * Reads JSON as RFC 8259 defines it, and nothing more: no comment, no single quote, no NaN, no word but true, false + * and null, no control character or unknown escape in a string, and nothing after the value. Gson's reader, even set + * to strict, takes TRUE, a raw tab and \' in a string, and refuses a number longer than its buffer, so it cannot be + * the judge of what the formats are. + * + *

The text is read in one loop, not by recursion, so how deep a value may nest is a fixed limit and not what a + * thread's stack holds, and the same text reads the same way on every run. A number keeps the text it was written in: + * it is Gson's own number type, the one Gson's parser makes, which prints as written. + */ +final class JsonText { + /** Why a text is not JSON; one instance and no stack trace, since the caller only needs to know that it is not. */ + private static final RuntimeException NOT_JSON = new RuntimeException(null, null, false, false) { + private static final long serialVersionUID = 1L; + }; + + private final String text; + private int pos; + + private JsonText(final String text) { + this.text = text; + } + + /** + * @param text JSON text + * @param maxDepth the most objects and lists that may be open at once + * @return the value, or null when the text is not JSON or nests deeper than the limit + */ + @Nullable + static JsonElement read(final String text, final int maxDepth) { + try { + return new JsonText(text).document(maxDepth); + } catch (final RuntimeException e) { + return null; + } + } + + private JsonElement document(final int maxDepth) { + final Deque open = new ArrayDeque<>(); + final Deque names = new ArrayDeque<>(); + JsonElement root = null; + while (true) { + // a value: added to the object or list it is in, and an object or a list is then read into + space(); + final JsonElement value; + if (peek() == '{') { + value = new JsonObject(); + } else if (peek() == '[') { + value = new JsonArray(); + } else { + value = scalar(); + } + if (open.isEmpty()) { + root = value; + } else if (open.peek().isJsonObject()) { + open.peek().getAsJsonObject().add(names.pop(), value); + } else { + open.peek().getAsJsonArray().add(value); + } + if (value.isJsonObject() || value.isJsonArray()) { + pos++; + if (open.size() == maxDepth) { + throw NOT_JSON; + } + open.push(value); + space(); + if (peek() != (value.isJsonObject() ? '}' : ']')) { + if (value.isJsonObject()) { + names.push(member()); + } + continue; + } + pos++; + open.pop(); + } + // after a value: the end of the text, the next element, or the end of an object or a list + while (true) { + space(); + if (open.isEmpty()) { + if (pos != text.length()) { + throw NOT_JSON; + } + return root; + } + final boolean object = open.peek().isJsonObject(); + final char c = peek(); + pos++; + if (c == ',') { + if (object) { + names.push(member()); + } + break; + } + if (c != (object ? '}' : ']')) { + throw NOT_JSON; + } + open.pop(); + } + } + } + + /** A member's name and the colon after it; the value is read next. */ + private String member() { + space(); + if (peek() != '"') { + throw NOT_JSON; + } + final String name = string(); + space(); + if (peek() != ':') { + throw NOT_JSON; + } + pos++; + return name; + } + + private JsonElement scalar() { + final char c = peek(); + if (c == '"') { + return new JsonPrimitive(string()); + } + if (c == '-' || c >= '0' && c <= '9') { + return new JsonPrimitive(new LazilyParsedNumber(number())); + } + if (text.startsWith("true", pos)) { + pos += 4; + return new JsonPrimitive(true); + } + if (text.startsWith("false", pos)) { + pos += 5; + return new JsonPrimitive(false); + } + if (text.startsWith("null", pos)) { + pos += 4; + return JsonNull.INSTANCE; + } + throw NOT_JSON; + } + + /** -? (0 | [1-9][0-9]*) (.[0-9]+)? ([eE][+-]?[0-9]+)?, as its text. */ + private String number() { + final int start = pos; + if (peek() == '-') { + pos++; + } + if (peek() == '0') { + pos++; + } else { + digits(); + } + if (pos < text.length() && text.charAt(pos) == '.') { + pos++; + digits(); + } + if (pos < text.length() && (text.charAt(pos) == 'e' || text.charAt(pos) == 'E')) { + pos++; + if (pos < text.length() && (text.charAt(pos) == '+' || text.charAt(pos) == '-')) { + pos++; + } + digits(); + } + return text.substring(start, pos); + } + + private void digits() { + final int start = pos; + while (pos < text.length() && text.charAt(pos) >= '0' && text.charAt(pos) <= '9') { + pos++; + } + if (pos == start) { + throw NOT_JSON; + } + } + + private String string() { + pos++; + final StringBuilder out = new StringBuilder(); + while (true) { + final char c = next(); + if (c == '"') { + return out.toString(); + } + if (c < 0x20) { + throw NOT_JSON; + } + if (c != '\\') { + out.append(c); + continue; + } + final char escape = next(); + switch (escape) { + case '"': + case '\\': + case '/': + out.append(escape); + 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': + out.append(hex()); + break; + default: + throw NOT_JSON; + } + } + } + + /** The four hexadecimal digits after a backslash and u. JSON's digits are ASCII only, as Character.digit's are not. */ + private char hex() { + int value = 0; + for (int i = 0; i < 4; i++) { + final char c = next(); + final int digit; + if (c >= '0' && c <= '9') { + digit = c - '0'; + } else if (c >= 'a' && c <= 'f') { + digit = c - 'a' + 10; + } else if (c >= 'A' && c <= 'F') { + digit = c - 'A' + 10; + } else { + throw NOT_JSON; + } + value = value * 16 + digit; + } + return (char) value; + } + + private void space() { + while (pos < text.length() && " \t\n\r".indexOf(text.charAt(pos)) >= 0) { + pos++; + } + } + + private char peek() { + if (pos >= text.length()) { + throw NOT_JSON; + } + return text.charAt(pos); + } + + private char next() { + final char c = peek(); + pos++; + return c; + } +} diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJson.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJson.java index 2fe9e9d0d61e..99bb28ebc346 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJson.java +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJson.java @@ -24,12 +24,12 @@ /** * Finds the raw text of each part's data value in one record line, so a part's data is rendered - * as the Sessionizer wrote it. Go keeps that value as json.RawMessage and prints it verbatim, escapes, - * key order and spacing included; a value that was parsed and printed again would differ in all three and break - * the document's equality with the Sessionizer's, and clip at another byte. + * as the line holds it. The formats keep data as it was written, escapes, key order and spacing included; a value + * that was parsed and printed again would differ in all three and break the document's equality with the + * Sessionizer's, and clip at another byte. * *

This is a walk over the line's JSON syntax, not a parser: it only needs the start and the end of each - * value, and it leaves the decoding to Gson, which has already accepted the line. + * value, and it leaves the decoding to Schema.parse, which has already read the line as JSON. */ final class RawJson { private final String text; @@ -145,9 +145,14 @@ private void skipValue() { } } } else { + final int start = pos; while (pos < text.length() && ",}] \t\r\n".indexOf(text.charAt(pos)) < 0) { pos++; } + if (pos == start) { + // a value is at least one character, so the walk always moves on + throw new IllegalStateException("no value at " + pos); + } } } diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Ref.java index df8676cf47e2..bc825d2c7387 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 @@ public final class Ref { private final long seq; private final long row; + /** A 64-bit integer, as the format writes it. */ @Nullable - private final Integer block; + private final Long block; @Nullable public static Ref of(@Nullable final JsonObject json) { @@ -47,10 +48,18 @@ public static Ref of(@Nullable final JsonObject json) { 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 ); } + /** + * @param fields a reference as {@link SessionFlowRound#REF} reads it + * @return the reference + */ + public static Ref of(final Map fields) { + return new Ref((Long) fields.get("seq"), (Long) fields.get("row"), (Long) fields.get("block")); + } + /** * @return the reference as a view map, in the order the format page lists the keys */ diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Schema.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Schema.java new file mode 100644 index 000000000000..5b71a8fcd340 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/Schema.java @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.apache.skywalking.oap.server.ai.agent.conversation.format; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * The shape of a record the Sessionizer's formats define: its fields in the order the format lists them, each with + * its type. A record is read against its shape: a field of another type means the data is not such a record, a key + * the shape does not list is left out, and the fields come back in the format's order, an optional one left out when + * it is empty. A field that is absent or null holds its type's empty value. A list holds values of its own type + * only, so a null in it is a value of another type. + */ +public final class Schema { + /** The type of one field. */ + public enum Kind { + /** a string; empty when absent */ + STRING, + /** an integer; 0 when absent */ + INTEGER, + /** an integer; null when absent */ + NULLABLE_INTEGER, + /** true or false; false when absent */ + BOOLEAN, + /** a list of strings; null when absent */ + STRINGS, + /** an object of a shape; its empty fields when absent */ + OBJECT, + /** an object of a shape; null when absent */ + NULLABLE_OBJECT, + /** a list of objects of a shape; null when absent */ + OBJECTS + } + + private final List fields = new ArrayList<>(); + + /** A field that is always written. */ + public Schema field(final String name, final Kind kind) { + return field(name, kind, null); + } + + /** A field that is always written, of an object shape. */ + public Schema field(final String name, final Kind kind, @Nullable final Schema shape) { + fields.add(new Field(name, kind, shape, false)); + return this; + } + + /** + * A field that is left out when empty: an empty string, 0, false, null or an empty list. A nullable field is empty + * only when null: a 0 it holds was written, and is kept. + */ + public Schema optional(final String name, final Kind kind) { + return optional(name, kind, null); + } + + /** A field that is left out when empty, of an object shape. */ + public Schema optional(final String name, final Kind kind, @Nullable final Schema shape) { + fields.add(new Field(name, kind, shape, true)); + return this; + } + + /** + * The most objects and lists a line may hold open at once. No line of the formats measured nests more than + * sixteen, and a deeper one is not read, the same way on every run. + */ + public static final int MAX_DEPTH = 256; + + /** + * @param text JSON text + * @return the value, or null when the text is not JSON as RFC 8259 defines it, or nests deeper than + * {@link #MAX_DEPTH}; see JsonText + */ + @Nullable + public static JsonElement parse(@Nullable final String text) { + return text == null ? null : JsonText.read(text, MAX_DEPTH); + } + + /** + * @param json a JSON value + * @return the record's fields in the format's order, or null when the value is not an object of this shape + */ + @Nullable + public Map read(@Nullable final JsonElement json) { + if (json == null || !json.isJsonObject()) { + return null; + } + try { + return readObject(json.getAsJsonObject()); + } catch (final WrongType e) { + return null; + } + } + + private Map readObject(final JsonObject json) { + final Map out = new LinkedHashMap<>(); + for (final Field f : fields) { + final JsonElement v = json.get(f.name); + final Object value = f.value(v == null || v.isJsonNull() ? null : v); + if (!f.optional || !f.isEmpty(value)) { + out.put(f.name, value); + } + } + return out; + } + + /** A value of another type than its field's. */ + private static final class WrongType extends RuntimeException { + private static final long serialVersionUID = 1L; + + WrongType() { + super(null, null, false, false); + } + } + + private static final class Field { + final String name; + final Kind kind; + final Schema shape; + final boolean optional; + + Field(final String name, final Kind kind, @Nullable final Schema shape, final boolean optional) { + this.name = name; + this.kind = kind; + this.shape = shape; + this.optional = optional; + } + + boolean isEmpty(@Nullable final Object value) { + if (kind == Kind.NULLABLE_INTEGER || kind == Kind.NULLABLE_OBJECT) { + return value == null; + } + return value == null || "".equals(value) || Long.valueOf(0).equals(value) || Boolean.FALSE.equals(value) + || value instanceof List && ((List) value).isEmpty(); + } + + /** The field's value from its JSON, or its empty value when it has none. */ + @Nullable + Object value(@Nullable final JsonElement v) { + switch (kind) { + case STRING: + return v == null ? "" : string(v); + case INTEGER: + return v == null ? 0L : integer(v); + case NULLABLE_INTEGER: + return v == null ? null : integer(v); + case BOOLEAN: + return v != null && bool(v); + case STRINGS: + if (v == null) { + return null; + } + final List strings = new ArrayList<>(); + for (final JsonElement e : array(v)) { + strings.add(string(e)); + } + return strings; + case OBJECT: + return shape.readObject(v == null ? new JsonObject() : object(v)); + case NULLABLE_OBJECT: + return v == null ? null : shape.readObject(object(v)); + default: + if (v == null) { + return null; + } + final List objects = new ArrayList<>(); + for (final JsonElement e : array(v)) { + objects.add(shape.readObject(object(e))); + } + return objects; + } + } + } + + private static String string(final JsonElement v) { + if (v.isJsonPrimitive() && v.getAsJsonPrimitive().isString()) { + return v.getAsString(); + } + throw new WrongType(); + } + + /** A whole number written as one: 1.0 and 1e2 are not integers, and neither is one past 64 bits. */ + private static long integer(final JsonElement v) { + if (!v.isJsonPrimitive() || !v.getAsJsonPrimitive().isNumber()) { + throw new WrongType(); + } + try { + return Long.parseLong(v.getAsString()); + } catch (final NumberFormatException e) { + throw new WrongType(); + } + } + + private static boolean bool(final JsonElement v) { + if (v.isJsonPrimitive() && v.getAsJsonPrimitive().isBoolean()) { + return v.getAsBoolean(); + } + throw new WrongType(); + } + + private static JsonObject object(final JsonElement v) { + if (v.isJsonObject()) { + return v.getAsJsonObject(); + } + throw new WrongType(); + } + + private static JsonArray array(final JsonElement v) { + if (v.isJsonArray()) { + return v.getAsJsonArray(); + } + throw new WrongType(); + } +} diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java index 08c255dda9be..f651b3336189 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionDataFile.java @@ -21,11 +21,11 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.function.Predicate; import javax.annotation.Nullable; import lombok.Getter; @@ -46,19 +46,13 @@ public final class SessionDataFile { private final int bytes; /** sha256 of the whole file, the digest on the wire and the one a round's input digest chains. */ private final String fileDigest; - /** - * Whether the records end at a line that does not decode, before the closing line. The Sessionizer's raw reader - * still returns that line, and a reader that needs every line, such as the gap check, must know it is there. - */ - private final boolean stoppedEarly; /** The earliest and the latest record time in the file, in milliseconds; 0 when no record carries a time. */ private final long fromTime; private final long throughTime; private SessionDataFile(final Header header, final List records, final int declaredRecords, final String declaredDigest, final int lines, final int bytes, - final String fileDigest, final boolean stoppedEarly, final long fromTime, - final long throughTime) { + final String fileDigest, final long fromTime, final long throughTime) { this.header = header; this.records = records; this.declaredRecords = declaredRecords; @@ -66,7 +60,6 @@ private SessionDataFile(final Header header, final List records, final i this.lines = lines; this.bytes = bytes; this.fileDigest = fileDigest; - this.stoppedEarly = stoppedEarly; this.fromTime = fromTime; this.throughTime = throughTime; } @@ -86,59 +79,52 @@ public static SessionDataFile parse(final byte[] body) { if (lineCount < 1) { throw new IllegalArgumentException("empty Session Data file"); } - final JsonObject headerJson = JsonParser.parseString(rawLines[0]).getAsJsonObject(); - if (!headerJson.has("h")) { - throw new IllegalArgumentException("the first line is not a Session Data header"); - } - final Header header = new Header(headerJson); + final Header header = new Header(headerLine(rawLines[0])); final List records = new ArrayList<>(Math.max(0, lineCount - 2)); int declaredRecords = -1; String declaredDigest = null; long from = 0; long through = 0; - boolean stoppedEarly = false; - // as the Sessionizer's reader: a header it would refuse yields no records, and an empty or undecodable - // line ends the records there, so a later row is never read and the rows stay contiguous + // a header the page does not let a reader open yields no records, and an empty line, or one that is not a + // record, ends the records there, so a later row is never read and the rows stay contiguous for (int i = 1; header.isValid() && i < lineCount; i++) { final String line = rawLines[i]; if (line.isEmpty()) { - stoppedEarly = true; break; } - final JsonObject json; - try { - json = JsonParser.parseString(line).getAsJsonObject(); - } catch (final RuntimeException e) { - stoppedEarly = true; + final JsonElement parsed = Schema.parse(line); + if (parsed == null || !parsed.isJsonObject()) { break; } + final JsonObject json = parsed.getAsJsonObject(); if (i == lineCount - 1 && "end".equals(string(json, "t"))) { - declaredRecords = json.has("records") ? json.get("records").getAsInt() : -1; + // a count that is not an integer, or more than a file can hold, is no count + final JsonElement count = json.get("records"); + declaredRecords = isInteger(count) && count.getAsLong() >= 0 && count.getAsLong() <= Integer.MAX_VALUE + ? count.getAsInt() : -1; declaredDigest = string(json, "digest"); break; } - final Record record = new Record(i, line, json); - if (!record.decodes()) { - // The Sessionizer decodes a whole typed record and stops the file where one does not: a - // record whose `off` is a string, say. Reading past it here would report bodies its own - // reader never sees. - stoppedEarly = true; + if (!Record.typed(json)) { + // a field of another type than the page gives it, an `off` that is a string, say: the line is not a + // record, and the file stops there as it does at a line that is not JSON break; } + final Record record = new Record(i, line, json); records.add(record); final long time = record.getTime(); if (time != 0) { if (from == 0 || time < from) { from = time; } - if (time > through) { + if (through == 0 || time > through) { through = time; } } } return new SessionDataFile( header, Collections.unmodifiableList(records), declaredRecords, declaredDigest, - Digests.countLines(body), body.length, Digests.sha256Hex(body), stoppedEarly, from, through); + Digests.countLines(body), body.length, Digests.sha256Hex(body), from, through); } /** @@ -151,11 +137,15 @@ public static Header header(final byte[] body) { while (end < body.length && body[end] != '\n') { end++; } - final JsonObject json = JsonParser.parseString(new String(body, 0, end, StandardCharsets.UTF_8)).getAsJsonObject(); - if (!json.has("h")) { + return new Header(headerLine(new String(body, 0, end, StandardCharsets.UTF_8))); + } + + private static JsonObject headerLine(final String line) { + final JsonElement json = Schema.parse(line); + if (json == null || !json.isJsonObject() || !json.getAsJsonObject().has("h")) { throw new IllegalArgumentException("the first line is not a Session Data header"); } - return new Header(json); + return json.getAsJsonObject(); } /** @@ -164,22 +154,41 @@ public static Header header(final byte[] body) { */ @Nullable public Record record(final long row) { - final int index = (int) row - 1; - if (index < 0 || index >= records.size()) { - return null; - } - return records.get(index); + return row >= 1 && row <= records.size() ? records.get((int) row - 1) : null; } + /** A field's value when it is a JSON string; null when it is absent or of another type. */ @Nullable static String string(final JsonObject json, final String key) { final JsonElement element = json.get(key); - return element == null || element.isJsonNull() ? null : element.getAsString(); + return isString(element) ? element.getAsString() : null; } + /** A field's value when it is an integer as the formats write one; 0 when it is absent or of another type. */ static long longOf(final JsonObject json, final String key) { final JsonElement element = json.get(key); - return element == null || element.isJsonNull() ? 0L : element.getAsLong(); + return isInteger(element) ? element.getAsLong() : 0L; + } + + private static boolean isString(@Nullable final JsonElement v) { + return v != null && v.isJsonPrimitive() && v.getAsJsonPrimitive().isString(); + } + + private static boolean isBoolean(@Nullable final JsonElement v) { + return v != null && v.isJsonPrimitive() && v.getAsJsonPrimitive().isBoolean(); + } + + /** A whole number with no fraction and no exponent that fits in 64 bits with its sign. */ + private static boolean isInteger(@Nullable final JsonElement v) { + if (v == null || !v.isJsonPrimitive() || !v.getAsJsonPrimitive().isNumber()) { + return false; + } + try { + Long.parseLong(v.getAsString()); + return true; + } catch (final NumberFormatException e) { + return false; + } } /** @@ -214,8 +223,8 @@ public static final class Header { } /** - * @return whether the Sessionizer's reader would open the file: the envelope version, the schema, the - * kind, the session, the source and the dialect are all there + * @return whether the Session Data page lets a reader open the file: the envelope version, the schema, + * the kind, the session, the source and the dialect are all there */ public boolean isValid() { return longOf(json, "h") == 1 && "sd/1".equals(schema) && kind != null && !kind.isEmpty() @@ -237,23 +246,16 @@ public static final class Record { /** The same moment in nanoseconds, the precision the Sessionizer computes intervals with. */ private final long timeNanos; private final List parts; - /** - * The digits after the line's leading {"ord":, as the Sessionizer reads an ord without decoding - * the line; empty when none follow, and null when the line does not start so. - */ - @Nullable - private final String leadingOrd; Record(final int row, final String line, final JsonObject json) { this.row = row; - this.leadingOrd = leadingOrd(line); this.json = json; this.id = string(json, "id"); this.timeNanos = Times.nanos(string(json, "time")); this.time = Math.floorDiv(timeNanos, 1_000_000L); final List list = new ArrayList<>(); if (json.has("parts") && json.get("parts").isJsonArray()) { - // the raw data text of each part, as the Sessionizer wrote it; see RawJson + // the data of each part as the line holds it; see RawJson final List raw = RawJson.partData(line); int i = 0; for (final JsonElement e : json.getAsJsonArray("parts")) { @@ -264,22 +266,9 @@ public static final class Record { this.parts = Collections.unmodifiableList(list); } - @Nullable - private static String leadingOrd(final String line) { - final String prefix = "{\"ord\":"; - if (!line.startsWith(prefix)) { - return null; - } - int i = prefix.length(); - while (i < line.length() && line.charAt(i) >= '0' && line.charAt(i) <= '9') { - i++; - } - return line.substring(prefix.length(), i); - } - /** - * @return the record's readable text: every text part joined by a newline, as the - * Sessionizer's Record.Text() returns it + * @return the record's readable text: every text part that is not empty, joined by a + * newline */ public String text() { final StringBuilder out = new StringBuilder(); @@ -334,90 +323,73 @@ private List strings(final String key) { } final List out = new ArrayList<>(); for (final JsonElement x : e.getAsJsonArray()) { - // a null element decodes to the empty string, as it does into a Go []string - out.add(x.isJsonNull() ? "" : x.getAsString()); + out.add(x.getAsString()); } return out; } /** - * @return whether the Sessionizer's own reader decodes this record. Its fields are typed, so a value - * of another type fails the whole record there, and the file's records end with it. Only the types - * are checked here; what the values mean is the reader's business. + * @return whether every field the Session Data page lists has the type the page gives it, inside the parts, + * the dropped entries and the usage too, a null standing for a field left out. A field the page does not + * list is not checked, and neither is what a value means: that is the reader's business. */ - boolean decodes() { - for (final String key : STRING_FIELDS) { - final JsonElement v = json.get(key); - if (v != null && !v.isJsonNull() && !(v.isJsonPrimitive() && v.getAsJsonPrimitive().isString())) { - return false; - } - } - for (final String key : UNSIGNED_FIELDS) { - if (!wholeNumber(json.get(key), true)) { + static boolean typed(final JsonObject json) { + return fields(json, STRING_FIELDS, INTEGER_FIELDS) + && absentOr(json.get("ord"), v -> v.getAsLong() >= 0) + && absentOr(json.get("off"), v -> v.getAsLong() >= 0) + && absentOr(json.get("flags"), v -> listOf(v, SessionDataFile::isString)) + && absentOr(json.get("usage"), v -> v.isJsonObject() + && fields(v.getAsJsonObject(), NONE, USAGE_FIELDS)) + && absentOr(json.get("parts"), v -> listOf(v, p -> p.isJsonObject() + && fields(p.getAsJsonObject(), PART_STRING_FIELDS, BYTES) + && absentOr(p.getAsJsonObject().get("failed"), SessionDataFile::isBoolean))) + && absentOr(json.get("dropped"), v -> listOf(v, d -> d.isJsonObject() + && fields(d.getAsJsonObject(), DROP_STRING_FIELDS, BYTES))); + } + + /** Whether each field named is absent, null, or of its type: a string, or an integer as the page writes one. */ + private static boolean fields(final JsonObject json, final String[] strings, final String[] integers) { + for (final String key : strings) { + if (!absentOr(json.get(key), SessionDataFile::isString)) { return false; } } - if (!wholeNumber(json.get("bytes"), false)) { - return false; - } - for (final String key : ARRAY_FIELDS) { - final JsonElement v = json.get(key); - if (v != null && !v.isJsonNull() && !v.isJsonArray()) { + for (final String key : integers) { + if (!absentOr(json.get(key), SessionDataFile::isInteger)) { return false; } } - final JsonElement flags = json.get("flags"); - if (flags != null && flags.isJsonArray()) { - for (final JsonElement x : flags.getAsJsonArray()) { - if (!x.isJsonNull() && !(x.isJsonPrimitive() && x.getAsJsonPrimitive().isString())) { - return false; - } - } - } return true; } - /** - * @param unsigned whether the Go type is an unsigned 64-bit integer rather than a signed one - * @return whether the value is absent, null, or a number Go decodes into that type: no fraction, no - * exponent, no sign where the type has none, and within its range. A JSON number this reader would - * round is one the Sessionizer refuses outright. - * - *

The record's own fields are checked, and the shape of the lists it carries; what is inside - * `parts`, `dropped` and `usage` is not. That is on purpose: Go ignores a field it does not declare, - * so a check that guessed at those types would refuse records the Sessionizer reads, and refusing - * one hides it and every record behind it in the file. Reading a record the Sessionizer would refuse - * is the safer way to be wrong. - */ - private static boolean wholeNumber(@Nullable final JsonElement v, final boolean unsigned) { - if (v == null || v.isJsonNull()) { - return true; - } - if (!v.isJsonPrimitive() || !v.getAsJsonPrimitive().isNumber()) { - return false; - } - final String text = v.getAsString(); - if (text.indexOf('.') >= 0 || text.indexOf('e') >= 0 || text.indexOf('E') >= 0) { - return false; - } - if (unsigned && text.startsWith("-")) { + private static boolean absentOr(@Nullable final JsonElement v, final Predicate type) { + return v == null || v.isJsonNull() || type.test(v); + } + + private static boolean listOf(final JsonElement v, final Predicate element) { + if (!v.isJsonArray()) { return false; } - try { - final java.math.BigInteger n = new java.math.BigInteger(text); - return unsigned ? n.bitLength() <= 64 : n.bitLength() < 64; - } catch (final NumberFormatException e) { - return false; + for (final JsonElement x : v.getAsJsonArray()) { + if (!element.test(x)) { + return false; + } } + return true; } + private static final String[] NONE = {}; + private static final String[] BYTES = {"bytes"}; private static final String[] STRING_FIELDS = { "sha", "id", "parent", "call", "run", "continues", "tool", "child", "batch", "label", "started_by", "from", "time", "trigger", "model" }; - private static final String[] UNSIGNED_FIELDS = {"ord", "off"}; - - private static final String[] ARRAY_FIELDS = {"flags", "parts", "dropped"}; + private static final String[] INTEGER_FIELDS = {"ord", "off", "bytes"}; + private static final String[] USAGE_FIELDS = {"in", "out", "cache_read", "cache_write"}; + private static final String[] PART_STRING_FIELDS = { + "k", "text", "id", "name", "of", "server", "server_tool", "media", "encoding", "state" + }; + private static final String[] DROP_STRING_FIELDS = {"what", "why"}; } /** @@ -432,7 +404,7 @@ public static final class Part { private final String id; private final String of; private final String state; - private final int bytes; + private final long bytes; private final Boolean failed; private final String rawData; @@ -445,13 +417,13 @@ public static final class Part { this.id = string(json, "id"); this.of = string(json, "of"); this.state = string(json, "state"); - this.bytes = (int) longOf(json, "bytes"); + this.bytes = longOf(json, "bytes"); final JsonElement f = json.get("failed"); this.failed = f == null || f.isJsonNull() ? null : f.getAsBoolean(); } /** - * @return the part's data as the Sessionizer wrote it, or null when it has none + * @return the part's data as the line holds it, or null when it has none */ @Nullable public String data() { @@ -459,7 +431,7 @@ public String data() { return rawData; } final JsonElement d = json.get("data"); - // a literal null is kept as the text "null": Go reads it into a raw message and prints it + // data is kept as written, so a data of null is the text "null" return d == null ? null : d.toString(); } } diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/SessionFlowRound.java index be245619c066..28ce4a1c35b4 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 @@ -20,7 +20,6 @@ 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 +29,7 @@ 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 +38,75 @@ */ @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"; + /** A reference into the landed data, as Session Flow gives it: a seq, a row, and a block for one part. */ + public static final Schema REF = new Schema() + .field("seq", Schema.Kind.INTEGER) + .field("row", Schema.Kind.INTEGER) + .optional("block", Schema.Kind.NULLABLE_INTEGER); + /** One provider body a call names, as Session Flow gives it: its role and its reference. */ + private static final Schema PROVIDER_BODY = new Schema() + .field("role", Schema.Kind.STRING) + .field("ref", Schema.Kind.OBJECT, REF); + /** + * The fields of each frame type and their types, as Session Flow writes them. A frame with a field of another type + * does not decode, and a line that does not decode makes the round refused. A node's attrs can be any JSON value. + */ + private static final Map FRAMES = Map.of( + "header", new Schema() + .field("schema", Schema.Kind.STRING) + .field("conversation", Schema.Kind.STRING) + .field("session", Schema.Kind.STRING) + .field("round", Schema.Kind.INTEGER) + .field("previous", Schema.Kind.STRING) + .field("from_seq", Schema.Kind.INTEGER) + .field("through_seq", Schema.Kind.INTEGER) + .field("input_digest", Schema.Kind.STRING) + .field("parser", Schema.Kind.STRING) + .field("policy", Schema.Kind.STRING) + .field("from_time", Schema.Kind.STRING) + .field("through_time", Schema.Kind.STRING) + .field("session_from_time", Schema.Kind.STRING) + .field("session_through_time", Schema.Kind.STRING) + .field("title", Schema.Kind.STRING) + .field("talks", Schema.Kind.INTEGER) + .field("steps", Schema.Kind.INTEGER) + .field("streams", Schema.Kind.INTEGER) + .field("segments", Schema.Kind.INTEGER) + .field("unresolved", Schema.Kind.INTEGER) + .field("changes", Schema.Kind.NULLABLE_INTEGER) + .field("lines_added", Schema.Kind.NULLABLE_INTEGER) + .field("lines_removed", Schema.Kind.NULLABLE_INTEGER) + .field("llm_calls", Schema.Kind.NULLABLE_INTEGER) + .field("subagents", Schema.Kind.NULLABLE_INTEGER) + .field("bash_runs", Schema.Kind.NULLABLE_INTEGER), + "node", entity() + .field("kind", Schema.Kind.STRING) + .field("parent", Schema.Kind.STRING) + .field("stream", Schema.Kind.STRING) + .field("ref", Schema.Kind.NULLABLE_OBJECT, REF) + .field("refs", Schema.Kind.OBJECTS, REF), + "relation", entity() + .field("type", Schema.Kind.STRING) + .field("from", Schema.Kind.STRING) + .field("to", Schema.Kind.STRING) + .field("quality", Schema.Kind.STRING) + .field("via", Schema.Kind.STRING) + .field("evidence", Schema.Kind.OBJECTS, REF), + "unresolved", entity() + .field("kind", Schema.Kind.STRING) + .field("ref", Schema.Kind.STRING) + .field("reason", Schema.Kind.STRING) + .field("state", Schema.Kind.STRING), + "commit", new Schema() + .field("digest", Schema.Kind.STRING) + .field("counts", Schema.Kind.OBJECT, new Schema() + .field("nodes", Schema.Kind.INTEGER) + .field("relations", Schema.Kind.INTEGER) + .field("unresolved", Schema.Kind.INTEGER))); private final Header header; private final List nodes; private final List relations; @@ -78,7 +147,7 @@ public static SessionFlowRound parse(final byte[] body) { String commitDigest = null; JsonObject commitCounts = null; boolean sawCommit = false; - // the checks the Sessionizer's own reader makes; a round that fails one is not a round + // the rules Session Flow gives a reader; a round that breaks one is not a round final Map ids = new HashMap<>(); int lineNo = 0; for (final String line : rawLines) { @@ -86,7 +155,12 @@ public static SessionFlowRound parse(final byte[] body) { if (line.isEmpty()) { continue; } - final JsonObject json = JsonParser.parseString(line).getAsJsonObject(); + final JsonElement frame = Schema.parse(line); + if (frame == null || !frame.isJsonObject()) { + throw new IllegalArgumentException( + "line " + lineNo + " is not a JSON object nested at most " + Schema.MAX_DEPTH + " levels deep"); + } + 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"); @@ -94,6 +168,11 @@ public static SessionFlowRound parse(final byte[] body) { if (sawCommit) { throw new IllegalArgumentException("content after the commit frame"); } + final Schema shape = FRAMES.get(t); + if (shape != null && shape.read(json) == null) { + throw new IllegalArgumentException( + "line " + lineNo + ": a field of the " + t + " frame is not of the type Session Flow gives it"); + } switch (t) { case "header": if (header != null) { @@ -103,10 +182,22 @@ public static SessionFlowRound parse(final byte[] body) { header.validate(); break; case "node": { - final Node n = new Node(json); + // an attribute that names provider bodies but does not read as them is refused, not skipped + final List bodies; + try { + bodies = providerBodiesOf(json.get("attrs")); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException("line " + lineNo + ": " + e.getMessage()); + } + final Node n = new Node(json, 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 +210,7 @@ public static SessionFlowRound parse(final byte[] body) { 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; } @@ -173,6 +264,14 @@ public static SessionFlowRound parse(final byte[] body) { Digests.countLines(body), body.length); } + /** The fields every entity frame has. */ + private static Schema entity() { + return new Schema() + .field("id", Schema.Kind.STRING) + .field("revision", Schema.Kind.INTEGER) + .field("tombstone", Schema.Kind.BOOLEAN); + } + private static void claim(final Map ids, final int line, final String kind, final String id) { if (StringUtil.isEmpty(id)) { throw new IllegalArgumentException("line " + line + ": " + kind + " frame has no id"); @@ -197,21 +296,67 @@ private static void checkRevision(final int line, final long revision, final Hea * A reference past the range the header declares it read describes evidence the round did not claim to * have seen, and its input digest does not cover it. */ - private static void checkRefs(final int line, @Nullable final Ref one, final List many, final Header header) { - final List all = new ArrayList<>(); - if (one != null) { - all.add(one); + 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"); + } + // landed sequences and rows count from 1, so neither is ever negative + if (r.getSeq() < 0 || r.getRow() < 0) { + throw new IllegalArgumentException("line " + line + ": a reference to a negative seq or row"); + } + 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 refs, final Header header) { + for (final Ref r : refs) { + checkRef(line, r, header); + } + } + + /** + * The provider bodies a node carries under its provider_bodies attribute: a list of + * {role, ref}, where role is request or response, and ref names the landed record. The join is made + * when the Sessionizer parses the round, and the round carries it, so no reader repeats it. + * + * @param attrs a node's attributes, 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 of another + * shape, a role that is not request or response, or a seq or row below one + */ + static List providerBodiesOf(@Nullable final JsonElement attrs) { + final JsonElement listed = attrs != null && attrs.isJsonObject() ? attrs.getAsJsonObject().get(PROVIDER_BODIES_ATTR) + : null; + if (listed == null || listed.isJsonNull()) { + return Collections.emptyList(); + } + if (!listed.isJsonArray()) { + throw new IllegalArgumentException(PROVIDER_BODIES_ATTR + " is not a list of provider bodies"); } - 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"); + final List out = new ArrayList<>(); + for (final JsonElement e : listed.getAsJsonArray()) { + final Map body = PROVIDER_BODY.read(e); + if (body == null) { + throw new IllegalArgumentException(PROVIDER_BODIES_ATTR + " is not a list of provider bodies"); } - if (r.getSeq() > header.getThroughSeq()) { - throw new IllegalArgumentException("line " + line + ": reference to landed sequence " + r.getSeq() - + ", past the round's declared " + header.getThroughSeq()); + final String role = (String) body.get("role"); + if (!ROLE_REQUEST.equals(role) && !ROLE_RESPONSE.equals(role)) { + throw new IllegalArgumentException("a provider body has the role \"" + role + "\""); } + final Ref ref = Ref.of((Map) body.get("ref")); + // both halves of the position: sequences and rows are counted from one + if (ref.getSeq() < 1 || ref.getRow() < 1) { + 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() { @@ -256,7 +401,7 @@ public static final class Header { } /** - * The header the Sessionizer's own reader would refuse: it cannot be acted on. + * A header that breaks one of Session Flow's header rules cannot be acted on. */ void validate() { if (!"sf/1".equals(schema)) { @@ -268,7 +413,7 @@ void validate() { if (StringUtil.isEmpty(session)) { throw new IllegalArgumentException("header missing session"); } - if (round == 0) { + if (round < 1) { throw new IllegalArgumentException("round must count from 1"); } if (round > 1 && StringUtil.isEmpty(previous)) { @@ -286,8 +431,8 @@ void validate() { if (StringUtil.isEmpty(inputDigest)) { throw new IllegalArgumentException("header missing input digest"); } - if (fromSeq == 0) { - throw new IllegalArgumentException("landed sequences count from 1, so from_seq must not be 0"); + if (fromSeq < 1) { + throw new IllegalArgumentException("landed sequences count from 1, so from_seq must be 1 or more"); } if (throughSeq < fromSeq - 1) { throw new IllegalArgumentException("round " + round + " consumes sequences " + fromSeq + ".." + throughSeq + ", which is not a range"); @@ -320,13 +465,16 @@ public static final class Node extends Entity { @Nullable private final Ref ref; private final List refs; - @Nullable /** The attrs as written, any JSON value, for rendering; null when the frame has none. */ + @Nullable private final JsonElement rawAttrs; /** The attrs when they are an object, for lookups. */ + @Nullable private final JsonObject attrs; + /** The provider bodies the node carries, read from its attributes as written. */ + private final List providerBodies; - Node(final JsonObject json) { + Node(final JsonObject json, final List providerBodies) { super(json); this.kind = SessionDataFile.string(json, "kind"); this.parent = SessionDataFile.string(json, "parent"); @@ -341,13 +489,13 @@ public static final class Node extends Entity { this.refs = Collections.unmodifiableList(list); this.rawAttrs = json.get("attrs"); this.attrs = rawAttrs != null && rawAttrs.isJsonObject() ? rawAttrs.getAsJsonObject() : null; + this.providerBodies = Collections.unmodifiableList(providerBodies); } - @Nullable /** - * @return the attr when it is a string, as the Sessionizer's attrString; null for a number, - * an object or nothing + * @return the attr when it is a JSON string; null for a number, an object or nothing */ + @Nullable public String attr(final String key) { if (attrs == null) { return null; @@ -375,6 +523,17 @@ public boolean attrBool(final String key) { } } + /** + * 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 b057f68c669d..456c0cfb0297 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 @@ -35,7 +35,7 @@ public final class Times { * The stamp in a landed file's name: 20260904T033423.840913000Z, from the header's at. */ private static final DateTimeFormatter FILE_STAMP = - DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss.nnnnnnnnn'Z'").withZone(ZoneOffset.UTC); + DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss.nnnnnnnnn'Z'").withZone(ZoneOffset.UTC); private Times() { } @@ -59,29 +59,47 @@ public static long nanos(final String rfc3339) { } /** - * RFC 3339 as Go's time.RFC3339Nano reads it: an offset is accepted, not only Z. + * Orders two record times by the instant they name. RFC 3339 text does not sort as text when its fractions differ + * in length, and the Sessionizer's plugin 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. + * + * @return negative, zero or positive as a is before, at or after b */ - @Nullable - private static Instant instant(@Nullable final String rfc3339) { - if (StringUtil.isEmpty(rfc3339)) { - return null; + 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); } - try { - return OffsetDateTime.parse(rfc3339, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant(); - } catch (final DateTimeParseException e) { - return null; + if ((ta == null) != (tb == null)) { + return ta != null ? -1 : 1; } + return 0; } /** * @param rfc3339 a header's at * @return the stamp used in the landed file's name, or null when absent or unparseable */ - public static String fileStamp(final String rfc3339) { + @Nullable + public static String fileStamp(@Nullable final String rfc3339) { + final Instant t = instant(rfc3339); + return t == null ? null : FILE_STAMP.format(t); + } + + /** RFC 3339, with Z or an offset, and a year of four digits, as RFC 3339 has it. */ + @Nullable + private static Instant instant(@Nullable final String rfc3339) { if (StringUtil.isEmpty(rfc3339)) { return null; } - final Instant t = instant(rfc3339); - return t == null ? null : FILE_STAMP.format(t); + try { + final OffsetDateTime t = OffsetDateTime.parse(rfc3339, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + // java.time also reads years past 9999 and before 0; a moment that far out overflows unix milliseconds + return t.getYear() >= 0 && t.getYear() <= 9999 ? t.toInstant() : null; + } catch (final DateTimeParseException e) { + return null; + } } } 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 000000000000..069428b629ef --- /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, keys in the order its format lists them + */ + Map fields(); +} diff --git a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryService.java b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryService.java index f1649e7e9fce..9c5da4ad84c2 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryService.java +++ b/oap-server/analyzer/ai-agent-conversation/src/main/java/org/apache/skywalking/oap/server/ai/agent/conversation/query/ConversationQueryService.java @@ -342,7 +342,7 @@ private static final class Chain { /** Every stored round in number order, readable or not, for the document's listing. */ final List roundInputs = new ArrayList<>(); final Map files = new TreeMap<>(); - /** What stopped the fold short of the chain's last round, in words, as the Sessionizer's FoldPartial. */ + /** What stopped the fold short of the chain's last round, in the words of the Sessionizer's document. */ final List problems = new ArrayList<>(); } @@ -441,8 +441,7 @@ private Chain readChain(final String serviceId, @Nullable final String serviceIn try { chain.files.put(f.getSeq(), SessionDataFile.parse(f.getBody())); } catch (final RuntimeException e) { - // as the Sessionizer's reader, a file that does not decode contributes no records and is - // named by the chain check as missing + // a file that does not read contributes no records, and the chain check names it as missing chain.problems.add("file seq " + f.getSeq() + " of session " + session + " cannot be decoded: " + e.getMessage()); } 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 a42d18440e4e..c427942814e5 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,32 +21,37 @@ 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.ArrayDeque; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.Deque; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; 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.BiConsumer; +import java.util.function.Function; 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.CodePointOrder; 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.Ref; +import org.apache.skywalking.oap.server.ai.agent.conversation.format.Schema; 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; /** @@ -60,30 +65,61 @@ public final class ConversationViewBuilder { /** The readable text a node carries is clipped to this many bytes; the full size is in bytes. */ static final int PREVIEW_BYTES = 2000; + /** + * How many levels below its root a tree is written, as the asz.view page says. No talk measured has more than three. + */ 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 shapes the view reads out of records and attributes. */ + private static final Schema JOURNAL_ROW = new Schema() + .field("type", Schema.Kind.STRING) + .field("result", Schema.Kind.OBJECT, new Schema() + .field("surface", Schema.Kind.STRING) + .field("summary", Schema.Kind.STRING) + .field("verdict", Schema.Kind.STRING) + .field("refuted_claims", Schema.Kind.OBJECTS, new Schema() + .field("claim", Schema.Kind.STRING))); + private static final Schema QUEUED_COMMAND = new Schema() + .field("type", Schema.Kind.STRING) + .field("prompt", Schema.Kind.OBJECTS, new Schema() + .field("text", Schema.Kind.STRING)); + private static final Schema DURATION = new Schema() + .field("durationMs", Schema.Kind.INTEGER); + private static final Schema SESSION_ATTRS = new Schema() + .field("provider_bodies_landed", Schema.Kind.INTEGER); + private static final Schema USAGE_AT = new Schema() + .field("usage_at", Schema.Kind.NULLABLE_OBJECT, SessionFlowRound.REF); + /** A call's token counts, the four the page lists, a count of zero left out. */ + private static final Schema USAGE = new Schema() + .optional("in", Schema.Kind.INTEGER) + .optional("out", Schema.Kind.INTEGER) + .optional("cache_read", Schema.Kind.INTEGER) + .optional("cache_write", Schema.Kind.INTEGER); + /** One thing a conversion left out, as the page lists it. */ + private static final Schema DROP = new Schema() + .field("what", Schema.Kind.STRING) + .field("bytes", Schema.Kind.INTEGER) + .optional("why", Schema.Kind.STRING); private final ConversationFold fold; private final List rounds; - private final Map files; + /** By seq, so every walk over the files is in landed order. */ + private final TreeMap files; private final List problems; /** Every timed record's moment in nanoseconds, the precision the Sessionizer computes intervals with. */ private final Map at = new HashMap<>(); + /** + * 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 lanes = new HashMap<>(); /** Each step's workspace change ids, in the order the document lists the records; filled by {@link #workspaceChanges}. */ private final Map> changesByStep = new HashMap<>(); - /** Each call step's joined provider bodies, its request then its response; filled by {@link #providerBodies}. */ + /** Each step's tool execution record ids, in the order the document lists the records; filled by {@link #toolExecutions}. */ + private final Map> executionsByStep = new HashMap<>(); + /** Each call step's joined provider bodies, its request then its response; filled by {@link #joinProviderBodies}. */ private final Map>> providerBodiesByStep = new HashMap<>(); /** @@ -99,9 +135,10 @@ public ConversationViewBuilder(final ConversationFold fold, final List 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 +155,8 @@ public Map build() { final Chain chain = chain(); // before the nodes are rendered: each step lists the ids of its records final List> workspaceChanges = workspaceChanges(); - final int providerBodies = providerBodies(); - int capturedPrompts = 0; - for (final List> bodies : providerBodiesByStep.values()) { - for (final Map b : bodies) { - if (ROLE_REQUEST.equals(b.get("role"))) { - capturedPrompts++; - } - } - } + final List> toolExecutions = toolExecutions(); + final int capturedPrompts = joinProviderBodies(); final Map doc = new LinkedHashMap<>(); doc.put("format", ViewYaml.FORMAT); @@ -151,7 +181,7 @@ public Map build() { summary.put("rounds", chain.rounds.size()); summary.put("unresolved", fold.openUnresolved().size()); summary.put("changes", workspaceChanges.size()); - summary.put("provider_bodies", providerBodies); + summary.put("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 +201,7 @@ public Map build() { doc.put("relations", relations()); doc.put("unresolved", unresolved()); doc.put("workspace_changes", workspaceChanges); + doc.put("tool_executions", toolExecutions); return doc; } @@ -189,7 +220,7 @@ private List sessions() { others.add(s); } } - Collections.sort(others); + others.sort(CodePointOrder.ORDER); out.addAll(others); return out; } @@ -276,28 +307,24 @@ private Chain chain() { ok = false; } final List added = new ArrayList<>(); - // A round names a range of sequences, and a round that names an impossible one - or a chain - // whose files are all gone - would otherwise cost one entry per absent sequence before they - // are coalesced, and the counter itself would wrap at the end of the range. - long missingFrom = 0; - long missingTo = 0; - for (long seq = h.getFromSeq(); seq <= h.getThroughSeq() && seq >= h.getFromSeq(); seq++) { - final SessionDataFile f = files.get(seq); - if (f == null) { - if (missingFrom == 0) { - missingFrom = seq; - } else if (seq != missingTo + 1) { - missingFiles.add(new long[] {h.getRound(), missingFrom, missingTo}); - missingFrom = seq; + // Only the files that landed are visited, and each gap between them is one range: a round can name a + // range of sequences far larger than the files a chain holds, so the range itself is never walked. + if (h.getThroughSeq() >= h.getFromSeq()) { + long next = h.getFromSeq(); + for (final Map.Entry e + : files.subMap(h.getFromSeq(), true, h.getThroughSeq(), true).entrySet()) { + if (e.getKey() > next) { + missingFiles.add(new long[] {h.getRound(), next, e.getKey() - 1}); + ok = false; } - missingTo = seq; + added.add(e.getValue().getFileDigest()); + next = e.getKey() + 1; + } + // next - 1 is the last sequence seen, so the comparison cannot wrap past the end of the range + if (next - 1 < h.getThroughSeq()) { + missingFiles.add(new long[] {h.getRound(), next, h.getThroughSeq()}); ok = false; - continue; } - added.add(f.getFileDigest()); - } - if (missingFrom != 0) { - missingFiles.add(new long[] {h.getRound(), missingFrom, missingTo}); } if (ok && !Digests.chainInputDigest(prevInput, added).equals(h.getInputDigest())) { chain.mismatch("round " + h.getRound() + ": the input digest does not match the landed files"); @@ -375,10 +402,7 @@ private static List missingFileProblems(final List missing) { private List> files() { final List> out = new ArrayList<>(); - final List seqs = new ArrayList<>(files.keySet()); - Collections.sort(seqs); - for (final Long seq : seqs) { - final SessionDataFile f = files.get(seq); + for (final SessionDataFile f : files.values()) { final SessionDataFile.Header h = f.getHeader(); final Map m = new LinkedHashMap<>(); m.put("file", FileNames.dataFile(h)); @@ -407,6 +431,8 @@ private static final class TalkRow { 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 +443,9 @@ private static final class TalkRow { private static final class Overview { String title = ""; - Map kinds; - Map relationTypes; - Map quality; + Map kinds; + Map relationTypes; + Map quality; List talks; List> streams; List> segments; @@ -427,27 +453,26 @@ private static final class Overview { private Overview overview() { final Overview o = new Overview(); - final TreeMap kinds = new TreeMap<>(); + o.kinds = new TreeMap<>(CodePointOrder.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 quality = new TreeMap<>(); - final TreeMap rels = new TreeMap<>(); + o.relationTypes = new TreeMap<>(CodePointOrder.ORDER); + o.quality = new TreeMap<>(CodePointOrder.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 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 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 inSegment = new HashMap<>(); @@ -460,16 +485,22 @@ private Overview overview() { for (final SessionFlowRound.Node t : fold.nodesOfKind("talk")) { final TalkRow row = new TalkRow(); row.node = t; - row.child = childStream.containsKey(t.getStream()); - final long[] span = span(t); + row.child = childStreams.contains(t.getStream()); + row.segment = inSegment.getOrDefault(t.getId(), ""); + final List tree = under(t.getId()); + final long[] span = span(t, tree); row.from = span[0]; row.to = span[1]; - row.segment = inSegment.getOrDefault(t.getId(), ""); - walkCounts(t.getId(), row); - row.labelAt = labelRefs(t); - row.replyAt = replyRef(t); + row.began = beganAt(t, tree); + count(tree, row); + row.labelAt = labelRefs(tree); + row.replyAt = replyRef(tree); 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); @@ -491,8 +522,33 @@ private Overview overview() { return o; } - private void walkCounts(final String id, final TalkRow row) { - for (final SessionFlowRound.Node k : fold.children(id)) { + /** + * Every node under a node, in record order: each child, then what is under it, before the next child. It is a + * loop that visits each node once, so a tree however deep ends, and so does a fold whose parents loop. + */ + private List under(final String id) { + final List out = new ArrayList<>(); + final Set seen = new HashSet<>(); + seen.add(id); + final Deque> open = new ArrayDeque<>(); + open.push(fold.children(id).iterator()); + while (!open.isEmpty()) { + final Iterator next = open.peek(); + if (!next.hasNext()) { + open.pop(); + continue; + } + final SessionFlowRound.Node k = next.next(); + if (seen.add(k.getId())) { + out.add(k); + open.push(fold.children(k.getId()).iterator()); + } + } + return out; + } + + private static void count(final List tree, final TalkRow row) { + for (final SessionFlowRound.Node k : tree) { if ("run".equals(k.getKind())) { row.runs++; } @@ -502,50 +558,33 @@ private void walkCounts(final String id, final TalkRow row) { if ("tool".equals(k.getKind()) || "agent.call".equals(k.getKind())) { row.tools++; } - walkCounts(k.getId(), row); - } - } - - private List labelRefs(final SessionFlowRound.Node t) { - final List inj = new ArrayList<>(); - final Ref[] ext = new Ref[1]; - walkLabel(t.getId(), inj, ext); - if (ext[0] != null) { - return Collections.singletonList(ext[0]); } - return inj; } - private boolean walkLabel(final String id, final List inj, final Ref[] ext) { - for (final SessionFlowRound.Node k : fold.children(id)) { + /** The talk's first external message; failing that, its first three injections. */ + private static List labelRefs(final List tree) { + final List injections = new ArrayList<>(); + for (final SessionFlowRound.Node k : tree) { if ("message.external".equals(k.getKind()) && k.getRef() != null) { - ext[0] = k.getRef(); - return true; + return Collections.singletonList(k.getRef()); } - if ("context.injection".equals(k.getKind()) && k.getRef() != null && inj.size() < 3) { - inj.add(k.getRef()); - } - if (walkLabel(k.getId(), inj, ext)) { - return true; + if ("context.injection".equals(k.getKind()) && k.getRef() != null && injections.size() < 3) { + injections.add(k.getRef()); } } - return false; + return injections; } + /** The talk's last assistant message or agent output. */ @Nullable - private Ref replyRef(final SessionFlowRound.Node t) { - final Ref[] found = new Ref[1]; - walkReply(t.getId(), found); - return found[0]; - } - - private void walkReply(final String id, final Ref[] found) { - for (final SessionFlowRound.Node k : fold.children(id)) { + private static Ref replyRef(final List tree) { + Ref found = null; + for (final SessionFlowRound.Node k : tree) { if (("message.assistant".equals(k.getKind()) || "agent.output".equals(k.getKind())) && k.getRef() != null) { - found[0] = k.getRef(); + found = k.getRef(); } - walkReply(k.getId(), found); } + return found; } private List> segmentRows(final List talks) { @@ -557,12 +596,8 @@ private List> segmentRows(final List talks) { } count.merge(t.segment, 1, Integer::sum); final long[] s = span.computeIfAbsent(t.segment, x -> new long[2]); - if (t.from != 0 && (s[0] == 0 || t.from < s[0])) { - s[0] = t.from; - } - if (t.to > s[1]) { - s[1] = t.to; - } + widen(s, t.from); + widen(s, t.to); } final List> out = new ArrayList<>(); for (final SessionFlowRound.Node n : fold.nodesOfKind("segment")) { @@ -602,19 +637,28 @@ private List> streamRows(final List talks) { } final Map parent = new HashMap<>(); final Map>> 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> 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 stepPoint = r -> pointOf(fold.node(r.getFrom()).getRef()); + for (final Map.Entry> 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 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 journalNames = journalNames(); @@ -632,7 +676,7 @@ private List> streamRows(final List talks) { 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 +691,7 @@ private List> streamRows(final List talks) { */ private Map journalNames() { final Map names = new HashMap<>(); - final List seqs = new ArrayList<>(files.keySet()); - Collections.sort(seqs); - for (final Long seq : seqs) { - final SessionDataFile f = files.get(seq); + for (final SessionDataFile f : files.values()) { if (StringUtil.isEmpty(f.getHeader().getBatch())) { continue; } @@ -659,100 +700,57 @@ private Map journalNames() { 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 refuted_claims is absent or a list of objects whose claim 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 Map row = JOURNAL_ROW.read(Schema.parse(raw)); + if (row == null || !"result".equals(row.get("type"))) { + continue; } + final Map result = (Map) row.get("result"); + String name = (String) result.get("surface"); + if (name.isEmpty()) { + name = (String) result.get("summary"); + } + if (name.isEmpty() && !((String) result.get("verdict")).isEmpty()) { + name = (String) result.get("verdict"); + final List refuted = (List) result.get("refuted_claims"); + final String claim = refuted == null || refuted.isEmpty() ? "" : (String) ((Map) refuted.get(0)).get("claim"); + 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; } /** - * The text split on Unicode white space, as Go's strings.Fields, so a no-break space splits too. + * The words of a text, split on white space, so a no-break space splits too. */ private static List fields(final String s) { final List out = new ArrayList<>(); @@ -776,7 +774,8 @@ private static List fields(final String s) { } /** - * Go's unicode.IsSpace: the Latin-1 spaces and the Unicode space separators. + * White space as the asz.view page defines it, Unicode's White_Space property: the + * Latin-1 spaces, the line and paragraph separators, and the space separators. */ private static boolean isSpace(final int cp) { return cp == ' ' || cp == '\t' || cp == '\n' || cp == '\u000B' || cp == '\f' || cp == '\r' || cp == 0x85 @@ -784,7 +783,7 @@ private static boolean isSpace(final int cp) { } /** - * Go's strings.TrimSpace. + * The text without the white space at either end. */ private static String trimSpace(final String s) { int start = 0; @@ -832,8 +831,8 @@ private List> talks(final Overview o) { } /** - * One node, keys in the order of sessionview.Node, empty values absent. A talk carries its row's - * summary keys between the record keys and the tool keys. + * One node, keys in the order the asz.view page lists them, empty values absent. A talk carries its + * row's summary keys between the record keys and the tool keys. */ private Map step(final SessionFlowRound.Node n, final int depth, @Nullable final TalkRow talk) { final Map out = new LinkedHashMap<>(); @@ -856,9 +855,10 @@ private Map step(final SessionFlowRound.Node n, final int depth, } out.put("refs", refs); } - if (n.getRawAttrs() != null) { - // as the Sessionizer prints the raw attrs: an empty object stays {}, an explicit null stays null - out.put("attrs", jsonToValue(n.getRawAttrs())); + final JsonElement attrs = withoutProviderBodies(n.getRawAttrs()); + if (attrs != null) { + // as the round wrote them: an empty object stays {}, an explicit null stays null + out.put("attrs", jsonToValue(attrs)); } // text, state, bytes; then usage, flags, dropped; then the talk keys; then the tool keys @@ -876,7 +876,7 @@ private Map step(final SessionFlowRound.Node n, final int depth, if (dropped != null && dropped.size() > 0) { final List drops = new ArrayList<>(); for (final JsonElement d : dropped) { - drops.add(d.isJsonObject() ? jsonToMap(d.getAsJsonObject()) : d.toString()); + drops.add(DROP.read(d)); } content.put("dropped", drops); } @@ -892,10 +892,10 @@ private Map step(final SessionFlowRound.Node n, final int depth, } 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)); + // by the fields the page lists, so a key it does not list is not copied; an empty usage stays {} + content.put("usage", USAGE.read(usage)); } - // sessionview.Node lists text, state, bytes, then usage, flags, dropped + // the page lists text, state, bytes, then usage, flags, dropped for (final String key : new String[] {"text", "state", "bytes", "usage", "flags", "dropped"}) { if (content.containsKey(key)) { out.put(key, content.get(key)); @@ -941,6 +941,10 @@ private Map step(final SessionFlowRound.Node n, final int depth, if (changes != null && !changes.isEmpty()) { out.put("changes", new ArrayList<>(changes)); } + final List executions = executionsByStep.get(n.getId()); + if (executions != null && !executions.isEmpty()) { + out.put("executions", new ArrayList<>(executions)); + } final List> bodies = providerBodiesByStep.get(n.getId()); if (bodies != null && !bodies.isEmpty()) { out.put("provider_bodies", bodies); @@ -954,16 +958,18 @@ private Map step(final SessionFlowRound.Node n, final int depth, out.put("children", children); } } - // every relation touching the node, by relation id and then direction, as the Sessionizer lists them - final List> edges = new ArrayList<>(); + // every relation touching the node, in the order the relations happened, as the Sessionizer lists them + final List> 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 e) -> (String) e.get("id")) - .thenComparing(e -> (String) e.get("dir"))); + final List> edges = Order.inOrder( + touching, e -> relationPoint(fold.getRelations().get((String) e.get("id"))), + Comparator.comparing((Map e) -> (String) e.get("id"), CodePointOrder.ORDER) + .thenComparing(e -> (String) e.get("dir"))); for (final Map e : edges) { e.remove("id"); } @@ -976,7 +982,8 @@ private Map step(final SessionFlowRound.Node n, final int depth, /** * The runs and steps no talk contains, as trees from their highest such ancestor, in record order: a step is * contained when a talk is above it; one whose ancestors are only structure, the session, a stream, an epoch - * or a segment, is loose. With talks this holds every run and step of the fold. + * or a segment, is loose. With talks this holds every run and step of the fold within twelve levels + * of its tree's root. */ private List> loose() { final Map roots = new HashMap<>(); @@ -986,8 +993,11 @@ private List> loose() { } 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 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 +1005,6 @@ private List> loose() { 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 +1034,7 @@ private static Map edge(final SessionFlowRound.Relation r, final } private static void fill(final Map content, final Map 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,20 +1070,22 @@ private static void fill(final Map content, final Map attrs = USAGE_AT.read(n.getRawAttrs()); + if (attrs == null || attrs.get("usage_at") == null) { return null; } - final SessionDataFile.Record at = record(Ref.of(n.getAttrs().getAsJsonObject("usage_at"))); + final SessionDataFile.Record at = record(Ref.of((Map) attrs.get("usage_at"))); return at == null ? null : at.usage(); } private static void fillResult(final Map tool, final SessionDataFile.Record rec, - @Nullable final Integer block) { - SessionDataFile.Part p = null; - if (block != null && block < rec.getParts().size()) { - p = rec.getParts().get(block); - } else { + @Nullable final Long block) { + SessionDataFile.Part p = named(rec, block); + if (p == null) { for (final SessionDataFile.Part x : rec.getParts()) { if ("result".equals(x.getKind())) { p = x; @@ -1085,7 +1096,7 @@ private static void fillResult(final Map tool, final SessionData if (p == null) { return; } - // assigned, not merged: a later result record with no state or size clears the earlier one's, as Go does + // assigned, not merged: each part read sets the state and the size, even when it has neither if (StringUtil.isNotEmpty(p.getState())) { tool.put("result_state", p.getState()); } else { @@ -1115,15 +1126,15 @@ private void fillRequestToResult(final Map tool, final SessionFl if (!"exact_unique".equals(join)) { return; } - // in nanoseconds, as the Sessionizer subtracts them, floored to milliseconds only at the end + // the difference at full precision, cut to whole milliseconds only at the end final long from = nanosAt(n.getRefs().get(0)); final long to = nanosAt(n.getRefs().get(1)); if (from == 0 || to == 0 || to < from) { return; } final long ms = (to - from) / 1_000_000L; + // a gap under one millisecond leaves the interval out and keeps the join if (ms != 0) { - // Go leaves a zero interval out (omitempty) and keeps the join tool.put("request_to_result_ms", ms); } tool.put("request_to_result_join", join); @@ -1134,26 +1145,7 @@ private static void fillDuration(final Map content, final Map content, final Map probe = DURATION.read(Schema.parse(raw)); + if (probe != null && (Long) probe.get("durationMs") != 0) { + return (Long) probe.get("durationMs"); + } + } + return 0; + } + + /** + * @return every relation of the fold, in the order they happened, each at the earliest record that supports it + */ private List> relations() { - final List sorted = new ArrayList<>(fold.getRelations().values()); - sorted.sort(Comparator.comparing(SessionFlowRound.Relation::getId)); final List> out = new ArrayList<>(); - for (final SessionFlowRound.Relation r : sorted) { + final List all = new ArrayList<>(fold.getRelations().values()); + for (final SessionFlowRound.Relation r : Order.inOrder(all, this::relationPoint, RELATION_TIE)) { final Map m = new LinkedHashMap<>(); m.put("id", r.getId()); m.put("type", nullToEmpty(r.getType())); @@ -1193,7 +1202,7 @@ private List> relations() { private List> unresolved() { final List sorted = new ArrayList<>(fold.getUnresolved().values()); - sorted.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId)); + sorted.sort(Comparator.comparing(SessionFlowRound.Unresolved::getId, CodePointOrder.ORDER)); final List> out = new ArrayList<>(); for (final SessionFlowRound.Unresolved u : sorted) { final Map m = new LinkedHashMap<>(); @@ -1207,522 +1216,281 @@ private List> unresolved() { 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 changes 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 changes 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> workspaceChanges() { - // which step carries which tool-use id, from the call part each tool step points at - final Map 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 entries = new ArrayList<>(); + final Map stepOf = stepsByToolUse(); + final List> entries = new ArrayList<>(); final Set seen = new HashSet<>(); - // the runtime's own patches, on the result records the tool steps read + final BiConsumer 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 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; + // 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)); + }); + return listed(entries, changesByStep); + } + + /** + * Every tool execution record the session's execution 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> toolExecutions() { + final Map stepOf = stepsByToolUse(); + final List> entries = new ArrayList<>(); + final Set seen = new HashSet<>(); + final BiConsumer 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); } - return a.record.getId().compareTo(b.record.getId()); + } + 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); + } + + /** + * 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 { + final R record; + final String step; + final Ref ref; + + Joined(final R record, @Nullable final String step, final Ref ref) { + this.record = record; + this.step = nullToEmpty(step); + this.ref = ref; + } + } + + /** + * @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 static List> listed( + final List> entries, final Map> byStep) { final List> out = new ArrayList<>(entries.size()); - for (final WorkspaceChange e : entries) { + for (final Joined e : entries) { final Map 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()); + byStep.computeIfAbsent(e.step, k -> new ArrayList<>()).add(e.record.getId()); } } return out; } - private static void collectChanges(@Nullable final SessionDataFile.Record rec, final long seq, final long row, - final Map stepOf, final Set seen, - final List entries) { + /** 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 consumer) { if (rec == null) { return; } - final List 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; + 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()); } - // 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; - } - entries.add(new WorkspaceChange(nullToEmpty(stepOf.get(r.getTool())), new Ref(seq, row, b), r)); } } - /** One change record with the step it joins to, or none, and the record it was read from. */ - private static final class WorkspaceChange { - 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; - this.record = record; + private List filesOfKind(final String kind) { + final List out = new ArrayList<>(); + for (final SessionDataFile f : files.values()) { + if (kind.equals(f.getHeader().getKind())) { + out.add(f); + } } + return out; } - // ---------------------------------------------------------------- provider bodies - /** - * Joins the session's provider bodies to their calls, as the Sessionizer's view joins them. A body is a - * provider_body record: the request or the response a runtime exchanged with its model provider, - * cut so it keeps only what the session did not hold yet, with a manifest as its last part. The document names - * where each joined body landed and never carries the body; a reader loads the files up to that seq and rebuilds - * it. Fills {@link #providerBodiesByStep} on the way, which {@link #step} reads, so this runs before the nodes are - * rendered. - * - *

A response joins by its message id, which is the call's own. A request names no call, only the request - * before it and its prompt, so it joins to the call of its stream whose previous call's response carries that - * request id and whose prompt is the one it names, when exactly one request and exactly one call carry those two - * ids. A synthetic call was never sent to a provider and takes part in no join. No request joins in a stream - * whose landed transcript lines have a gap, since a call may be missing between two that look consecutive. - * Nothing is joined by position or by time. - * - * @return how many bodies the session holds + * 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. */ - private int providerBodies() { - final List out = new ArrayList<>(); - final Set seen = new HashSet<>(); - final List seqs = new ArrayList<>(files.keySet()); - Collections.sort(seqs); - for (final Long seq : seqs) { - final SessionDataFile f = files.get(seq); - if (!KIND_PROVIDER_BODY.equals(f.getHeader().getKind())) { - continue; - } - for (final SessionDataFile.Record rec : f.getRecords()) { - final JsonObject m = manifest(rec); - // a body landed twice by an interrupted pass is one body - if (m == null || !seen.add(nullToEmpty(rec.getId()))) { - continue; - } - out.add(new Body(new Ref(seq, rec.getRow(), null), nullToEmpty(string(m, "role")), - nullToEmpty(string(m, "run")), nullToEmpty(string(m, "previous_request")), - nullToEmpty(string(m, "request")), nullToEmpty(string(m, "call")))); - } - } - if (out.isEmpty()) { - return 0; - } - - // the calls, with their message id, their prompt and their stream, in line order within a stream - final List calls = new ArrayList<>(); + private Map stepsByToolUse() { + final Map stepOf = new HashMap<>(); for (final SessionFlowRound.Node n : fold.getNodes().values()) { - if (!"llm.call".equals(n.getKind()) || n.getRef() == null) { + if (!"tool".equals(n.getKind()) && !"agent.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")); - } + 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()); } - 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 stepOf; + } - // responses, by message id - final Map> byMsg = new HashMap<>(); - for (int i = 0; i < out.size(); i++) { - final Body b = out.get(i); - if (ROLE_RESPONSE.equals(b.role) && !b.call.isEmpty()) { - byMsg.computeIfAbsent(b.call, x -> new ArrayList<>()).add(i); - } - } - final Map requestOf = new HashMap<>(); - final Map responseOf = new HashMap<>(); - for (final Call k : calls) { - if (k.msg.isEmpty()) { - continue; - } - final List hits = byMsg.getOrDefault(k.msg, Collections.emptyList()); - if (hits.size() == 1) { - final Body b = out.get(hits.get(0)); - b.join = JOIN_EXACT; - requestOf.put(k.id, b.request); - responseOf.put(k.id, hits.get(0)); - } else { - for (final int i : hits) { - out.get(i).join = JOIN_AMBIGUOUS; - } - } - } + // ---------------------------------------------------------------- provider bodies - // requests, by the request before them and their prompt - final Map, List> byKey = new HashMap<>(); - for (int i = 0; i < out.size(); i++) { - final Body b = out.get(i); - if (ROLE_REQUEST.equals(b.role) && !b.run.isEmpty()) { - byKey.computeIfAbsent(Arrays.asList(b.previousRequest, b.run), x -> new ArrayList<>()).add(i); - } - } - // the key each call's request would carry: none when the previous call has no response carrying its request - // id, and none in a stream whose landed lines have a gap - final Map gapped = new HashMap<>(); - for (final Call k : calls) { - gapped.computeIfAbsent(k.stream, this::streamHasGap); - } - final Map> callKey = new HashMap<>(); - final Map, Integer> callsByKey = new HashMap<>(); - for (int i = 0; i < calls.size(); i++) { - final Call k = calls.get(i); - if (gapped.get(k.stream)) { - continue; - } - String prev = ""; - if (i > 0 && calls.get(i - 1).stream.equals(k.stream)) { - prev = requestOf.get(calls.get(i - 1).id); - if (StringUtil.isEmpty(prev)) { - continue; - } - } - if (k.prompt.isEmpty()) { - continue; - } - final List key = Arrays.asList(prev, k.prompt); - callKey.put(k.id, key); - callsByKey.merge(key, 1, Integer::sum); - } - final Map requestFor = new HashMap<>(); - for (final Call k : calls) { - final List key = callKey.get(k.id); - if (key == null) { - continue; - } - final List hits = byKey.getOrDefault(key, Collections.emptyList()); - if (hits.isEmpty()) { - continue; - } - if (hits.size() == 1 && callsByKey.get(key) == 1) { - // one request and one call carry the key: nothing else could be this call's request - final Body b = out.get(hits.get(0)); - if (JOIN_UNRESOLVED.equals(b.join)) { - b.join = JOIN_EXACT; - requestFor.put(k.id, hits.get(0)); - } - } else { - for (final int i : hits) { - if (JOIN_UNRESOLVED.equals(out.get(i).join)) { - out.get(i).join = JOIN_AMBIGUOUS; - } - } - } + /** + * A node's attributes without the provider bodies a call carries. The document gives them a field of their own, + * provider_bodies 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, in its order, and nothing is left when nothing + * else was there. + */ + @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 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()); + final JsonObject out = new JsonObject(); + for (final Map.Entry e : raw.getAsJsonObject().entrySet()) { + if (!SessionFlowRound.PROVIDER_BODIES_ATTR.equals(e.getKey())) { + out.add(e.getKey(), e.getValue()); } } - return out.size(); + return out.size() == 0 ? null : out; } /** - * @param rec a record of a provider_body file - * @return its manifest: the last part, when it is a data part holding a provider_body/1 object that - * decodes as the Sessionizer's providerbody.Manifest, or null + * 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. + * + *

The join is not made here. The Sessionizer makes it once, when it parses the round, and the round carries it + * on each llm.call: 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 */ - @Nullable - private static JsonObject manifest(final SessionDataFile.Record rec) { - final List parts = rec.getParts(); - if (parts.isEmpty()) { - return null; - } - final SessionDataFile.Part last = parts.get(parts.size() - 1); - final String data = last.data(); - if (!"data".equals(last.getKind()) || data == null) { - return null; - } - final JsonObject m; - try { - final JsonElement e = JsonParser.parseString(data); - if (!e.isJsonObject()) { - return null; - } - m = e.getAsJsonObject(); - } catch (final RuntimeException e) { - return null; - } - if (!fieldsDecode(m, MANIFEST_STRINGS, MANIFEST_INTS)) { - return null; - } - final JsonElement segments = m.get("segments"); - if (segments != null && !segments.isJsonNull()) { - if (!segments.isJsonArray()) { - return null; + private int joinProviderBodies() { + int requests = 0; + for (final SessionFlowRound.Node n : fold.getNodes().values()) { + if (!"llm.call".equals(n.getKind())) { + 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; + for (final SessionFlowRound.ProviderBody y : n.getProviderBodies()) { + final Map 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++; } } } - return PROVIDER_BODY_SCHEMA.equals(string(m, "schema")) ? m : null; + return requests; } - private static final String[] MANIFEST_STRINGS = { - "schema", "role", "src", "sha256", "chain", "why", "model", "session", "run", "call", "request", "previous_request"}; - private static final String[] MANIFEST_INTS = {"bytes", "depth"}; - private static final String[] SEGMENT_STRINGS = {"lit", "piece"}; - private static final String[] SEGMENT_INTS = {"part"}; - private static final String[] COPY_STRINGS = {"from", "sha256"}; - private static final String[] COPY_INTS = {"len"}; - /** - * @return whether each named key is absent, null, or of the type Go decodes it into: a string, or an integer that - * fits a 64-bit int. A value of another type fails Go's decoding of the whole object. + * @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 something other than a whole number */ - private static boolean fieldsDecode(final JsonObject o, final String[] strings, final String[] ints) { - for (final String key : strings) { - final JsonElement v = o.get(key); - if (v != null && !v.isJsonNull() && !(v.isJsonPrimitive() && v.getAsJsonPrimitive().isString())) { - return false; - } - } - for (final String key : ints) { - final JsonElement v = o.get(key); - if (v != null && !v.isJsonNull() && integer(v, false) == null) { - return false; - } - } - return true; + private long landedProviderBodies() { + final SessionFlowRound.Node session = fold.node(sessionNodeId()); + final Map attrs = session == null ? null : SESSION_ATTRS.read(session.getRawAttrs()); + return attrs == null ? 0 : (Long) attrs.get("provider_bodies_landed"); } /** - * @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 + * @param rec a record + * @param block the part a reference names, or null + * @return the part named, or the only part when none is named, or null */ @Nullable - private static Long integer(final JsonElement v, final boolean unsigned) { - if (!v.isJsonPrimitive() || !v.getAsJsonPrimitive().isNumber() || !INTEGER_LITERAL.matcher(v.getAsString()).matches()) { - return null; + private static SessionDataFile.Part partAt(final SessionDataFile.Record rec, @Nullable final Long block) { + final SessionDataFile.Part p = named(rec, block); + if (p != null) { + return p; } - 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(); + return rec.getParts().size() == 1 ? rec.getParts().get(0) : null; } - /** - * @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 - */ + /** The part a block names, or null when the record has no part at that position, a negative one included. */ @Nullable - private static SessionDataFile.Record decodable(@Nullable final SessionDataFile.Record rec) { - return rec == null || !fieldsDecode(rec.getJson(), RECORD_STRINGS, new String[0]) ? null : rec; + private static SessionDataFile.Part named(final SessionDataFile.Record rec, @Nullable final Long block) { + return block != null && block >= 0 && block < rec.getParts().size() ? rec.getParts().get(block.intValue()) : null; } - private static final String[] RECORD_STRINGS = {"call", "run"}; - - /** - * @param stream a stream - * @return whether the stream's landed transcript lines skip a line: the first is not line 1, or a line after a - * later one is missing. A line landed twice is a repeat, not a gap. Only each record's ord is read, as the - * Sessionizer reads it from the raw line: the digits after a leading {"ord":, or else the line - * decoded. A line that does not decode, or a file the reader refuses, may hide a missing call, so it counts as a - * gap. - */ - private boolean streamHasGap(final String stream) { - final List seqs = new ArrayList<>(files.keySet()); - Collections.sort(seqs); - long prev = 0; - for (final Long seq : seqs) { - final SessionDataFile f = files.get(seq); - final SessionDataFile.Header h = f.getHeader(); - if (!"transcript".equals(h.getKind()) || !stream.equals(nullToEmpty(h.getStream()))) { - continue; - } - if (!h.isValid()) { - return true; - } - for (final SessionDataFile.Record rec : f.getRecords()) { - final long ord; - if (rec.getLeadingOrd() != null) { - if (rec.getLeadingOrd().isEmpty()) { - return true; - } - // Go reads the digits into a uint64 and lets it wrap, so the long's bits are that value - long n = 0; - for (int i = 0; i < rec.getLeadingOrd().length(); i++) { - n = n * 10 + (rec.getLeadingOrd().charAt(i) - '0'); - } - ord = n; - } else { - final JsonElement v = rec.getJson().get("ord"); - if (v == null || v.isJsonNull()) { - ord = 0; - } else { - final Long n = integer(v, true); - if (n == null) { - return true; - } - ord = n; - } - } - // unsigned, as the Sessionizer compares uint64 values - if (Long.compareUnsigned(ord, prev + 1) > 0) { - return true; - } - if (Long.compareUnsigned(ord, prev) > 0) { - prev = ord; - } - } - if (f.isStoppedEarly()) { - return true; - } - } - return false; - } + // ---------------------------------------------------------------- records and times - /** 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; - } + /** Relations one record supports are listed by id, as nothing else tells them apart. */ + private static final Comparator RELATION_TIE = + Comparator.comparing(SessionFlowRound.Relation::getId, CodePointOrder.ORDER); - Map toMap() { - final Map m = new LinkedHashMap<>(); - m.put("role", role); - m.put("ref", ref.toMap()); - return m; + /** 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)); } - /** 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; + /** 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; } - } - - /** - * @param rec a record - * @param block the part a reference names, or null - * @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) { - if (block != null && block < rec.getParts().size()) { - return rec.getParts().get(block); + final List points = new ArrayList<>(); + for (final Ref e : r.getEvidence()) { + points.add(pointOf(e)); } - return rec.getParts().size() == 1 ? rec.getParts().get(0) : null; + return Order.earliest(points); } - // ---------------------------------------------------------------- records and times - @Nullable private SessionDataFile.Record record(@Nullable final Ref ref) { if (ref == null) { @@ -1733,7 +1501,7 @@ private SessionDataFile.Record record(@Nullable final Ref ref) { } private long timeAt(@Nullable final Ref ref) { - // floored, as Go's UnixMilli, so a moment before 1970 rounds the same way; a duration truncates instead + // rounded down, a moment before 1970 included; an interval drops its remainder instead return Math.floorDiv(nanosAt(ref), 1_000_000L); } @@ -1749,27 +1517,47 @@ private long time(final SessionFlowRound.Node n) { return timeAt(n.getRef()); } - private long[] span(final SessionFlowRound.Node n) { + /** The first and the last record time of a node and its tree, in milliseconds; 0 for each when none is timed. */ + private long[] span(final SessionFlowRound.Node n, final List tree) { final long[] lohi = new long[2]; - walkSpan(n, lohi); + widen(lohi, time(n)); + for (final SessionFlowRound.Node k : tree) { + widen(lohi, time(k)); + } return lohi; } - private void walkSpan(final SessionFlowRound.Node x, final long[] lohi) { - final long t = time(x); - if (t != 0) { - if (lohi[0] == 0 || t < lohi[0]) { - lohi[0] = t; - } - if (t > lohi[1]) { - lohi[1] = t; - } + /** + * Widens a first and last time to take in one more. 0 is no time, for the time given and for an end not set yet, + * so a time before 1970 is taken like any other. + */ + private static void widen(final long[] lohi, final long t) { + if (t == 0) { + return; + } + if (lohi[0] == 0 || t < lohi[0]) { + lohi[0] = t; } - for (final SessionFlowRound.Node k : fold.children(x.getId())) { - walkSpan(k, lohi); + if (lohi[1] == 0 || t > lohi[1]) { + lohi[1] = t; } } + /** + * 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, final List tree) { + long lo = nanosAt(x.getRef()); + for (final SessionFlowRound.Node k : tree) { + final long t = nanosAt(k.getRef()); + if (t != 0 && (lo == 0 || t < lo)) { + lo = t; + } + } + return lo; + } + private String readableAt(final Ref ref) { final SessionDataFile.Record rec = record(ref); return rec == null ? "" : readable(rec); @@ -1784,41 +1572,33 @@ static String readable(final SessionDataFile.Record rec) { return t; } for (final String raw : candidates(rec)) { - try { - final JsonElement e = JsonParser.parseString(raw); - if (!e.isJsonObject() || !"queued_command".equals(string(e.getAsJsonObject(), "type"))) { - continue; - } - final JsonElement prompt = e.getAsJsonObject().get("prompt"); - if (prompt == null || !prompt.isJsonArray()) { - continue; - } - // the whole candidate is the shape Go decodes, or it is skipped: every element an object whose - // text, when present, is a string - final List out = new ArrayList<>(); - boolean shaped = true; - for (final JsonElement p : prompt.getAsJsonArray()) { - if (!p.isJsonObject() || !isStringOrAbsent(p.getAsJsonObject(), "text")) { - shaped = false; - break; - } - if (StringUtil.isNotEmpty(string(p.getAsJsonObject(), "text"))) { - out.add(string(p.getAsJsonObject(), "text")); - } - } - if (shaped && !out.isEmpty()) { - return trimSpace(String.join("\n", out)); - } - } catch (final RuntimeException ignored) { - // not JSON; the next candidate may be + 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 Map envelope = QUEUED_COMMAND.read(Schema.parse(raw)); + if (envelope == null || !"queued_command".equals(envelope.get("type")) || envelope.get("prompt") == null) { + return null; + } + final List out = new ArrayList<>(); + for (final Object p : (List) envelope.get("prompt")) { + final String text = (String) ((Map) p).get("text"); + if (!text.isEmpty()) { + out.add(text); + } + } + return out.isEmpty() ? null : trimSpace(String.join("\n", out)); } static List candidates(final SessionDataFile.Record rec) { @@ -1891,7 +1671,7 @@ private static String clipBytes(final String t, final int limit) { return new String(bytes, 0, cut, StandardCharsets.UTF_8); } - static Map jsonToMap(final JsonObject json) { + private static Map jsonToMap(final JsonObject json) { final Map out = new LinkedHashMap<>(); for (final Map.Entry e : json.entrySet()) { out.put(e.getKey(), jsonToValue(e.getValue())); @@ -1917,30 +1697,32 @@ private static Object jsonToValue(final JsonElement e) { return e.getAsBoolean(); } if (e.getAsJsonPrimitive().isNumber()) { - // as written: Go prints the raw attrs, so 1.0 stays 1.0 and 1 stays 1 + // as written, since the values are the round's own. An integer is a long, or past 64 bits an exact big + // integer, so JSON and YAML both keep every digit. Any other number keeps its text, so 1.0, 1e2 and -0 + // stay as they were written. final String literal = e.getAsString(); - if (INTEGER_LITERAL.matcher(literal).matches()) { - try { - return Long.parseLong(literal); - } catch (final NumberFormatException ignored) { - return new BigInteger(literal); - } + final BigInteger integer = integerOf(literal); + if (integer != null && integer.toString().equals(literal)) { + return integer.bitLength() < 64 ? (Object) integer.longValue() : integer; } - return new BigDecimal(literal); + return e.getAsNumber(); } 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)); + private static BigInteger integerOf(final String literal) { + try { + return new BigInteger(literal); + } catch (final NumberFormatException notAnInteger) { + return null; + } } @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 Long millisOrNull(@Nullable final String rfc3339) { + // absent is null, and a value that is not a time is 0 + return StringUtil.isEmpty(rfc3339) ? null : Long.valueOf(Times.millis(rfc3339)); } private static String nullToEmpty(@Nullable final String 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 000000000000..9f662840a8c3 --- /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 points) { + final Map 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 List inOrder(final List items, final Function pointOf, final Comparator tie) { + final Map> byLane = new LinkedHashMap<>(); + final List 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> lanes = new ArrayList<>(byLane.values()); + for (final List lane : lanes) { + lane.sort(Comparator.comparing(pointOf, Order::comparePositions).thenComparing(tie)); + } + final List 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/ChangesRecordTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ChangesRecordTest.java index e79d7654baa7..c391bd6eaac3 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ChangesRecordTest.java +++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/ChangesRecordTest.java @@ -82,8 +82,8 @@ public void aRecordIsRenderedAsTheFormatListsIt() { /** * What is not a change record is not one, and not an error: another schema, not an object, or a value of a type - * the record cannot hold, as the Sessionizer's reader refuses them. A record never given its list of changes - * says so with null, which is not the same as an empty list. + * the format does not give the field. A record never given its list of changes says so with null, which is not + * the same as an empty list. */ @Test public void whatIsNotAChangeRecordIsNotOne() { 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 545eef7e9920..883018301d40 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 @@ -22,10 +22,13 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonParser; +import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; @@ -34,8 +37,10 @@ import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile; import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound; import org.apache.skywalking.oap.server.ai.agent.conversation.view.ConversationViewBuilder; +import org.apache.skywalking.oap.server.ai.agent.conversation.view.ViewJson; import org.apache.skywalking.oap.server.ai.agent.conversation.view.ViewYaml; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.yaml.snakeyaml.Yaml; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -57,7 +62,7 @@ private static Map view(final byte[] roundBytes, final Map 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 step = node(doc, "tool/s3-tool"); assertEquals(Collections.singletonList("s3-tool"), step.get("changes")); @@ -236,6 +241,60 @@ public void theRuntimesOwnPatchIsShownWithoutThePluginsFiles() throws Exception 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> files = Arrays.asList(Fixtures.dataFiles(), Fixtures.workspaceChangesDataFiles()); + for (int i = 0; i < sets.length; i++) { + final List seqs = new ArrayList<>(files.get(i).keySet()); + Collections.sort(seqs, Collections.reverseOrder()); + final Map reversed = new LinkedHashMap<>(); + for (final Long seq : seqs) { + reversed.put(seq, files.get(i).get(seq)); + } + final Map 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 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 doc = view( + Fixtures.bytes(Fixtures.WORKSPACE_CHANGES_DIR + Fixtures.WORKSPACE_CHANGES_ROUND_FILE), files, Collections.emptyList()); + final List listed = new ArrayList<>(); + for (final Map c : (List>) 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 provider_body file. The document equals the Sessionizer's. Each call names its request @@ -255,7 +314,7 @@ public void providerBodiesJoinTheirCallsAsTheSessionizerJoinsThem() throws Excep assertEquals(GSON.toJson(expected), GSON.toJson(actual)); final Map summary = (Map) doc.get("summary"); - assertEquals(14, summary.get("provider_bodies")); + assertEquals(14L, summary.get("provider_bodies")); assertEquals(7, summary.get("captured_prompts")); final Map first = node(doc, "call/s2-call-fdae022ac306"); assertEquals(Arrays.asList( @@ -266,44 +325,25 @@ public void providerBodiesJoinTheirCallsAsTheSessionizerJoinsThem() throws Excep } /** - * 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 provider_body 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 files = Fixtures.providerBodiesDataFiles(); final Map whole = view(round, files, Collections.emptyList()); files.remove(4L); final Map without = view(round, files, Collections.emptyList()); final Map summary = (Map) without.get("summary"); - assertEquals(0, summary.get("provider_bodies")); - assertEquals(0, summary.get("captured_prompts")); - assertEquals(stripProviderBodies(GSON.toJsonTree(whole.get("talks"))), GSON.toJsonTree(without.get("talks"))); - assertEquals(stripProviderBodies(GSON.toJsonTree(whole.get("loose"))), GSON.toJsonTree(without.get("loose"))); - } - - /** - * A stream whose landed lines skip one may be missing a call between two that look consecutive, so no request - * joins in it; its responses still join by message id, and the other stream is untouched. - */ - @Test - @SuppressWarnings("unchecked") - public void noRequestJoinsInAStreamWithAGap() throws Exception { - final Map files = Fixtures.providerBodiesDataFiles(); - final String main = new String( - Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_DATA_FILES[0]), StandardCharsets.UTF_8); - assertTrue(main.contains("\n{\"ord\":3,")); - files.put(1L, SessionDataFile.parse(main.replace("\n{\"ord\":3,", "\n{\"ord\":4,").getBytes(StandardCharsets.UTF_8))); - final Map doc = view( - Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList()); - final Map summary = (Map) doc.get("summary"); - assertEquals(14, summary.get("provider_bodies")); - assertEquals(2, summary.get("captured_prompts")); - assertEquals(Collections.singletonList(Map.of("role", "response", "ref", Map.of("seq", 4L, "row", 2L))), - node(doc, "call/s2-call-fdae022ac306").get("provider_bodies")); - assertEquals(2, ((List) node(doc, "call/searcher-s2-call-fdae022ac306").get("provider_bodies")).size()); + 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 +364,7 @@ public void aSyntheticCallTakesPartInNoJoin() throws Exception { assertEquals(GSON.toJson(expected), GSON.toJson(actual)); final Map summary = (Map) 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 +373,645 @@ public void aSyntheticCallTakesPartInNoJoin() throws Exception { } /** - * 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 execution 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 aManifestTheSessionizerDoesNotDecodeIsNoBody() throws Exception { - final String bodies = new String( - Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_DATA_FILES[3]), StandardCharsets.UTF_8); - final String first = "\"sha256\":\"25a4cf0c2c2a0f485f56519e56e1f6bcf79466df6599fc4f38fd0f0df84540fa\",\"bytes\":8155,\"depth\":0,"; - assertTrue(bodies.contains(first)); - for (final String broken : new String[] { - bodies.replace(first, first.replace("\"depth\":0,", "\"depth\":\"0\",")), - bodies.replace(first, first.replace("\"bytes\":8155,", "\"bytes\":8155.5,")), - bodies.replace(first + "\"chain\":\"f48484e6a7a8135e\",", first + "\"chain\":7,"), - }) { - assertFalse(broken.equals(bodies)); - final Map files = Fixtures.providerBodiesDataFiles(); - files.put(4L, SessionDataFile.parse(broken.getBytes(StandardCharsets.UTF_8))); - final Map doc = view( - Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList()); - final Map summary = (Map) doc.get("summary"); - assertEquals(13, summary.get("provider_bodies")); - assertEquals(6, summary.get("captured_prompts")); - assertEquals(Collections.singletonList(Map.of("role", "response", "ref", Map.of("seq", 4L, "row", 2L))), - node(doc, "call/s2-call-fdae022ac306").get("provider_bodies")); + public void executionsJoinTheirStepsAsTheSessionizerJoinsThem() throws Exception { + final Map 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> executions = (List>) doc.get("tool_executions"); + assertEquals(4, executions.size()); + final Map outcomes = new TreeMap<>(); + for (final Map 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 step = node(doc, "tool/s2-tool"); + assertEquals(Collections.singletonList("s2-tool/client_hook"), step.get("executions")); + final List keys = new ArrayList<>(step.keySet()); + assertEquals(keys.indexOf("request_to_result_join") + 1, keys.indexOf("executions")); + final Map attrs = (Map) 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 odd = node(doc, "tool/s5-tool"); + assertNull(odd.get("executions")); + assertFalse(((Map) 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 talksBeginningInOneMillisecondKeepTheirOrder() throws Exception { + final Map 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 doc = view( + Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_ROUND_FILE), files, Collections.emptyList()); + final List> talks = (List>) 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 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 listed = new ArrayList<>(); + for (final Map c : (List>) 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 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 observed = new ArrayList<>(); + for (final Map e : (List>) 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); + } + + /** + * 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 anUntimedTalkComesAfterEveryTimedOne() throws Exception { + final Map 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 doc = view( + Fixtures.bytes(Fixtures.MCP_CALLS_DIR + Fixtures.MCP_CALLS_ROUND_FILE), files, Collections.emptyList()); + final List> talks = (List>) 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")); + } + + /** + * A session's body count is an integer: written as 1.5 it is none, so the document counts 0 bodies while the calls + * still name theirs. The Sessionizer's document for the same round, re-signed, says 0 bodies and 7 captured + * prompts. With the provider bodies taken out of a call's attributes, the rest keep the order the round wrote them + * in, here one that is not sorted. The Sessionizer writes the keys sorted, so no round it writes tells the two + * apart. + */ + @Test + @SuppressWarnings("unchecked") + public void aBodyCountIsAnIntegerAndAttrsKeepTheirOrder() 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 doc = view(resign(round), Fixtures.providerBodiesDataFiles(), Collections.emptyList()); + final Map summary = (Map) doc.get("summary"); + assertEquals("verified", summary.get("state")); + assertEquals(0L, summary.get("provider_bodies")); + assertEquals(7, summary.get("captured_prompts")); + final Map attrs = (Map) node(doc, "call/s2-call-fdae022ac306").get("attrs"); + assertEquals(Arrays.asList("fragments", "\ue000", "\ud800\udc00", "usage", "usage_at", "usage_from"), + new ArrayList<>(attrs.keySet())); + // the count is a whole number; written any other way, there is none + 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\"", "0"}, + }; + for (final String[] c : counts) { + final Map changed = view(resign(original.replace("\"provider_bodies_landed\":14", c[0])), + Fixtures.providerBodiesDataFiles(), Collections.emptyList()); + assertEquals(Long.parseLong(c[1]), ((Map) 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 code point order: + * 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 r : renamed) { + final Map doc = view(resign(r), Fixtures.providerBodiesDataFiles(), Collections.emptyList()); + assertEquals("verified", ((Map) doc.get("summary")).get("state")); + final List origins = new ArrayList<>(); + for (final Map st : (List>) doc.get("streams")) { + if ("stream/a02ba01b82410e9e3".equals(st.get("id"))) { + for (final Map o : (List>) 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 relations = new ArrayList<>(); + for (final Map rel : (List>) 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")); + } + + /** + * @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 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 doc = view(round, files, Collections.emptyList()); + final List origins = new ArrayList<>(); + for (final Map st : (List>) doc.get("streams")) { + if ("stream/S".equals(st.get("id"))) { + for (final Map o : (List>) st.get("opened_by")) { + origins.add((String) o.get("step")); + } + } } + assertEquals(Arrays.asList("tool/a2", "tool/b1"), origins); } /** - * An ord is read as the Sessionizer reads it. A null ord that does not lead the line decodes as 0, which on the last - * line is no gap. - * The digits after a leading {"ord": are an unsigned 64-bit number, so the largest one is far past the - * next line, a gap. A line that does not decode, even after every record, may hide a call, and is one too. Each - * file is one the Sessionizer's reader decodes up to that line, so its whole document is comparable. + * 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 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 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 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 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 under usage_at. A reference with nothing in it + * names no record, and one of another shape gives no usage, rather than failing the document. + */ + @Test + public void aCallsUsageIsReadFromTheRecordItsAttrsName() 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 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 String[] c : cases) { - assertFalse(c[0].equals(main)); - final Map files = Fixtures.providerBodiesDataFiles(); - files.put(1L, SessionDataFile.parse(c[0].getBytes(StandardCharsets.UTF_8))); - final Map doc = view( - Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), files, Collections.emptyList()); - final Map summary = (Map) doc.get("summary"); - assertEquals(14, summary.get("provider_bodies")); - assertEquals(Integer.parseInt(c[1]), summary.get("captured_prompts")); + for (final Object[] c : cases) { + final Map 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 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 files = new TreeMap<>(); + files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:01Z")); + final List loose = new ArrayList<>(); + for (final Map n : (List>) 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); } - 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())); + /** + * A fold whose parents loop still gives a document: a talk that is its own parent, and a talk and a run that are + * each other's. Every node under a talk is counted once. + */ + @Test + @SuppressWarnings("unchecked") + public void aTalkWhoseParentsLoopStillBuilds() throws Exception { + final Map files = new TreeMap<>(); + files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:01Z")); + final Map doc = view(round( + frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"), + frameNode("talk/t", "talk", "A", 1, 1, ",\"parent\":\"talk/t\""), + frameNode("talk/u", "talk", "A", 1, 1, ",\"parent\":\"run/u\""), + frameNode("run/u", "run", "A", 1, 1, ",\"parent\":\"talk/u\"")), files, Collections.emptyList()); + final Map runs = new HashMap<>(); + for (final Map t : (List>) doc.get("talks")) { + runs.put((String) t.get("id"), t.get("runs")); + } + assertEquals(1, runs.get("talk/u")); + // the talk that is its own parent is listed, with no run under it, so its count is left out + assertTrue(runs.containsKey("talk/t")); + assertNull(runs.get("talk/t")); + } + + /** A block below zero names no part: the step takes the record's only part, and a result its result part. */ + @Test + public void aNegativeBlockNamesNoPart() throws Exception { + final Map files = new TreeMap<>(); + files.put(1L, SessionDataFile.parse(("{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"kind\":\"transcript\"," + + "\"src\":\"x\",\"dialect\":\"mock/1\",\"session\":\"s\",\"stream\":\"A\"}\n" + + "{\"ord\":1,\"off\":0,\"sha\":\"0\",\"bytes\":1,\"parts\":[{\"k\":\"call\",\"id\":\"x\",\"name\":\"Bash\"}]}\n" + + "{\"ord\":2,\"off\":2,\"sha\":\"0\",\"bytes\":1,\"parts\":[{\"k\":\"text\",\"text\":\"t\"}," + + "{\"k\":\"result\",\"of\":\"x\",\"text\":\"done\"}]}\n" + + "{\"t\":\"end\",\"records\":2,\"digest\":\"0\"}\n").getBytes(StandardCharsets.UTF_8))); + final Map doc = view(round( + frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"), + frameNode("talk/a", "talk", "A", 1, 1, ""), + frameNode("run/a", "run", "A", 1, 1, ",\"parent\":\"talk/a\""), + "{\"t\":\"node\",\"id\":\"tool/x\",\"revision\":1,\"kind\":\"tool\",\"stream\":\"A\",\"parent\":\"run/a\"," + + "\"ref\":{\"seq\":1,\"row\":1,\"block\":-1}," + + "\"refs\":[{\"seq\":1,\"row\":1,\"block\":-1},{\"seq\":1,\"row\":2,\"block\":-1}]}"), + files, Collections.emptyList()); + final Map tool = node(doc, "tool/x"); + assertEquals("Bash", tool.get("name")); + assertEquals("done", tool.get("result")); + } + + /** + * A number in the attributes keeps the text the round wrote it in, whatever its size: an exponent too large for + * any number type, an exponent, trailing zeros, a negative zero, and an integer past 64 bits. An integer a long + * writes the same way is a long. + */ + @Test + public void attrsNumbersKeepTheirText() throws Exception { + final Map files = new TreeMap<>(); + files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:01Z")); + final Map doc = view(round( + frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"), + frameNode("talk/a", "talk", "A", 1, 1, ",\"attrs\":{\"huge\":1e2147483649,\"small\":1e2,\"half\":1.50,\"n\":7," + + "\"zero\":-0,\"wide\":123456789012345678901234567890}")), + files, Collections.emptyList()); + final StringWriter out = new StringWriter(); + ViewJson.write(node(doc, "talk/a"), out); + assertTrue(out.toString().contains("\"attrs\":{\"huge\":1e2147483649,\"small\":1e2,\"half\":1.50,\"n\":7," + + "\"zero\":-0,\"wide\":123456789012345678901234567890}"), out.toString()); + assertEquals(7L, ((Map) node(doc, "talk/a").get("attrs")).get("n")); + // an integer past 64 bits is an integer in YAML too, every digit kept + assertTrue(ViewYaml.dump(node(doc, "talk/a")).contains("wide: 123456789012345678901234567890\n")); + } + + /** + * A round may name a range of sequences far larger than the files a chain holds. Only the files that landed are + * visited, and each gap between them is named once, up to the end of the range. + */ + @Test + @Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) + @SuppressWarnings("unchecked") + public void aHugeRangeOfSequencesIsNotWalked() throws Exception { + final Map files = new TreeMap<>(); + files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:01Z")); + files.put(3L, dataFile(3, "transcript", "A", "", "2026-01-01T00:00:02Z")); + final byte[] round = resign("{\"t\":\"header\",\"schema\":\"sf/1\",\"conversation\":\"c\",\"session\":\"s\"," + + "\"round\":1,\"from_seq\":1,\"through_seq\":9223372036854775807,\"input_digest\":\"x\",\"parser\":\"p\"," + + "\"policy\":\"p\"}\n" + + frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}") + "\n" + + "{\"t\":\"commit\",\"digest\":\"0\",\"counts\":{\"nodes\":1,\"relations\":0,\"unresolved\":0}}\n"); + final Map doc = view(round, files, Collections.emptyList()); + final List problems = (List) ((Map) doc.get("summary")).get("problems"); + assertTrue(problems.contains("round 1: landed file seq 2 is missing"), problems.toString()); + assertTrue(problems.contains("round 1: landed files seq 4-9223372036854775807 are missing"), problems.toString()); + // a file at the last sequence there is leaves no gap after it + files.put(Long.MAX_VALUE, dataFile(Long.MAX_VALUE, "transcript", "A", "", "2026-01-01T00:00:03Z")); + final Map ended = view(round, files, Collections.emptyList()); + assertEquals(Arrays.asList("round 1: landed file seq 2 is missing", + "round 1: landed files seq 4-9223372036854775806 are missing"), + ((Map) ended.get("summary")).get("problems")); + } + + /** + * Siblings are in record order: by seq, row and block, a node that names no block before one that names one, and + * by id at one position. The order is total, so siblings that mix both kinds of reference at one record sort too. + */ + @Test + @SuppressWarnings("unchecked") + public void siblingsAreInRecordOrder() throws Exception { + final List frames = new ArrayList<>(); + frames.add(frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}")); + frames.add(frameNode("talk/a", "talk", "A", 1, 1, "")); + frames.add(frameNode("run/a", "run", "A", 1, 1, ",\"parent\":\"talk/a\"")); + final List noBlock = new ArrayList<>(); + final List byBlock = new ArrayList<>(); + for (int i = 0; i < 64; i++) { + final String id = String.format("msg/%02d", i); + final String block = i % 3 == 0 ? "" : ",\"block\":" + (64 - i); + frames.add("{\"t\":\"node\",\"id\":\"" + id + "\",\"revision\":1,\"kind\":\"message.assistant\"," + + "\"stream\":\"A\",\"parent\":\"run/a\",\"ref\":{\"seq\":1,\"row\":1" + block + "}}"); + if (i % 3 == 0) { + noBlock.add(id); + } else { + byBlock.add(0, id); + } + } + final Map files = new TreeMap<>(); + files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:01Z")); + final Map doc = view(round(frames.toArray(new String[0])), files, Collections.emptyList()); + final List order = new ArrayList<>(); + for (final Map k : (List>) node(doc, "run/a").get("children")) { + order.add((String) k.get("id")); + } + final List expected = new ArrayList<>(noBlock); + expected.addAll(byBlock); + assertEquals(expected, order); + } + + /** + * A call's usage and a step's dropped entries are written by the fields the page lists: the four token counts, a + * count of zero left out, and a dropped entry's what, bytes and why. A key the page does not list is not copied, + * nested lists and all. + */ + @Test + @SuppressWarnings("unchecked") + public void usageAndDroppedAreWrittenByTheirListedFields() throws Exception { + final String deep = "[".repeat(100) + "]".repeat(100); + final Map files = new TreeMap<>(); + files.put(1L, SessionDataFile.parse(("{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"kind\":\"transcript\"," + + "\"src\":\"x\",\"dialect\":\"mock/1\",\"session\":\"s\",\"stream\":\"A\"}\n" + + "{\"ord\":1,\"off\":0,\"sha\":\"0\",\"bytes\":1,\"usage\":{\"in\":5,\"out\":0,\"later\":" + deep + "}," + + "\"dropped\":[{\"what\":\"signature\",\"bytes\":3,\"later\":" + deep + "}]," + + "\"parts\":[{\"k\":\"text\",\"text\":\"hi\"}]}\n" + + "{\"t\":\"end\",\"records\":1,\"digest\":\"0\"}\n").getBytes(StandardCharsets.UTF_8))); + final Map doc = view(round( + frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"), + frameNode("talk/a", "talk", "A", 1, 1, ""), + frameNode("run/a", "run", "A", 1, 1, ",\"parent\":\"talk/a\""), + frameNode("call/c", "llm.call", "A", 1, 1, ",\"parent\":\"run/a\",\"attrs\":{\"usage_at\":{\"seq\":1,\"row\":1}}"), + frameNode("msg/1/1:0", "message.assistant", "A", 1, 1, ",\"parent\":\"call/c\"")), + files, Collections.emptyList()); + final Map usage = new LinkedHashMap<>(); + usage.put("in", 5L); + assertEquals(usage, node(doc, "call/c").get("usage")); + final Map drop = new LinkedHashMap<>(); + drop.put("what", "signature"); + drop.put("bytes", 3L); + assertEquals(Collections.singletonList(drop), node(doc, "msg/1/1:0").get("dropped")); + } + + /** + * A time before 1970 is a time like any other: a talk, its segment and its file whose records are all timed then + * span those times, not zero. 0 stays what it is in the document, no time. + */ + @Test + @SuppressWarnings("unchecked") + public void timesBefore1970AreTaken() throws Exception { + final Map files = new TreeMap<>(); + files.put(1L, dataFile(1, "transcript", "A", "", "1969-12-31T23:59:58Z", "1969-12-31T23:59:59Z")); + final Map doc = view(round( + frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\"}"), + frameNode("segment/s", "segment", "A", 1, 1, ""), + frameNode("talk/a", "talk", "A", 1, 1, ""), + frameNode("run/a", "run", "A", 1, 2, ",\"parent\":\"talk/a\""), + "{\"t\":\"relation\",\"id\":\"rel/in_segment/talk_a/segment_s\",\"revision\":1,\"type\":\"in_segment\"," + + "\"from\":\"talk/a\",\"to\":\"segment/s\",\"quality\":\"exact_unique\"}"), + files, Collections.emptyList()); + final Map talk = ((List>) doc.get("talks")).get(0); + assertEquals(-2000L, talk.get("from")); + assertEquals(-1000L, talk.get("to")); + final Map segment = ((List>) doc.get("segments")).get(0); + assertEquals(-2000L, segment.get("from")); + assertEquals(-1000L, segment.get("to")); + final Map file = ((List>) doc.get("files")).get(0); + assertEquals(-2000L, file.get("from_time")); + assertEquals(-1000L, file.get("through_time")); + } + + /** A stream's record count is a 64-bit integer, kept past 32 bits. */ + @Test + @SuppressWarnings("unchecked") + public void aStreamsRecordCountKeepsSixtyFourBits() throws Exception { + final Map files = new TreeMap<>(); + files.put(1L, dataFile(1, "transcript", "A", "", "2026-01-01T00:00:01Z")); + final Map doc = view(round( + frameNode("stream/A", "stream", "A", 1, 1, ",\"attrs\":{\"role\":\"main\",\"records\":2147483648}")), + files, Collections.emptyList()); + assertEquals(2147483648L, ((List>) 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 relationIds(final Map doc) { + final List out = new ArrayList<>(); + for (final Map r : (List>) 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 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 child = new TreeMap<>(); + for (final Map t : (List>) 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 000000000000..ab4e684dbb3d --- /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 is read by the fields execution/1 lists and written in that order: a key the format does + * not list is dropped, an empty optional field is left out, a field the format always writes is written with its + * empty value when the line has none, so a server given as {} has an empty name and a size not given + * is 0, and a duration of 0 is kept. The Sessionizer's document writes each case the same way. + */ + @Test + public void aRecordIsReadByTheFieldsItsFormatLists() { + 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()); + } + + /** + * A value of another type than its field's means the data is not a 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 aValueOfAnotherTypeIsNoRecord() { + 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 a9f26655fb28..566c6c0d8b86 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 @@ * with a subagent whose calls' request and response bodies landed in one provider_body file. Under * provider-bodies-errors/, the same for tests/scenarios/provider-bodies-errors.yaml: an API * error the runtime wrote as a synthetic call, between two calls it did send. + * + *

Under mcp-calls/, the same for tests/scenarios/mcp-calls.yaml: calls to MCP servers on + * the main stream and in a subagent, each with the execution record the plugin wrote after it in an + * execution file of its stream, one of them written twice. + * + *

Under langchain-subagent/, one LangChain conversation as the Sessionizer lands it from what a real + * LangSmith client sent, its subagent 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 static Map providerBodiesErrorsDataFiles() throws return dataFiles(PROVIDER_BODIES_ERRORS_DIR, PROVIDER_BODIES_ERRORS_DATA_FILES); } + public static Map mcpCallsDataFiles() throws IOException { + return dataFiles(MCP_CALLS_DIR, MCP_CALLS_DATA_FILES); + } + + public static Map langchainSubagentDataFiles() throws IOException { + return dataFiles(LANGCHAIN_SUBAGENT_DIR, LANGCHAIN_SUBAGENT_DATA_FILES); + } + private static Map dataFiles(final String dir, final String[] names) throws IOException { final Map out = new TreeMap<>(); for (final String name : names) { diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/SessionFormatsTest.java index fac7716a5324..7692e17dfdfb 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,14 +24,18 @@ 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.Schema; import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionDataFile; import org.apache.skywalking.oap.server.ai.agent.conversation.format.SessionFlowRound; import org.apache.skywalking.oap.server.ai.agent.conversation.format.Times; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class SessionFormatsTest { @@ -115,6 +119,18 @@ public void tamperedRoundIsNotIntact() throws Exception { 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 files = Fixtures.dataFiles(); @@ -143,8 +159,8 @@ public void lineCountIsTheNewlineCount() throws Exception { } /** - * A part's data is rendered as the Sessionizer wrote it: escapes, key order and spacing kept, the way Go - * prints a raw message, so the document equals the Sessionizer's byte for byte. + * A part's data is rendered as the line holds it: escapes, key order and spacing kept, as the formats keep data + * as it was written, so the document equals the Sessionizer's byte for byte. */ @Test public void partDataIsTheRawTextTheSessionizerWrote() { @@ -162,8 +178,289 @@ public void partDataIsTheRawTextTheSessionizerWrote() { assertEquals(4, parts.size()); assertNull(parts.get(0).data()); assertEquals(data, parts.get(1).data()); - // a literal null is the text "null", as Go keeps it in a raw message and prints it + // data is kept as written, so a data of null is the text "null" assertEquals("null", parts.get(2).data()); assertEquals("\"plain\"", parts.get(3).data()); } + + /** + * A call's provider bodies are references like any other. The round is refused when its bodies do not read as a + * list of {role, ref}: a role other than request or response, a seq or row below one, a value of + * another type, or a record past the round's range. A body's own block is kept. + */ + @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\":\"request\",\"ref\":{\"seq\":4,\"row\":1,\"block\":2}},", true}, + {"\"provider_bodies\":[{\"role\":\"prompt\",\"ref\":{\"seq\":4,\"row\":1}},", false}, + {"\"provider_bodies\":[{\"role\":7,\"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\":-4,\"row\":1}},", false}, + {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":\"4\",\"row\":1}},", false}, + {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4.0,\"row\":1}},", false}, + {"\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":5,\"row\":1}},", false}, + {"\"provider_bodies\":[null,", false}, + }; + for (final Object[] c : cases) { + final byte[] changed = round.replace(from, (String) c[0]).getBytes(StandardCharsets.UTF_8); + if ((Boolean) c[1]) { + SessionFlowRound.parse(changed); + } else { + assertThrows(IllegalArgumentException.class, () -> SessionFlowRound.parse(changed), (String) c[0]); + } + } + // the block is kept, past 32 bits too + final String wide = "\"provider_bodies\":[{\"role\":\"request\",\"ref\":{\"seq\":4,\"row\":1,\"block\":2147483648}},"; + assertEquals(Long.valueOf(2147483648L), call(round.replace(from, wide)).getProviderBodies().get(0).getRef().getBlock()); + assertEquals(2147483648L, call(round.replace(from, wide)).getProviderBodies().get(0).getRef().toMap().get("block")); + // 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))); + } + + /** + * A frame decodes only when every field Session Flow lists has the type it gives: a string, an integer, a boolean, + * an object or a list of references. A frame with a field of another type makes the round refused, even where the + * value would not change what the round says. Sequences, rows and rounds count from 1, so a negative one is refused + * too. + */ + @Test + public void aFrameWithAFieldOfAnotherTypeIsRefused() throws Exception { + final String round = new String( + Fixtures.bytes(Fixtures.PROVIDER_BODIES_DIR + Fixtures.PROVIDER_BODIES_ROUND_FILE), StandardCharsets.UTF_8); + final String node = "{\"t\":\"node\",\"id\":\"ack/1/9\",\"revision\":1,\"kind\":\"agent.launch_ack\","; + final String ref = "\"stream\":\"main\",\"ref\":{\"seq\":1,\"row\":9}}"; + final String counts = "\"unresolved\":0}}"; + final String commit = "{\"t\":\"commit\""; + final String open = "{\"t\":\"unresolved\",\"id\":\"unres/tool_result/x\",\"revision\":1,\"kind\":\"tool_result\"," + + "\"ref\":%s,\"state\":\"open\"}\n"; + final String withOpen = round.replace(commit, String.format(open, "\"x\"") + commit).replace(counts, "\"unresolved\":1}}"); + for (final String s : new String[] {node, ref, counts, commit}) { + assertTrue(round.contains(s), s); + } + SessionFlowRound.parse(round.getBytes(StandardCharsets.UTF_8)); + SessionFlowRound.parse(withOpen.getBytes(StandardCharsets.UTF_8)); + final String[] refused = { + round.replace(counts, "\"unresolved\":\"0\"}}"), + round.replaceFirst("\"talks\":(\\d+)", "\"talks\":\"$1\""), + round.replaceFirst("\"from_seq\":1,", "\"from_seq\":-1,"), + round.replace(node, node.replace("\"kind\":\"agent.launch_ack\"", "\"kind\":7")), + round.replace(node, node.replace("\"revision\":1,", "\"revision\":1,\"tombstone\":\"yes\",")), + round.replace(ref, "\"stream\":[\"main\"],\"ref\":{\"seq\":1,\"row\":9}}"), + round.replace(ref, "\"stream\":\"main\",\"ref\":{\"seq\":\"1\",\"row\":9}}"), + round.replace(ref, "\"stream\":\"main\",\"ref\":{\"seq\":1,\"row\":-9}}"), + round.replaceFirst("\"quality\":\"exact_unique\"", "\"quality\":1"), + withOpen.replace(String.format(open, "\"x\""), String.format(open, "7")), + }; + for (final String r : refused) { + assertThrows(IllegalArgumentException.class, () -> SessionFlowRound.parse(r.getBytes(StandardCharsets.UTF_8)), r); + } + } + + /** + * No frame the Sessionizer writes nests more than a few levels. A frame that holds more objects and lists open at + * once than the reader takes is refused, the same way on every run; a frame at the limit still reads. + */ + @Test + public void aFrameNestedTooDeepIsRefused() 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}},"; + // the frame and its attrs are two levels, so these reach the limit and one past it + final String deep = "\"x\":" + "[".repeat(254) + "0" + "]".repeat(254) + "," + from; + final String deeper = "\"x\":" + "[".repeat(255) + "0" + "]".repeat(255) + "," + from; + SessionFlowRound.parse(round.replace(from, deep).getBytes(StandardCharsets.UTF_8)); + assertThrows(IllegalArgumentException.class, + () -> SessionFlowRound.parse(round.replace(from, deeper).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(); + } + + /** + * Record times are RFC 3339, with Z or an offset, and compare as the instants they name, whatever the length of + * their fractions or the offset they are written in. A time that does not parse sorts after every one that does, + * and two such times are equal, so the caller decides between them by where each record was read. + */ + @Test + public void recordTimesCompareAsInstants() { + assertEquals(1767225600123456789L, Times.nanos("2026-01-01T00:00:00.123456789Z")); + assertEquals(1767196800000000000L, Times.nanos("2026-01-01T00:00:00+08:00")); + assertEquals(0L, Times.nanos("2026-01-01 00:00:00Z")); + // a year of four digits, as RFC 3339 has it: one past 9999 is not a time, and is never converted + assertEquals(0L, Times.millis("+999999999-12-31T23:59:59Z")); + assertEquals(253402300799000L, Times.millis("9999-12-31T23:59:59Z")); + assertTrue(Times.compare("2026-01-01T00:00:00.11Z", "2026-01-01T00:00:00.1Z") > 0); + assertTrue(Times.compare("2026-01-01T00:00:00.5Z", "2026-01-01T00:00:01Z") < 0); + assertEquals(0, Times.compare("2026-01-01T00:00:01Z", "2026-01-01T00:00:01.0Z")); + assertEquals(0, Times.compare("2026-01-01T08:00:01+08:00", "2026-01-01T00:00:01Z")); + assertTrue(Times.compare("not a time", "2026-01-01T00:00:01Z") > 0); + assertEquals(0, Times.compare("not a time", "another")); + } + + /** + * A record's fields have the types the Session Data page gives them, inside its parts, dropped entries and usage + * too, and a null is a field left out. A line with a field of another type is not a record, and the file's records + * end before it: a later row is never read. A field the page does not list is not checked, and a part's data may + * be any value. + */ + @Test + public void aLineWithAFieldOfAnotherTypeEndsTheRecords() { + final String[][] cases = { + {"\"parent\":null", "3"}, + {"\"flags\":null", "3"}, + {"\"flags\":[\"finished\"]", "3"}, + {"\"usage\":{}", "3"}, + {"\"ord\":9223372036854775807", "3"}, + {"\"bytes\":-1", "3"}, + {"\"later\":7", "3"}, + {"\"parts\":[{\"k\":\"data\",\"data\":{\"any\":[1,null]},\"later\":7}]", "3"}, + {"\"usage\":{\"in\":1,\"later\":\"x\"}", "3"}, + {"\"off\":\"25\"", "1"}, + {"\"ord\":-1", "1"}, + {"\"off\":-1", "1"}, + {"\"ord\":9223372036854775808", "1"}, + {"\"bytes\":9223372036854775808", "1"}, + {"\"bytes\":1.5", "1"}, + {"\"bytes\":1e2", "1"}, + {"\"id\":7", "1"}, + {"\"flags\":[null]", "1"}, + {"\"flags\":[7]", "1"}, + {"\"parts\":{}", "1"}, + {"\"parts\":[1]", "1"}, + {"\"dropped\":[null]", "1"}, + {"\"usage\":\"x\"", "1"}, + {"\"usage\":{\"in\":1.5}", "1"}, + {"\"parts\":[{\"k\":\"text\",\"text\":{}}]", "1"}, + {"\"parts\":[{\"k\":\"text\",\"text\":7}]", "1"}, + {"\"parts\":[{\"k\":\"result\",\"failed\":\"yes\"}]", "1"}, + {"\"parts\":[{\"k\":\"text\",\"bytes\":1.5}]", "1"}, + {"\"dropped\":[{\"what\":\"x\",\"bytes\":\"1\"}]", "1"}, + }; + for (final String[] c : cases) { + final String file = "{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"kind\":\"transcript\",\"session\":\"s\"," + + "\"stream\":\"main\",\"src\":\"x\",\"dialect\":\"mock/1\"}\n" + + "{\"ord\":1,\"off\":0,\"sha\":\"0\",\"bytes\":1}\n" + + "{\"ord\":2,\"off\":2,\"sha\":\"0\",\"bytes\":1," + c[0] + "}\n" + + "{\"ord\":3,\"off\":4,\"sha\":\"0\",\"bytes\":1}\n" + + "{\"t\":\"end\",\"records\":3,\"digest\":\"0\"}\n"; + final SessionDataFile parsed = SessionDataFile.parse(file.getBytes(StandardCharsets.UTF_8)); + assertEquals(Integer.parseInt(c[1]), parsed.getRecords().size(), c[0]); + } + // a part's size is a 64-bit integer too + final String wide = "{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"kind\":\"transcript\",\"session\":\"s\"," + + "\"stream\":\"main\",\"src\":\"x\",\"dialect\":\"mock/1\"}\n" + + "{\"ord\":1,\"off\":0,\"sha\":\"0\",\"bytes\":1,\"parts\":[{\"k\":\"text\",\"text\":\"a\",\"bytes\":4294967296}]}\n" + + "{\"t\":\"end\",\"records\":1,\"digest\":\"0\"}\n"; + final SessionDataFile one = SessionDataFile.parse(wide.getBytes(StandardCharsets.UTF_8)); + assertEquals(4294967296L, one.getRecords().get(0).getParts().get(0).getBytes()); + // a row is a 64-bit number as well, so one past 32 bits names no record rather than wrapping round to row 1 + assertNotNull(one.record(1)); + assertNull(one.record(4294967297L)); + assertNull(one.record(0)); + } + + /** + * Only a last line whose `t` is the string "end" closes a file. A last line that is a record with a `t` of another + * type is a record, and the file has no closing line. A closing line whose count or digest has another type + * declares none. A header field of another type is no value, so a header whose `h` is the string "1" does not open. + */ + @Test + public void fieldsOfAnotherTypeAreNoValue() { + final String header = "{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"kind\":\"transcript\",\"session\":\"s\"," + + "\"stream\":\"main\",\"src\":\"x\",\"dialect\":\"mock/1\"}\n"; + final String first = "{\"ord\":1,\"off\":0,\"sha\":\"0\",\"bytes\":1}\n"; + final SessionDataFile open = SessionDataFile.parse( + (header + first + "{\"ord\":2,\"off\":2,\"sha\":\"0\",\"bytes\":1,\"t\":{}}\n").getBytes(StandardCharsets.UTF_8)); + assertEquals(2, open.getRecords().size()); + assertEquals(-1, open.getDeclaredRecords()); + final SessionDataFile odd = SessionDataFile.parse( + (header + first + "{\"t\":\"end\",\"records\":{},\"digest\":7}\n").getBytes(StandardCharsets.UTF_8)); + assertEquals(1, odd.getRecords().size()); + assertEquals(-1, odd.getDeclaredRecords()); + assertNull(odd.getDeclaredDigest()); + final SessionDataFile unopened = SessionDataFile.parse( + (header.replace("\"h\":1", "\"h\":\"1\"") + first).getBytes(StandardCharsets.UTF_8)); + assertFalse(unopened.getHeader().isValid()); + assertEquals(0, unopened.getRecords().size()); + } + + /** + * The formats are JSON, so a line that only a lenient reader takes is not JSON: a comment, a single quote, NaN, + * TRUE, a raw tab or an escape JSON does not have inside a string, text after the value, or a value nested deeper + * than the reader takes. A record line of that kind ends the + * records before it, and a round with such a frame does not read. + */ + @Test + @Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) + public void onlyStrictJsonIsRead() throws Exception { + final String header = "{\"h\":1,\"schema\":\"sd/1\",\"seq\":1,\"kind\":\"transcript\",\"session\":\"s\"," + + "\"stream\":\"main\",\"src\":\"x\",\"dialect\":\"mock/1\"}\n"; + final String first = "{\"ord\":1,\"off\":0,\"sha\":\"0\",\"bytes\":1}\n"; + final String[] lines = { + "{\"ord\":2,\"parts\":[{\"k\":\"data\",\"data\":[1, /* } */2]}]}", + "{'ord':2}", + "{\"ord\":2,\"parts\":[{\"k\":\"data\",\"data\":NaN}]}", + "{\"ord\":2} {\"ord\":3}", + "{\"ord\":2,\"parts\":[{\"k\":\"data\",\"data\":TRUE}]}", + "{\"ord\":2,\"parts\":[{\"k\":\"data\",\"data\":truE}]}", + "{\"ord\":2,\"parts\":[{\"k\":\"text\",\"text\":\"a\tb\"}]}", + "{\"ord\":2,\"parts\":[{\"k\":\"text\",\"text\":\"a\\'b\"}]}", + "{\"ord\":2,\"parts\":[{\"k\":\"data\",\"data\":" + "[".repeat(100_000) + "]".repeat(100_000) + "}]}", + }; + for (final String line : lines) { + final SessionDataFile parsed = SessionDataFile.parse((header + first + line + "\n").getBytes(StandardCharsets.UTF_8)); + assertEquals(1, parsed.getRecords().size(), line.length() > 80 ? line.substring(0, 80) : line); + } + assertNull(Schema.parse("{\"a\":1} // a comment")); + assertEquals(7, Schema.parse(" 7 ").getAsInt()); + for (final String notJson : new String[] { + "[1,,2]", "[1,]", "{\"a\":1,}", "{\"a\" 1}", "{1:2}", "01", "1.", "1e", "-", "+1", ".5", "tru", "nul", "[1 2]", + "\"\\u12G4\"", "\"\\u\uff11234\"", "\"open", "", " ", "[", "]"}) { + assertNull(Schema.parse(notJson), notJson); + } + // a number keeps its text however long it is + final String longNumber = "9".repeat(1_100); + assertEquals(longNumber, Schema.parse("[" + longNumber + "]").getAsJsonArray().get(0).getAsString()); + final SessionDataFile withLongNumber = SessionDataFile.parse((header + first + + "{\"ord\":2,\"parts\":[{\"k\":\"data\",\"data\":" + longNumber + "}]}\n").getBytes(StandardCharsets.UTF_8)); + assertEquals(2, withLongNumber.getRecords().size()); + assertEquals(longNumber, withLongNumber.getRecords().get(1).getParts().get(0).data()); + // inside a string, an escaped quote does not end it, so a word or a bracket after it is still text; every + // escape JSON has reads + assertTrue(Schema.parse("[true,false,null,1e2,-1.5E-3,\"x \\\" TRUE [[\"]").isJsonArray()); + assertEquals("\"\\/\b\f\n\r\t\u00e9", Schema.parse("\"\\\"\\\\\\/\\b\\f\\n\\r\\t\\u00e9\"").getAsString()); + // at most MAX_DEPTH objects and lists open at once, the same way on every run + assertNotNull(Schema.parse("[".repeat(Schema.MAX_DEPTH) + "]".repeat(Schema.MAX_DEPTH))); + assertNull(Schema.parse("[".repeat(Schema.MAX_DEPTH + 1) + "]".repeat(Schema.MAX_DEPTH + 1))); + final String round = new String(Fixtures.bytes(Fixtures.ROUND_FILE), StandardCharsets.UTF_8); + final int second = round.indexOf('\n') + 1; + final String commented = round.substring(0, second) + "/* a note */ " + round.substring(second); + assertThrows(IllegalArgumentException.class, () -> SessionFlowRound.parse(commented.getBytes(StandardCharsets.UTF_8))); + } + + /** + * A list holds values of its own type only: a null in a list of strings or of objects is a value of another type, + * so the data is not the record. A null list is a list left out. + */ + @Test + public void aNullInAListIsAValueOfAnotherType() { + final Schema shape = new Schema() + .field("names", Schema.Kind.STRINGS) + .field("items", Schema.Kind.OBJECTS, new Schema().field("n", Schema.Kind.INTEGER)); + assertNotNull(shape.read(Schema.parse("{\"names\":[\"a\"],\"items\":[{\"n\":1}]}"))); + assertNotNull(shape.read(Schema.parse("{\"names\":null,\"items\":null}"))); + assertNull(shape.read(Schema.parse("{\"names\":[\"a\",null]}"))); + assertNull(shape.read(Schema.parse("{\"items\":[{\"n\":1},null]}"))); + } } diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJsonTest.java b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJsonTest.java new file mode 100644 index 000000000000..430cd051c83a --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/java/org/apache/skywalking/oap/server/ai/agent/conversation/format/RawJsonTest.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.apache.skywalking.oap.server.ai.agent.conversation.format; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class RawJsonTest { + /** + * Each part's data is the text the line holds, a literal null included, and null for a part without data. + */ + @Test + public void partDataIsTheTextTheLineHolds() { + assertEquals(Arrays.asList("{\"z\": 1, \"a\":[ 1,2 ]}", "null", null), + RawJson.partData("{\"ord\":1,\"parts\":[{\"k\":\"data\",\"data\":{\"z\": 1, \"a\":[ 1,2 ]}}," + + "{\"k\":\"result\",\"data\":null},{\"k\":\"text\",\"text\":\"t\"}]}")); + } + + /** + * A line that is not the JSON the walk expects gives no data and always ends: a value is at least one character, + * so the walk never stands still. + */ + @Test + @Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) + public void aLineThatIsNotJsonGivesNoData() { + assertEquals(Collections.emptyList(), RawJson.partData("{\"parts\":[{\"data\":[1}]}]}")); + assertEquals(Collections.emptyList(), RawJson.partData("{\"parts\":[{\"data\":[,]}]}")); + } +} 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 000000000000..3418d765a328 --- /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 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 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 000000000000..a218c618ca0a --- /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,100 @@ +/* + * 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 value out of: 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 what the document reads from them: a value + * of another type, a null inside a list included, means the part is not that record, the first result row decides, + * and a prompt that has text ends the search. + */ +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 is read from them: 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\":\"a\"},null,{\"text\":\"b\"}]}"}, "", 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\":\" \"}]}", "{\"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\":1.5}"}, "", 0L, null}, + {new String[] {"{\"durationMs\":\"1500\"}"}, "", 0L, 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, 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, null}, + {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\":\" two\\u00a0words\\n\"}}"}, "", 0L, "two words"}, + }; + + @Test + public void aRecordIsReadByItsShape() { + 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. + */ + @Test + public void aLongNameIsCutOnACharacterBoundary() { + 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 9471d8697df4..d8cdaf546bb7 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 e8e09609a154..ed936411ad3d 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/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 000000000000..14590ff5a735 --- /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 000000000000..ffde8c3aac2e --- /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","session":"ls-asz-harness-thread-subagent-4e1037625675","run":"01a0be6d-1724-7870-b615-7535de6b6e91","call":"01a0be6d-1735-76d3-b90b-cd6a57657ebe","segments":[{"lit":"{\"messages\":[[{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"HumanMessage\"],\"kwargs\":{\"content\":\"Is prod-1 healthy?\",\"type\":\"human\",\"id\":\"cb8bd706-c89b-4e5b-b30b-2837a343a9f2\"}}]]}"}]},"state":"available","bytes":650}]} +{"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-1735-76d3-b90b-cd6a57657ebe","request":"01a0be6d-1735-76d3-b90b-cd6a57657ebe","segments":[{"lit":"{\"generations\":[[{\"text\":\"\",\"generation_info\":{\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ChatGeneration\",\"message\":{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":123,\"total_tokens\":133,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-23\",\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-1735-76d3-b90b-cd6a57657ebe-0\",\"tool_calls\":[{\"name\":\"delegate_to_analyst\",\"args\":{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"},\"id\":\"call_delegate_to_analyst_23\",\"type\":\"tool_call\"}],\"usage_metadata\":{\"input_tokens\":123,\"output_tokens\":10,\"total_tokens\":133,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"invalid_tool_calls\":[]}}}]],\"llm_output\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":123,\"total_tokens\":133,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-23\"},\"run\":null,\"type\":\"LLMResult\"}"}]},"state":"available","bytes":2048}]} +{"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-asz-harness-thread-subagent-4e1037625675","run":"01a0be6d-173d-7c81-a464-6e581483db74","call":"01a0be6d-173e-7082-be42-8daec07f7b13","segments":[{"lit":"{\"messages\":[[{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"HumanMessage\"],\"kwargs\":{\"content\":\"check prod-1\",\"type\":\"human\",\"id\":\"10cecd5f-7bf8-4bac-b59b-78c3fd1ef9ab\"}}]]}"}]},"state":"available","bytes":638}]} +{"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-be42-8daec07f7b13","request":"01a0be6d-173e-7082-be42-8daec07f7b13","segments":[{"lit":"{\"generations\":[[{\"text\":\"\",\"generation_info\":{\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ChatGeneration\",\"message\":{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":124,\"total_tokens\":134,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-24\",\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-173e-7082-be42-8daec07f7b13-0\",\"tool_calls\":[{\"name\":\"lookup_status\",\"args\":{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"},\"id\":\"call_lookup_status_24\",\"type\":\"tool_call\"}],\"usage_metadata\":{\"input_tokens\":124,\"output_tokens\":10,\"total_tokens\":134,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"invalid_tool_calls\":[]}}}]],\"llm_output\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":124,\"total_tokens\":134,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-24\"},\"run\":null,\"type\":\"LLMResult\"}"}]},"state":"available","bytes":2018}]} +{"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-asz-harness-thread-subagent-4e1037625675","run":"01a0be6d-173d-7c81-a464-6e581483db74","call":"01a0be6d-1741-7073-b4de-f8b2848b3db2","segments":[{"lit":"{\"messages\":[[{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"HumanMessage\"],\"kwargs\":{\"content\":\"check prod-1\",\"type\":\"human\",\"id\":\"10cecd5f-7bf8-4bac-b59b-78c3fd1ef9ab\"}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":124,\"total_tokens\":134,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-24\",\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ai\",\"name\":\"analyst\",\"id\":\"lc_run--01a0be6d-173e-7082-be42-8daec07f7b13-0\",\"tool_calls\":[{\"name\":\"lookup_status\",\"args\":{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"},\"id\":\"call_lookup_status_24\",\"type\":\"tool_call\"}],\"usage_metadata\":{\"input_tokens\":124,\"output_tokens\":10,\"total_tokens\":134,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"invalid_tool_calls\":[]}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"ToolMessage\"],\"kwargs\":{\"content\":\"cluster prod-1: 3/3 pods ready\",\"type\":\"tool\",\"name\":\"lookup_status\",\"id\":\"61889e95-04a8-45c3-99e5-791bc95fba2b\",\"tool_call_id\":\"call_lookup_status_24\",\"status\":\"success\"}}]]}"}]},"state":"available","bytes":2036}]} +{"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-b4de-f8b2848b3db2","request":"01a0be6d-1741-7073-b4de-f8b2848b3db2","segments":[{"lit":"{\"generations\":[[{\"text\":\"answer 25\",\"generation_info\":{\"finish_reason\":\"stop\",\"logprobs\":null},\"type\":\"ChatGeneration\",\"message\":{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"answer 25\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":125,\"total_tokens\":135,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-25\",\"finish_reason\":\"stop\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-1741-7073-b4de-f8b2848b3db2-0\",\"usage_metadata\":{\"input_tokens\":125,\"output_tokens\":10,\"total_tokens\":135,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"tool_calls\":[],\"invalid_tool_calls\":[]}}}]],\"llm_output\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":125,\"total_tokens\":135,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-25\"},\"run\":null,\"type\":\"LLMResult\"}"}]},"state":"available","bytes":1849}]} +{"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","session":"ls-asz-harness-thread-subagent-4e1037625675","run":"01a0be6d-1724-7870-b615-7535de6b6e91","call":"01a0be6d-1744-73e1-b54f-bcfda53a8067","segments":[{"lit":"{\"messages\":[[{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"HumanMessage\"],\"kwargs\":{\"content\":\"Is prod-1 healthy?\",\"type\":\"human\",\"id\":\"cb8bd706-c89b-4e5b-b30b-2837a343a9f2\"}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":123,\"total_tokens\":133,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-23\",\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-1735-76d3-b90b-cd6a57657ebe-0\",\"tool_calls\":[{\"name\":\"delegate_to_analyst\",\"args\":{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"},\"id\":\"call_delegate_to_analyst_23\",\"type\":\"tool_call\"}],\"usage_metadata\":{\"input_tokens\":123,\"output_tokens\":10,\"total_tokens\":133,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"invalid_tool_calls\":[]}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"ToolMessage\"],\"kwargs\":{\"content\":\"answer 25\",\"type\":\"tool\",\"name\":\"delegate_to_analyst\",\"id\":\"bcd4352e-f489-4481-8536-e56af65cb974\",\"tool_call_id\":\"call_delegate_to_analyst_23\",\"status\":\"success\"}}]]}"}]},"state":"available","bytes":2036}]} +{"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-1744-73e1-b54f-bcfda53a8067","request":"01a0be6d-1744-73e1-b54f-bcfda53a8067","segments":[{"lit":"{\"generations\":[[{\"text\":\"\",\"generation_info\":{\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ChatGeneration\",\"message\":{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":126,\"total_tokens\":136,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-26\",\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-1744-73e1-b54f-bcfda53a8067-0\",\"tool_calls\":[{\"name\":\"summarise\",\"args\":{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"},\"id\":\"call_summarise_26\",\"type\":\"tool_call\"}],\"usage_metadata\":{\"input_tokens\":126,\"output_tokens\":10,\"total_tokens\":136,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"invalid_tool_calls\":[]}}}]],\"llm_output\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":126,\"total_tokens\":136,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-26\"},\"run\":null,\"type\":\"LLMResult\"}"}]},"state":"available","bytes":2028}]} +{"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-asz-harness-thread-subagent-4e1037625675","run":"01a0be6d-1746-7170-b6d0-d6aa2d533d9e","call":"01a0be6d-1746-7170-b6d0-d6ba40ae33b2","segments":[{"lit":"{\"messages\":[[{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"HumanMessage\"],\"kwargs\":{\"content\":\"Summarise this: some findings\",\"type\":\"human\"}}]]}"}]},"state":"available","bytes":608}]} +{"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-b6d0-d6ba40ae33b2","request":"01a0be6d-1746-7170-b6d0-d6ba40ae33b2","segments":[{"lit":"{\"generations\":[[{\"text\":\"answer 27\",\"generation_info\":{\"finish_reason\":\"stop\",\"logprobs\":null},\"type\":\"ChatGeneration\",\"message\":{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"answer 27\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":127,\"total_tokens\":137,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"plain\",\"system_fingerprint\":null,\"id\":\"chatcmpl-27\",\"finish_reason\":\"stop\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-1746-7170-b6d0-d6ba40ae33b2-0\",\"usage_metadata\":{\"input_tokens\":127,\"output_tokens\":10,\"total_tokens\":137,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"tool_calls\":[],\"invalid_tool_calls\":[]}}}]],\"llm_output\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":127,\"total_tokens\":137,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"plain\",\"system_fingerprint\":null,\"id\":\"chatcmpl-27\"},\"run\":null,\"type\":\"LLMResult\"}"}]},"state":"available","bytes":1852}]} +{"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","session":"ls-asz-harness-thread-subagent-4e1037625675","run":"01a0be6d-1724-7870-b615-7535de6b6e91","call":"01a0be6d-1748-76f1-8883-e83b7c887dc1","segments":[{"lit":"{\"messages\":[[{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"HumanMessage\"],\"kwargs\":{\"content\":\"Is prod-1 healthy?\",\"type\":\"human\",\"id\":\"cb8bd706-c89b-4e5b-b30b-2837a343a9f2\"}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":123,\"total_tokens\":133,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-23\",\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-1735-76d3-b90b-cd6a57657ebe-0\",\"tool_calls\":[{\"name\":\"delegate_to_analyst\",\"args\":{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"},\"id\":\"call_delegate_to_analyst_23\",\"type\":\"tool_call\"}],\"usage_metadata\":{\"input_tokens\":123,\"output_tokens\":10,\"total_tokens\":133,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"invalid_tool_calls\":[]}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"ToolMessage\"],\"kwargs\":{\"content\":\"answer 25\",\"type\":\"tool\",\"name\":\"delegate_to_analyst\",\"id\":\"bcd4352e-f489-4481-8536-e56af65cb974\",\"tool_call_id\":\"call_delegate_to_analyst_23\",\"status\":\"success\"}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":126,\"total_tokens\":136,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-26\",\"finish_reason\":\"tool_calls\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-1744-73e1-b54f-bcfda53a8067-0\",\"tool_calls\":[{\"name\":\"summarise\",\"args\":{\"cluster\":\"prod-1\",\"question\":\"check prod-1\",\"text\":\"some findings\"},\"id\":\"call_summarise_26\",\"type\":\"tool_call\"}],\"usage_metadata\":{\"input_tokens\":126,\"output_tokens\":10,\"total_tokens\":136,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"invalid_tool_calls\":[]}},{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"ToolMessage\"],\"kwargs\":{\"content\":\"answer 27\",\"type\":\"tool\",\"name\":\"summarise\",\"id\":\"0a8a0d9d-58d3-4eb6-832b-916b8c6fe22e\",\"tool_call_id\":\"call_summarise_26\",\"status\":\"success\"}}]]}"}]},"state":"available","bytes":3381}]} +{"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-1748-76f1-8883-e83b7c887dc1","request":"01a0be6d-1748-76f1-8883-e83b7c887dc1","segments":[{"lit":"{\"generations\":[[{\"text\":\"answer 28\",\"generation_info\":{\"finish_reason\":\"stop\",\"logprobs\":null},\"type\":\"ChatGeneration\",\"message\":{\"lc\":1,\"type\":\"constructor\",\"id\":[\"langchain\",\"schema\",\"messages\",\"AIMessage\"],\"kwargs\":{\"content\":\"answer 28\",\"additional_kwargs\":{\"refusal\":null},\"response_metadata\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":128,\"total_tokens\":138,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-28\",\"finish_reason\":\"stop\",\"logprobs\":null},\"type\":\"ai\",\"id\":\"lc_run--01a0be6d-1748-76f1-8883-e83b7c887dc1-0\",\"usage_metadata\":{\"input_tokens\":128,\"output_tokens\":10,\"total_tokens\":138,\"input_token_details\":{\"cache_read\":5},\"output_token_details\":{}},\"tool_calls\":[],\"invalid_tool_calls\":[]}}}]],\"llm_output\":{\"token_usage\":{\"completion_tokens\":10,\"prompt_tokens\":128,\"total_tokens\":138,\"completion_tokens_details\":null,\"prompt_tokens_details\":{\"audio_tokens\":null,\"cache_write_tokens\":null,\"cached_tokens\":5,\"image_tokens\":null,\"text_tokens\":null}},\"model_provider\":\"openai\",\"model_name\":\"every-tool\",\"system_fingerprint\":null,\"id\":\"chatcmpl-28\"},\"run\":null,\"type\":\"LLMResult\"}"}]},"state":"available","bytes":1867}]} +{"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 000000000000..ced1844eefa7 --- /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.562022Z","title":"Is prod-1 healthy?","talks":3,"steps":14,"streams":3,"segments":1,"unresolved":0,"changes":0,"lines_added":0,"lines_removed":0,"llm_calls":6,"subagents":1,"bash_runs":0} +{"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_fragment_in_line_order"}} +{"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_fragment_in_line_order"}} +{"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 000000000000..c9c7676c3810 --- /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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb","id":"01a0be6d-173d-7c81-a464-6e60edad7ecb","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566","langgraph_node":"tools","langgraph_path":["__pregel_push",0,false],"langgraph_step":2,"langgraph_triggers":["__pregel_push"],"ls_integration":"langgraph"},"name":"analyst","parent_run_id":"01a0be6d-173d-7c81-a464-6e581483db74","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":678}],"dropped":[{"what":"inputs and outputs","bytes":2011,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655550105Z01a0be6d-173e-7082-be42-8d6353660dd3","id":"01a0be6d-173e-7082-be42-8d6353660dd3","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:0304ddb2-5653-f30a-9e34-013a074f2b80","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"agent","parent_run_id":"01a0be6d-173d-7c81-a464-6e60edad7ecb","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":780}],"dropped":[{"what":"inputs and outputs","bytes":1044,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655550105Z01a0be6d-173e-7082-be42-8d6353660dd3.20260920T104655550200Z01a0be6d-173e-7082-be42-8d7dcfbce29a","id":"01a0be6d-173e-7082-be42-8d7dcfbce29a","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:0304ddb2-5653-f30a-9e34-013a074f2b80","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"call_model","parent_run_id":"01a0be6d-173e-7082-be42-8d6353660dd3","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":844}],"dropped":[{"what":"inputs and outputs","bytes":1044,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655550105Z01a0be6d-173e-7082-be42-8d6353660dd3.20260920T104655550200Z01a0be6d-173e-7082-be42-8d7dcfbce29a.20260920T104655550310Z01a0be6d-173e-7082-be42-8d88c69aeb62","id":"01a0be6d-173e-7082-be42-8d88c69aeb62","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:0304ddb2-5653-f30a-9e34-013a074f2b80","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"RunnableSequence","parent_run_id":"01a0be6d-173e-7082-be42-8d7dcfbce29a","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":909}],"dropped":[{"what":"inputs and outputs","bytes":1023,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655550105Z01a0be6d-173e-7082-be42-8d6353660dd3.20260920T104655550200Z01a0be6d-173e-7082-be42-8d7dcfbce29a.20260920T104655550310Z01a0be6d-173e-7082-be42-8d88c69aeb62.20260920T104655550373Z01a0be6d-173e-7082-be42-8d99011a6188","id":"01a0be6d-173e-7082-be42-8d99011a6188","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:0304ddb2-5653-f30a-9e34-013a074f2b80","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"Prompt","parent_run_id":"01a0be6d-173e-7082-be42-8d88c69aeb62","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":958}],"dropped":[{"what":"inputs and outputs","bytes":311,"why":"a graph's own run repeats content its model call already carries"}]} +{"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_status_24","name":"lookup_status","state":"available","bytes":69}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655550105Z01a0be6d-173e-7082-be42-8d6353660dd3.20260920T104655552020Z01a0be6d-1740-7423-8bc2-d6afca6c09cc","id":"01a0be6d-1740-7423-8bc2-d6afca6c09cc","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:0304ddb2-5653-f30a-9e34-013a074f2b80","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"should_continue","parent_run_id":"01a0be6d-173e-7082-be42-8d6353660dd3","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":849}],"dropped":[{"what":"inputs and outputs","bytes":2360,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655552487Z01a0be6d-1740-7423-8bc2-d6bec1b04246","id":"01a0be6d-1740-7423-8bc2-d6bec1b04246","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|tools:dc41bcfd-39b2-ae59-88a2-0e174fe86d50","langgraph_node":"tools","langgraph_path":["__pregel_push",0,false],"langgraph_step":2,"langgraph_triggers":["__pregel_push"],"ls_integration":"langgraph"},"name":"tools","parent_run_id":"01a0be6d-173d-7c81-a464-6e60edad7ecb","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":778}],"dropped":[{"what":"inputs and outputs","bytes":1436,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655553123Z01a0be6d-1741-7073-b4de-f8795804378e","id":"01a0be6d-1741-7073-b4de-f8795804378e","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:8f686d7e-2684-9a87-a938-52624eae6f94","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"agent","parent_run_id":"01a0be6d-173d-7c81-a464-6e60edad7ecb","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":780}],"dropped":[{"what":"inputs and outputs","bytes":1991,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655553123Z01a0be6d-1741-7073-b4de-f8795804378e.20260920T104655553187Z01a0be6d-1741-7073-b4de-f88ab5fb02c1","id":"01a0be6d-1741-7073-b4de-f88ab5fb02c1","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:8f686d7e-2684-9a87-a938-52624eae6f94","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"call_model","parent_run_id":"01a0be6d-1741-7073-b4de-f8795804378e","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":844}],"dropped":[{"what":"inputs and outputs","bytes":1991,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655553123Z01a0be6d-1741-7073-b4de-f8795804378e.20260920T104655553187Z01a0be6d-1741-7073-b4de-f88ab5fb02c1.20260920T104655553301Z01a0be6d-1741-7073-b4de-f890dd143145","id":"01a0be6d-1741-7073-b4de-f890dd143145","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:8f686d7e-2684-9a87-a938-52624eae6f94","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"RunnableSequence","parent_run_id":"01a0be6d-1741-7073-b4de-f88ab5fb02c1","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":909}],"dropped":[{"what":"inputs and outputs","bytes":1970,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655553123Z01a0be6d-1741-7073-b4de-f8795804378e.20260920T104655553187Z01a0be6d-1741-7073-b4de-f88ab5fb02c1.20260920T104655553301Z01a0be6d-1741-7073-b4de-f890dd143145.20260920T104655553361Z01a0be6d-1741-7073-b4de-f8a2091504a9","id":"01a0be6d-1741-7073-b4de-f8a2091504a9","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:8f686d7e-2684-9a87-a938-52624eae6f94","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"Prompt","parent_run_id":"01a0be6d-1741-7073-b4de-f890dd143145","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":958}],"dropped":[{"what":"inputs and outputs","bytes":2497,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655549592Z01a0be6d-173d-7c81-a464-6e60edad7ecb.20260920T104655553123Z01a0be6d-1741-7073-b4de-f8795804378e.20260920T104655554956Z01a0be6d-1742-75f2-ab3b-5db86201f2f9","id":"01a0be6d-1742-75f2-ab3b-5db86201f2f9","metadata":{"langgraph_checkpoint_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566|agent:8f686d7e-2684-9a87-a938-52624eae6f94","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"should_continue","parent_run_id":"01a0be6d-1741-7073-b4de-f8795804378e","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":849}],"dropped":[{"what":"inputs and outputs","bytes":1997,"why":"a graph's own run repeats content its model call already carries"}]} +{"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 000000000000..febc08f11dc8 --- /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":"available","bytes":251}],"dropped":[{"what":"inputs and outputs","bytes":3049,"why":"a graph's own run repeats content its model call already carries"}]} +{"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_ns":"agent:e14eb60b-8d55-22a7-2b61-de794c3864f4","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"agent","parent_run_id":"01a0be6d-1724-7870-b615-7535de6b6e91","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":560}],"dropped":[{"what":"inputs and outputs","bytes":1054,"why":"a graph's own run repeats content its model call already carries"}]} +{"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-1735-76d3-b90b-cd3af992fb8c","metadata":{"langgraph_checkpoint_ns":"agent:e14eb60b-8d55-22a7-2b61-de794c3864f4","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"call_model","parent_run_id":"01a0be6d-1735-76d3-b90b-cd250112a122","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":624}],"dropped":[{"what":"inputs and outputs","bytes":1054,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655541549Z01a0be6d-1735-76d3-b90b-cd409b826c50","id":"01a0be6d-1735-76d3-b90b-cd409b826c50","metadata":{"langgraph_checkpoint_ns":"agent:e14eb60b-8d55-22a7-2b61-de794c3864f4","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"RunnableSequence","parent_run_id":"01a0be6d-1735-76d3-b90b-cd3af992fb8c","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":689}],"dropped":[{"what":"inputs and outputs","bytes":1050,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655541549Z01a0be6d-1735-76d3-b90b-cd409b826c50.20260920T104655541618Z01a0be6d-1735-76d3-b90b-cd5b7c6cee79","id":"01a0be6d-1735-76d3-b90b-cd5b7c6cee79","metadata":{"langgraph_checkpoint_ns":"agent:e14eb60b-8d55-22a7-2b61-de794c3864f4","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"Prompt","parent_run_id":"01a0be6d-1735-76d3-b90b-cd409b826c50","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":738}],"dropped":[{"what":"inputs and outputs","bytes":326,"why":"a graph's own run repeats content its model call already carries"}]} +{"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_delegate_to_analyst_23","name":"delegate_to_analyst","state":"available","bytes":69}]} +{"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":"01a0be6d-173c-76d0-b51e-e1793319669f","metadata":{"langgraph_checkpoint_ns":"agent:e14eb60b-8d55-22a7-2b61-de794c3864f4","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":1,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"should_continue","parent_run_id":"01a0be6d-1735-76d3-b90b-cd250112a122","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":629}],"dropped":[{"what":"inputs and outputs","bytes":2393,"why":"a graph's own run repeats content its model call already carries"}]} +{"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_ns":"tools:0f07ed81-fa80-594c-7c8c-7ffddedb7566","langgraph_node":"tools","langgraph_path":["__pregel_push",0,false],"langgraph_step":2,"langgraph_triggers":["__pregel_push"],"ls_integration":"langgraph"},"name":"tools","parent_run_id":"01a0be6d-1724-7870-b615-7535de6b6e91","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":558}],"dropped":[{"what":"inputs and outputs","bytes":1449,"why":"a graph's own run repeats content its model call already carries"}]} +{"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_ns":"agent:9ed20ada-d3b6-25a3-84b1-86299f2aa893","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"agent","parent_run_id":"01a0be6d-1724-7870-b615-7535de6b6e91","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":560}],"dropped":[{"what":"inputs and outputs","bytes":2119,"why":"a graph's own run repeats content its model call already carries"}]} +{"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":"01a0be6d-1743-7352-8769-f29ecc02bfcb","metadata":{"langgraph_checkpoint_ns":"agent:9ed20ada-d3b6-25a3-84b1-86299f2aa893","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"call_model","parent_run_id":"01a0be6d-1743-7352-8769-f28c498055dd","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":624}],"dropped":[{"what":"inputs and outputs","bytes":2119,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655555835Z01a0be6d-1743-7352-8769-f2ae98ebfffb","id":"01a0be6d-1743-7352-8769-f2ae98ebfffb","metadata":{"langgraph_checkpoint_ns":"agent:9ed20ada-d3b6-25a3-84b1-86299f2aa893","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"RunnableSequence","parent_run_id":"01a0be6d-1743-7352-8769-f29ecc02bfcb","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":689}],"dropped":[{"what":"inputs and outputs","bytes":2115,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655555835Z01a0be6d-1743-7352-8769-f2ae98ebfffb.20260920T104655555894Z01a0be6d-1743-7352-8769-f2bab1d4df0e","id":"01a0be6d-1743-7352-8769-f2bab1d4df0e","metadata":{"langgraph_checkpoint_ns":"agent:9ed20ada-d3b6-25a3-84b1-86299f2aa893","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"Prompt","parent_run_id":"01a0be6d-1743-7352-8769-f2ae98ebfffb","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":738}],"dropped":[{"what":"inputs and outputs","bytes":2496,"why":"a graph's own run repeats content its model call already carries"}]} +{"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_summarise_26","name":"summarise","state":"available","bytes":69}]} +{"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":"01a0be6d-1745-7c41-90ad-0cf9fd1c93c5","metadata":{"langgraph_checkpoint_ns":"agent:9ed20ada-d3b6-25a3-84b1-86299f2aa893","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":3,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"should_continue","parent_run_id":"01a0be6d-1743-7352-8769-f28c498055dd","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":629}],"dropped":[{"what":"inputs and outputs","bytes":4475,"why":"a graph's own run repeats content its model call already carries"}]} +{"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_ns":"tools:85146e83-93c4-b6e0-52b5-3a7c6dd0ca57","langgraph_node":"tools","langgraph_path":["__pregel_push",0,false],"langgraph_step":4,"langgraph_triggers":["__pregel_push"],"ls_integration":"langgraph"},"name":"tools","parent_run_id":"01a0be6d-1724-7870-b615-7535de6b6e91","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":558}],"dropped":[{"what":"inputs and outputs","bytes":2474,"why":"a graph's own run repeats content its model call already carries"}]} +{"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_ns":"agent:9c46d505-5fde-189a-60e5-f0f2805e39e1","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":5,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"agent","parent_run_id":"01a0be6d-1724-7870-b615-7535de6b6e91","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":560}],"dropped":[{"what":"inputs and outputs","bytes":3026,"why":"a graph's own run repeats content its model call already carries"}]} +{"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":"01a0be6d-1747-7010-9bc9-e3efaa900eb0","metadata":{"langgraph_checkpoint_ns":"agent:9c46d505-5fde-189a-60e5-f0f2805e39e1","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":5,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"call_model","parent_run_id":"01a0be6d-1747-7010-9bc9-e3dea29ef4b0","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":624}],"dropped":[{"what":"inputs and outputs","bytes":3026,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655559999Z01a0be6d-1747-7010-9bc9-e3f8caa0b7aa","id":"01a0be6d-1747-7010-9bc9-e3f8caa0b7aa","metadata":{"langgraph_checkpoint_ns":"agent:9c46d505-5fde-189a-60e5-f0f2805e39e1","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":5,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"RunnableSequence","parent_run_id":"01a0be6d-1747-7010-9bc9-e3efaa900eb0","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":689}],"dropped":[{"what":"inputs and outputs","bytes":3022,"why":"a graph's own run repeats content its model call already carries"}]} +{"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.20260920T104655559999Z01a0be6d-1747-7010-9bc9-e3f8caa0b7aa.20260920T104655560065Z01a0be6d-1748-76f1-8883-e82db9f6a6cb","id":"01a0be6d-1748-76f1-8883-e82db9f6a6cb","metadata":{"langgraph_checkpoint_ns":"agent:9c46d505-5fde-189a-60e5-f0f2805e39e1","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":5,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"Prompt","parent_run_id":"01a0be6d-1747-7010-9bc9-e3f8caa0b7aa","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":738}],"dropped":[{"what":"inputs and outputs","bytes":4586,"why":"a graph's own run repeats content its model call already carries"}]} +{"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":"01a0be6d-1749-7172-a50d-bb301148784c","metadata":{"langgraph_checkpoint_ns":"agent:9c46d505-5fde-189a-60e5-f0f2805e39e1","langgraph_node":"agent","langgraph_path":["__pregel_pull","agent"],"langgraph_step":5,"langgraph_triggers":["branch:to:agent"],"ls_integration":"langgraph"},"name":"should_continue","parent_run_id":"01a0be6d-1747-7010-9bc9-e3dea29ef4b0","run_type":"chain","trace_id":"01a0be6d-1724-7870-b615-7535de6b6e91"},"state":"available","bytes":629}],"dropped":[{"what":"inputs and outputs","bytes":3032,"why":"a graph's own run repeats content its model call already carries"}]} +{"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 000000000000..d9666ce325b5 --- /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/mcp-calls/asz-view-example.json b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/asz-view-example.json new file mode 100644 index 000000000000..2c953f4fd495 --- /dev/null +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/mcp-calls/asz-view-example.json @@ -0,0 +1,1307 @@ +{ + "format": "asz.view", + "version": "1.0", + "conversation": "720d2f9e-dca7-49be-8c5e-89b667b39a01", + "sessions": [ + "720d2f9e-dca7-49be-8c5e-89b667b39a01" + ], + "head": { + "round": 1, + "digest": "0465bcdcdb729b5e5022b97e10f03c2589937c735debe88956af173df7f612d1" + }, + "parser": "v1", + "policy": "v1+idle=10m0s", + "summary": { + "title": "calls to MCP servers", + "state": "verified", + "problems": [], + "talks": 2, + "steps": 25, + "streams": 2, + "segments": 1, + "rounds": 1, + "unresolved": 0, + "changes": 0, + "provider_bodies": 0, + "captured_prompts": 0, + "from": 1767225600000, + "to": 1767225613900, + "kinds": { + "agent.call": 1, + "agent.launch_ack": 1, + "agent.output": 1, + "epoch": 2, + "llm.call": 8, + "message.assistant": 7, + "message.external": 1, + "run": 3, + "runtime.notification": 1, + "segment": 1, + "session": 1, + "stream": 2, + "talk": 2, + "tool": 5 + }, + "relation_types": { + "ends_with": 1, + "in_segment": 2, + "reports": 1, + "starts": 1 + }, + "quality": { + "exact_unique": 4, + "strong_inference": 1 + } + }, + "rounds": [ + { + "round": 1, + "digest": "0465bcdcdb729b5e5022b97e10f03c2589937c735debe88956af173df7f612d1", + "previous": null, + "from_seq": 1, + "through_seq": 5, + "input_digest": "d89701ec46c206724a1881d3571defaab92ce201520b19f84362aa85c9f47627", + "from_time": 1767225600000, + "through_time": 1767225613900, + "verified": true + } + ], + "files": [ + { + "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": 20, + "bytes": 6910, + "digest": "76e8539b8ad8ea080bd5daec3c187554d68c8a648533974d40cfff535081e0a0", + "from_time": 1767225600000, + "through_time": 1767225613900 + }, + { + "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/main/execution-20260101T000000.000000000Z-000002.sd", + "format": "sd", + "kind": "execution", + "seq": 2, + "round": null, + "stream": "main", + "run": null, + "lines": 6, + "bytes": 3507, + "digest": "9a6de86e0baa955694fc14b3f1764820a93b7bb30fedf92854a1ea0cbdae4c94", + "from_time": 1767225601500, + "through_time": 1767225606200 + }, + { + "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/transcript-20260101T000000.000000000Z-000003.sd", + "format": "sd", + "kind": "transcript", + "seq": 3, + "round": null, + "stream": "a39a255c7ba239348", + "run": null, + "lines": 7, + "bytes": 2147, + "digest": "3211096e8b6f85c6b6459be1d3d855a2930d49f4065f4d1d4315a7fc357ee5d4", + "from_time": 1767225609500, + "through_time": 1767225611900 + }, + { + "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/execution-20260101T000000.000000000Z-000004.sd", + "format": "sd", + "kind": "execution", + "seq": 4, + "round": null, + "stream": "a39a255c7ba239348", + "run": null, + "lines": 3, + "bytes": 1253, + "digest": "aea3dc04001736aba88914ff0eacea3731ebbf62915d58941b09110d6099f066", + "from_time": 1767225610900, + "through_time": 1767225610900 + }, + { + "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/meta-20260101T000000.000000000Z-000005.sd", + "format": "sd", + "kind": "agent_meta", + "seq": 5, + "round": null, + "stream": "a39a255c7ba239348", + "run": null, + "lines": 3, + "bytes": 664, + "digest": "eec87b3a2a776af6ebcbc9209d820cbe05dff81c067b8ca70f829e59e5e08781", + "from_time": null, + "through_time": null + }, + { + "file": "_conversations/720d2f9e-dca7-49be-8c5e-89b667b39a01/rounds/r000001-0465bcdcdb72.sf", + "format": "sf", + "kind": "round", + "seq": null, + "round": 1, + "stream": null, + "run": null, + "lines": 43, + "bytes": 10874, + "digest": "2ea12f2b3a05408df1eefc01e2273a681e6f26a37ff6ec92bc950b5bd8a7b8a6", + "from_time": 1767225600000, + "through_time": 1767225613900 + } + ], + "streams": [ + { + "id": "stream/main", + "name": "main", + "role": "main", + "label": "", + "parent": "", + "records": 18, + "steps": 19, + "talk": "talk/main/s1-cycle", + "named_by": "", + "opened_by": [] + }, + { + "id": "stream/a39a255c7ba239348", + "name": "a39a255c7ba239348", + "role": "child", + "label": "auditor", + "parent": "main", + "records": 6, + "steps": 6, + "talk": "talk/a39a255c7ba239348", + "named_by": "", + "opened_by": [ + { + "step": "tool/s6-tool", + "stream": "main", + "talk": "talk/main/s1-cycle", + "quality": "exact_unique" + } + ] + } + ], + "segments": [ + { + "id": "segment/at_1_2", + "state": "open", + "committable": false, + "talks": 2, + "from": 1767225600000, + "to": 1767225613900 + } + ], + "talks": [ + { + "id": "talk/main/s1-cycle", + "kind": "talk", + "parent": "epoch/main/0", + "stream": "main", + "at": 1767225600000, + "ref": { + "seq": 1, + "row": 2 + }, + "attrs": { + "loops": 2, + "runs": 2, + "trigger": "external" + }, + "label": "check the checkout service and then the payment service", + "reply": "Checkout is healthy and payment is not.", + "runs": 2, + "steps": 19, + "tools": 5, + "from": 1767225600000, + "to": 1767225613900, + "segment": "segment/at_1_2", + "children": [ + { + "id": "run/talk_main_s1-cycle/s1-cycle", + "kind": "run", + "parent": "talk/main/s1-cycle", + "stream": "main", + "at": 1767225600000, + "ref": { + "seq": 1, + "row": 2 + }, + "attrs": { + "trigger": "external" + }, + "children": [ + { + "id": "input/1/2", + "kind": "message.external", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225600000, + "ref": { + "seq": 1, + "row": 2 + }, + "text": "check the checkout service and then the payment service", + "state": "available", + "bytes": 55, + "flags": [ + "external_input" + ] + }, + { + "id": "call/s2-call", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225601000, + "ref": { + "seq": 1, + "row": 3 + }, + "refs": [ + { + "seq": 1, + "row": 3 + }, + { + "seq": 1, + "row": 4 + } + ], + "attrs": { + "fragments": 2, + "usage": "observed_replayable", + "usage_at": { + "row": 4, + "seq": 1 + }, + "usage_from": "last_fragment_in_line_order" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "children": [ + { + "id": "msg/1/3:0", + "kind": "message.assistant", + "parent": "call/s2-call", + "stream": "main", + "at": 1767225601000, + "ref": { + "seq": 1, + "row": 3, + "block": 0 + }, + "text": "Asking the status server about checkout.", + "state": "available", + "bytes": 40 + }, + { + "id": "tool/s2-tool", + "kind": "tool", + "parent": "call/s2-call", + "stream": "main", + "at": 1767225601100, + "ref": { + "seq": 1, + "row": 4, + "block": 0 + }, + "refs": [ + { + "seq": 1, + "row": 4, + "block": 0 + }, + { + "seq": 1, + "row": 5, + "block": 0 + } + ], + "attrs": { + "mcp_server": "status", + "mcp_tool": "lookup", + "name": "mcp__status__lookup", + "result": "available", + "result_join": "exact_unique", + "timing": "unavailable" + }, + "text": "{\"service\":\"checkout\"}", + "state": "available", + "bytes": 22, + "flags": [ + "finished" + ], + "name": "mcp__status__lookup", + "result": "checkout: 3 of 3 ready", + "result_state": "available", + "result_bytes": 22, + "request_to_result_ms": 400, + "request_to_result_join": "exact_unique", + "executions": [ + "s2-tool/client_hook" + ] + } + ] + }, + { + "id": "call/s3-call", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225602500, + "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" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "children": [ + { + "id": "msg/1/6:0", + "kind": "message.assistant", + "parent": "call/s3-call", + "stream": "main", + "at": 1767225602500, + "ref": { + "seq": 1, + "row": 6, + "block": 0 + }, + "text": "And about payment.", + "state": "available", + "bytes": 18 + }, + { + "id": "tool/s3-tool", + "kind": "tool", + "parent": "call/s3-call", + "stream": "main", + "at": 1767225602600, + "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" + }, + "text": "{\"service\":\"payment\"}", + "state": "available", + "bytes": 21, + "flags": [ + "finished" + ], + "name": "mcp__status__lookup", + "failed": true, + "result": "the status server could not reach payment", + "result_state": "available", + "result_bytes": 41, + "request_to_result_ms": 2000, + "request_to_result_join": "exact_unique", + "executions": [ + "s3-tool/client_hook" + ] + } + ] + }, + { + "id": "call/s4-call", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225605600, + "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" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "children": [ + { + "id": "msg/1/9:0", + "kind": "message.assistant", + "parent": "call/s4-call", + "stream": "main", + "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": 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" + }, + "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 + } + ], + "attrs": { + "name": "Agent", + "result": "available", + "result_join": "exact_unique", + "timing": "unavailable" + }, + "text": "{\"description\":\"auditor\",\"prompt\":\"read the release notes\"}", + "state": "available", + "bytes": 59, + "flags": [ + "finished" + ], + "name": "Agent", + "result": "launched", + "result_state": "available", + "result_bytes": 8, + "request_to_result_ms": 100, + "request_to_result_join": "exact_unique", + "edges": [ + { + "type": "starts", + "other": "stream/a39a255c7ba239348", + "dir": "out", + "quality": "exact_unique", + "via": "parent tool result" + } + ] + } + ] + }, + { + "id": "ack/1/16", + "kind": "agent.launch_ack", + "parent": "run/talk_main_s1-cycle/s1-cycle", + "stream": "main", + "at": 1767225608500, + "ref": { + "seq": 1, + "row": 16 + }, + "text": "launched", + "state": "available", + "bytes": 8, + "flags": [ + "launch_ack" + ] + } + ] + }, + { + "id": "run/talk_main_s1-cycle/s6-cycle-notification", + "kind": "run", + "parent": "talk/main/s1-cycle", + "stream": "main", + "at": 1767225612900, + "ref": { + "seq": 1, + "row": 17 + }, + "attrs": { + "trigger": "notification" + }, + "children": [ + { + "id": "notify/1/17", + "kind": "runtime.notification", + "parent": "run/talk_main_s1-cycle/s6-cycle-notification", + "stream": "main", + "at": 1767225612900, + "ref": { + "seq": 1, + "row": 17 + }, + "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/a39a255c7ba239348", + "dir": "out", + "quality": "exact_unique", + "via": "task id on the notification" + } + ] + }, + { + "id": "call/s7-call", + "kind": "llm.call", + "parent": "run/talk_main_s1-cycle/s6-cycle-notification", + "stream": "main", + "at": 1767225613900, + "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" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "children": [ + { + "id": "msg/1/18:0", + "kind": "message.assistant", + "parent": "call/s7-call", + "stream": "main", + "at": 1767225613900, + "ref": { + "seq": 1, + "row": 18, + "block": 0 + }, + "text": "Checkout is healthy and payment is not.", + "state": "available", + "bytes": 39, + "flags": [ + "finished" + ] + } + ] + } + ] + } + ], + "edges": [ + { + "type": "in_segment", + "other": "segment/at_1_2", + "dir": "out", + "quality": "exact_unique", + "via": "activity window" + } + ] + }, + { + "id": "talk/a39a255c7ba239348", + "kind": "talk", + "parent": "epoch/a39a255c7ba239348/0", + "stream": "a39a255c7ba239348", + "at": 1767225609500, + "ref": { + "seq": 3, + "row": 1 + }, + "attrs": { + "loops": 1, + "runs": 1, + "trigger": "unknown" + }, + "reply": "Read.", + "runs": 1, + "steps": 6, + "tools": 1, + "from": 1767225609500, + "to": 1767225611900, + "child": true, + "segment": "segment/at_1_2", + "children": [ + { + "id": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle", + "kind": "run", + "parent": "talk/a39a255c7ba239348", + "stream": "a39a255c7ba239348", + "at": 1767225609500, + "ref": { + "seq": 3, + "row": 1 + }, + "attrs": { + "trigger": "external" + }, + "children": [ + { + "id": "call/auditor-s1-call", + "kind": "llm.call", + "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle", + "stream": "a39a255c7ba239348", + "at": 1767225610500, + "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" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "children": [ + { + "id": "msg/3/2:0", + "kind": "message.assistant", + "parent": "call/auditor-s1-call", + "stream": "a39a255c7ba239348", + "at": 1767225610500, + "ref": { + "seq": 3, + "row": 2, + "block": 0 + }, + "text": "Reading them.", + "state": "available", + "bytes": 13 + }, + { + "id": "tool/auditor-s1-tool", + "kind": "tool", + "parent": "call/auditor-s1-call", + "stream": "a39a255c7ba239348", + "at": 1767225610600, + "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" + }, + "text": "{\"page\":\"release-notes\"}", + "state": "available", + "bytes": 24, + "flags": [ + "finished" + ], + "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", + "executions": [ + "auditor-s1-tool/client_hook" + ] + } + ] + }, + { + "id": "call/auditor-s2-call", + "kind": "llm.call", + "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle", + "stream": "a39a255c7ba239348", + "at": 1767225611900, + "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" + }, + "usage": { + "in": 2, + "out": 40, + "cache_read": 900, + "cache_write": 100 + }, + "children": [ + { + "id": "msg/3/5:0", + "kind": "message.assistant", + "parent": "call/auditor-s2-call", + "stream": "a39a255c7ba239348", + "at": 1767225611900, + "ref": { + "seq": 3, + "row": 5, + "block": 0 + }, + "text": "Read.", + "state": "available", + "bytes": 5, + "flags": [ + "finished" + ] + } + ] + }, + { + "id": "output/a39a255c7ba239348", + "kind": "agent.output", + "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle", + "stream": "a39a255c7ba239348", + "at": 1767225611900, + "ref": { + "seq": 3, + "row": 5 + }, + "refs": [ + { + "seq": 3, + "row": 5 + } + ], + "attrs": { + "returned_value": "unavailable" + }, + "text": "Read.", + "state": "available", + "bytes": 5, + "flags": [ + "finished" + ], + "edges": [ + { + "type": "ends_with", + "other": "stream/a39a255c7ba239348", + "dir": "in", + "quality": "exact_unique", + "via": "the last response in the child stream, and what it returned" + } + ] + } + ] + } + ], + "edges": [ + { + "type": "in_segment", + "other": "segment/at_1_2", + "dir": "out", + "quality": "strong_inference", + "via": "inside the window of the talk that delegated it" + } + ] + } + ], + "loose": [], + "relations": [ + { + "id": "rel/in_segment/talk_main_s1-cycle/segment_at_1_2", + "type": "in_segment", + "from": "talk/main/s1-cycle", + "to": "segment/at_1_2", + "quality": "exact_unique", + "via": "activity window", + "evidence": [ + { + "seq": 1, + "row": 2 + } + ] + }, + { + "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": 1, + "row": 16 + } + ] + }, + { + "id": "rel/in_segment/talk_a39a255c7ba239348/segment_at_1_2", + "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 + } + ] + }, + { + "id": "rel/ends_with/stream_a39a255c7ba239348/output_a39a255c7ba239348", + "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 + } + ] + }, + { + "id": "rel/reports/notify_1_17/stream_a39a255c7ba239348", + "type": "reports", + "from": "notify/1/17", + "to": "stream/a39a255c7ba239348", + "quality": "exact_unique", + "via": "task id on the notification", + "evidence": [ + { + "seq": 1, + "row": 17 + } + ] + } + ], + "unresolved": [], + "workspace_changes": [], + "tool_executions": [ + { + "step": "tool/s2-tool", + "ref": { + "seq": 2, + "row": 1, + "block": 0 + }, + "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.500Z", + "duration_ms": 380, + "outcome": "returned", + "arguments": { + "state": "size_only", + "bytes": 22, + "sha256": "ea3a64398a82e2ee89a19c80733d2be7a2496e0a0a64050fa307a20f7be8879b" + }, + "result": { + "state": "size_only", + "bytes": 49, + "sha256": "b8af7b751371023596105f146d227c1f580834e3a84903b6d822a166de343be9" + } + }, + { + "step": "tool/s3-tool", + "ref": { + "seq": 2, + "row": 3, + "block": 0 + }, + "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:04.600Z", + "duration_ms": 2000, + "outcome": "failed", + "arguments": { + "state": "size_only", + "bytes": 21, + "sha256": "e0f7631369273f52e3e7c14ca01c6bbf3f8416a36ed125c8d45fc14156596c36" + } + }, + { + "step": "tool/s4-tool", + "ref": { + "seq": 2, + "row": 4, + "block": 0 + }, + "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:06.200Z", + "duration_ms": 450, + "outcome": "returned", + "arguments": { + "state": "size_only", + "bytes": 42, + "sha256": "ea25b35f703111d67a809bd52709c167eda433619f18d87eb668eeab5dcc0851" + }, + "result": { + "state": "size_only", + "bytes": 48, + "sha256": "0397134b1598c5571ad554ef3963b3ad77adaca182426b6850b6b89be698fc32" + } + }, + { + "step": "tool/auditor-s1-tool", + "ref": { + "seq": 4, + "row": 1, + "block": 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" + }, + "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 000000000000..c5bfbb6986d2 --- /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.500Z","duration_ms":380,"outcome":"returned","arguments":{"state":"size_only","bytes":22,"sha256":"ea3a64398a82e2ee89a19c80733d2be7a2496e0a0a64050fa307a20f7be8879b"},"result":{"state":"size_only","bytes":49,"sha256":"b8af7b751371023596105f146d227c1f580834e3a84903b6d822a166de343be9"}},"state":"available","bytes":619}]} +{"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.500Z","duration_ms":380,"outcome":"returned","arguments":{"state":"size_only","bytes":22,"sha256":"ea3a64398a82e2ee89a19c80733d2be7a2496e0a0a64050fa307a20f7be8879b"},"result":{"state":"size_only","bytes":49,"sha256":"b8af7b751371023596105f146d227c1f580834e3a84903b6d822a166de343be9"}},"state":"available","bytes":619}]} +{"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:04.600Z","duration_ms":2000,"outcome":"failed","arguments":{"state":"size_only","bytes":21,"sha256":"e0f7631369273f52e3e7c14ca01c6bbf3f8416a36ed125c8d45fc14156596c36"}},"state":"available","bytes":500}]} +{"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:06.200Z","duration_ms":450,"outcome":"returned","arguments":{"state":"size_only","bytes":42,"sha256":"ea25b35f703111d67a809bd52709c167eda433619f18d87eb668eeab5dcc0851"},"result":{"state":"size_only","bytes":48,"sha256":"0397134b1598c5571ad554ef3963b3ad77adaca182426b6850b6b89be698fc32"}},"state":"available","bytes":619}]} +{"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 000000000000..69ff6d0ff1e4 --- /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":"project"},"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"}},"state":"available","bytes":645}]} +{"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 000000000000..f6ddef79c968 --- /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 000000000000..40cb36859d87 --- /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","talks":2,"steps":25,"streams":2,"segments":1,"unresolved":0,"changes":0,"lines_added":0,"lines_removed":0,"llm_calls":8,"subagents":1,"bash_runs":0} +{"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 000000000000..a951ba4ba951 --- /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":"\na39a255c7ba239348\ns6-tool\ncompleted\n","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 000000000000..cc9271f090a8 --- /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 577d9458b992..e985dec95874 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 3e7280dcd07a..f1e0b9ba5600 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":"\nCodebase and user instructions are shown below.\nFollow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. \n","state":"available","bytes":1770},{"k":"data","data":"You are Claude Code, working in a scenario. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. ","state":"available","bytes":2046},{"k":"data","data":{"description":"Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. ","input_schema":{"properties":{"file_path":{"type":"string"}},"type":"object"},"name":"Read"},"state":"available","bytes":1710},{"k":"data","data":{"description":"Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. ","input_schema":{"properties":{"command":{"type":"string"}},"type":"object"},"name":"Bash"},"state":"available","bytes":2068},{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"e62d8105-a1c8-4589-8ab4-1d1d4628dd9f.request.json","sha256":"6fcc13bd46ca07860d3404c4b6ea749540e567319032db87a3046348bc5d99ae","bytes":8134,"depth":0,"chain":"1d93f56d39742f1a","model":"claude-opus-5","session":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","run":"s1-cycle","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"part":0},{"lit":",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"summarise the file\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prompt_id=s1-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"part":1},{"lit":",\"type\":\"text\"}],\"tools\":["},{"part":2},{"lit":","},{"part":3},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1059}]} -{"ord":1,"off":0,"sha":"5925d886f7c8","bytes":163,"id":"s2-req-f7d240c7da38.response","call":"s2-call-f7d240c7da38","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s2-req-f7d240c7da38.response.json","sha256":"5925d886f7c87c6f7bcc06fe1514bb54c822d1f0ff8bd11a04980d34d152c446","bytes":163,"depth":0,"model":"claude-opus-5","call":"s2-call-f7d240c7da38","request":"s2-req-f7d240c7da38","segments":[{"lit":"{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"}],\"id\":\"s2-call-f7d240c7da38\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":489}]} +{"ord":1,"off":0,"sha":"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\":\"Reading it.\",\"type\":\"text\"}],\"id\":\"s2-call-f7d240c7da38\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":725}]} {"ord":1,"off":0,"sha":"8c19758d9fb7","bytes":8237,"id":"aca29c78-e4bf-46c6-83f4-f4d50763336b.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"aca29c78-e4bf-46c6-83f4-f4d50763336b.request.json","sha256":"8c19758d9fb7af7fbbdd57fc5206325a0bbdce17744b00395fe1f9763bd4122a","bytes":8237,"depth":0,"chain":"1d93f56d39742f1a","model":"claude-opus-5","session":"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d","run":"s1-cycle","previous_request":"s2-req-f7d240c7da38","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"summarise the file\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"Reading it.\",\"type\":\"text\"}],\"role\":\"assistant\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s2-req-f7d240c7da38; cc_prompt_id=s1-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"f41b7a16-49fa-44ea-8ef6-bb39db57ab1d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1481}]} -{"ord":1,"off":0,"sha":"fa5b1bd72222","bytes":179,"id":"s4-req-f7d240c7da38.response","call":"s4-call-f7d240c7da38","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s4-req-f7d240c7da38.response.json","sha256":"fa5b1bd722227582c3d9a48383fee4618f88e90d86da0ba17763c1828a1eb2ac","bytes":179,"depth":0,"model":"claude-opus-5","call":"s4-call-f7d240c7da38","request":"s4-req-f7d240c7da38","segments":[{"lit":"{\"content\":[{\"text\":\"It is a configuration file.\",\"type\":\"text\"}],\"id\":\"s4-call-f7d240c7da38\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":505}]} -{"t":"end","records":4,"digest":"d8bbc389639d63bea5e577738050699841ea1c8550abadd12fdbc7d5b81e1008"} +{"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 a configuration file.\",\"type\":\"text\"}],\"id\":\"s4-call-f7d240c7da38\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":741}]} +{"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 f977786c3bf7..ab3557256c2b 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","talks":1,"steps":7,"streams":1,"segments":1,"unresolved":0,"changes":0,"lines_added":0,"lines_removed":0,"llm_calls":3,"subagents":0,"bash_runs":0} -{"t":"node","id":"call/s2-call-f7d240c7da38","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":3},"refs":[{"seq":1,"row":3}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":3,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"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","talks":1,"steps":7,"streams":1,"segments":1,"unresolved":0,"changes":0,"lines_added":0,"lines_removed":0,"llm_calls":3,"subagents":0,"bash_runs":0} +{"t":"node","id":"call/s2-call-f7d240c7da38","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":3},"refs":[{"seq":1,"row":3}],"attrs":{"fragments":1,"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 c4d468eeb4dd..33aefd54d609 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 } ] } ], "unresolved": [], - "workspace_changes": [] + "workspace_changes": [], + "tool_executions": [] } diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.yaml b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.yaml index 73391b4f7dc9..e480ffec8779 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.yaml +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/asz-view-example.yaml @@ -5,7 +5,7 @@ sessions: - 6b7a6063-8714-4f6b-87cd-6c2da3a5094d head: round: 1 - digest: ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920 + digest: e292bd0f6de64aa79b2c5c03d04d6affed8efd317d2cb31fee346fc06fa394aa parser: v1 policy: v1+idle=10m0s summary: @@ -50,11 +50,11 @@ summary: strong_inference: 1 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 @@ -103,11 +103,11 @@ files: 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 @@ -115,8 +115,8 @@ files: stream: null run: null lines: 51 - bytes: 12009 - digest: 2a0088c80cdf2abd2d9ff4517ccba7ec070035f904019d25f1c24eeec8346f10 + bytes: 12791 + digest: dddf2a17aac5687ff39924e86293168553a1d04810b1a981b667bc2f6732c59d from_time: 1767225600000 through_time: 1767225608400 streams: @@ -513,136 +513,6 @@ talks: dir: out quality: exact_unique via: activity window - - id: talk/main/s7-cycle-compact - kind: talk - parent: epoch/main/s7-boundary - stream: main - at: 1767225606000 - ref: - seq: 1 - row: 12 - attrs: - loops: 1 - runs: 1 - trigger: external - runs: 1 - from: 1767225606000 - to: 1767225606000 - segment: segment/at_1_2 - children: - - id: run/talk_main_s7-cycle-compact/s7-cycle-compact - kind: run - parent: talk/main/s7-cycle-compact - stream: main - at: 1767225606000 - ref: - seq: 1 - row: 12 - attrs: - trigger: external - edges: - - type: in_segment - other: segment/at_1_2 - dir: out - quality: exact_unique - via: activity window - - id: talk/main/s8-cycle - kind: talk - parent: epoch/main/s7-boundary - stream: main - at: 1767225607400 - ref: - seq: 1 - row: 13 - attrs: - loops: 1 - runs: 1 - trigger: external - label: and the retries? - reply: There are 3 retries. - runs: 1 - steps: 3 - from: 1767225607400 - to: 1767225608400 - segment: segment/at_1_2 - children: - - id: run/talk_main_s8-cycle/s8-cycle - kind: run - parent: talk/main/s8-cycle - stream: main - at: 1767225607400 - ref: - seq: 1 - row: 13 - attrs: - trigger: external - children: - - id: input/1/13 - kind: message.external - parent: run/talk_main_s8-cycle/s8-cycle - stream: main - at: 1767225607400 - ref: - seq: 1 - row: 13 - text: and the retries? - state: available - bytes: 16 - flags: - - external_input - - id: call/s9-call-fdae022ac306 - kind: llm.call - parent: run/talk_main_s8-cycle/s8-cycle - stream: main - at: 1767225608400 - ref: - seq: 1 - row: 14 - refs: - - seq: 1 - row: 14 - attrs: - fragments: 1 - usage: observed_replayable - usage_at: - row: 14 - seq: 1 - usage_from: last_fragment_in_line_order - usage: - in: 2 - out: 40 - cache_read: 900 - cache_write: 100 - provider_bodies: - - role: request - ref: - seq: 4 - row: 13 - - role: response - ref: - seq: 4 - row: 14 - children: - - id: msg/1/14:0 - kind: message.assistant - parent: call/s9-call-fdae022ac306 - stream: main - at: 1767225608400 - ref: - seq: 1 - row: 14 - block: 0 - text: There are 3 retries. - state: available - bytes: 20 - flags: - - finished - edges: - - type: in_segment - other: segment/at_1_2 - dir: out - quality: exact_unique - via: activity window - id: talk/a02ba01b82410e9e3 kind: talk parent: epoch/a02ba01b82410e9e3/0 @@ -831,6 +701,136 @@ talks: dir: out quality: strong_inference 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: - id: boundary/1/11 kind: epoch.boundary @@ -871,33 +871,6 @@ loose: quality: exact_unique via: containment parent relations: - - id: rel/ends_with/stream_a02ba01b82410e9e3/output_a02ba01b82410e9e3 - type: ends_with - from: stream/a02ba01b82410e9e3 - to: output/a02ba01b82410e9e3 - quality: exact_unique - via: the last response in the child stream, and what it returned - evidence: - - seq: 2 - row: 5 - - id: rel/follows/epoch_main_s7-boundary/epoch_main_0 - type: follows - from: epoch/main/s7-boundary - to: epoch/main/0 - quality: exact_unique - via: explicit context reset - evidence: - - seq: 1 - row: 11 - - id: rel/in_segment/talk_a02ba01b82410e9e3/segment_at_1_2 - type: in_segment - from: talk/a02ba01b82410e9e3 - to: segment/at_1_2 - quality: strong_inference - via: inside the window of the talk that delegated it - evidence: - - seq: 2 - row: 1 - id: rel/in_segment/talk_main_s1-cycle/segment_at_1_2 type: in_segment from: talk/main/s1-cycle @@ -916,33 +889,42 @@ relations: evidence: - seq: 1 row: 7 - - id: rel/in_segment/talk_main_s7-cycle-compact/segment_at_1_2 - type: in_segment - from: talk/main/s7-cycle-compact - to: segment/at_1_2 + - id: rel/starts/tool_s5-tool/stream_a02ba01b82410e9e3 + type: starts + from: tool/s5-tool + to: stream/a02ba01b82410e9e3 quality: exact_unique - via: activity window + via: parent tool result evidence: - seq: 1 - row: 12 - - id: rel/in_segment/talk_main_s8-cycle/segment_at_1_2 + row: 9 + - id: rel/in_segment/talk_a02ba01b82410e9e3/segment_at_1_2 type: in_segment - from: talk/main/s8-cycle + from: talk/a02ba01b82410e9e3 to: segment/at_1_2 + quality: strong_inference + via: inside the window of the talk that delegated it + evidence: + - seq: 2 + row: 1 + - id: rel/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: 13 - - id: rel/starts/tool_s5-tool/stream_a02ba01b82410e9e3 - type: starts - from: tool/s5-tool - to: stream/a02ba01b82410e9e3 + row: 11 + - id: rel/in_segment/talk_main_s7-cycle-compact/segment_at_1_2 + type: in_segment + from: talk/main/s7-cycle-compact + to: segment/at_1_2 quality: exact_unique - via: parent tool result + via: activity window evidence: - seq: 1 - row: 9 + row: 12 - id: rel/summarizes/summary_1_12/boundary_1_11 type: summarizes from: summary/1/12 @@ -952,6 +934,25 @@ relations: evidence: - seq: 1 row: 12 + - id: rel/in_segment/talk_main_s8-cycle/segment_at_1_2 + type: in_segment + from: talk/main/s8-cycle + to: segment/at_1_2 + quality: exact_unique + via: activity window + evidence: + - seq: 1 + row: 13 + - id: rel/ends_with/stream_a02ba01b82410e9e3/output_a02ba01b82410e9e3 + type: ends_with + from: stream/a02ba01b82410e9e3 + to: output/a02ba01b82410e9e3 + quality: exact_unique + via: the last response in the child stream, and what it returned + evidence: + - seq: 2 + row: 5 unresolved: [] workspace_changes: [] +tool_executions: [] diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/provider_body-20260101T000000.000000000Z-000004.sd b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/provider_body-20260101T000000.000000000Z-000004.sd index ae2ea551c232..01b092a584d7 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/provider_body-20260101T000000.000000000Z-000004.sd +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/provider_body-20260101T000000.000000000Z-000004.sd @@ -1,16 +1,16 @@ {"h":1,"schema":"sd/1","seq":4,"at":"2026-01-01T00:00:00Z","kind":"provider_body","adapter":"mock/0.2.0","dialect":"mock/1","src":".","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d"} {"ord":1,"off":0,"sha":"25a4cf0c2c2a","bytes":8155,"id":"62c5142f-0b52-4b3d-8f5b-5ba0a44cb6b7.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":"\nCodebase and user instructions are shown below.\nFollow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. Follow the project's rules. \n","state":"available","bytes":1770},{"k":"data","data":"You are Claude Code, working in a scenario. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. Use the tools to answer. ","state":"available","bytes":2046},{"k":"data","data":{"description":"Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. Reads a file from the local filesystem. ","input_schema":{"properties":{"file_path":{"type":"string"}},"type":"object"},"name":"Read"},"state":"available","bytes":1710},{"k":"data","data":{"description":"Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. Executes a shell command and returns its output. ","input_schema":{"properties":{"command":{"type":"string"}},"type":"object"},"name":"Bash"},"state":"available","bytes":2068},{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"62c5142f-0b52-4b3d-8f5b-5ba0a44cb6b7.request.json","sha256":"25a4cf0c2c2a0f485f56519e56e1f6bcf79466df6599fc4f38fd0f0df84540fa","bytes":8155,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s1-cycle","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"part":0},{"lit":",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"read the configuration and summarise it\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prompt_id=s1-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"part":1},{"lit":",\"type\":\"text\"}],\"tools\":["},{"part":2},{"lit":","},{"part":3},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1080}]} -{"ord":1,"off":0,"sha":"6854dac2921c","bytes":268,"id":"s2-req-fdae022ac306.response","call":"s2-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s2-req-fdae022ac306.response.json","sha256":"6854dac2921cbbb9de25c2e7b640b45a0148e9870102a47fe719476ff37997d2","bytes":268,"depth":0,"model":"claude-opus-5","call":"s2-call-fdae022ac306","request":"s2-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"id\":\"s2-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\"}"}]},"state":"available","bytes":612}]} +{"ord":1,"off":0,"sha":"cc832e08f39e","bytes":478,"id":"s2-req-fdae022ac306.response","call":"s2-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s2-req-fdae022ac306.response.json","sha256":"cc832e08f39eab4f17ae9d6d50ed260b1512dd6f4e3787bbac5e65124219d9f9","bytes":478,"depth":0,"model":"claude-opus-5","call":"s2-call-fdae022ac306","request":"s2-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"id\":\"s2-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":848}]} {"ord":1,"off":0,"sha":"60c205135751","bytes":8476,"id":"e6250647-cc72-4db4-87f8-35d5d8532118.request","run":"s1-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"e6250647-cc72-4db4-87f8-35d5d8532118.request.json","sha256":"60c20513575143a9f0166a04f236954c6d0831eb62418ff105c6f30f86151505","bytes":8476,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s1-cycle","previous_request":"s2-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"read the configuration and summarise it\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"content\":\"timeout: 30\\nretries: 3\\n\",\"tool_use_id\":\"s2-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s2-req-fdae022ac306; cc_prompt_id=s1-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1758}]} -{"ord":1,"off":0,"sha":"07626e6a5551","bytes":194,"id":"s3-req-fdae022ac306.response","call":"s3-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s3-req-fdae022ac306.response.json","sha256":"07626e6a55516660d4045b4bd4615992f71539ed94b9e38aee5b5c1fc563ed68","bytes":194,"depth":0,"model":"claude-opus-5","call":"s3-call-fdae022ac306","request":"s3-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"The timeout is 30 seconds, with 3 retries.\",\"type\":\"text\"}],\"id\":\"s3-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":520}]} +{"ord":1,"off":0,"sha":"daa95af2ce9d","bytes":404,"id":"s3-req-fdae022ac306.response","call":"s3-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s3-req-fdae022ac306.response.json","sha256":"daa95af2ce9db8f574cc41f80c47dbad74745dfdeb50d8afe0832324e076089f","bytes":404,"depth":0,"model":"claude-opus-5","call":"s3-call-fdae022ac306","request":"s3-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"The timeout is 30 seconds, with 3 retries.\",\"type\":\"text\"}],\"id\":\"s3-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":756}]} {"ord":1,"off":0,"sha":"fcf4469b068b","bytes":8636,"id":"2e0ed3d7-b6d2-46eb-8c3f-c06a030dd7d5.request","run":"s4-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"2e0ed3d7-b6d2-46eb-8c3f-c06a030dd7d5.request.json","sha256":"fcf4469b068bc21e97d6e4754a3b2454c0876da594b6f0fb8b00e3c7eb056eab","bytes":8636,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s4-cycle","previous_request":"s3-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"read the configuration and summarise it\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"content\":\"timeout: 30\\nretries: 3\\n\",\"tool_use_id\":\"s2-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"},{\"content\":\"The timeout is 30 seconds, with 3 retries.\",\"role\":\"assistant\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"find where the timeout is used\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s3-req-fdae022ac306; cc_prompt_id=s4-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1940}]} -{"ord":1,"off":0,"sha":"f66b868a1f3b","bytes":252,"id":"s5-req-fdae022ac306.response","call":"s5-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s5-req-fdae022ac306.response.json","sha256":"f66b868a1f3b5d2c80e28a8314f7ce57f5f55e8e4625ba403fff5ab0e22b613e","bytes":252,"depth":0,"model":"claude-opus-5","call":"s5-call-fdae022ac306","request":"s5-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"id\":\"s5-tool\",\"input\":{\"description\":\"searcher\",\"prompt\":\"find every use of the timeout\"},\"name\":\"Agent\",\"type\":\"tool_use\"}],\"id\":\"s5-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\"}"}]},"state":"available","bytes":592}]} +{"ord":1,"off":0,"sha":"3302e4ad65c8","bytes":462,"id":"s5-req-fdae022ac306.response","call":"s5-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s5-req-fdae022ac306.response.json","sha256":"3302e4ad65c8c541777325d4c593dd7ab9166a9e6694319dc3fd84012256e32d","bytes":462,"depth":0,"model":"claude-opus-5","call":"s5-call-fdae022ac306","request":"s5-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"id\":\"s5-tool\",\"input\":{\"description\":\"searcher\",\"prompt\":\"find every use of the timeout\"},\"name\":\"Agent\",\"type\":\"tool_use\"}],\"id\":\"s5-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":828}]} {"ord":1,"off":0,"sha":"faa5ca836651","bytes":8866,"id":"935833f9-330c-4a2f-84e7-90072ac2f4db.request","run":"s4-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"935833f9-330c-4a2f-84e7-90072ac2f4db.request.json","sha256":"faa5ca836651fe57bf3b41d154a72d46560bba35dfc15d872cf86606ad195b27","bytes":8866,"depth":0,"chain":"f48484e6a7a8135e","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s4-cycle","previous_request":"s5-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"read the configuration and summarise it\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"Reading it.\",\"type\":\"text\"},{\"id\":\"s2-tool\",\"input\":{\"file_path\":\"/Users/dev/scenario/config.yaml\"},\"name\":\"Read\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"content\":\"timeout: 30\\nretries: 3\\n\",\"tool_use_id\":\"s2-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"},{\"content\":\"The timeout is 30 seconds, with 3 retries.\",\"role\":\"assistant\"},{\"content\":\"find where the timeout is used\",\"role\":\"user\"},{\"content\":[{\"id\":\"s5-tool\",\"input\":{\"description\":\"searcher\",\"prompt\":\"find every use of the timeout\"},\"name\":\"Agent\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"content\":\"launched\",\"tool_use_id\":\"s5-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s5-req-fdae022ac306; cc_prompt_id=s4-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":2210}]} -{"ord":1,"off":0,"sha":"c4f1645ec661","bytes":187,"id":"s6-req-fdae022ac306.response","call":"s6-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s6-req-fdae022ac306.response.json","sha256":"c4f1645ec661dd0072bccb3c9addc19563fc22d140f5e66e3bfd7d5c96a436e2","bytes":187,"depth":0,"model":"claude-opus-5","call":"s6-call-fdae022ac306","request":"s6-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"The searcher found it in server.go.\",\"type\":\"text\"}],\"id\":\"s6-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":513}]} +{"ord":1,"off":0,"sha":"4d391a1e56cf","bytes":397,"id":"s6-req-fdae022ac306.response","call":"s6-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s6-req-fdae022ac306.response.json","sha256":"4d391a1e56cfe89148d7b51fe229021a818bd8b49dd95d4e5237c62f44ae366c","bytes":397,"depth":0,"model":"claude-opus-5","call":"s6-call-fdae022ac306","request":"s6-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"The searcher found it in server.go.\",\"type\":\"text\"}],\"id\":\"s6-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":749}]} {"ord":1,"off":0,"sha":"a564142b6ea8","bytes":3753,"id":"32c40efc-db2a-4811-85e8-0d40b51c6fe7.request","run":"a02ba01b82410e9e3-cycle","model":"claude-opus-5","parts":[{"k":"data","data":"You are an agent that searches a repository. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. Report what you find. ","state":"available","bytes":1367},{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"32c40efc-db2a-4811-85e8-0d40b51c6fe7.request.json","sha256":"a564142b6ea86784fb801d22f151f7817d7dcbce32de577a6cadd35026f9ea2a","bytes":3753,"depth":0,"chain":"13f2eed00f2e44d1","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"a02ba01b82410e9e3-cycle","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":\"\\nAs you answer the user's questions, you can use the following context.\\n\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"find every use of the timeout\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prompt_id=a02ba01b82410e9e3-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"part":0},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1236}]} -{"ord":1,"off":0,"sha":"db0df7cae147","bytes":270,"id":"searcher-s1-req-fdae022ac306.response","call":"searcher-s1-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"searcher-s1-req-fdae022ac306.response.json","sha256":"db0df7cae14773759eb967e78b5999c47377673475c09e0da2cd44d81356ef56","bytes":270,"depth":0,"model":"claude-opus-5","call":"searcher-s1-call-fdae022ac306","request":"searcher-s1-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"Searching.\",\"type\":\"text\"},{\"id\":\"searcher-s1-tool\",\"input\":{\"command\":\"grep -rn timeout .\"},\"name\":\"Bash\",\"type\":\"tool_use\"}],\"id\":\"searcher-s1-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\"}"}]},"state":"available","bytes":641}]} +{"ord":1,"off":0,"sha":"190d3f2154d2","bytes":480,"id":"searcher-s1-req-fdae022ac306.response","call":"searcher-s1-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"searcher-s1-req-fdae022ac306.response.json","sha256":"190d3f2154d23f758f1534de81f58e488520c0d00f4b06f850f777c17b0e33c1","bytes":480,"depth":0,"model":"claude-opus-5","call":"searcher-s1-call-fdae022ac306","request":"searcher-s1-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"Searching.\",\"type\":\"text\"},{\"id\":\"searcher-s1-tool\",\"input\":{\"command\":\"grep -rn timeout .\"},\"name\":\"Bash\",\"type\":\"tool_use\"}],\"id\":\"searcher-s1-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"tool_use\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":877}]} {"ord":1,"off":0,"sha":"0047b6fad9bb","bytes":4089,"id":"c58ece40-0a74-4e25-8426-53f9ccca9203.request","run":"a02ba01b82410e9e3-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"c58ece40-0a74-4e25-8426-53f9ccca9203.request.json","sha256":"0047b6fad9bbe463e67ed5ed18a023e62959433c12d427b14cce04b8de492248","bytes":4089,"depth":0,"chain":"13f2eed00f2e44d1","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"a02ba01b82410e9e3-cycle","previous_request":"searcher-s1-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":\"\\nAs you answer the user's questions, you can use the following context.\\n\",\"type\":\"text\"},{\"text\":\"find every use of the timeout\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"Searching.\",\"type\":\"text\"},{\"id\":\"searcher-s1-tool\",\"input\":{\"command\":\"grep -rn timeout .\"},\"name\":\"Bash\",\"type\":\"tool_use\"}],\"role\":\"assistant\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"content\":\"server.go:3: var timeout = 30\",\"tool_use_id\":\"searcher-s1-tool\",\"type\":\"tool_result\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=searcher-s1-req-fdae022ac306; cc_prompt_id=a02ba01b82410e9e3-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"210fbe461835a4f21a27c136f311b69ab52a1440e2e23f2395c102b67744f2be"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1738}]} -{"ord":1,"off":0,"sha":"9aa0936b42dc","bytes":185,"id":"searcher-s2-req-fdae022ac306.response","call":"searcher-s2-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"searcher-s2-req-fdae022ac306.response.json","sha256":"9aa0936b42dc784b266936891a85359e54500e1f3fb9e490cc53b192b313d499","bytes":185,"depth":0,"model":"claude-opus-5","call":"searcher-s2-call-fdae022ac306","request":"searcher-s2-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"It is used in server.go.\",\"type\":\"text\"}],\"id\":\"searcher-s2-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":538}]} +{"ord":1,"off":0,"sha":"bc2a644b6278","bytes":395,"id":"searcher-s2-req-fdae022ac306.response","call":"searcher-s2-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"searcher-s2-req-fdae022ac306.response.json","sha256":"bc2a644b6278c5c10577e04d30e47a6a9eddbf569ab98c5057837193da25c3ae","bytes":395,"depth":0,"model":"claude-opus-5","call":"searcher-s2-call-fdae022ac306","request":"searcher-s2-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"It is used in server.go.\",\"type\":\"text\"}],\"id\":\"searcher-s2-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":774}]} {"ord":1,"off":0,"sha":"c98fdf59718d","bytes":8281,"id":"89c950ad-d61b-47bb-84da-bf0f39990473.request","run":"s8-cycle","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"request","src":"89c950ad-d61b-47bb-84da-bf0f39990473.request.json","sha256":"c98fdf59718dda1877f1107a8a3eae19ec0c55d0989ce6d03d6b33aaf20758ff","bytes":8281,"depth":0,"chain":"d4550b67cba4cf93","model":"claude-opus-5","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","run":"s8-cycle","previous_request":"s6-req-fdae022ac306","segments":[{"lit":"{\"model\":\"claude-opus-5\",\"messages\":[{\"content\":[{\"text\":"},{"piece":"45acd8fd698090271c94b467014cbbee7da6b12cd3a8688250b7477708ecbf8e"},{"lit":",\"type\":\"text\"},{\"text\":\"The configuration sets a 30 second timeout, used in server.go.\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":\"and the retries?\",\"type\":\"text\"}],\"role\":\"user\"}],\"system\":[{\"text\":\"x-anthropic-billing-header: cc_version=2.1.260; cc_entrypoint=cli; cch=00000; cc_prev_req=s6-req-fdae022ac306; cc_prompt_id=s8-cycle;\",\"type\":\"text\"},{\"cache_control\":{\"type\":\"ephemeral\"},\"text\":"},{"piece":"1f624856ebbc447374f6846f9cc7d85e56c63b82402012bb8218a69ed84b2b10"},{"lit":",\"type\":\"text\"}],\"tools\":["},{"piece":"2e351518883ab8369b861de4ce00c3aa37328583bc2ba3b42bbe1a571e24da4a"},{"lit":","},{"piece":"4006827662e8cf431010a0face899a28f0adc0b4a1dc3688666a3e706aaab6af"},{"lit":"],\"metadata\":{\"user_id\":\"{\\\"account_uuid\\\":\\\"scenario\\\",\\\"device_id\\\":\\\"scenario\\\",\\\"session_id\\\":\\\"6b7a6063-8714-4f6b-87cd-6c2da3a5094d\\\"}\"},\"max_tokens\":64000}"}]},"state":"available","bytes":1525}]} -{"ord":1,"off":0,"sha":"b61d2be4bb7b","bytes":172,"id":"s9-req-fdae022ac306.response","call":"s9-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s9-req-fdae022ac306.response.json","sha256":"b61d2be4bb7bc571ab39192cfe0219474fa688a9690861226d5f2b67509e325e","bytes":172,"depth":0,"model":"claude-opus-5","call":"s9-call-fdae022ac306","request":"s9-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"There are 3 retries.\",\"type\":\"text\"}],\"id\":\"s9-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\"}"}]},"state":"available","bytes":498}]} -{"t":"end","records":14,"digest":"979442eae01734ce54d96f1bbec3df5a1382295a8cd80880475b0df3ba4510c0"} +{"ord":1,"off":0,"sha":"0648149a433f","bytes":382,"id":"s9-req-fdae022ac306.response","call":"s9-call-fdae022ac306","model":"claude-opus-5","parts":[{"k":"data","data":{"schema":"provider_body/1","role":"response","src":"s9-req-fdae022ac306.response.json","sha256":"0648149a433f8492e907edff0db377ad242df73482509659252f8ee14f8160fd","bytes":382,"depth":0,"model":"claude-opus-5","call":"s9-call-fdae022ac306","request":"s9-req-fdae022ac306","segments":[{"lit":"{\"content\":[{\"text\":\"There are 3 retries.\",\"type\":\"text\"}],\"id\":\"s9-call-fdae022ac306\",\"model\":\"claude-opus-5\",\"role\":\"assistant\",\"stop_reason\":\"end_turn\",\"type\":\"message\",\"usage\":{\"cache_creation_input_tokens\":100,\"cache_read_input_tokens\":900,\"input_tokens\":2,\"iterations\":[{\"type\":\"message\"}],\"output_tokens\":40,\"server_tool_use\":{},\"service_tier\":\"standard\",\"speed\":\"standard\"}}"}]},"state":"available","bytes":734}]} +{"t":"end","records":14,"digest":"24ffdc16ce45b0b226b7850fe4ce216a9874e29bea7ec3961390237d2e38aa97"} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-ff8eaba03b03.sf b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-e292bd0f6de6.sf similarity index 83% rename from oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-ff8eaba03b03.sf rename to oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-e292bd0f6de6.sf index 4fe5058174f9..bf37f46a89cb 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-ff8eaba03b03.sf +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/provider-bodies/r000001-e292bd0f6de6.sf @@ -1,13 +1,13 @@ -{"t":"header","schema":"sf/1","conversation":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","round":1,"from_seq":1,"through_seq":4,"input_digest":"bb774e3f497f8618d3e944070b5d074c0ab6589f0c089db4c2371a99dc4528e3","parser":"v1","policy":"v1+idle=10m0s","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:08.4Z","session_from_time":"2026-01-01T00:00:00Z","session_through_time":"2026-01-01T00:00:08.4Z","title":"provider bodies beside the calls","talks":5,"steps":23,"streams":2,"segments":1,"unresolved":0,"changes":0,"lines_added":0,"lines_removed":0,"llm_calls":7,"subagents":1,"bash_runs":1} +{"t":"header","schema":"sf/1","conversation":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","session":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","round":1,"from_seq":1,"through_seq":4,"input_digest":"8a4c1af7839a7c893ecc33e9c0631639b3e17e16eb41441495d7095762d42160","parser":"v1","policy":"v1+idle=10m0s","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:08.4Z","session_from_time":"2026-01-01T00:00:00Z","session_through_time":"2026-01-01T00:00:08.4Z","title":"provider bodies beside the calls","talks":5,"steps":23,"streams":2,"segments":1,"unresolved":0,"changes":0,"lines_added":0,"lines_removed":0,"llm_calls":7,"subagents":1,"bash_runs":1} {"t":"node","id":"ack/1/9","revision":1,"kind":"agent.launch_ack","parent":"run/talk_main_s4-cycle/s4-cycle","stream":"main","ref":{"seq":1,"row":9}} {"t":"node","id":"boundary/1/11","revision":1,"kind":"epoch.boundary","parent":"epoch/main/s7-boundary","stream":"main","ref":{"seq":1,"row":11}} -{"t":"node","id":"call/s2-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":3},"refs":[{"seq":1,"row":3},{"seq":1,"row":4}],"attrs":{"fragments":2,"usage":"observed_replayable","usage_at":{"row":4,"seq":1},"usage_from":"last_fragment_in_line_order"}} -{"t":"node","id":"call/s3-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":6},"refs":[{"seq":1,"row":6}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":6,"seq":1},"usage_from":"last_fragment_in_line_order"}} -{"t":"node","id":"call/s5-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s4-cycle/s4-cycle","stream":"main","ref":{"seq":1,"row":8},"refs":[{"seq":1,"row":8}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":8,"seq":1},"usage_from":"last_fragment_in_line_order"}} -{"t":"node","id":"call/s6-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s4-cycle/s4-cycle","stream":"main","ref":{"seq":1,"row":10},"refs":[{"seq":1,"row":10}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":10,"seq":1},"usage_from":"last_fragment_in_line_order"}} -{"t":"node","id":"call/s9-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s8-cycle/s8-cycle","stream":"main","ref":{"seq":1,"row":14},"refs":[{"seq":1,"row":14}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":14,"seq":1},"usage_from":"last_fragment_in_line_order"}} -{"t":"node","id":"call/searcher-s1-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":2},"refs":[{"seq":2,"row":2},{"seq":2,"row":3}],"attrs":{"fragments":2,"usage":"observed_replayable","usage_at":{"row":3,"seq":2},"usage_from":"last_fragment_in_line_order"}} -{"t":"node","id":"call/searcher-s2-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":5},"refs":[{"seq":2,"row":5}],"attrs":{"fragments":1,"usage":"observed_replayable","usage_at":{"row":5,"seq":2},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/s2-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":3},"refs":[{"seq":1,"row":3},{"seq":1,"row":4}],"attrs":{"fragments":2,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":1}},{"role":"response","ref":{"seq":4,"row":2}}],"usage":"observed_replayable","usage_at":{"row":4,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/s3-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s1-cycle/s1-cycle","stream":"main","ref":{"seq":1,"row":6},"refs":[{"seq":1,"row":6}],"attrs":{"fragments":1,"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_fragment_in_line_order"}} +{"t":"node","id":"call/s5-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s4-cycle/s4-cycle","stream":"main","ref":{"seq":1,"row":8},"refs":[{"seq":1,"row":8}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":5}},{"role":"response","ref":{"seq":4,"row":6}}],"usage":"observed_replayable","usage_at":{"row":8,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/s6-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s4-cycle/s4-cycle","stream":"main","ref":{"seq":1,"row":10},"refs":[{"seq":1,"row":10}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":7}},{"role":"response","ref":{"seq":4,"row":8}}],"usage":"observed_replayable","usage_at":{"row":10,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/s9-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_main_s8-cycle/s8-cycle","stream":"main","ref":{"seq":1,"row":14},"refs":[{"seq":1,"row":14}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":13}},{"role":"response","ref":{"seq":4,"row":14}}],"usage":"observed_replayable","usage_at":{"row":14,"seq":1},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/searcher-s1-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":2},"refs":[{"seq":2,"row":2},{"seq":2,"row":3}],"attrs":{"fragments":2,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":9}},{"role":"response","ref":{"seq":4,"row":10}}],"usage":"observed_replayable","usage_at":{"row":3,"seq":2},"usage_from":"last_fragment_in_line_order"}} +{"t":"node","id":"call/searcher-s2-call-fdae022ac306","revision":1,"kind":"llm.call","parent":"run/talk_a02ba01b82410e9e3/a02ba01b82410e9e3-cycle","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":5},"refs":[{"seq":2,"row":5}],"attrs":{"fragments":1,"provider_bodies":[{"role":"request","ref":{"seq":4,"row":11}},{"role":"response","ref":{"seq":4,"row":12}}],"usage":"observed_replayable","usage_at":{"row":5,"seq":2},"usage_from":"last_fragment_in_line_order"}} {"t":"node","id":"epoch/a02ba01b82410e9e3/0","revision":1,"kind":"epoch","parent":"stream/a02ba01b82410e9e3","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":1},"attrs":{"records":6,"reset":"none"}} {"t":"node","id":"epoch/main/0","revision":1,"kind":"epoch","parent":"stream/main","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":10,"reset":"none"}} {"t":"node","id":"epoch/main/s7-boundary","revision":1,"kind":"epoch","parent":"stream/main","stream":"main","ref":{"seq":1,"row":11},"refs":[{"seq":1,"row":11}],"attrs":{"continues_from":"s6-call-f1","records":4,"reset":"observed_replayable"}} @@ -27,7 +27,7 @@ {"t":"node","id":"run/talk_main_s7-cycle-compact/s7-cycle-compact","revision":1,"kind":"run","parent":"talk/main/s7-cycle-compact","stream":"main","ref":{"seq":1,"row":12},"attrs":{"trigger":"external"}} {"t":"node","id":"run/talk_main_s8-cycle/s8-cycle","revision":1,"kind":"run","parent":"talk/main/s8-cycle","stream":"main","ref":{"seq":1,"row":13},"attrs":{"trigger":"external"}} {"t":"node","id":"segment/at_1_2","revision":1,"kind":"segment","parent":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","ref":{"seq":1,"row":2},"attrs":{"committable":false,"gates_unmet":["activity_boundary","lateness_watermark"],"state":"open","talks":4}} -{"t":"node","id":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","revision":1,"kind":"session","attrs":{"conversation":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","from_time":"2026-01-01T00:00:00Z","through_time":"2026-01-01T00:00:08.4Z","title":"provider bodies beside the calls","title_from":"observed_replayable"}} +{"t":"node","id":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","revision":1,"kind":"session","attrs":{"conversation":"6b7a6063-8714-4f6b-87cd-6c2da3a5094d","from_time":"2026-01-01T00:00:00Z","provider_bodies_landed":14,"through_time":"2026-01-01T00:00:08.4Z","title":"provider bodies beside the calls","title_from":"observed_replayable"}} {"t":"node","id":"stream/a02ba01b82410e9e3","revision":1,"kind":"stream","parent":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","stream":"a02ba01b82410e9e3","ref":{"seq":2,"row":1},"attrs":{"label":"searcher","records":6,"role":"child"}} {"t":"node","id":"stream/main","revision":1,"kind":"stream","parent":"session/6b7a6063-8714-4f6b-87cd-6c2da3a5094d","stream":"main","ref":{"seq":1,"row":1},"attrs":{"records":14,"role":"main"}} {"t":"node","id":"summary/1/12","revision":1,"kind":"epoch.summary","parent":"epoch/main/s7-boundary","stream":"main","ref":{"seq":1,"row":12}} @@ -48,4 +48,4 @@ {"t":"relation","id":"rel/in_segment/talk_main_s8-cycle/segment_at_1_2","revision":1,"type":"in_segment","from":"talk/main/s8-cycle","to":"segment/at_1_2","quality":"exact_unique","via":"activity window","evidence":[{"seq":1,"row":13}]} {"t":"relation","id":"rel/starts/tool_s5-tool/stream_a02ba01b82410e9e3","revision":1,"type":"starts","from":"tool/s5-tool","to":"stream/a02ba01b82410e9e3","quality":"exact_unique","via":"parent tool result","evidence":[{"seq":1,"row":9}]} {"t":"relation","id":"rel/summarizes/summary_1_12/boundary_1_11","revision":1,"type":"summarizes","from":"summary/1/12","to":"boundary/1/11","quality":"exact_unique","via":"containment parent","evidence":[{"seq":1,"row":12}]} -{"t":"commit","digest":"ff8eaba03b03ab64a73e06df2a5dff6177ef6038e328e8de792aa28af7ba6920","counts":{"nodes":40,"relations":9,"unresolved":0}} +{"t":"commit","digest":"e292bd0f6de64aa79b2c5c03d04d6affed8efd317d2cb31fee346fc06fa394aa","counts":{"nodes":40,"relations":9,"unresolved":0}} diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json index 4754730ff1c3..4ff2f01b747a 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.json @@ -891,16 +891,30 @@ "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/starts/tool_s4-tool/stream_a16c61ba3c4753923", + "type": "starts", + "from": "tool/s4-tool", + "to": "stream/a16c61ba3c4753923", + "quality": "exact_unique", + "via": "parent tool result", + "evidence": [ + { + "seq": 1, + "row": 10 } ] }, @@ -919,16 +933,16 @@ ] }, { - "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_a16c61ba3c4753923/output_a16c61ba3c4753923", + "type": "ends_with", + "from": "stream/a16c61ba3c4753923", + "to": "output/a16c61ba3c4753923", "quality": "exact_unique", - "via": "activity window", + "via": "the last response in the child stream, and what it returned", "evidence": [ { - "seq": 1, - "row": 2 + "seq": 3, + "row": 5 } ] }, @@ -945,20 +959,6 @@ "row": 11 } ] - }, - { - "id": "rel/starts/tool_s4-tool/stream_a16c61ba3c4753923", - "type": "starts", - "from": "tool/s4-tool", - "to": "stream/a16c61ba3c4753923", - "quality": "exact_unique", - "via": "parent tool result", - "evidence": [ - { - "seq": 1, - "row": 10 - } - ] } ], "unresolved": [], @@ -1197,5 +1197,6 @@ } ] } - ] + ], + "tool_executions": [] } diff --git a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.yaml b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.yaml index dcc2c27ced65..d3c408ae686a 100644 --- a/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.yaml +++ b/oap-server/analyzer/ai-agent-conversation/src/test/resources/fixtures/workspace-changes/asz-view-example.yaml @@ -675,15 +675,24 @@ talks: via: inside the window of the talk that delegated it 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/starts/tool_s4-tool/stream_a16c61ba3c4753923 + type: starts + from: tool/s4-tool + to: stream/a16c61ba3c4753923 + quality: exact_unique + via: parent tool result + evidence: + - seq: 1 + row: 10 - id: rel/in_segment/talk_a16c61ba3c4753923/segment_at_1_2 type: in_segment from: talk/a16c61ba3c4753923 @@ -693,15 +702,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_a16c61ba3c4753923/output_a16c61ba3c4753923 + type: ends_with + from: stream/a16c61ba3c4753923 + to: output/a16c61ba3c4753923 quality: exact_unique - via: activity window + via: the last response in the child stream, and what it returned evidence: - - seq: 1 - row: 2 + - seq: 3 + row: 5 - id: rel/reports/notify_1_11/stream_a16c61ba3c4753923 type: reports from: notify/1/11 @@ -711,15 +720,6 @@ relations: evidence: - seq: 1 row: 11 - - id: rel/starts/tool_s4-tool/stream_a16c61ba3c4753923 - type: starts - from: tool/s4-tool - to: stream/a16c61ba3c4753923 - quality: exact_unique - via: parent tool result - evidence: - - seq: 1 - row: 10 unresolved: [] workspace_changes: - step: tool/s2-tool @@ -896,4 +896,5 @@ workspace_changes: new_lines: 0 lines: - -old notes +tool_executions: [] diff --git a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/java/org/apache/skywalking/oap/server/dsl/tester/mal/MALExpressionExecutionTest.java b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/java/org/apache/skywalking/oap/server/dsl/tester/mal/MALExpressionExecutionTest.java index 296be78fb5b3..6776ef6a6c12 100644 --- a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/java/org/apache/skywalking/oap/server/dsl/tester/mal/MALExpressionExecutionTest.java +++ b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/java/org/apache/skywalking/oap/server/dsl/tester/mal/MALExpressionExecutionTest.java @@ -333,8 +333,18 @@ private void validateExpected(final String metricName, metricName + ": sample[" + i + "] labels mismatch"); } - // Compare values with tolerance - // For time()-dependent expressions (large magnitudes), use relative tolerance + // A value taken from time() is written as value_minus_now: a fixed value holds only near the day it + // was recorded. The expression ran a moment before this check, so a minute covers the gap. + if (expSample.containsKey("value_minus_now")) { + final double expValue = ((Number) expSample.get("value_minus_now")).doubleValue() + - java.time.Instant.now().getEpochSecond(); + final double actValue = actSample.getValue(); + assertEquals(expValue, actValue, 60, + metricName + ": sample[" + i + "] value mismatch" + + " (expected=" + expValue + ", actual=" + actValue + ")"); + } + + // Compare values with tolerance; a large value within 1% if (expSample.containsKey("value")) { final double expValue = ((Number) expSample.get("value")).doubleValue(); final double actValue = actSample.getValue(); diff --git a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-envoy-metrics-rules/envoy-ca.data.yaml b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-envoy-metrics-rules/envoy-ca.data.yaml index 940dbd467400..150ea416cbf5 100644 --- a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-envoy-metrics-rules/envoy-ca.data.yaml +++ b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-envoy-metrics-rules/envoy-ca.data.yaml @@ -28,6 +28,8 @@ input: instance: test-instance metrics_name: cluster.outbound.test-cluster.ssl.certificate.test-cert.expiration_unix_time_seconds value: 100.0 +# Each rule subtracts time() from the certificate's expiry, 100 in the input, so each value is written relative to +# the second the test checks it. expected: envoy_service_cluster_ssl_ca_expiration_seconds: entities: @@ -38,7 +40,7 @@ expected: - labels: app: test-app secret_name: test-cert - value: -1.772760608E9 + value_minus_now: 100.0 envoy_service_listener_ssl_ca_expiration_seconds: entities: - scope: SERVICE @@ -48,7 +50,7 @@ expected: - labels: app: test-app secret_name: test-cert - value: -1.772760608E9 + value_minus_now: 100.0 envoy_instance_cluster_ssl_ca_expiration_seconds: entities: - scope: SERVICE_INSTANCE @@ -60,7 +62,7 @@ expected: app: test-app instance: test-instance secret_name: test-cert - value: -1.772760608E9 + value_minus_now: 100.0 envoy_instance_listener_ssl_ca_expiration_seconds: entities: - scope: SERVICE_INSTANCE @@ -72,4 +74,4 @@ expected: app: test-app instance: test-instance secret_name: test-cert - value: -1.772760608E9 + value_minus_now: 100.0 diff --git a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/mcp_endpoint.data.yaml b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/mcp_endpoint.data.yaml new file mode 100644 index 000000000000..7b579a8c09aa --- /dev/null +++ b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/mcp_endpoint.data.yaml @@ -0,0 +1,242 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Two sessions from two senders, three MCP targets: a tool called on both streams and by both senders, one that +# failed once, and a name the runtime could not split, which arrives as the whole name. Each rule sums away the +# session, the sender, the stream and the source of the server's configuration, and keeps one endpoint per target. +script: oap-server/server-starter/src/main/resources/otel-rules/ai-agent/mcp_endpoint.yaml +input: + agent_mcp_calls: + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev@host' + session_id: 's1' + query_source: 'main' + mcp_server_name: 'status' + mcp_server_source: 'user' + mcp_tool_name: 'lookup' + outcome: 'returned' + value: 2.0 + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev@host' + session_id: 's1' + query_source: 'main' + mcp_server_name: 'status' + mcp_server_source: 'user' + mcp_tool_name: 'lookup' + outcome: 'failed' + value: 1.0 + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev2@host' + session_id: 's2' + query_source: 'subagent' + mcp_server_name: 'status' + mcp_server_source: 'user' + mcp_tool_name: 'lookup' + outcome: 'returned' + value: 1.0 + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev@host' + session_id: 's1' + query_source: 'subagent' + mcp_server_name: 'docs' + mcp_server_source: 'project' + mcp_tool_name: 'read' + outcome: 'returned' + value: 1.0 + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev2@host' + session_id: 's2' + query_source: 'main' + mcp_server_name: 'odd' + mcp_server_source: 'dynamic' + mcp_tool_name: 'mcp__odd__name__twice' + outcome: 'returned' + value: 1.0 + agent_mcp_duration: + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev@host' + session_id: 's1' + query_source: 'main' + mcp_server_name: 'status' + mcp_server_source: 'user' + mcp_tool_name: 'lookup' + outcome: 'returned' + value: 760.0 + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev@host' + session_id: 's1' + query_source: 'main' + mcp_server_name: 'status' + mcp_server_source: 'user' + mcp_tool_name: 'lookup' + outcome: 'failed' + value: 2000.0 + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev2@host' + session_id: 's2' + query_source: 'subagent' + mcp_server_name: 'status' + mcp_server_source: 'user' + mcp_tool_name: 'lookup' + outcome: 'returned' + value: 400.0 + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev@host' + session_id: 's1' + query_source: 'subagent' + mcp_server_name: 'docs' + mcp_server_source: 'project' + mcp_tool_name: 'read' + outcome: 'returned' + value: 280.0 + - labels: + service_name: 'Claude Code' + service_layer: 'AI_AGENT' + telemetry_sdk_name: 'asz' + service_instance_id: 'dev2@host' + session_id: 's2' + query_source: 'main' + mcp_server_name: 'odd' + mcp_server_source: 'dynamic' + mcp_tool_name: 'mcp__odd__name__twice' + outcome: 'returned' + value: 100.0 +expected: + meter_ai_agent_mcp_calls: + entities: + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'status/lookup' + layer: AI_AGENT + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'docs/read' + layer: AI_AGENT + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'odd/mcp__odd__name__twice' + layer: AI_AGENT + samples: + - labels: + service_name: 'Claude Code' + mcp_server_name: 'status' + mcp_tool_name: 'lookup' + value: 4.0 + - labels: + service_name: 'Claude Code' + mcp_server_name: 'docs' + mcp_tool_name: 'read' + value: 1.0 + - labels: + service_name: 'Claude Code' + mcp_server_name: 'odd' + mcp_tool_name: 'mcp__odd__name__twice' + value: 1.0 + meter_ai_agent_mcp_calls_by_outcome: + entities: + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'status/lookup' + layer: AI_AGENT + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'docs/read' + layer: AI_AGENT + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'odd/mcp__odd__name__twice' + layer: AI_AGENT + samples: + - labels: + outcome: 'returned' + service_name: 'Claude Code' + mcp_server_name: 'status' + mcp_tool_name: 'lookup' + value: 3.0 + - labels: + outcome: 'failed' + service_name: 'Claude Code' + mcp_server_name: 'status' + mcp_tool_name: 'lookup' + value: 1.0 + - labels: + outcome: 'returned' + service_name: 'Claude Code' + mcp_server_name: 'docs' + mcp_tool_name: 'read' + value: 1.0 + - labels: + outcome: 'returned' + service_name: 'Claude Code' + mcp_server_name: 'odd' + mcp_tool_name: 'mcp__odd__name__twice' + value: 1.0 + meter_ai_agent_mcp_duration: + entities: + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'status/lookup' + layer: AI_AGENT + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'docs/read' + layer: AI_AGENT + - scope: ENDPOINT + service: 'Claude Code' + endpoint: 'odd/mcp__odd__name__twice' + layer: AI_AGENT + samples: + - labels: + service_name: 'Claude Code' + mcp_server_name: 'status' + mcp_tool_name: 'lookup' + value: 3160.0 + - labels: + service_name: 'Claude Code' + mcp_server_name: 'docs' + mcp_tool_name: 'read' + value: 280.0 + - labels: + service_name: 'Claude Code' + mcp_server_name: 'odd' + mcp_tool_name: 'mcp__odd__name__twice' + value: 100.0 diff --git a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/runtime-instance.data.yaml b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/runtime-instance.data.yaml index 5ea7da66d5d0..9615998a5bb0 100644 --- a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/runtime-instance.data.yaml +++ b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/runtime-instance.data.yaml @@ -17,7 +17,7 @@ # session.id always, the sender at service level, the model and the source where the rule does not keep them. script: oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-instance.yaml input: - claude_code_token_usage: + agent_token_usage: - labels: service_name: 'Claude Code' service_layer: 'AI_AGENT' diff --git a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/runtime-service.data.yaml b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/runtime-service.data.yaml index e00f85df8615..89e56d432a2b 100644 --- a/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/runtime-service.data.yaml +++ b/oap-server/analyzer/meter-analyzer-scripts-test/src/test/resources/scripts/mal/test-otel-rules/ai-agent/runtime-service.data.yaml @@ -17,7 +17,7 @@ # session.id always, the sender at service level, the model and the source where the rule does not keep them. script: oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-service.yaml input: - claude_code_token_usage: + agent_token_usage: - labels: service_name: 'Claude Code' service_layer: 'AI_AGENT' diff --git a/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/mcp_endpoint.yaml b/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/mcp_endpoint.yaml new file mode 100644 index 000000000000..f6a251fb1e2d --- /dev/null +++ b/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/mcp_endpoint.yaml @@ -0,0 +1,42 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# AI agent runtimes, the MCP targets an agent called, one endpoint each under the agent's service in the AI_AGENT +# layer. The AI Sessionizer derives two metrics from the record the Claude Code plugin writes after every call to an +# MCP server: agent.mcp.calls, one per call, and agent.mcp.duration, the milliseconds the runtime measured around it, +# which include any wait before the call started and are not the server's own time. The endpoint is the server and +# the tool, `/`. When the runtime's name for a call does not split into exactly one server and one tool, +# the tool is that whole name. +# +# A point is a delta, the calls of the minute they were observed, kept as the point's value at the point's time, so +# no rule calls increase(). Each rule sums a minute's points over the labels it does not keep and downsamples by SUM. +# session.id, the query source (main or subagent) and the source of the server's configuration are summed away. + +filter: "{ tags -> tags.service_layer == 'AI_AGENT' }" +expSuffix: endpoint(['service_name'], ['mcp_server_name', 'mcp_tool_name'], '/', Layer.AI_AGENT) +metricPrefix: meter_ai_agent_mcp + +metricsRules: + # Calls per minute + - name: calls + exp: agent_mcp_calls.sum(['service_name', 'mcp_server_name', 'mcp_tool_name']).downsampling(SUM) + + # By how the call ended: returned, failed, interrupted + - name: calls_by_outcome + exp: agent_mcp_calls.sum(['outcome', 'service_name', 'mcp_server_name', 'mcp_tool_name']).downsampling(SUM) + + # Milliseconds per minute, summed over the calls. Divided by calls it is the mean over any time range. + - name: duration + exp: agent_mcp_duration.sum(['service_name', 'mcp_server_name', 'mcp_tool_name']).downsampling(SUM) diff --git a/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-instance.yaml b/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-instance.yaml index e00a25f18414..d6e5ad515b78 100644 --- a/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-instance.yaml +++ b/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-instance.yaml @@ -21,29 +21,29 @@ expSuffix: instance(['service_name'], ['service_instance_id'], Layer.AI_AGENT) metricPrefix: meter_ai_agent_instance metricsRules: - # ===================== claude_code.token.usage: sent by the exporter and derived by the Sessionizer alike + # ===================== agent.token.usage: derived by the Sessionizer from the landed transcripts # Tokens per minute, every type - name: tokens - exp: claude_code_token_usage.sum(['service_name', 'service_instance_id']).downsampling(SUM) + exp: agent_token_usage.sum(['service_name', 'service_instance_id']).downsampling(SUM) # By type: input, output, cacheRead, cacheCreation - name: tokens_by_type - exp: claude_code_token_usage.sum(['type', 'service_name', 'service_instance_id']).downsampling(SUM) + exp: agent_token_usage.sum(['type', 'service_name', 'service_instance_id']).downsampling(SUM) # By the model the call ran on - name: tokens_by_model - exp: claude_code_token_usage.sum(['model', 'type', 'service_name', 'service_instance_id']).downsampling(SUM) + exp: agent_token_usage.sum(['model', 'type', 'service_name', 'service_instance_id']).downsampling(SUM) - # By where the call was made: main or subagent, and from the exporter also auxiliary + # By where the call was made: main or subagent - name: tokens_by_source - exp: claude_code_token_usage.sum(['query_source', 'type', 'service_name', 'service_instance_id']).downsampling(SUM) + exp: agent_token_usage.sum(['query_source', 'type', 'service_name', 'service_instance_id']).downsampling(SUM) # How much of what the model read came from cache, in percent: cacheRead over every type but output - name: cache_read_share - exp: claude_code_token_usage.tagEqual('type', 'cacheRead').sum(['service_name', 'service_instance_id']).safeDiv(claude_code_token_usage.tagNotEqual('type', 'output').sum(['service_name', 'service_instance_id'])) * 100 + exp: agent_token_usage.tagEqual('type', 'cacheRead').sum(['service_name', 'service_instance_id']).safeDiv(agent_token_usage.tagNotEqual('type', 'output').sum(['service_name', 'service_instance_id'])) * 100 - # ===================== the exporter's other metrics; absent when the Sessionizer derived the points + # ===================== Claude Code's own exporter, sent to the OAP directly; the Sessionizer sends none of these # Cost in micro-dollars (USD * 1,000,000), by model - name: cost_by_model diff --git a/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-service.yaml b/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-service.yaml index d989cd765abf..5085364d43ba 100644 --- a/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-service.yaml +++ b/oap-server/server-starter/src/main/resources/otel-rules/ai-agent/runtime-service.yaml @@ -13,9 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -# AI agent runtimes, service level: the metrics an agent runtime's own OpenTelemetry exporter sends, or the -# AI Sessionizer derives from the landed transcripts, under the AI_AGENT layer. One service per kind of -# runtime, `Claude Code` by default, summed over every sender and every session. +# AI agent runtimes, service level, under the AI_AGENT layer: the tokens the AI Sessionizer derives from the landed +# transcripts, and the metrics only Claude Code's own OpenTelemetry exporter sends, when it is pointed at the OAP +# directly. One service per kind of runtime, `Claude Code` by default, summed over every sender and every session. +# The exporter's own token metric, claude_code.token.usage, is not read: the tokens come from the Sessionizer, so a +# call is never counted twice when both send. # # A point is a delta, the tokens of the minute the call ended, and the receiver keeps it as the point's value # at the point's time, so no rule here calls increase(). Each rule sums a minute's points over the labels it @@ -31,29 +33,29 @@ expSuffix: service(['service_name'], Layer.AI_AGENT) metricPrefix: meter_ai_agent metricsRules: - # ===================== claude_code.token.usage: sent by the exporter and derived by the Sessionizer alike + # ===================== agent.token.usage: derived by the Sessionizer from the landed transcripts # Tokens per minute, every type - name: tokens - exp: claude_code_token_usage.sum(['service_name']).downsampling(SUM) + exp: agent_token_usage.sum(['service_name']).downsampling(SUM) # By type: input, output, cacheRead, cacheCreation - name: tokens_by_type - exp: claude_code_token_usage.sum(['type', 'service_name']).downsampling(SUM) + exp: agent_token_usage.sum(['type', 'service_name']).downsampling(SUM) # By the model the call ran on - name: tokens_by_model - exp: claude_code_token_usage.sum(['model', 'type', 'service_name']).downsampling(SUM) + exp: agent_token_usage.sum(['model', 'type', 'service_name']).downsampling(SUM) - # By where the call was made: main or subagent, and from the exporter also auxiliary + # By where the call was made: main or subagent - name: tokens_by_source - exp: claude_code_token_usage.sum(['query_source', 'type', 'service_name']).downsampling(SUM) + exp: agent_token_usage.sum(['query_source', 'type', 'service_name']).downsampling(SUM) # How much of what the model read came from cache, in percent: cacheRead over every type but output - name: cache_read_share - exp: claude_code_token_usage.tagEqual('type', 'cacheRead').sum(['service_name']).safeDiv(claude_code_token_usage.tagNotEqual('type', 'output').sum(['service_name'])) * 100 + exp: agent_token_usage.tagEqual('type', 'cacheRead').sum(['service_name']).safeDiv(agent_token_usage.tagNotEqual('type', 'output').sum(['service_name'])) * 100 - # ===================== the exporter's other metrics; absent when the Sessionizer derived the points + # ===================== Claude Code's own exporter, sent to the OAP directly; the Sessionizer sends none of these # Cost in micro-dollars (USD * 1,000,000), by model - name: cost_by_model diff --git a/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml b/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml index 399b33fdf2a4..e60bd2732681 100644 --- a/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml +++ b/test/e2e-v2/cases/ai-agent/ai-agent-cases.yaml @@ -71,6 +71,10 @@ cases: # request and response landed, and the export names the file - query: bash test/e2e-v2/cases/ai-agent/verify.sh provider-bodies http://${oap_host}:${oap_12800} expected: expected/provider-bodies.yml + # the mcp-calls conversation: the plugin's execution files landed beside the transcripts, every record joins its step, + # and the calls to each MCP server and their time are kept per endpoint, one per server and tool + - query: bash test/e2e-v2/cases/ai-agent/verify.sh mcp http://${oap_host}:${oap_12800} + expected: expected/mcp.yml # the runtime's token metric the Sessionizer derived, summed per minute over every session and sender, and over the # run equal to what the scenarios declare; the service and the sender both - query: bash test/e2e-v2/cases/ai-agent/verify.sh metrics http://${oap_host}:${oap_12800} diff --git a/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml b/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml index 623c11607848..e2f41272d5ac 100644 --- a/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml +++ b/test/e2e-v2/cases/ai-agent/banyandb/docker-compose.yml @@ -19,6 +19,8 @@ # session tool call changed files, in the same root # provider-bodies.yaml, one build -> collect, once, after the workspace-changes pass: a session whose # session calls' request and response bodies landed as provider_body records +# mcp-calls.yaml, one session build -> collect, once, after the provider-bodies pass: calls to MCP servers, +# each with the plugin's execution record landed beside the transcripts # three-rounds.yaml, one session build through the first checkpoint -> collect, then through the second, # then to the end: three rounds over files cut at each stage, and the final # document must cover it all @@ -41,6 +43,7 @@ x-asz: &asz - ../lost-file.yaml:/asz/lost-file.yaml:ro - ../workspace-changes.yaml:/asz/workspace-changes.yaml:ro - ../provider-bodies.yaml:/asz/provider-bodies.yaml:ro + - ../mcp-calls.yaml:/asz/mcp-calls.yaml:ro networks: - e2e @@ -135,12 +138,27 @@ services: depends_on: pb-build: condition: service_completed_successfully + # ---- mcp-calls.yaml: calls to MCP servers, one session in the same root. The build lands the plugin's execution + # records as execution files beside the transcripts; the pass sends them like any landed file, and derives from them + # the calls to each MCP server and their time. + mcp-build: + <<: *asz + command: ["scenario", "build", "/asz/mcp-calls.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + pb-collect: + condition: service_completed_successfully + mcp-collect: + <<: *asz + command: ["collect", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mcp-build: + condition: service_completed_successfully # ---- three-rounds.yaml: one session in three stages, each parsed and sent before the next is built mr-build-1: <<: *asz command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] depends_on: - pb-collect: + mcp-collect: condition: service_completed_successfully mr-collect-1: <<: *asz diff --git a/test/e2e-v2/cases/ai-agent/es/docker-compose.yml b/test/e2e-v2/cases/ai-agent/es/docker-compose.yml index 1e27557550ff..9e790f482444 100644 --- a/test/e2e-v2/cases/ai-agent/es/docker-compose.yml +++ b/test/e2e-v2/cases/ai-agent/es/docker-compose.yml @@ -19,6 +19,8 @@ # session tool call changed files, in the same root # provider-bodies.yaml, one build -> collect, once, after the workspace-changes pass: a session whose # session calls' request and response bodies landed as provider_body records +# mcp-calls.yaml, one session build -> collect, once, after the provider-bodies pass: calls to MCP servers, +# each with the plugin's execution record landed beside the transcripts # three-rounds.yaml, one session build through the first checkpoint -> collect, then through the second, # then to the end: three rounds over files cut at each stage, and the final # document must cover it all @@ -41,6 +43,7 @@ x-asz: &asz - ../lost-file.yaml:/asz/lost-file.yaml:ro - ../workspace-changes.yaml:/asz/workspace-changes.yaml:ro - ../provider-bodies.yaml:/asz/provider-bodies.yaml:ro + - ../mcp-calls.yaml:/asz/mcp-calls.yaml:ro networks: - e2e @@ -144,12 +147,27 @@ services: depends_on: pb-build: condition: service_completed_successfully + # ---- mcp-calls.yaml: calls to MCP servers, one session in the same root. The build lands the plugin's execution + # records as execution files beside the transcripts; the pass sends them like any landed file, and derives from them + # the calls to each MCP server and their time. + mcp-build: + <<: *asz + command: ["scenario", "build", "/asz/mcp-calls.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + pb-collect: + condition: service_completed_successfully + mcp-collect: + <<: *asz + command: ["collect", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mcp-build: + condition: service_completed_successfully # ---- three-rounds.yaml: one session in three stages, each parsed and sent before the next is built mr-build-1: <<: *asz command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] depends_on: - pb-collect: + mcp-collect: condition: service_completed_successfully mr-collect-1: <<: *asz diff --git a/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml b/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml index c138a54c835e..36624d34dd6f 100644 --- a/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml +++ b/test/e2e-v2/cases/ai-agent/expected/conversations-instance.yml @@ -13,5 +13,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -hit: 7 +hit: 8 miss: 0 diff --git a/test/e2e-v2/cases/ai-agent/expected/conversations.yml b/test/e2e-v2/cases/ai-agent/expected/conversations.yml index 1715e864c3df..9ab5730b6798 100644 --- a/test/e2e-v2/cases/ai-agent/expected/conversations.yml +++ b/test/e2e-v2/cases/ai-agent/expected/conversations.yml @@ -63,6 +63,16 @@ segments: 1 unresolved: 0 timed: true +- conversation: 720d2f9e-dca7-49be-8c5e-89b667b39a01 + instance: e2e-sender + title: calls to MCP servers + round: 1 + talks: 2 + steps: 25 + streams: 2 + segments: 1 + unresolved: 0 + timed: true - conversation: bd16edc4-0b6b-4020-8405-3ce58724f2bc instance: e2e-sender title: three rounds diff --git a/test/e2e-v2/cases/ai-agent/expected/list-horizon.yml b/test/e2e-v2/cases/ai-agent/expected/list-horizon.yml index ee2d9a44d40f..d587aeb68b01 100644 --- a/test/e2e-v2/cases/ai-agent/expected/list-horizon.yml +++ b/test/e2e-v2/cases/ai-agent/expected/list-horizon.yml @@ -98,6 +98,23 @@ subagents: 1 bashRuns: 1 timed: true +- conversation: 720d2f9e-dca7-49be-8c5e-89b667b39a01 + instance: e2e-sender + instanceIdSet: true + title: calls to MCP servers + round: 1 + talks: 2 + steps: 25 + streams: 2 + segments: 1 + unresolved: 0 + changes: 0 + linesAdded: 0 + linesRemoved: 0 + llmCalls: 8 + subagents: 1 + bashRuns: 0 + timed: true - conversation: bd16edc4-0b6b-4020-8405-3ce58724f2bc instance: e2e-sender instanceIdSet: true diff --git a/test/e2e-v2/cases/ai-agent/expected/mcp.yml b/test/e2e-v2/cases/ai-agent/expected/mcp.yml new file mode 100644 index 000000000000..cd05bc87e6cd --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/expected/mcp.yml @@ -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. + +state: verified +execution_files: + - seq: 2 + stream: main + lines: 6 + - seq: 4 + stream: a39a255c7ba239348 + lines: 3 +executions: + - step: tool/s2-tool + ref: + seq: 2 + row: 1 + block: 0 + server: + name: status + source: user + tool_name: mcp__status__lookup + outcome: returned + duration_ms: 380 + - step: tool/s3-tool + ref: + seq: 2 + row: 3 + block: 0 + server: + name: status + source: user + tool_name: mcp__status__lookup + outcome: failed + duration_ms: 2000 + - step: tool/s4-tool + ref: + seq: 2 + row: 4 + block: 0 + server: + name: status + source: user + tool_name: mcp__status__lookup + outcome: returned + duration_ms: 450 + - step: tool/auditor-s1-tool + ref: + seq: 4 + row: 1 + block: 0 + server: + name: docs + source: project + tool_name: mcp__docs__read + outcome: returned + duration_ms: 280 +steps: + - id: tool/auditor-s1-tool + mcp_server: docs + mcp_tool: read + executions: + - auditor-s1-tool/client_hook + - id: tool/s2-tool + mcp_server: status + mcp_tool: lookup + executions: + - s2-tool/client_hook + - id: tool/s3-tool + mcp_server: status + mcp_tool: lookup + executions: + - s3-tool/client_hook + - id: tool/s4-tool + mcp_server: status + mcp_tool: lookup + executions: + - s4-tool/client_hook + - id: tool/s5-tool + mcp_server: null + mcp_tool: null + executions: null +endpoints: + - docs/read + - status/lookup +status/lookup: + calls: 3 + by_outcome: + - outcome: failed + value: 1 + - outcome: returned + value: 2 + duration: 2830 +docs/read: + calls: 1 + by_outcome: + - outcome: returned + value: 1 + duration: 280 diff --git a/test/e2e-v2/cases/ai-agent/expected/metrics.yml b/test/e2e-v2/cases/ai-agent/expected/metrics.yml index ee5c1c103d6a..293626bdf79f 100644 --- a/test/e2e-v2/cases/ai-agent/expected/metrics.yml +++ b/test/e2e-v2/cases/ai-agent/expected/metrics.yml @@ -13,97 +13,97 @@ # See the License for the specific language governing permissions and # limitations under the License. -tokens: 44662 -instance_tokens: 44662 +tokens: 52998 +instance_tokens: 52998 by_type: - labels: - key: type value: cacheCreation - value: 4300 + value: 5100 - labels: - key: type value: cacheRead - value: 38700 + value: 45900 - labels: - key: type value: input - value: 86 + value: 102 - labels: - key: type value: output - value: 1576 + value: 1896 by_model: - labels: - key: model value: claude-opus-5 - key: type value: cacheCreation - value: 4300 + value: 5100 - labels: - key: model value: claude-opus-5 - key: type value: cacheRead - value: 38700 + value: 45900 - labels: - key: model value: claude-opus-5 - key: type value: input - value: 86 + value: 102 - labels: - key: model value: claude-opus-5 - key: type value: output - value: 1576 + value: 1896 by_source: - labels: - key: query_source value: main - key: type value: cacheCreation - value: 3200 + value: 3800 - labels: - key: query_source value: main - key: type value: cacheRead - value: 28800 + value: 34200 - labels: - key: query_source value: main - key: type value: input - value: 64 + value: 76 - labels: - key: query_source value: main - key: type value: output - value: 1130 + value: 1370 - labels: - key: query_source value: subagent - key: type value: cacheCreation - value: 1100 + value: 1300 - labels: - key: query_source value: subagent - key: type value: cacheRead - value: 9900 + value: 11700 - labels: - key: query_source value: subagent - key: type value: input - value: 22 + value: 26 - labels: - key: query_source value: subagent - key: type value: output - value: 446 + value: 526 cache_read_share_in_range: true diff --git a/test/e2e-v2/cases/ai-agent/expected/views.yml b/test/e2e-v2/cases/ai-agent/expected/views.yml index 129cb0489490..113c0f5b5f08 100644 --- a/test/e2e-v2/cases/ai-agent/expected/views.yml +++ b/test/e2e-v2/cases/ai-agent/expected/views.yml @@ -13,11 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -conversations: 7 -equal: 7 -yaml_equal: 7 -h2_equal: 7 -gzip_equal: 7 +conversations: 8 +equal: 8 +yaml_equal: 8 +h2_equal: 8 +gzip_equal: 8 format: asz.view version: "1.0" json_type: "application/vnd.skywalking.asz.view+json; version=1.0; charset=utf-8" diff --git a/test/e2e-v2/cases/ai-agent/mcp-calls.yaml b/test/e2e-v2/cases/ai-agent/mcp-calls.yaml new file mode 100644 index 000000000000..be586743f8b8 --- /dev/null +++ b/test/e2e-v2/cases/ai-agent/mcp-calls.yaml @@ -0,0 +1,76 @@ +# 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. + +# Calls to MCP servers. +# +# Claude Code names a call to an MCP server mcp____, and the +# adapter reads the server and the tool from the name where it splits +# exactly. The plugin's hook after each call saw which server ran it, where +# its configuration came from, how the call ended and how long it took, and +# wrote an execution record the view joins to the step by the tool-use id. +# The shapes are the ones measured on Claude Code 2.1.282: a call that +# returned, an error the server returned, a call whose input a hook rewrote +# before it was sent, a name with a second "__" that is left whole, and a +# call inside a subagent. The hook of the first call was handed its event +# twice, so its record is written twice, and it is still one call. None of +# the records is a step: the same session without them folds to the same +# nodes. +title: calls to MCP servers +steps: + - input: check the checkout service and then the payment service + - call: + text: Asking the status server about checkout. + tool: + name: mcp__status__lookup + input: {service: checkout} + result: {text: "checkout: 3 of 3 ready", after: 400ms} + execution: {server: status, source: user, duration: 380ms, twice: true} + - call: + text: And about payment. + tool: + name: mcp__status__lookup + input: {service: payment} + result: {text: "the status server could not reach payment", failed: true, after: 2s} + execution: {server: status, source: user, duration: 2s} + - call: + text: Asking again, through a hook that names the region. + tool: + name: mcp__status__lookup + input: {service: payment} + result: {text: "payment: 1 of 3 ready", after: 500ms} + execution: {server: status, source: user, duration: 450ms, arguments: {service: payment, region: us-west-2}} + - call: + text: A tool whose name the runtime cannot split. + tool: + name: mcp__odd__name__twice + input: {} + result: {text: "ok", after: 100ms} + - call: + agent: + name: auditor + prompt: read the release notes + notify: true + steps: + - call: + text: Reading them. + tool: + name: mcp__docs__read + input: {page: release-notes} + result: {text: "0.6.0: ...", after: 300ms} + execution: {server: docs, source: project, duration: 280ms} + - call: {text: Read.} + - call: {text: Checkout is healthy and payment is not.} diff --git a/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml b/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml index 8d023aad8240..682f27c97b0a 100644 --- a/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml +++ b/test/e2e-v2/cases/ai-agent/mysql/docker-compose.yml @@ -19,6 +19,8 @@ # session tool call changed files, in the same root # provider-bodies.yaml, one build -> collect, once, after the workspace-changes pass: a session whose # session calls' request and response bodies landed as provider_body records +# mcp-calls.yaml, one session build -> collect, once, after the provider-bodies pass: calls to MCP servers, +# each with the plugin's execution record landed beside the transcripts # three-rounds.yaml, one session build through the first checkpoint -> collect, then through the second, # then to the end: three rounds over files cut at each stage, and the final # document must cover it all @@ -41,6 +43,7 @@ x-asz: &asz - ../lost-file.yaml:/asz/lost-file.yaml:ro - ../workspace-changes.yaml:/asz/workspace-changes.yaml:ro - ../provider-bodies.yaml:/asz/provider-bodies.yaml:ro + - ../mcp-calls.yaml:/asz/mcp-calls.yaml:ro networks: - e2e @@ -146,12 +149,27 @@ services: depends_on: pb-build: condition: service_completed_successfully + # ---- mcp-calls.yaml: calls to MCP servers, one session in the same root. The build lands the plugin's execution + # records as execution files beside the transcripts; the pass sends them like any landed file, and derives from them + # the calls to each MCP server and their time. + mcp-build: + <<: *asz + command: ["scenario", "build", "/asz/mcp-calls.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + pb-collect: + condition: service_completed_successfully + mcp-collect: + <<: *asz + command: ["collect", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mcp-build: + condition: service_completed_successfully # ---- three-rounds.yaml: one session in three stages, each parsed and sent before the next is built mr-build-1: <<: *asz command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] depends_on: - pb-collect: + mcp-collect: condition: service_completed_successfully mr-collect-1: <<: *asz diff --git a/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml b/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml index eaf20faa885b..0d915508e617 100644 --- a/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml +++ b/test/e2e-v2/cases/ai-agent/postgres/docker-compose.yml @@ -19,6 +19,8 @@ # session tool call changed files, in the same root # provider-bodies.yaml, one build -> collect, once, after the workspace-changes pass: a session whose # session calls' request and response bodies landed as provider_body records +# mcp-calls.yaml, one session build -> collect, once, after the provider-bodies pass: calls to MCP servers, +# each with the plugin's execution record landed beside the transcripts # three-rounds.yaml, one session build through the first checkpoint -> collect, then through the second, # then to the end: three rounds over files cut at each stage, and the final # document must cover it all @@ -41,6 +43,7 @@ x-asz: &asz - ../lost-file.yaml:/asz/lost-file.yaml:ro - ../workspace-changes.yaml:/asz/workspace-changes.yaml:ro - ../provider-bodies.yaml:/asz/provider-bodies.yaml:ro + - ../mcp-calls.yaml:/asz/mcp-calls.yaml:ro networks: - e2e @@ -144,12 +147,27 @@ services: depends_on: pb-build: condition: service_completed_successfully + # ---- mcp-calls.yaml: calls to MCP servers, one session in the same root. The build lands the plugin's execution + # records as execution files beside the transcripts; the pass sends them like any landed file, and derives from them + # the calls to each MCP server and their time. + mcp-build: + <<: *asz + command: ["scenario", "build", "/asz/mcp-calls.yaml", "--format", "sd", "--out", "/asz/data"] + depends_on: + pb-collect: + condition: service_completed_successfully + mcp-collect: + <<: *asz + command: ["collect", "-once", "-config", "/asz/data/asz.yaml"] + depends_on: + mcp-build: + condition: service_completed_successfully # ---- three-rounds.yaml: one session in three stages, each parsed and sent before the next is built mr-build-1: <<: *asz command: ["scenario", "build", "/asz/three-rounds.yaml", "--format", "sd", "--out", "/asz/data", "--through", "first"] depends_on: - pb-collect: + mcp-collect: condition: service_completed_successfully mr-collect-1: <<: *asz diff --git a/test/e2e-v2/cases/ai-agent/verify.sh b/test/e2e-v2/cases/ai-agent/verify.sh index 9df7701046fb..bec4aed086f9 100755 --- a/test/e2e-v2/cases/ai-agent/verify.sh +++ b/test/e2e-v2/cases/ai-agent/verify.sh @@ -54,6 +54,7 @@ LOST="c9d9b18b-be85-4906-850d-40a1cd240171" WC="3189c1f0-9ec4-4bd2-88dc-8eda88ac6db3" # provider-bodies.yaml likewise: every call's request and response bodies in one session. PB="6b7a6063-8714-4f6b-87cd-6c2da3a5094d" +MCP="720d2f9e-dca7-49be-8c5e-89b667b39a01" # swctl against this OAP, JSON out. sw() { @@ -330,6 +331,32 @@ case "$MODE" in files_of "$PB" provider_body \ | yq -p=json -P '{"exported_provider_files": (.files | map(.file) | map(select(test("/provider_body/"))) | map(sub("provider_body-[^/]*-0", "provider_body-*-0")) | sort)}' ;; + mcp) + # Calls to MCP servers, on the main stream and in a subagent, each with the record the Claude Code plugin wrote + # after it, in an execution file beside the stream's transcript, and the first record written twice. The document + # lists each record once, joined to its step by tool-use id, and a call to an MCP server names its server and tool; + # a name that does not split into one server and one tool names neither. The Sessionizer derives a call count and a + # duration from the records, and the OAP keeps them per endpoint, one per server and tool under the service; over + # the run they are what the scenario declares. The document equals the Sessionizer's, which the views case checks. + view "$MCP" --yaml | yq -P '{ + "state": .summary.state, + "execution_files": [.files[] | select(.kind == "execution") | {"seq": .seq, "stream": .stream, "lines": .lines}], + "executions": [.tool_executions[] | {"step": .step, "ref": .ref, "server": .server, "tool_name": .tool_name, + "outcome": .outcome, "duration_ms": .duration_ms}], + "steps": ([.. | select(tag == "!!map" and .kind == "tool") | {"id": .id, "mcp_server": .attrs.mcp_server, + "mcp_tool": .attrs.mcp_tool, "executions": .executions}] | sort_by(.id)) + }' + m() { sw metrics exec --expression="$1" --service-name "$SERVICE" --endpoint-name "$2" --start "$wide_start" --end "$wide_end"; } + total() { m "$@" | yq -p=json -o=json '.results[0].values[0].value | tonumber'; } + by() { m "$@" | yq -p=json -o=json '[.results[] | {"outcome": (.metric.labels[] | select(.key == "outcome") | .value), "value": (.values[0].value | tonumber)}] | sort_by(.outcome)'; } + printf '{"endpoints": %s, "status/lookup": {"calls": %s, "by_outcome": %s, "duration": %s}, "docs/read": {"calls": %s, "by_outcome": %s, "duration": %s}}' \ + "$(sw endpoint list --service-name "$SERVICE" | yq -p=json -o=json '[.[] | .name] | sort')" \ + "$(total 'sum(meter_ai_agent_mcp_calls)' status/lookup)" "$(by 'sum(meter_ai_agent_mcp_calls_by_outcome)' status/lookup)" \ + "$(total 'sum(meter_ai_agent_mcp_duration)' status/lookup)" \ + "$(total 'sum(meter_ai_agent_mcp_calls)' docs/read)" "$(by 'sum(meter_ai_agent_mcp_calls_by_outcome)' docs/read)" \ + "$(total 'sum(meter_ai_agent_mcp_duration)' docs/read)" \ + | yq -p=json -P + ;; list-horizon) # The list exactly as Horizon's conversation page queries it: the same condition, the service and the sender, # and every field its columns read, the counts a round's header carries among them. swctl has no flag for the diff --git a/test/e2e-v2/script/env b/test/e2e-v2/script/env index c3812d59a96e..fc547627c576 100644 --- a/test/e2e-v2/script/env +++ b/test/e2e-v2/script/env @@ -25,7 +25,7 @@ SW_AGENT_CLIENT_JS_TEST_COMMIT=4f1eb1dcdbde3ec4a38534bf01dded4ab5d2f016 SW_KUBERNETES_COMMIT_SHA=da0e267f877b9b8e5f7728ae4ea7dc7723a2a073 SW_ROVER_COMMIT=79292fe07f17f98f486e0c4471213e1961fb2d1d SW_BANYANDB_COMMIT=3b83e18fb0481d02e44eaa5df137fcf7b000754b -SW_AI_SESSIONIZER_COMMIT=73733153a851006d33bfdde587770e619849090f +SW_AI_SESSIONIZER_COMMIT=130601cb857ded78989bd883bc7fc2ff1367d8a0 SW_AGENT_PHP_COMMIT=de311c9cd084e21becade0742cd289bc0f43181d SW_PREDICTOR_COMMIT=54a0197654a3781a6f73ce35146c712af297c994