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.
- anlyon_langchain-0.1.0/.gitignore +35 -0
- anlyon_langchain-0.1.0/LICENSE +21 -0
- anlyon_langchain-0.1.0/PKG-INFO +100 -0
- anlyon_langchain-0.1.0/README.md +70 -0
- anlyon_langchain-0.1.0/pyproject.toml +43 -0
- anlyon_langchain-0.1.0/src/anlyon_langchain/__init__.py +12 -0
- anlyon_langchain-0.1.0/src/anlyon_langchain/chat_history.py +149 -0
- anlyon_langchain-0.1.0/src/anlyon_langchain/retriever.py +46 -0
- anlyon_langchain-0.1.0/src/anlyon_langchain/tracing.py +246 -0
- anlyon_langchain-0.1.0/src/anlyon_langchain/vectorstore.py +74 -0
- anlyon_langchain-0.1.0/tests/test_chat_history.py +147 -0
- anlyon_langchain-0.1.0/tests/test_tracing.py +115 -0
|
@@ -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"))
|