You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/20 14:21:37 UTC
(skywalking-ai-sessionizer) 07/23: Say what LangChain calls things, and how to point it here
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch langchain-harness
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git
commit 2da576071eaab04c3c13610daa6f3783b0105a75
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Sep 20 19:21:59 2026 +0800
Say what LangChain calls things, and how to point it here
The setup page is four environment variables, because that is the whole
integration: the tracing client is already a dependency of langchain-core, so
every LangChain application carries it. The page also says what the wire cannot
give — a sub-agent with its own conversation, the call a failed tool answered,
a context reset — because finding that out from a missing node is worse than
reading it first.
The adapter page is where the measurements live: what the client does, what a
record is and why it is an arrival rather than a run, why a graph's own runs
land trimmed, and how ownership is a namespace rather than a key.
The glossary has an entry for every name the model can emit, including the
forty-three that say LangChain has no word for the thing. A framework that
records its own graph in detail still has no term for a conversation boundary,
a context reset or a delegated agent, and the ratio against Claude Code's is
worth looking at: seventeen named here against thirty-nine there.
The glossary is no longer one runtime's. `asz glossary` takes a dialect, and
`asz conversation -terms native` reads the one a session's own records were
landed in — a root can hold conversations from two runtimes, and the runtime's
words have to be the ones belonging to what is being read. A dialect this build
does not know renders in the model's own names rather than inventing a word.
---
cmd/asz/dialects.go | 87 +++++++++++++++++
cmd/asz/parse.go | 27 ++++-
docs/en/adapters/langsmith-glossary.md | 107 ++++++++++++++++++++
docs/en/adapters/langsmith.md | 115 ++++++++++++++++++++++
docs/en/changes/changes.md | 46 +++++++--
docs/en/setup/langchain.md | 141 +++++++++++++++++++++++++++
docs/menu.yml | 6 ++
internal/adapters/langsmith/collect.go | 3 +-
internal/adapters/langsmith/dialect_test.go | 2 +-
internal/adapters/langsmith/glossary.go | 128 ++++++++++++++++++++++++
internal/adapters/langsmith/glossary_test.go | 42 ++++++++
internal/adapters/langsmith/receiver_test.go | 5 +-
internal/adapters/langsmith/wire.go | 32 ++++--
13 files changed, 719 insertions(+), 22 deletions(-)
diff --git a/cmd/asz/dialects.go b/cmd/asz/dialects.go
new file mode 100644
index 0000000..67d2a50
--- /dev/null
+++ b/cmd/asz/dialects.go
@@ -0,0 +1,87 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+ "os"
+ "sort"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/langsmith"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
+)
+
+// glossaries is every vocabulary this command can render a name in.
+//
+// A dialect is not an adapter: it says how records were read, not how they
+// were acquired, so a pull reader and a push receiver for one runtime share
+// one entry here.
+func glossaries() map[string]*model.Glossary {
+ return map[string]*model.Glossary{
+ claudecode.Dialect: claudecode.Glossary(),
+ langsmith.Dialect: langsmith.Glossary(),
+ }
+}
+
+// dialectNames lists what a reader can ask for.
+func dialectNames() []string {
+ var out []string
+ for name := range glossaries() {
+ out = append(out, name)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// glossaryFor returns a dialect's vocabulary, and whether it is one this
+// command knows.
+func glossaryFor(dialect string) (*model.Glossary, bool) {
+ g, ok := glossaries()[dialect]
+ return g, ok
+}
+
+// dialectOf reads which vocabulary a session's records were read in.
+//
+// It comes from the data rather than from the configuration, because a root
+// can hold sessions from more than one runtime and a reader asking for "the
+// runtime's words" means the ones belonging to what they are looking at. A
+// session whose dialect is not one this command knows renders in the model's
+// own names, which is always correct if less familiar.
+func dialectOf(zone *storage.Zone, session string) string {
+ files, err := storage.LandedFiles(zone, session)
+ if err != nil || len(files) == 0 {
+ return ""
+ }
+ for _, f := range files {
+ file, err := os.Open(f.Path)
+ if err != nil {
+ continue
+ }
+ reader, err := sessiondata.NewReader(file)
+ _ = file.Close()
+ if err != nil {
+ continue
+ }
+ if d := reader.Header().Dialect; d != "" {
+ return d
+ }
+ }
+ return ""
+}
diff --git a/cmd/asz/parse.go b/cmd/asz/parse.go
index 7917c1e..38cb027 100644
--- a/cmd/asz/parse.go
+++ b/cmd/asz/parse.go
@@ -145,8 +145,14 @@ func printDocument(zoneRoot, id, format string) error {
// A reader who knows their own agent product recognises its words; a reader
// comparing two products needs one vocabulary. Neither is the right default for
// both, so the choice is theirs and nothing is lost either way.
-func termsFor(mode string) func(string) string {
- g := claudecode.Glossary()
+func termsFor(mode, dialect string) func(string) string {
+ g, ok := glossaryFor(dialect)
+ if !ok {
+ // A dialect this command does not know renders in the model's own
+ // names. Inventing a runtime's word for it would be worse than
+ // saying nothing.
+ return func(u string) string { return u }
+ }
switch mode {
case "native":
return g.Native
@@ -162,8 +168,21 @@ func termsFor(mode string) func(string) string {
}
// cmdGlossary prints what a runtime calls the things the model names.
+//
+// It takes the dialect to print, because a root can hold sessions from more
+// than one runtime and there is no single answer. With none it prints the
+// dialects there are.
func cmdGlossary(_ *config.Config, _ config.Adapter, _ bool) error {
- g := claudecode.Glossary()
+ wanted := claudecode.Dialect
+ if given := arg(0); given != "" {
+ wanted = given
+ }
+ g, ok := glossaryFor(wanted)
+ if !ok {
+ fmt.Fprintf(os.Stderr, "asz glossary: no dialect %q; this command knows %s\n",
+ wanted, strings.Join(dialectNames(), " and "))
+ os.Exit(2)
+ }
fmt.Printf("dialect %s\n\n", g.Dialect)
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "MODEL\tRUNTIME\tWHERE\tNOTE")
@@ -222,7 +241,7 @@ func cmdConversation(cfg *config.Config, _ config.Adapter, _ bool) error {
fmt.Printf(" entities %d nodes, %d relations, %d unresolved (%d still open)\n\n",
len(v.Nodes), len(v.Relations), len(v.Unresolved), len(v.OpenUnresolved()))
- term := termsFor(termMode)
+ term := termsFor(termMode, dialectOf(storage.NewZone(zoneRoot), id))
byKind := map[string]int{}
for _, n := range v.Nodes {
byKind[n.Kind]++
diff --git a/docs/en/adapters/langsmith-glossary.md b/docs/en/adapters/langsmith-glossary.md
new file mode 100644
index 0000000..97d1e4b
--- /dev/null
+++ b/docs/en/adapters/langsmith-glossary.md
@@ -0,0 +1,107 @@
+# LangChain Glossary
+
+What LangChain and its tracing client call the things the
+[project glossary](../concepts-and-designs/glossary.md) names, and where each one appears in the
+data they send.
+
+The dialect is **`langsmith/1`**. That names the vocabulary, not the adapter: `langsmith-ingest`
+says how records were acquired, the dialect says how they are read.
+
+Two words here mean something other than what a reader might expect, and both matter. The client's
+**session** is the project, not a conversation. And its **run** is any step of the graph, so most
+runs are not what this model calls a Run — the invocation they share, `trace_id`, is.
+
+The tables below are copied from what `asz glossary langsmith/1` prints. The command reads the
+adapter's own declarations, so where this page and the command differ, the command is right.
+
+```sh
+asz glossary langsmith/1 this table
+asz conversation ID talk · run · llm.call · tool
+asz conversation -terms native ID talk · trace_id · id · run_type: tool
+asz conversation -terms both ID run (trace_id)
+```
+
+**60 terms: 17 that LangChain names, 43 this project derives.** That ratio is worth watching against
+the Claude Code one. A framework that records its own graph in detail still has no word for a
+conversation boundary, a context reset or a delegated agent — which is the whole reason this model
+exists separately from any product's.
+
+---
+
+## Named by LangChain
+
+Each row says where the thing is recorded in a run.
+
+| Model | LangChain | Where | Note |
+| --- | --- | --- | --- |
+| `agent.call` | `a tool call whose run has model calls beneath it` | the wire has no word for delegation; it is read from the nested context | |
+| `agent.output` | `outputs.output.content` | the delegating tool run | |
+| `call` | `id` | a run of type llm | shared by every arrival about that run |
+| `conversation` | `extra.metadata.thread_id` | a run | with session_id and conversation_id as the other spellings; supplied, never inferred |
+| `error.api` | `error` | a run | a string with the exception and its traceback |
+| `id` | `id` | a run | the record is the arrival, so a run that is posted and later patched has two |
+| `input_of` | `inputs` | a run | |
+| `llm.call` | `run_type: llm` | a run | |
+| `message.assistant` | `outputs.generations[].message.kwargs.content` | a run of type llm | |
+| `message.external` | `inputs.messages` | the root run of a trace | the message that opened the turn has no run of its own |
+| `model` | `extra.metadata.ls_model_name` | a run of type llm | |
+| `parent` | `parent_run_id` | a run | the child names the parent; dotted_order carries the whole ancestry |
+| `result_of` | `tool_call_id` | outputs.output of a run of type tool | |
+| `run` | `trace_id` | a run | the client calls every graph step a run; this is the invocation they share |
+| `session` | `session_name` | a run | the client's word for the PROJECT; a conversation is the thread key, and a session here is the two together |
+| `time` | `start_time and end_time` | a run | |
+| `tool` | `run_type: tool` | a run | |
+
+---
+
+## Derived by this project
+
+Nothing on the wire carries these. They are what assembly works out from the evidence above, and
+saying so is more useful than a plausible name that would send a reader looking for a field that is
+not there.
+
+| Model | LangChain | Where | Note |
+| --- | --- | --- | --- |
+| `agent.launch_ack` | — | derived here | no equivalent: nothing acknowledges a delegation separately |
+| `available` | — | derived here | the client sends inputs and outputs whole, as their own parts |
+| `batch` | — | derived here | no equivalent: nothing here launches a group of agents at once |
+| `cancels` | — | derived here | no equivalent |
+| `child` | — | derived here | no equivalent: a nested agent is a run beneath a tool, named as nothing else |
+| `conflict` | — | derived here | derived |
+| `context.injection` | — | derived here | no equivalent |
+| `continues` | — | derived here | no equivalent: the client has no context reset to resume from |
+| `control.command` | — | derived here | no equivalent |
+| `control.interrupt` | — | derived here | no equivalent |
+| `control.permission` | — | derived here | no equivalent: nothing here asks before running a tool |
+| `ends_with` | — | derived here | derived from a nested agent's last response |
+| `epoch` | — | derived here | no equivalent: the client has no context reset |
+| `epoch.boundary` | — | derived here | no equivalent: the client has no context reset |
+| `epoch.summary` | — | derived here | no equivalent |
+| `exact_ambiguous` | — | derived here | derived |
+| `exact_unique` | — | derived here | one call id, named by the tool run that answered it |
+| `external` | — | derived here | the root run of a trace carries the message that opened it |
+| `follows` | — | derived here | derived from order within a trace |
+| `hash_only` | — | derived here | no equivalent |
+| `in_segment` | — | derived here | derived |
+| `message.synthetic` | — | derived here | no equivalent: nothing here fabricates a message on the model's behalf |
+| `notification` | — | derived here | no equivalent |
+| `omitted` | — | derived here | a graph's own run: its repeated message list is not landed |
+| `redacted` | — | derived here | no equivalent: nothing on the wire says it redacted anything |
+| `reports` | — | derived here | derived |
+| `retries` | — | derived here | no equivalent yet: the client does not say a run retried another |
+| `runtime.notification` | — | derived here | no equivalent |
+| `segment` | — | derived here | an activity window chosen for commit |
+| `size_only` | — | derived here | no equivalent |
+| `starts` | — | derived here | derived from a delegating tool and the runs beneath it |
+| `stream` | — | derived here | no equivalent: a nested agent is a run like any other |
+| `strong_inference` | — | derived here | derived: a failed tool joined to the one unanswered call of its name |
+| `summarizes` | — | derived here | no equivalent |
+| `talk` | — | derived here | one readable interaction; one trace is one turn |
+| `thinking` | — | derived here | no equivalent yet: a provider's reasoning arrives inside the message content |
+| `trigger` | — | derived here | no equivalent: derived from a trace's root run |
+| `truncated` | — | derived here | no equivalent: nothing measured arrived truncated |
+| `turn.duration` | — | derived here | no equivalent as a record; a run's own start and end give it |
+| `unavailable` | — | derived here | a run that arrived unfinished: its content is not there yet |
+| `unknown` | — | derived here | derived |
+| `unresolved` | — | derived here | derived: a failed tool whose model call arrived in another request |
+| `weak_inference` | — | derived here | derived |
diff --git a/docs/en/adapters/langsmith.md b/docs/en/adapters/langsmith.md
new file mode 100644
index 0000000..f5ff931
--- /dev/null
+++ b/docs/en/adapters/langsmith.md
@@ -0,0 +1,115 @@
+# LangChain
+
+How an agent built on LangChain or LangGraph maps onto the
+[unified conversation model](../concepts-and-designs/unified-conversation-model.md), what the
+`langsmith-ingest` adapter reads, and what the wire cannot supply.
+
+For turning it on, see [LangChain and LangGraph](../setup/langchain.md). For the words, see the
+[LangChain glossary](langsmith-glossary.md).
+
+## Where the data comes from
+
+`langsmith`, the tracing client, is a dependency of `langchain-core`, so every LangChain
+application already carries it. It is pointed at asz with environment variables and sends what the
+graph did. asz accepts what that client sends; it carries none of its code and claims no
+affiliation with the product it was written for.
+
+Everything below was measured against a running agent, and the captures are in the repository under
+`tests/apps/langchain/testdata`. Where a number appears here, that is where it came from.
+
+## What the client does
+
+| Measured | Result |
+| --- | --- |
+| Endpoint | `POST /runs/multipart`, preceded by `GET /info` every time |
+| Compression | none, unless `/info` advertises it — so this receiver does not |
+| Success | 202 |
+| Unfinished runs | 6 of 18 in one capture arrived with no end time, completed 12 s later |
+| A rejected batch | retried with the same bytes, 10 ms later |
+| Content | a 20,908-byte command and a 192,000-byte result arrived whole |
+
+Two of those decide the design. Runs arrive more than once, so a run is a thing that is *updated*
+rather than a thing that is *reported*. And a rejected batch comes back identical, so delivery is
+at-least-once and the receiver deduplicates.
+
+## The mapping
+
+| Model | LangChain |
+| --- | --- |
+| Conversation | the thread key in a run's metadata, with the project |
+| Session | the same |
+| ExecutionStream | `main`; a nested run with its own model context is its own stream |
+| Run | `trace_id` — one invocation of the graph, which is one turn |
+| `llm.call` | a run of type `llm`; the call is its id, shared by every arrival |
+| `tool` | a run of type `tool`, joined to its call by `tool_call_id` |
+| `message.external` | the human message inside the root run's `inputs` |
+| `message.assistant` | the model's content in a run's `outputs` |
+
+### A record is an arrival, not a run
+
+A run is posted when it starts and patched when it ends, so the same run reaches asz more than
+once. Each arrival is its own record, with the run as its `call`.
+
+They cannot share one record id. The index keeps the first entry for an id, and in the streaming
+capture a tool's result exists only in the patch — one id would have hidden it, and the
+conversation would have shown a tool call that never returned.
+
+**Only the arrival carrying the run's end carries its content.** Every text block of every fragment
+becomes a message, so a post and a patch that both carried text would emit the assistant message
+twice, in a round that called itself verified with nothing unresolved: a wrong conversation that
+passes every check. An arrival with no end lands its bytes as data instead.
+
+### A graph's own runs land trimmed
+
+Most runs are the graph's own: nodes, prompts, branches. In one capture they were 30 of 39 runs and
+**74% of the bytes**, because each repeats the whole message list its model call already carries.
+
+Landing them whole would nearly quadruple a conversation for content it already holds. Skipping
+them would lose the shape that says why an agent looped. So the envelope and the `langgraph_` and
+`ls_` metadata land as a data record, the repeated content does not, and the record says how many
+bytes went with it.
+
+| What is kept | Share of the request |
+| --- | --- |
+| everything | 74.0% |
+| envelope and all metadata | 32.1% |
+| envelope and graph metadata only | 18.8% |
+
+### Ownership is a namespace
+
+A thread key alone is not an identity: two applications can both use the project `production` and
+the thread `123`. The adapter names the dimensions that own a conversation, defaulting to project
+and thread.
+
+A supplied key is never used as a path. The captures carry `../outside`, `team/customer`, `_hidden`
+and `会话-1` as real thread keys, so the session directory is derived from them: a readable slug and
+a digest of the whole namespace, under an `ls-` prefix that keeps it off the leading underscore
+enumeration skips and away from every Windows device name.
+
+A trace with no supplied key is not given one. It lands under a name that says the identity was not
+supplied, and nothing merges it with anything else.
+
+## What the wire cannot supply
+
+- **A child's own conversation.** An agent invoked with its own `thread_id` reports the parent's on
+ every one of its 30 runs: the surrounding run context wins and the child's key never reaches the
+ wire. A sub-agent's work belongs to the conversation that delegated to it, and no configuration
+ can change that.
+- **Which call a failed tool answered.** A tool that raises has empty outputs and names the call
+ nowhere. asz reads it from the model call that asked for the tool, and only when exactly one call
+ of that name in that trace is unanswered. Otherwise the result lands joined to nothing.
+- **A context reset.** LangChain has no compaction, so a conversation is one epoch however long it
+ runs.
+- **A delegation, by name.** Nothing on the wire says a nested run is an agent. It is read from
+ whether that run's model calls continue the parent's message history or start their own, which is
+ what this model means by an execution stream.
+- **Cost, and a provider's own request.** A run's `inputs.messages` is the framework's view of what
+ it sent, not the bytes the provider received.
+
+## What it supplies that a transcript does not
+
+- **Work in progress.** Runs arrive before they finish, so a slow turn is visible while it runs and
+ a process that died mid-turn still leaves what it had reported.
+- **Per-call duration.** Every run carries its own start and end.
+- **The whole message list per call.** `inputs.messages` is what the framework sent, so continuity
+ between turns can be checked rather than assumed.
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index f1c67fa..1f76418 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -4,15 +4,45 @@
## LangChain and LangGraph
+- asz assembles conversations from an agent built on LangChain or LangGraph without changing the
+ application. The `langsmith-ingest` adapter is a receiver the tracing client is pointed at, and
+ since that client is a dependency of `langchain-core`, every LangChain application already
+ carries it: four environment variables are the whole integration. See
+ [LangChain and LangGraph](../setup/langchain.md).
+
+- What the receiver leaves out of its capability answer is what keeps it simple. The client
+ compresses only when told the receiver can read zstd, so not advertising it keeps every body
+ plain and keeps a compression library out of this project. A client that compresses anyway is
+ refused with the flag named.
+
+- A run arrives more than once: posted when it starts, patched when it ends. Each arrival is its
+ own record sharing one call, and only the arrival carrying the run's end carries its content —
+ otherwise one call would emit the assistant message twice, in a round that called itself
+ verified. Work in progress lands as data, so a slow turn is visible before it finishes and a
+ process that died mid-turn still leaves what it had reported.
+
+- A graph's own runs land trimmed. They were 30 of 39 runs and 74% of the bytes in one capture,
+ because each repeats the whole message list its model call already carries. The envelope and the
+ graph's metadata land, the repeated content does not, and the record says how many bytes went
+ with it.
+
+- Ownership is a namespace, not a thread key: two applications can both use the project
+ `production` and the thread `123`. A supplied key is never used as a path either, since
+ `../outside`, `team/customer`, `_hidden` and `会话-1` all arrive as real thread keys.
+
- A demo application under `tests/apps/langchain`, and the fixtures captured from it. It is a real
- LangGraph application, one case per shape a conversation can take, driven by a stand-in model over
- HTTP so it needs no API key, no network and no provider account. `make langchain-capture` runs
- every case and writes `MEASUREMENTS.md` from what it captured, so the numbers there never drift
- from the fixtures. Fifteen cases cover a plain turn, three turns on one thread, a tool that fails,
- parallel tool calls, a graph that loops, two threads at once, a slow tool whose runs are posted
- before they finish, a process that dies mid-turn, large content, a sub-agent, a missing thread key,
- two applications sharing one, keys that are not safe as paths, and the tracing client's own
- decorator with no LangChain in it.
+ LangGraph application, one case per shape a conversation can take, driven by a stand-in model
+ over HTTP so it needs no API key, no network and no provider account. `make langchain-capture`
+ runs every case and writes `MEASUREMENTS.md` from what it captured, so the numbers there never
+ drift from the fixtures. Fifteen cases cover a plain turn, three turns on one thread, a tool that
+ fails, parallel tool calls, a graph that loops, two threads at once, a slow tool whose runs are
+ posted before they finish, a process that dies mid-turn, large content, a sub-agent, a missing
+ thread key, two applications sharing one, keys that are not safe as paths, and the tracing
+ client's own decorator with no LangChain in it.
+
+- `asz glossary` takes a dialect, and `asz conversation -terms native` reads the one a session's
+ own records were landed in. A root can hold conversations from more than one runtime, and the
+ runtime's words have to be the ones belonging to what is being read.
## Install
diff --git a/docs/en/setup/langchain.md b/docs/en/setup/langchain.md
new file mode 100644
index 0000000..4c064fb
--- /dev/null
+++ b/docs/en/setup/langchain.md
@@ -0,0 +1,141 @@
+# LangChain and LangGraph
+
+asz assembles conversations from an agent built on LangChain or LangGraph
+without changing the application. It takes four environment variables.
+
+The reason it is that small: `langsmith`, the tracing client, is a dependency
+of `langchain-core`. Every LangChain application already carries it. Pointing
+it at asz is the whole integration.
+
+## Turn the receiver on
+
+In `asz.yaml`:
+
+```yaml
+adapters:
+ - name: langsmith-ingest
+ enabled: true
+ listen: 127.0.0.1:1985
+```
+
+Then `asz collect` (or `asz server`, which serves the page beside it):
+
+```text
+receiver : langsmith-ingest on 127.0.0.1:1985; point LANGSMITH_ENDPOINT at it; any key accepted
+source : received on 127.0.0.1:1985 (every 10m)
+```
+
+## Point the application at it
+
+```sh
+export LANGSMITH_TRACING=true
+export LANGSMITH_ENDPOINT=http://127.0.0.1:1985
+export LANGSMITH_API_KEY=asz
+export LANGSMITH_PROJECT=my-agent
+```
+
+`LANGSMITH_API_KEY` must be set because the client insists on sending one. Its
+value does not matter unless the receiver is configured with a `token`.
+
+Run the application. Within one collector period its conversations are in the
+storage root:
+
+```sh
+asz conversation ls-my-agent-thread-1-9718de8d3d72
+```
+
+## Give each conversation a thread
+
+A conversation is a thread, and the thread is supplied by the application. In
+LangGraph that is the checkpointer's thread id:
+
+```python
+agent.invoke(
+ {"messages": [{"role": "user", "content": "..."}]},
+ config={"configurable": {"thread_id": "the-conversation"}},
+)
+```
+
+Without one, asz has no conversation to assemble. It lands what arrived under a
+session whose name says so, and invents nothing.
+
+In plain LangChain, or with the client's own `@traceable`, pass the key as
+metadata instead:
+
+```python
+@traceable(metadata={"thread_id": "the-conversation"})
+def answer(question): ...
+```
+
+## What owns a conversation
+
+A thread key alone is not an identity. Two applications can both use the
+project `production` and the thread `123`, and merging them would put two
+people's histories in one conversation. So the receiver names the dimensions
+that own one:
+
+```yaml
+ scope:
+ - project
+ - thread
+```
+
+`project` is `LANGSMITH_PROJECT`. Taking it out merges every project that
+shares a thread key, which is sometimes what you want and is never assumed.
+
+`thread_keys` names the metadata keys that may carry the thread, in the order
+they are tried. The default is `thread_id`, then `session_id`, then
+`conversation_id`.
+
+## Reaching it from another machine
+
+`listen` binds locally by default, because what arrives is whole prompts and
+whole tool output. An application in another container needs an address it can
+reach and a token:
+
+```yaml
+ listen: 0.0.0.0:1985
+ token: a-shared-secret
+```
+
+```sh
+export LANGSMITH_API_KEY=a-shared-secret
+```
+
+## What arrives, and when
+
+The client sends in the background, in batches. A run is sent when it starts
+and again when it finishes, so asz sees work while it is still running: a turn
+with a slow tool appears before the tool returns, and a process that dies
+mid-turn still leaves what it had reported. That evidence lands as it arrived,
+and the conversation says the work was unfinished rather than pretending it
+never happened.
+
+A whole prompt, a whole tool command and a whole tool result are landed as they
+were sent. Nothing is sampled and nothing is redacted, which is the same
+treatment a Claude Code transcript gets.
+
+## What it cannot do
+
+- **A sub-agent cannot have its own conversation.** Measured: an agent invoked
+ with its own `thread_id` reports the parent's on every one of its runs,
+ because the surrounding run context wins. Its work is part of the
+ conversation that delegated to it.
+- **A failed tool does not always say which call it answered.** When a tool
+ raises, its output is empty and the call id is nowhere in its run. asz reads
+ it from the model call that asked for the tool, and when that arrived in a
+ different batch, the result lands joined to nothing rather than to a guess.
+- **There is no context reset.** LangChain has no compaction, so a conversation
+ is one epoch however long it runs.
+
+## Seeing it work without an application
+
+The repository carries a demo application that needs no API key and no network:
+
+```sh
+make langchain-capture
+```
+
+It runs fifteen shapes of conversation against a stand-in model and writes what
+the client sent into `tests/apps/langchain/testdata`, with `MEASUREMENTS.md`
+beside it.
diff --git a/docs/menu.yml b/docs/menu.yml
index 6ed4f99..e364827 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -36,6 +36,8 @@ catalog:
path: /en/setup/quick-start
- name: Configuration
path: /en/setup/configuration
+ - name: LangChain and LangGraph
+ path: /en/setup/langchain
- name: Claude Code Plugin
path: /en/setup/claude-code-plugin
- name: Claude Code Provider Bodies
@@ -62,6 +64,10 @@ catalog:
path: /en/formats/otlp
- name: Adapters
catalog:
+ - name: LangChain
+ path: /en/adapters/langsmith
+ - name: LangChain Glossary
+ path: /en/adapters/langsmith-glossary
- name: Claude Code
path: /en/adapters/claude-code
- name: Claude Code Glossary
diff --git a/internal/adapters/langsmith/collect.go b/internal/adapters/langsmith/collect.go
index 1e346d0..2fe0392 100644
--- a/internal/adapters/langsmith/collect.go
+++ b/internal/adapters/langsmith/collect.go
@@ -20,6 +20,7 @@ package langsmith
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"os"
@@ -311,7 +312,7 @@ func nextPosition(streamDir string) (uint64, uint64, error) {
var last *sessiondata.Record
for {
record, err := reader.Next()
- if err == io.EOF {
+ if errors.Is(err, io.EOF) {
break
}
if err != nil {
diff --git a/internal/adapters/langsmith/dialect_test.go b/internal/adapters/langsmith/dialect_test.go
index 4d2b97d..9ecfb90 100644
--- a/internal/adapters/langsmith/dialect_test.go
+++ b/internal/adapters/langsmith/dialect_test.go
@@ -46,7 +46,7 @@ func convertCase(t *testing.T, kase string) []sessiondata.Record {
// dialect, and nothing it produces is a record with no content at all.
func TestEveryArrivalConverts(t *testing.T) {
cases, total := 0, 0
- captured(t, func(kase, _ string, _ capturedMeta, _ []byte) {})
+ captured(t, func(_, _ string, _ capturedMeta, _ []byte) {})
for _, kase := range []string{"plain", "three-turns", "tool-error", "parallel-tools",
"loop", "two-threads", "slow-tool", "large-content", "subagent",
"subagent-own-thread", "no-thread-key", "shared-thread-key",
diff --git a/internal/adapters/langsmith/glossary.go b/internal/adapters/langsmith/glossary.go
new file mode 100644
index 0000000..d011de7
--- /dev/null
+++ b/internal/adapters/langsmith/glossary.go
@@ -0,0 +1,128 @@
+// 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 langsmith
+
+import "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+
+// glossary is what LangChain and its tracing client call the things the model
+// names.
+//
+// It lives beside the code that reads them, so a rename on the wire is made in
+// both places at once. An empty Native is not a gap: a Talk, a Segment and a
+// correlation quality are things this project derived, and saying the runtime
+// has no word for them is more useful than a plausible one that would send a
+// reader looking for a field that is not there.
+//
+// Two words here mean something different from what a reader might expect, and
+// both are worth the note. The client's "session" is the project, not a
+// conversation. And its "run" is any step of the graph, so most runs are not
+// what this model calls a Run.
+var glossary = model.NewGlossary(Dialect,
+ // ---- the roles a record plays ----
+ model.Term{Unified: model.RoleID, Native: "id", Where: "a run",
+ Note: "the record is the arrival, so a run that is posted and later patched has two"},
+ model.Term{Unified: model.RoleParent, Native: "parent_run_id", Where: "a run",
+ Note: "the child names the parent; dotted_order carries the whole ancestry"},
+ model.Term{Unified: model.RoleCall, Native: "id", Where: "a run of type llm",
+ Note: "shared by every arrival about that run"},
+ model.Term{Unified: model.RoleRun, Native: "trace_id", Where: "a run",
+ Note: "one invocation of the graph, which is one turn"},
+ model.Term{Unified: model.RoleBatch, Note: "no equivalent: nothing here launches a group of agents at once"},
+ model.Term{Unified: model.RoleContinues, Note: "no equivalent: the client has no context reset to resume from"},
+ model.Term{Unified: model.RoleTool, Native: "tool_call_id", Where: "outputs.output of a run of type tool",
+ Note: "absent when the tool raised; then it is read from the model call that asked for it"},
+ model.Term{Unified: model.RoleChild, Note: "no equivalent: a nested agent is a run beneath a tool, named as nothing else"},
+ model.Term{Unified: model.RoleTime, Native: "start_time and end_time", Where: "a run"},
+ model.Term{Unified: model.RoleTrigger, Note: "no equivalent: derived from a trace's root run"},
+ model.Term{Unified: model.RoleModel, Native: "extra.metadata.ls_model_name", Where: "a run of type llm"},
+ model.Term{Unified: model.RoleStream, Native: "", Where: "",
+ Note: "no equivalent: derived from whether a nested run's model calls continue the parent's history"},
+
+ // ---- structure ----
+ model.Term{Unified: model.KindConversation, Native: "extra.metadata.thread_id", Where: "a run",
+ Note: "with session_id and conversation_id as the other spellings; supplied, never inferred"},
+ model.Term{Unified: model.KindSegment, Note: "an activity window chosen for commit"},
+ model.Term{Unified: model.KindSession, Native: "session_name", Where: "a run",
+ Note: "the client's word for the PROJECT; a conversation is the thread key, and a session here is the two together"},
+ model.Term{Unified: model.KindStream, Note: "no equivalent: a nested agent is a run like any other"},
+ model.Term{Unified: model.KindEpoch, Note: "no equivalent: the client has no context reset"},
+ model.Term{Unified: model.KindTalk, Note: "one readable interaction; one trace is one turn"},
+ model.Term{Unified: model.KindRun, Native: "trace_id", Where: "a run",
+ Note: "the client calls every graph step a run; this is the invocation they share"},
+
+ // ---- steps ----
+ model.Term{Unified: model.KindMessageExternal, Native: "inputs.messages", Where: "the root run of a trace",
+ Note: "the message that opened the turn has no run of its own"},
+ model.Term{Unified: model.KindMessageAssistant, Native: "outputs.generations[].message.kwargs.content",
+ Where: "a run of type llm"},
+ model.Term{Unified: model.KindMessageSynthetic, Note: "no equivalent: nothing here fabricates a message on the model's behalf"},
+ model.Term{Unified: model.KindContextInjection, Note: "no equivalent"},
+ model.Term{Unified: model.KindLLMCall, Native: "run_type: llm", Where: "a run"},
+ model.Term{Unified: model.KindThinking, Note: "no equivalent yet: a provider's reasoning arrives inside the message content"},
+ model.Term{Unified: model.KindTool, Native: "run_type: tool", Where: "a run"},
+ model.Term{Unified: model.KindAgentCall, Native: "a tool call whose run has model calls beneath it",
+ Note: "the wire has no word for delegation; it is read from the nested context"},
+ model.Term{Unified: model.KindAgentLaunchAck, Note: "no equivalent: nothing acknowledges a delegation separately"},
+ model.Term{Unified: model.KindAgentOutput, Native: "outputs.output.content", Where: "the delegating tool run"},
+ model.Term{Unified: model.KindRuntimeNotification, Note: "no equivalent"},
+ model.Term{Unified: model.KindEpochBoundary, Note: "no equivalent: the client has no context reset"},
+ model.Term{Unified: model.KindEpochSummary, Note: "no equivalent"},
+ model.Term{Unified: model.KindErrorAPI, Native: "error", Where: "a run",
+ Note: "a string with the exception and its traceback"},
+ model.Term{Unified: model.KindControlInterrupt, Note: "no equivalent"},
+ model.Term{Unified: model.KindControlPermission, Note: "no equivalent: nothing here asks before running a tool"},
+ model.Term{Unified: model.KindControlCommand, Note: "no equivalent"},
+ model.Term{Unified: model.KindTurnDuration, Note: "no equivalent as a record; a run's own start and end give it"},
+
+ // ---- relations ----
+ model.Term{Unified: model.RelStarts, Note: "derived from a delegating tool and the runs beneath it"},
+ model.Term{Unified: model.RelReports, Note: "derived"},
+ model.Term{Unified: model.RelEndsWith, Note: "derived from a nested agent's last response"},
+ model.Term{Unified: model.RelResultOf, Native: "tool_call_id", Where: "outputs.output of a run of type tool"},
+ model.Term{Unified: model.RelFollows, Note: "derived from order within a trace"},
+ model.Term{Unified: model.RelSummarizes, Note: "no equivalent"},
+ model.Term{Unified: model.RelInSegment, Note: "derived"},
+ model.Term{Unified: model.RelRetries, Note: "no equivalent yet: the client does not say a run retried another"},
+ model.Term{Unified: model.RelCancels, Note: "no equivalent"},
+ model.Term{Unified: model.RelInputOf, Native: "inputs", Where: "a run"},
+
+ // ---- qualification; all derived, none of it on the wire ----
+ model.Term{Unified: model.ExactUnique, Note: "one call id, named by the tool run that answered it"},
+ model.Term{Unified: model.ExactAmbiguous, Note: "derived"},
+ model.Term{Unified: model.StrongInference, Note: "derived: a failed tool joined to the one unanswered call of its name"},
+ model.Term{Unified: model.WeakInference, Note: "derived"},
+ model.Term{Unified: model.Unresolved, Note: "derived: a failed tool whose model call arrived in another request"},
+ model.Term{Unified: model.Conflict, Note: "derived"},
+
+ // ---- what a part holds ----
+ model.Term{Unified: model.ContentAvailable, Note: "the client sends inputs and outputs whole, as their own parts"},
+ model.Term{Unified: model.ContentRedacted, Note: "no equivalent: nothing on the wire says it redacted anything"},
+ model.Term{Unified: model.ContentOmitted, Note: "a graph's own run: its repeated message list is not landed"},
+ model.Term{Unified: model.ContentHashOnly, Note: "no equivalent"},
+ model.Term{Unified: model.ContentSizeOnly, Note: "no equivalent"},
+ model.Term{Unified: model.ContentTruncated, Note: "no equivalent: nothing measured arrived truncated"},
+ model.Term{Unified: model.ContentUnavailable, Note: "a run that arrived unfinished: its content is not there yet"},
+
+ // ---- what began a turn ----
+ model.Term{Unified: model.TriggerExternal, Note: "the root run of a trace carries the message that opened it"},
+ model.Term{Unified: model.TriggerNotification, Note: "no equivalent"},
+ model.Term{Unified: model.TriggerUnknown, Note: "derived"},
+)
+
+// Glossary is what this runtime calls the things the model names.
+func Glossary() *model.Glossary { return glossary }
diff --git a/internal/adapters/langsmith/glossary_test.go b/internal/adapters/langsmith/glossary_test.go
new file mode 100644
index 0000000..96ad10f
--- /dev/null
+++ b/internal/adapters/langsmith/glossary_test.go
@@ -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.
+
+package langsmith_test
+
+import (
+ "testing"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/langsmith"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/model"
+)
+
+// TestGlossaryCoversTheWholeVocabulary: every name the model can emit has an
+// entry, including the ones saying this runtime has no word for it. A reader
+// asking for the runtime's terms must never meet a name with nothing behind
+// it.
+func TestGlossaryCoversTheWholeVocabulary(t *testing.T) {
+ g := langsmith.Glossary()
+ var missing []string
+ for _, name := range model.Vocabulary() {
+ if _, ok := g.Lookup(name); !ok {
+ missing = append(missing, name)
+ }
+ }
+ if len(missing) > 0 {
+ t.Errorf("%d name(s) have no term:\n %v", len(missing), missing)
+ }
+}
diff --git a/internal/adapters/langsmith/receiver_test.go b/internal/adapters/langsmith/receiver_test.go
index ea9ccf6..0d23be7 100644
--- a/internal/adapters/langsmith/receiver_test.go
+++ b/internal/adapters/langsmith/receiver_test.go
@@ -20,6 +20,7 @@ package langsmith
import (
"bytes"
"encoding/json"
+ "errors"
"io"
"net/http"
"os"
@@ -174,7 +175,7 @@ func assertContiguous(t *testing.T, zone *storage.Zone, sessions []string) {
}
for {
record, err := reader.Next()
- if err == io.EOF {
+ if errors.Is(err, io.EOF) {
break
}
if err != nil {
@@ -232,7 +233,7 @@ func TestARetriedBatchLandsAgainAndTheIndexCanTellIsRepeated(t *testing.T) {
}
for {
record, err := reader.Next()
- if err == io.EOF {
+ if errors.Is(err, io.EOF) {
break
}
if err != nil {
diff --git a/internal/adapters/langsmith/wire.go b/internal/adapters/langsmith/wire.go
index 3f1102b..b9be6d0 100644
--- a/internal/adapters/langsmith/wire.go
+++ b/internal/adapters/langsmith/wire.go
@@ -19,6 +19,7 @@ package langsmith
import (
"encoding/json"
+ "errors"
"fmt"
"io"
"mime"
@@ -76,15 +77,34 @@ type Request struct {
Attachments int
}
-// runFields are the field names the client sends out of band. A name outside
-// this set is kept anyway, under its own key: a client that grows a field must
-// not lose it here, and the dialect can say it did not understand rather than
-// the receiver pretending it never came.
-var runFields = map[string]bool{
+// KnownFields are the field names the client sends out of band.
+//
+// A name outside this set is kept anyway, under its own key: a client that
+// grows a field must not lose it here, and the dialect can say it did not
+// understand something rather than the receiver pretending it never came. The
+// set is here so a reader can tell the two apart.
+var KnownFields = map[string]bool{
"inputs": true, "outputs": true, "events": true,
"extra": true, "serialized": true, "error": true,
}
+// UnknownFields names the parts of a request whose field this build does not
+// know. It is what a status line reports when a client has grown something.
+func (r *Request) UnknownFields() []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, op := range r.Operations {
+ for name := range op.Fields {
+ if !KnownFields[name] && !seen[name] {
+ seen[name] = true
+ out = append(out, name)
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
// ParseMultipart reads a multipart ingest body into the operations it carries.
//
// The part name is the whole protocol here: "post.<uuid>" is a run's envelope,
@@ -106,7 +126,7 @@ func ParseMultipart(body io.Reader, contentType string) (*Request, error) {
position := 0
for {
part, err := reader.NextPart()
- if err == io.EOF {
+ if errors.Is(err, io.EOF) {
break
}
if err != nil {