anlyon-langchain 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ node_modules/
2
+ # pnpm puts its content-addressable store beside the project when HOME is
3
+ # on a different filesystem, which is what happens inside a container. It is
4
+ # ~2GB and ~100k files, and .dockerignore already excludes it from builds --
5
+ # this keeps it out of commits too.
6
+ .pnpm-store/
7
+ dist/
8
+ build/
9
+ .next/
10
+ coverage/
11
+ *.tsbuildinfo
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.egg-info/
16
+ .venv/
17
+ .mypy_cache/
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+
21
+ # Env
22
+ .env
23
+ .env.*
24
+ !.env.example
25
+ skills-lock.json
26
+
27
+ # Local MCP / tool overrides (may contain secrets never commit)
28
+ *.local.json
29
+ .mcp.local.json
30
+
31
+ # Local agent tooling config, not part of the build.
32
+ .commandcode/
33
+
34
+ # Redis snapshot left behind by a locally-run redis-server.
35
+ dump.rdb
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anlyon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.5
2
+ Name: anlyon-langchain
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for Anlyon state primitives and trace callbacks
5
+ Project-URL: Homepage, https://anlyon.com
6
+ Project-URL: Documentation, https://docs.anlyon.com
7
+ Project-URL: Repository, https://github.com/AnlyonHQ/anlyon-platform
8
+ Project-URL: Issues, https://github.com/AnlyonHQ/anlyon-platform/issues
9
+ Author-email: Anlyon <support@anlyon.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent-governance,ai-agents,anlyon,approvals,langchain,memory,sdk,tracing
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: anlyon>=0.1.0
24
+ Requires-Dist: langchain-core>=1.6.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: respx>=0.21; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # anlyon-langchain
32
+
33
+ LangChain integration for [Anlyon](https://anlyon.com): `AnlyonVectorStore` and `AnlyonRetriever` over Anlyon Memory, `AnlyonChatMessageHistory` over Anlyon Sessions, and `AnlyonTracer` for nested run/span tracing.
34
+
35
+ ## Trace a LangChain invocation
36
+
37
+ ```python
38
+ from anlyon import Client
39
+ from anlyon_langchain import AnlyonTracer
40
+
41
+ client = Client(api_key="anlyon_live_...")
42
+ tracer = AnlyonTracer(client, application="support-agent")
43
+
44
+ # Pass the callback to any LangChain runnable/agent invocation.
45
+ result = chain.invoke({"question": "Where is my order?"}, config={"callbacks": [tracer]})
46
+ ```
47
+
48
+ The handler creates one Anlyon run for the root callback and parent-linked
49
+ `workflow_step`, `model_call`, and `tool_call` spans for nested callbacks. It is
50
+ best-effort: Anlyon transport failures never change the application result.
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ pip install anlyon-langchain
56
+ ```
57
+
58
+ ## Retrieval
59
+
60
+ ```python
61
+ from anlyon import Client
62
+ from anlyon_langchain import AnlyonVectorStore
63
+
64
+ client = Client() # uses ANLYON_API_KEY
65
+ store = AnlyonVectorStore(client, collection="docs")
66
+ retriever = store.as_retriever()
67
+ ```
68
+
69
+ ## Chat history
70
+
71
+ `AnlyonChatMessageHistory` puts a chain's conversation in an Anlyon Session durable across processes, and summarized automatically once the thread gets long.
72
+
73
+ ```python
74
+ from anlyon import Client
75
+ from anlyon_langchain import AnlyonChatMessageHistory
76
+ from langchain_core.runnables.history import RunnableWithMessageHistory
77
+
78
+ client = Client()
79
+
80
+ chain_with_history = RunnableWithMessageHistory(
81
+ chain,
82
+ lambda session_id: AnlyonChatMessageHistory(client, session_id),
83
+ input_messages_key="input",
84
+ history_messages_key="history",
85
+ )
86
+
87
+ # `session_id` is an Anlyon session id create one with client.sessions.create()
88
+ chain_with_history.invoke(
89
+ {"input": "Where is my order?"},
90
+ config={"configurable": {"session_id": "ses_abc123"}},
91
+ )
92
+ ```
93
+
94
+ `.messages` reads the thread back as LangChain messages, `.add_messages()` appends a turn in one round-trip, and `.clear()` truncates the thread while keeping the session id usable.
95
+
96
+ See the [Anlyon docs](https://docs.anlyon.com) for the full guide.
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,70 @@
1
+ # anlyon-langchain
2
+
3
+ LangChain integration for [Anlyon](https://anlyon.com): `AnlyonVectorStore` and `AnlyonRetriever` over Anlyon Memory, `AnlyonChatMessageHistory` over Anlyon Sessions, and `AnlyonTracer` for nested run/span tracing.
4
+
5
+ ## Trace a LangChain invocation
6
+
7
+ ```python
8
+ from anlyon import Client
9
+ from anlyon_langchain import AnlyonTracer
10
+
11
+ client = Client(api_key="anlyon_live_...")
12
+ tracer = AnlyonTracer(client, application="support-agent")
13
+
14
+ # Pass the callback to any LangChain runnable/agent invocation.
15
+ result = chain.invoke({"question": "Where is my order?"}, config={"callbacks": [tracer]})
16
+ ```
17
+
18
+ The handler creates one Anlyon run for the root callback and parent-linked
19
+ `workflow_step`, `model_call`, and `tool_call` spans for nested callbacks. It is
20
+ best-effort: Anlyon transport failures never change the application result.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install anlyon-langchain
26
+ ```
27
+
28
+ ## Retrieval
29
+
30
+ ```python
31
+ from anlyon import Client
32
+ from anlyon_langchain import AnlyonVectorStore
33
+
34
+ client = Client() # uses ANLYON_API_KEY
35
+ store = AnlyonVectorStore(client, collection="docs")
36
+ retriever = store.as_retriever()
37
+ ```
38
+
39
+ ## Chat history
40
+
41
+ `AnlyonChatMessageHistory` puts a chain's conversation in an Anlyon Session durable across processes, and summarized automatically once the thread gets long.
42
+
43
+ ```python
44
+ from anlyon import Client
45
+ from anlyon_langchain import AnlyonChatMessageHistory
46
+ from langchain_core.runnables.history import RunnableWithMessageHistory
47
+
48
+ client = Client()
49
+
50
+ chain_with_history = RunnableWithMessageHistory(
51
+ chain,
52
+ lambda session_id: AnlyonChatMessageHistory(client, session_id),
53
+ input_messages_key="input",
54
+ history_messages_key="history",
55
+ )
56
+
57
+ # `session_id` is an Anlyon session id create one with client.sessions.create()
58
+ chain_with_history.invoke(
59
+ {"input": "Where is my order?"},
60
+ config={"configurable": {"session_id": "ses_abc123"}},
61
+ )
62
+ ```
63
+
64
+ `.messages` reads the thread back as LangChain messages, `.add_messages()` appends a turn in one round-trip, and `.clear()` truncates the thread while keeping the session id usable.
65
+
66
+ See the [Anlyon docs](https://docs.anlyon.com) for the full guide.
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "anlyon-langchain"
7
+ version = "0.1.0"
8
+ description = "LangChain integration for Anlyon state primitives and trace callbacks"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Anlyon", email = "support@anlyon.com" }]
13
+ keywords = ["anlyon", "langchain", "ai-agents", "agent-governance", "approvals", "tracing", "memory", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "anlyon>=0.1.0",
27
+ "langchain-core>=1.6.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://anlyon.com"
32
+ Documentation = "https://docs.anlyon.com"
33
+ Repository = "https://github.com/AnlyonHQ/anlyon-platform"
34
+ Issues = "https://github.com/AnlyonHQ/anlyon-platform/issues"
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
38
+
39
+ [tool.uv.sources]
40
+ anlyon = { workspace = true }
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/anlyon_langchain"]
@@ -0,0 +1,12 @@
1
+ """
2
+ Anlyon LangChain integration state primitives and trace callbacks.
3
+
4
+ Thin wrappers over the `anlyon` SDK. No re-implementation.
5
+ """
6
+
7
+ from .vectorstore import AnlyonVectorStore
8
+ from .retriever import AnlyonRetriever
9
+ from .chat_history import AnlyonChatMessageHistory
10
+ from .tracing import AnlyonTracer
11
+
12
+ __all__ = ["AnlyonVectorStore", "AnlyonRetriever", "AnlyonChatMessageHistory", "AnlyonTracer"]
@@ -0,0 +1,149 @@
1
+ """
2
+ AnlyonChatMessageHistory LangChain chat history backed by an Anlyon Session.
3
+
4
+ Drop this in wherever LangChain wants a `BaseChatMessageHistory` (most often
5
+ inside `RunnableWithMessageHistory`) and the conversation persists in Anlyon:
6
+ durable, workspace-scoped, and summarized automatically once it gets long.
7
+
8
+ Thin wrapper calls the SDK, never re-implements API logic.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ from typing import Any, List, Sequence
13
+
14
+ from langchain_core.chat_history import BaseChatMessageHistory
15
+ from langchain_core.messages import (
16
+ AIMessage,
17
+ BaseMessage,
18
+ HumanMessage,
19
+ SystemMessage,
20
+ ToolMessage,
21
+ )
22
+
23
+ from anlyon import Client
24
+
25
+ # LangChain message types ↔ Anlyon session roles.
26
+ _ROLE_BY_TYPE = {
27
+ "human": "user",
28
+ "ai": "assistant",
29
+ "system": "system",
30
+ "tool": "tool",
31
+ }
32
+
33
+ # How many messages to read back. Anlyon caps a page at 200.
34
+ _PAGE_LIMIT = 200
35
+
36
+
37
+ def _to_role(message: BaseMessage) -> str:
38
+ """A LangChain message's role, defaulting anything exotic to `user`."""
39
+ return _ROLE_BY_TYPE.get(message.type, "user")
40
+
41
+
42
+ def _to_langchain(message: dict[str, Any]) -> BaseMessage:
43
+ role = message.get("role")
44
+ content = message.get("content", "")
45
+ metadata = message.get("metadata") or {}
46
+
47
+ if role == "assistant":
48
+ return AIMessage(content=content, additional_kwargs=metadata)
49
+ if role == "system":
50
+ return SystemMessage(content=content, additional_kwargs=metadata)
51
+ if role == "tool":
52
+ # tool_call_id is required by LangChain; the session column carries it.
53
+ return ToolMessage(
54
+ content=content,
55
+ tool_call_id=message.get("toolCallId") or "",
56
+ additional_kwargs=metadata,
57
+ )
58
+ return HumanMessage(content=content, additional_kwargs=metadata)
59
+
60
+
61
+ class AnlyonChatMessageHistory(BaseChatMessageHistory):
62
+ """
63
+ An Anlyon Session as a LangChain chat history.
64
+
65
+ Example:
66
+ ```python
67
+ from anlyon import Client
68
+ from anlyon_langchain import AnlyonChatMessageHistory
69
+
70
+ client = Client(api_key="anlyon_live_...")
71
+ session = client.sessions.create(title="Support chat")
72
+
73
+ history = AnlyonChatMessageHistory(client, session["id"])
74
+ history.add_user_message("Where is my order?")
75
+ history.messages # → [HumanMessage(...)]
76
+ ```
77
+
78
+ Pass it to `RunnableWithMessageHistory` to persist a chain's conversation:
79
+
80
+ ```python
81
+ RunnableWithMessageHistory(
82
+ chain,
83
+ lambda session_id: AnlyonChatMessageHistory(client, session_id),
84
+ input_messages_key="input",
85
+ history_messages_key="history",
86
+ )
87
+ ```
88
+ """
89
+
90
+ def __init__(self, client: Client, session_id: str) -> None:
91
+ self.client = client
92
+ self.session_id = session_id
93
+
94
+ @property
95
+ def messages(self) -> List[BaseMessage]:
96
+ """The whole thread, oldest first."""
97
+ collected: List[BaseMessage] = []
98
+ offset = 0
99
+
100
+ while True:
101
+ page = self.client.sessions.messages(
102
+ self.session_id, limit=_PAGE_LIMIT, offset=offset
103
+ )
104
+ collected.extend(_to_langchain(m) for m in page)
105
+ if len(page) < _PAGE_LIMIT:
106
+ return collected
107
+ offset += _PAGE_LIMIT
108
+
109
+ def add_messages(self, messages: Sequence[BaseMessage]) -> None:
110
+ """Append a turn. One round-trip per call, not one per message."""
111
+ if not messages:
112
+ return
113
+
114
+ payload = [
115
+ {
116
+ "role": _to_role(m),
117
+ "content": _text(m),
118
+ **({"toolCallId": m.tool_call_id} if isinstance(m, ToolMessage) else {}),
119
+ }
120
+ for m in messages
121
+ ]
122
+ self.client.sessions.append(self.session_id, payload)
123
+
124
+ def clear(self) -> None:
125
+ """Truncate the thread. The session survives, so its id stays usable."""
126
+ self.client.sessions.clear_messages(self.session_id)
127
+
128
+
129
+ def _text(message: BaseMessage) -> str:
130
+ """
131
+ Anlyon stores message content as text. LangChain content can also be a list
132
+ of parts (multimodal); keep the text parts and drop the rest rather than
133
+ storing a Python repr of a dict.
134
+ """
135
+ content: Any = message.content
136
+ if isinstance(content, str):
137
+ return content
138
+ if isinstance(content, list):
139
+ parts: list[str] = []
140
+ for part in content:
141
+ if isinstance(part, str):
142
+ parts.append(part)
143
+ elif isinstance(part, dict) and part.get("type") == "text":
144
+ parts.append(str(part.get("text", "")))
145
+ return "\n".join(parts)
146
+ return str(content)
147
+
148
+
149
+ __all__ = ["AnlyonChatMessageHistory"]
@@ -0,0 +1,46 @@
1
+ """
2
+ AnlyonRetriever LangChain BaseRetriever backed by Anlyon Memory recall().
3
+
4
+ Thin wrapper calls the SDK, never re-implements API logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ from typing import Optional
9
+
10
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
11
+ from langchain_core.documents import Document
12
+ from langchain_core.retrievers import BaseRetriever
13
+
14
+ from anlyon import Client
15
+
16
+
17
+ class AnlyonRetriever(BaseRetriever):
18
+ """Anlyon Memory as a LangChain retriever."""
19
+
20
+ client: Client
21
+ collection: Optional[str] = None
22
+ top_k: int = 5
23
+ mode: str = "hybrid"
24
+ rerank: bool = False
25
+
26
+ def _get_relevant_documents(
27
+ self,
28
+ query: str,
29
+ *,
30
+ run_manager: CallbackManagerForRetrieverRun,
31
+ ) -> list[Document]:
32
+ result = self.client.memory.recall(
33
+ query=query,
34
+ collection=self.collection,
35
+ top_k=self.top_k,
36
+ mode=self.mode,
37
+ rerank=self.rerank if self.rerank else None,
38
+ )
39
+ results = result.get("results", []) if isinstance(result, dict) else []
40
+ return [
41
+ Document(
42
+ page_content=r.get("content", ""),
43
+ metadata={"score": r.get("score", 0), **r.get("metadata", {})},
44
+ )
45
+ for r in results
46
+ ]
@@ -0,0 +1,246 @@
1
+ """LangChain callback adapter for the Anlyon run/span trace model.
2
+
3
+ The handler is deliberately best-effort: observability must never change the
4
+ result of an agent run. It maps LangChain run UUIDs to Anlyon span IDs and uses
5
+ the parent UUID supplied by LangChain to preserve nesting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from threading import RLock
13
+ from typing import Any, Optional
14
+ from uuid import UUID
15
+
16
+ from langchain_core.callbacks import BaseCallbackHandler
17
+
18
+ from anlyon import Client
19
+
20
+
21
+ def _now() -> str:
22
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
23
+
24
+
25
+ def _span_id(run_id: UUID) -> str:
26
+ return f"lc_{run_id.hex}"
27
+
28
+
29
+ def _safe_value(value: Any) -> Any:
30
+ """Keep callback payloads JSON-compatible before SDK serialization."""
31
+ if value is None or isinstance(value, (str, int, float, bool)):
32
+ return value
33
+ if isinstance(value, dict):
34
+ return {str(key): _safe_value(item) for key, item in value.items()}
35
+ if isinstance(value, (list, tuple)):
36
+ return [_safe_value(item) for item in value]
37
+ return str(value)
38
+
39
+
40
+ def _extract_llm_usage(response: Any) -> tuple[Optional[dict[str, int]], Optional[str]]:
41
+ """Pull token usage and model name from an LLMResult, best-effort.
42
+
43
+ Providers report usage in two places: ``llm_output["token_usage"]``
44
+ (OpenAI-style ``prompt_tokens``/``completion_tokens``/``total_tokens``) and
45
+ the newer per-generation ``message.usage_metadata`` (``input_tokens``/
46
+ ``output_tokens``/``total_tokens``). Both normalize to the Anlyon span
47
+ convention: ``{"input": ..., "output": ..., "total": ...}``.
48
+ """
49
+ tokens: Optional[dict[str, int]] = None
50
+ model: Optional[str] = None
51
+
52
+ llm_output = getattr(response, "llm_output", None)
53
+ if isinstance(llm_output, dict):
54
+ model = llm_output.get("model_name") or llm_output.get("model")
55
+ usage = llm_output.get("token_usage") or llm_output.get("usage")
56
+ if isinstance(usage, dict):
57
+ normalized = {
58
+ "input": usage.get("prompt_tokens", usage.get("input_tokens")),
59
+ "output": usage.get("completion_tokens", usage.get("output_tokens")),
60
+ "total": usage.get("total_tokens"),
61
+ }
62
+ tokens = {k: int(v) for k, v in normalized.items() if isinstance(v, (int, float))}
63
+
64
+ if tokens is None:
65
+ for generations in getattr(response, "generations", []) or []:
66
+ for generation in generations:
67
+ usage = getattr(getattr(generation, "message", None), "usage_metadata", None)
68
+ if isinstance(usage, dict) and usage:
69
+ tokens = {
70
+ k: int(v)
71
+ for k, v in {
72
+ "input": usage.get("input_tokens"),
73
+ "output": usage.get("output_tokens"),
74
+ "total": usage.get("total_tokens"),
75
+ }.items()
76
+ if isinstance(v, (int, float))
77
+ }
78
+ break
79
+ if tokens:
80
+ break
81
+
82
+ return (tokens or None), model
83
+
84
+
85
+ @dataclass
86
+ class _TraceState:
87
+ run_id: str
88
+ root_langchain_id: UUID
89
+ spans: dict[UUID, "_TrackedSpan"] = field(default_factory=dict)
90
+
91
+
92
+ @dataclass
93
+ class _TrackedSpan:
94
+ id: str
95
+ name: str
96
+ type: str
97
+ parent_id: Optional[str]
98
+ started_at: str
99
+ input: Any
100
+ attributes: dict[str, Any]
101
+
102
+
103
+ class AnlyonTracer(BaseCallbackHandler):
104
+ """Emit a run and nested spans for LangChain chain, LLM, and tool callbacks.
105
+
106
+ Attach one instance to a LangChain invocation with ``callbacks=[tracer]``.
107
+ The adapter keeps only in-flight UUID mappings in memory and is safe to
108
+ share across concurrent invocations.
109
+ """
110
+
111
+ raise_error = False
112
+
113
+ def __init__(self, client: Client, *, application: str = "langchain", run_name: str = "LangChain run") -> None:
114
+ super().__init__()
115
+ self.client = client
116
+ self.application = application
117
+ self.run_name = run_name
118
+ self._states: dict[UUID, _TraceState] = {}
119
+ self._lock = RLock()
120
+
121
+ def _start(
122
+ self,
123
+ kind: str,
124
+ run_id: UUID,
125
+ parent_run_id: Optional[UUID],
126
+ name: str,
127
+ input: Any = None,
128
+ tags: Optional[list[str]] = None,
129
+ metadata: Optional[dict[str, Any]] = None,
130
+ ) -> None:
131
+ try:
132
+ with self._lock:
133
+ state = self._states.get(parent_run_id) if parent_run_id else None
134
+ if state is None:
135
+ started = self.client.runs.start(
136
+ name=self.run_name,
137
+ application=self.application,
138
+ tags=tags,
139
+ metadata={"framework": "langchain", **(metadata or {})},
140
+ )
141
+ state = _TraceState(run_id=started.data["id"], root_langchain_id=run_id)
142
+ parent_id = state.spans.get(parent_run_id).id if parent_run_id in state.spans else None
143
+ started_at = _now()
144
+ span = _TrackedSpan(
145
+ id=_span_id(run_id),
146
+ name=name,
147
+ type=kind,
148
+ parent_id=parent_id,
149
+ started_at=started_at,
150
+ input=_safe_value(input),
151
+ attributes={
152
+ "langchain.run_id": str(run_id),
153
+ "langchain.parent_run_id": str(parent_run_id) if parent_run_id else None,
154
+ "langchain.kind": kind,
155
+ },
156
+ )
157
+ state.spans[run_id] = span
158
+ self._states[run_id] = state
159
+
160
+ self.client.runs.ingest_spans(
161
+ state.run_id,
162
+ [{
163
+ "id": span.id,
164
+ **({"parentId": parent_id} if parent_id else {}),
165
+ "name": span.name,
166
+ "type": span.type,
167
+ "startedAt": span.started_at,
168
+ **({"input": span.input} if input is not None else {}),
169
+ "attributes": span.attributes,
170
+ }],
171
+ )
172
+ except Exception:
173
+ # Callback instrumentation must never break a LangChain invocation.
174
+ return
175
+
176
+ def _finish(
177
+ self,
178
+ run_id: UUID,
179
+ *,
180
+ error: Optional[BaseException] = None,
181
+ output: Any = None,
182
+ tokens: Optional[dict[str, int]] = None,
183
+ model: Optional[str] = None,
184
+ ) -> None:
185
+ try:
186
+ with self._lock:
187
+ state = self._states.get(run_id)
188
+ if state is None:
189
+ return
190
+ is_root = state.root_langchain_id == run_id
191
+ span = state.spans[run_id]
192
+ status = "failed" if error else "succeeded"
193
+ self.client.runs.ingest_spans(
194
+ state.run_id,
195
+ [{
196
+ "id": span.id,
197
+ **({"parentId": span.parent_id} if span.parent_id else {}),
198
+ "name": span.name,
199
+ "type": span.type,
200
+ "status": status,
201
+ "startedAt": span.started_at,
202
+ "endedAt": _now(),
203
+ **({"tokens": tokens} if tokens else {}),
204
+ **({"model": model} if model else {}),
205
+ **({"input": span.input} if span.input is not None else {}),
206
+ **({"output": _safe_value(output)} if output is not None else {}),
207
+ **({"error": {"message": str(error)}} if error else {}),
208
+ "attributes": span.attributes,
209
+ }],
210
+ )
211
+ if is_root:
212
+ self.client.runs.end(state.run_id, status=status, **({"output": _safe_value(output)} if output is not None else {}))
213
+ with self._lock:
214
+ for langchain_id, mapped in list(self._states.items()):
215
+ if mapped is state:
216
+ del self._states[langchain_id]
217
+ except Exception:
218
+ return
219
+
220
+ def on_chain_start(self, serialized: dict[str, Any], inputs: dict[str, Any], *, run_id: UUID, parent_run_id: Optional[UUID] = None, tags: Optional[list[str]] = None, metadata: Optional[dict[str, Any]] = None, **_: Any) -> None:
221
+ self._start("workflow_step", run_id, parent_run_id, serialized.get("name", "chain"), inputs, tags, metadata)
222
+
223
+ def on_chain_end(self, outputs: dict[str, Any], *, run_id: UUID, **_: Any) -> None:
224
+ self._finish(run_id, output=outputs)
225
+
226
+ def on_chain_error(self, error: BaseException, *, run_id: UUID, **_: Any) -> None:
227
+ self._finish(run_id, error=error)
228
+
229
+ def on_llm_start(self, serialized: dict[str, Any], prompts: list[str], *, run_id: UUID, parent_run_id: Optional[UUID] = None, tags: Optional[list[str]] = None, metadata: Optional[dict[str, Any]] = None, **_: Any) -> None:
230
+ self._start("model_call", run_id, parent_run_id, serialized.get("name", "llm"), {"prompts": prompts}, tags, metadata)
231
+
232
+ def on_llm_end(self, response: Any, *, run_id: UUID, **_: Any) -> None:
233
+ tokens, model = _extract_llm_usage(response)
234
+ self._finish(run_id, output=getattr(response, "llm_output", None), tokens=tokens, model=model)
235
+
236
+ def on_llm_error(self, error: BaseException, *, run_id: UUID, **_: Any) -> None:
237
+ self._finish(run_id, error=error)
238
+
239
+ def on_tool_start(self, serialized: dict[str, Any], input_str: str, *, run_id: UUID, parent_run_id: Optional[UUID] = None, tags: Optional[list[str]] = None, metadata: Optional[dict[str, Any]] = None, **_: Any) -> None:
240
+ self._start("tool_call", run_id, parent_run_id, serialized.get("name", "tool"), input_str, tags, metadata)
241
+
242
+ def on_tool_end(self, output: Any, *, run_id: UUID, **_: Any) -> None:
243
+ self._finish(run_id, output=output)
244
+
245
+ def on_tool_error(self, error: BaseException, *, run_id: UUID, **_: Any) -> None:
246
+ self._finish(run_id, error=error)
@@ -0,0 +1,74 @@
1
+ """
2
+ AnlyonVectorStore LangChain VectorStore backed by Anlyon Memory.
3
+
4
+ add_texts() → client.memory.remember_many()
5
+ similarity_search() → client.memory.recall()
6
+
7
+ Thin wrapper calls the SDK, never re-implements API logic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from typing import Any, Optional, Sequence
12
+
13
+ from langchain_core.documents import Document
14
+ from langchain_core.vectorstores import VectorStore
15
+
16
+ from anlyon import Client
17
+
18
+
19
+ class AnlyonVectorStore(VectorStore):
20
+ """Anlyon Memory as a LangChain VectorStore."""
21
+
22
+ def __init__(self, client: Client, *, collection: Optional[str] = None) -> None:
23
+ self._client = client
24
+ self._collection = collection
25
+
26
+ def add_texts(
27
+ self,
28
+ texts: Sequence[str],
29
+ metadatas: Optional[list[dict]] = None,
30
+ **kwargs: Any,
31
+ ) -> list[str]:
32
+ items: list[dict[str, Any]] = []
33
+ for i, text in enumerate(texts):
34
+ item: dict[str, Any] = {"content": text}
35
+ if self._collection:
36
+ item["collection"] = self._collection
37
+ if metadatas and i < len(metadatas):
38
+ item["metadata"] = metadatas[i]
39
+ items.append(item)
40
+
41
+ result = self._client.memory.remember_many(items=items)
42
+ ids: list[str] = []
43
+ for r in (result.get("results", []) if isinstance(result, dict) else []):
44
+ if r.get("memoryId"):
45
+ ids.append(r["memoryId"])
46
+ return ids
47
+
48
+ def similarity_search(
49
+ self,
50
+ query: str,
51
+ k: int = 4,
52
+ **kwargs: Any,
53
+ ) -> list[Document]:
54
+ result = self._client.memory.recall(
55
+ query=query,
56
+ collection=self._collection,
57
+ top_k=k,
58
+ mode="semantic",
59
+ )
60
+ results = result.get("results", []) if isinstance(result, dict) else []
61
+ return [
62
+ Document(page_content=r.get("content", ""), metadata={"score": r.get("score", 0), **r.get("metadata", {})})
63
+ for r in results
64
+ ]
65
+
66
+ def add_documents(self, documents: Sequence[Document], **kwargs: Any) -> list[str]:
67
+ return self.add_texts(
68
+ [d.page_content for d in documents],
69
+ metadatas=[d.metadata for d in documents],
70
+ )
71
+
72
+ @classmethod
73
+ def from_texts(cls, texts: Sequence[str], embedding: Any, metadatas: Optional[list[dict]] = None, **kwargs: Any) -> "AnlyonVectorStore":
74
+ raise NotImplementedError("AnlyonVectorStore does not use embeddings Anlyon manages them internally. Use the constructor with a Client.")
@@ -0,0 +1,147 @@
1
+ """
2
+ AnlyonChatMessageHistory an Anlyon Session behind LangChain's chat history.
3
+ respx mocks the HTTP layer.
4
+ """
5
+
6
+ import json
7
+
8
+ import respx
9
+ import httpx
10
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
11
+
12
+ from anlyon import Client
13
+ from anlyon_langchain import AnlyonChatMessageHistory
14
+
15
+ BASE_URL = "https://api.anlyon.com"
16
+ MESSAGES = f"{BASE_URL}/api/v2/sessions/ses_1/messages"
17
+
18
+
19
+ def message(index, role, content, tool_call_id=None):
20
+ return {
21
+ "index": index,
22
+ "role": role,
23
+ "content": content,
24
+ "toolCallId": tool_call_id,
25
+ "tokenCount": 4,
26
+ "metadata": {},
27
+ "createdAt": "2026-01-01T00:00:00.000Z",
28
+ }
29
+
30
+
31
+ def page(messages):
32
+ return httpx.Response(200, json={"success": True, "data": messages, "pagination": {}})
33
+
34
+
35
+ @respx.mock
36
+ def test_messages_maps_session_roles_to_langchain_types():
37
+ respx.get(MESSAGES).mock(
38
+ return_value=page(
39
+ [
40
+ message(0, "system", "You are helpful."),
41
+ message(1, "user", "Where is my order?"),
42
+ message(2, "assistant", "Let me check."),
43
+ message(3, "tool", "shipped", tool_call_id="call_1"),
44
+ ]
45
+ )
46
+ )
47
+
48
+ with Client(api_key="anlyon_live_test") as client:
49
+ history = AnlyonChatMessageHistory(client, "ses_1")
50
+ messages = history.messages
51
+
52
+ assert [type(m) for m in messages] == [SystemMessage, HumanMessage, AIMessage, ToolMessage]
53
+ assert messages[1].content == "Where is my order?"
54
+ assert messages[3].tool_call_id == "call_1"
55
+
56
+
57
+ @respx.mock
58
+ def test_add_messages_appends_the_batch_in_one_call():
59
+ route = respx.post(MESSAGES).mock(
60
+ return_value=httpx.Response(
61
+ 201, json={"success": True, "data": {"sessionId": "ses_1", "appended": 2}}
62
+ )
63
+ )
64
+
65
+ with Client(api_key="anlyon_live_test") as client:
66
+ history = AnlyonChatMessageHistory(client, "ses_1")
67
+ history.add_messages([HumanMessage(content="hi"), AIMessage(content="hello")])
68
+
69
+ assert route.call_count == 1
70
+ assert json.loads(route.calls.last.request.content) == {
71
+ "messages": [
72
+ {"role": "user", "content": "hi"},
73
+ {"role": "assistant", "content": "hello"},
74
+ ]
75
+ }
76
+
77
+
78
+ @respx.mock
79
+ def test_add_messages_carries_the_tool_call_id():
80
+ route = respx.post(MESSAGES).mock(
81
+ return_value=httpx.Response(201, json={"success": True, "data": {"appended": 1}})
82
+ )
83
+
84
+ with Client(api_key="anlyon_live_test") as client:
85
+ AnlyonChatMessageHistory(client, "ses_1").add_messages(
86
+ [ToolMessage(content="shipped", tool_call_id="call_1")]
87
+ )
88
+
89
+ stored = json.loads(route.calls.last.request.content)["messages"][0]
90
+ assert stored == {"role": "tool", "content": "shipped", "toolCallId": "call_1"}
91
+
92
+
93
+ @respx.mock
94
+ def test_multimodal_content_is_stored_as_its_text_parts():
95
+ route = respx.post(MESSAGES).mock(
96
+ return_value=httpx.Response(201, json={"success": True, "data": {"appended": 1}})
97
+ )
98
+
99
+ with Client(api_key="anlyon_live_test") as client:
100
+ AnlyonChatMessageHistory(client, "ses_1").add_messages(
101
+ [
102
+ HumanMessage(
103
+ content=[
104
+ {"type": "text", "text": "What is in this image?"},
105
+ {"type": "image_url", "image_url": {"url": "https://x/y.png"}},
106
+ ]
107
+ )
108
+ ]
109
+ )
110
+
111
+ stored = json.loads(route.calls.last.request.content)["messages"][0]
112
+ assert stored["content"] == "What is in this image?"
113
+
114
+
115
+ @respx.mock
116
+ def test_add_messages_with_nothing_makes_no_call():
117
+ with Client(api_key="anlyon_live_test") as client:
118
+ # No routes registered an HTTP call would fail the test.
119
+ AnlyonChatMessageHistory(client, "ses_1").add_messages([])
120
+
121
+
122
+ @respx.mock
123
+ def test_clear_truncates_the_thread_but_keeps_the_session():
124
+ route = respx.delete(MESSAGES).mock(
125
+ return_value=httpx.Response(
126
+ 200, json={"success": True, "data": {"sessionId": "ses_1", "deleted": 3}}
127
+ )
128
+ )
129
+
130
+ with Client(api_key="anlyon_live_test") as client:
131
+ AnlyonChatMessageHistory(client, "ses_1").clear()
132
+
133
+ assert route.called
134
+
135
+
136
+ @respx.mock
137
+ def test_messages_pages_through_a_long_thread():
138
+ first = [message(i, "user", f"m{i}") for i in range(200)]
139
+ second = [message(200, "user", "m200")]
140
+ route = respx.get(MESSAGES).mock(side_effect=[page(first), page(second)])
141
+
142
+ with Client(api_key="anlyon_live_test") as client:
143
+ messages = AnlyonChatMessageHistory(client, "ses_1").messages
144
+
145
+ assert len(messages) == 201
146
+ assert route.call_count == 2
147
+ assert route.calls[1].request.url.params["offset"] == "200"
@@ -0,0 +1,115 @@
1
+ from types import SimpleNamespace
2
+ from uuid import UUID
3
+
4
+ from anlyon_langchain import AnlyonTracer
5
+
6
+
7
+ class _Runs:
8
+ def __init__(self) -> None:
9
+ self.started: list[dict] = []
10
+ self.spans: list[tuple[str, list[dict]]] = []
11
+ self.ended: list[tuple[str, dict]] = []
12
+
13
+ def start(self, **kwargs):
14
+ self.started.append(kwargs)
15
+ return SimpleNamespace(data={"id": "run_langchain"})
16
+
17
+ def ingest_spans(self, run_id, spans):
18
+ self.spans.append((run_id, spans))
19
+
20
+ def end(self, run_id, **kwargs):
21
+ self.ended.append((run_id, kwargs))
22
+
23
+
24
+ class _Client:
25
+ def __init__(self) -> None:
26
+ self.runs = _Runs()
27
+
28
+
29
+ def test_tracer_creates_a_run_and_parent_linked_chain_llm_tool_spans() -> None:
30
+ client = _Client()
31
+ tracer = AnlyonTracer(client, application="support-agent")
32
+ root = UUID("00000000-0000-0000-0000-000000000001")
33
+ llm = UUID("00000000-0000-0000-0000-000000000002")
34
+ tool = UUID("00000000-0000-0000-0000-000000000003")
35
+
36
+ tracer.on_chain_start({"name": "agent"}, {"question": "Where is my order?"}, run_id=root, tags=["prod"])
37
+ tracer.on_llm_start({"name": "gpt"}, ["answer"], run_id=llm, parent_run_id=root)
38
+ tracer.on_llm_end(SimpleNamespace(llm_output={"model": "gpt"}), run_id=llm)
39
+ tracer.on_tool_start({"name": "lookup_order"}, "123", run_id=tool, parent_run_id=root)
40
+ tracer.on_tool_end({"state": "shipped"}, run_id=tool)
41
+ tracer.on_chain_end({"answer": "shipped"}, run_id=root)
42
+
43
+ assert client.runs.started == [{"name": "LangChain run", "application": "support-agent", "tags": ["prod"], "metadata": {"framework": "langchain"}}]
44
+ written = [batch[0] for _, batch in client.runs.spans]
45
+ created = [span for span in written if "endedAt" not in span]
46
+ completed = [span for span in written if "endedAt" in span]
47
+ assert [span["type"] for span in created] == ["workflow_step", "model_call", "tool_call"]
48
+ assert created[1]["parentId"] == "lc_00000000000000000000000000000001"
49
+ assert created[2]["parentId"] == "lc_00000000000000000000000000000001"
50
+ assert completed[0]["type"] == "model_call"
51
+ assert completed[0]["startedAt"] == created[1]["startedAt"]
52
+ assert client.runs.ended == [("run_langchain", {"status": "succeeded", "output": {"answer": "shipped"}})]
53
+
54
+
55
+ def test_tracer_captures_openai_style_token_usage_and_model() -> None:
56
+ client = _Client()
57
+ tracer = AnlyonTracer(client)
58
+ llm = UUID("00000000-0000-0000-0000-000000000002")
59
+
60
+ tracer.on_llm_start({"name": "gpt"}, ["hi"], run_id=llm)
61
+ tracer.on_llm_end(
62
+ SimpleNamespace(
63
+ llm_output={
64
+ "model_name": "gpt-4o",
65
+ "token_usage": {"prompt_tokens": 120, "completion_tokens": 30, "total_tokens": 150},
66
+ }
67
+ ),
68
+ run_id=llm,
69
+ )
70
+
71
+ completed = [span for _, batch in client.runs.spans for span in batch if "endedAt" in span]
72
+ assert completed[0]["tokens"] == {"input": 120, "output": 30, "total": 150}
73
+ assert completed[0]["model"] == "gpt-4o"
74
+
75
+
76
+ def test_tracer_captures_usage_metadata_from_generations() -> None:
77
+ client = _Client()
78
+ tracer = AnlyonTracer(client)
79
+ llm = UUID("00000000-0000-0000-0000-000000000003")
80
+
81
+ tracer.on_llm_start({"name": "claude"}, ["hi"], run_id=llm)
82
+ generation = SimpleNamespace(
83
+ message=SimpleNamespace(usage_metadata={"input_tokens": 200, "output_tokens": 45, "total_tokens": 245})
84
+ )
85
+ tracer.on_llm_end(
86
+ SimpleNamespace(llm_output=None, generations=[[generation]]),
87
+ run_id=llm,
88
+ )
89
+
90
+ completed = [span for _, batch in client.runs.spans for span in batch if "endedAt" in span]
91
+ assert completed[0]["tokens"] == {"input": 200, "output": 45, "total": 245}
92
+
93
+
94
+ def test_tracer_omits_tokens_when_provider_reports_none() -> None:
95
+ client = _Client()
96
+ tracer = AnlyonTracer(client)
97
+ llm = UUID("00000000-0000-0000-0000-000000000004")
98
+
99
+ tracer.on_llm_start({"name": "gpt"}, ["hi"], run_id=llm)
100
+ tracer.on_llm_end(SimpleNamespace(llm_output={"model": "gpt"}), run_id=llm)
101
+
102
+ completed = [span for _, batch in client.runs.spans for span in batch if "endedAt" in span]
103
+ assert "tokens" not in completed[0]
104
+ assert completed[0]["model"] == "gpt"
105
+
106
+
107
+ def test_tracer_swallows_observability_failures() -> None:
108
+ class FailingRuns(_Runs):
109
+ def start(self, **kwargs):
110
+ raise RuntimeError("network unavailable")
111
+
112
+ client = _Client()
113
+ client.runs = FailingRuns()
114
+ tracer = AnlyonTracer(client)
115
+ tracer.on_chain_start({"name": "agent"}, {}, run_id=UUID("00000000-0000-0000-0000-000000000001"))