copass-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.
- copass_langchain-0.1.0/.gitignore +38 -0
- copass_langchain-0.1.0/PKG-INFO +100 -0
- copass_langchain-0.1.0/README.md +70 -0
- copass_langchain-0.1.0/pyproject.toml +59 -0
- copass_langchain-0.1.0/src/copass_langchain/__init__.py +47 -0
- copass_langchain-0.1.0/src/copass_langchain/agent.py +110 -0
- copass_langchain-0.1.0/src/copass_langchain/callback.py +158 -0
- copass_langchain-0.1.0/src/copass_langchain/tools.py +162 -0
- copass_langchain-0.1.0/src/copass_langchain/types.py +32 -0
- copass_langchain-0.1.0/tests/test_callback.py +150 -0
- copass_langchain-0.1.0/tests/test_tools.py +155 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Build output
|
|
5
|
+
dist/
|
|
6
|
+
*.tsbuildinfo
|
|
7
|
+
|
|
8
|
+
# Environment
|
|
9
|
+
.env
|
|
10
|
+
.env.*
|
|
11
|
+
|
|
12
|
+
# IDE
|
|
13
|
+
.vscode/
|
|
14
|
+
.idea/
|
|
15
|
+
*.swp
|
|
16
|
+
*.swo
|
|
17
|
+
*~
|
|
18
|
+
|
|
19
|
+
# OS
|
|
20
|
+
.DS_Store
|
|
21
|
+
Thumbs.db
|
|
22
|
+
|
|
23
|
+
# Test
|
|
24
|
+
coverage/
|
|
25
|
+
|
|
26
|
+
# Lerna
|
|
27
|
+
lerna-debug.log
|
|
28
|
+
.nx/cache
|
|
29
|
+
.nx/workspace-data
|
|
30
|
+
|
|
31
|
+
# Python
|
|
32
|
+
__pycache__/
|
|
33
|
+
*.pyc
|
|
34
|
+
*.pyo
|
|
35
|
+
*.egg-info/
|
|
36
|
+
.venv/
|
|
37
|
+
venv/
|
|
38
|
+
.olane
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: copass-langchain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tool adapters for Copass — drop-in discover/interpret/search tools for LangChain agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/olane-labs/copass-harness
|
|
6
|
+
Project-URL: Repository, https://github.com/olane-labs/copass-harness.git
|
|
7
|
+
Author: Olane Inc.
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agent,copass,knowledge-graph,langchain,langgraph,rag,tools
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: copass-config>=0.1.0
|
|
17
|
+
Requires-Dist: copass-core>=0.1.0
|
|
18
|
+
Requires-Dist: langchain-core>=0.3
|
|
19
|
+
Requires-Dist: pydantic>=2.7
|
|
20
|
+
Provides-Extra: agent
|
|
21
|
+
Requires-Dist: langgraph>=0.2; extra == 'agent'
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: langgraph>=0.2; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# copass-langchain
|
|
32
|
+
|
|
33
|
+
LangChain tool adapters for Copass. Python mirror of [`@copass/langchain`](../../typescript/packages/langchain). Pulls `discover` / `interpret` / `search` into any LangChain agent, and (optionally) wires them into LangGraph's ReAct prebuilt.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install copass-langchain
|
|
39
|
+
# or, with the create_copass_agent convenience wrapper:
|
|
40
|
+
pip install copass-langchain[agent]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Depends on `copass-core` + `copass-config` + `langchain-core`. `langgraph` is optional — only needed for `create_copass_agent`.
|
|
44
|
+
|
|
45
|
+
## Drop-in tools
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from copass_core import ApiKeyAuth, CopassClient
|
|
49
|
+
from copass_langchain import copass_tools
|
|
50
|
+
|
|
51
|
+
client = CopassClient(auth=ApiKeyAuth(key="olk_..."))
|
|
52
|
+
|
|
53
|
+
tools = copass_tools(client=client, sandbox_id="sb_...")
|
|
54
|
+
# Pass `tools.all()` (a list of three StructuredTool instances) to your
|
|
55
|
+
# agent framework, or pull them individually:
|
|
56
|
+
# tools.discover, tools.interpret, tools.search
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Tool descriptions and parameter descriptions come from `copass-config` — identical to the strings used across every other Copass adapter.
|
|
60
|
+
|
|
61
|
+
## Window-aware retrieval (when a Context Window exists)
|
|
62
|
+
|
|
63
|
+
`copass-core` v0.1 does not yet ship a `ContextWindow` class (deferred to v0.2). Until then, `copass_tools` and `CopassWindowCallback` accept any object satisfying `ContextWindowLike`:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
class ContextWindowLike(Protocol):
|
|
67
|
+
def get_turns(self) -> list[ChatMessage]: ...
|
|
68
|
+
def add_turn(self, turn: ChatMessage) -> Awaitable[None]: ...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
When `copass-core` ships `ContextWindow`, it will satisfy this protocol and window-aware retrieval will light up without any consumer changes.
|
|
72
|
+
|
|
73
|
+
## Full agent in one call
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from copass_langchain import create_copass_agent
|
|
77
|
+
from langchain_anthropic import ChatAnthropic
|
|
78
|
+
|
|
79
|
+
agent = create_copass_agent(
|
|
80
|
+
client=client,
|
|
81
|
+
sandbox_id="sb_...",
|
|
82
|
+
llm=ChatAnthropic(model="claude-opus-4-7"),
|
|
83
|
+
# window=my_window, # optional — enables window-aware retrieval + auto-mirroring
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
result = await agent.ainvoke({
|
|
87
|
+
"messages": [("user", "why is checkout flaky?")]
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Status
|
|
92
|
+
|
|
93
|
+
- `copass_tools` — shipped.
|
|
94
|
+
- `CopassWindowCallback` — shipped (generic on `ContextWindowLike`).
|
|
95
|
+
- `create_copass_agent` — shipped (lazy-imports `langgraph`).
|
|
96
|
+
- First-class `ContextWindow` integration — lands when `copass-core` v0.2 ships the primitive.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# copass-langchain
|
|
2
|
+
|
|
3
|
+
LangChain tool adapters for Copass. Python mirror of [`@copass/langchain`](../../typescript/packages/langchain). Pulls `discover` / `interpret` / `search` into any LangChain agent, and (optionally) wires them into LangGraph's ReAct prebuilt.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install copass-langchain
|
|
9
|
+
# or, with the create_copass_agent convenience wrapper:
|
|
10
|
+
pip install copass-langchain[agent]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Depends on `copass-core` + `copass-config` + `langchain-core`. `langgraph` is optional — only needed for `create_copass_agent`.
|
|
14
|
+
|
|
15
|
+
## Drop-in tools
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from copass_core import ApiKeyAuth, CopassClient
|
|
19
|
+
from copass_langchain import copass_tools
|
|
20
|
+
|
|
21
|
+
client = CopassClient(auth=ApiKeyAuth(key="olk_..."))
|
|
22
|
+
|
|
23
|
+
tools = copass_tools(client=client, sandbox_id="sb_...")
|
|
24
|
+
# Pass `tools.all()` (a list of three StructuredTool instances) to your
|
|
25
|
+
# agent framework, or pull them individually:
|
|
26
|
+
# tools.discover, tools.interpret, tools.search
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Tool descriptions and parameter descriptions come from `copass-config` — identical to the strings used across every other Copass adapter.
|
|
30
|
+
|
|
31
|
+
## Window-aware retrieval (when a Context Window exists)
|
|
32
|
+
|
|
33
|
+
`copass-core` v0.1 does not yet ship a `ContextWindow` class (deferred to v0.2). Until then, `copass_tools` and `CopassWindowCallback` accept any object satisfying `ContextWindowLike`:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
class ContextWindowLike(Protocol):
|
|
37
|
+
def get_turns(self) -> list[ChatMessage]: ...
|
|
38
|
+
def add_turn(self, turn: ChatMessage) -> Awaitable[None]: ...
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
When `copass-core` ships `ContextWindow`, it will satisfy this protocol and window-aware retrieval will light up without any consumer changes.
|
|
42
|
+
|
|
43
|
+
## Full agent in one call
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from copass_langchain import create_copass_agent
|
|
47
|
+
from langchain_anthropic import ChatAnthropic
|
|
48
|
+
|
|
49
|
+
agent = create_copass_agent(
|
|
50
|
+
client=client,
|
|
51
|
+
sandbox_id="sb_...",
|
|
52
|
+
llm=ChatAnthropic(model="claude-opus-4-7"),
|
|
53
|
+
# window=my_window, # optional — enables window-aware retrieval + auto-mirroring
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
result = await agent.ainvoke({
|
|
57
|
+
"messages": [("user", "why is checkout flaky?")]
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Status
|
|
62
|
+
|
|
63
|
+
- `copass_tools` — shipped.
|
|
64
|
+
- `CopassWindowCallback` — shipped (generic on `ContextWindowLike`).
|
|
65
|
+
- `create_copass_agent` — shipped (lazy-imports `langgraph`).
|
|
66
|
+
- First-class `ContextWindow` integration — lands when `copass-core` v0.2 ships the primitive.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "copass-langchain"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain tool adapters for Copass — drop-in discover/interpret/search tools for LangChain agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Olane Inc." }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = ["copass", "knowledge-graph", "langchain", "langgraph", "agent", "tools", "rag"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"copass-core>=0.1.0",
|
|
23
|
+
"copass-config>=0.1.0",
|
|
24
|
+
"langchain-core>=0.3",
|
|
25
|
+
"pydantic>=2.7",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
agent = [
|
|
30
|
+
"langgraph>=0.2",
|
|
31
|
+
]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0",
|
|
34
|
+
"pytest-asyncio>=0.23",
|
|
35
|
+
"respx>=0.21",
|
|
36
|
+
"mypy>=1.10",
|
|
37
|
+
"ruff>=0.5",
|
|
38
|
+
"langgraph>=0.2",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/olane-labs/copass-harness"
|
|
43
|
+
Repository = "https://github.com/olane-labs/copass-harness.git"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/copass_langchain"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py310"
|
|
55
|
+
|
|
56
|
+
[tool.mypy]
|
|
57
|
+
python_version = "3.10"
|
|
58
|
+
strict = true
|
|
59
|
+
packages = ["copass_langchain"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""LangChain adapters for Copass.
|
|
2
|
+
|
|
3
|
+
Python mirror of ``@copass/langchain``. Three entry points:
|
|
4
|
+
|
|
5
|
+
- :func:`copass_tools` — bundle of LangChain ``StructuredTool``
|
|
6
|
+
instances for the three Copass retrieval endpoints
|
|
7
|
+
(``discover`` / ``interpret`` / ``search``).
|
|
8
|
+
- :class:`CopassWindowCallback` — a
|
|
9
|
+
``langchain_core.callbacks.BaseCallbackHandler`` that auto-mirrors
|
|
10
|
+
a chat model's conversation into a Copass Context Window.
|
|
11
|
+
- :func:`create_copass_agent` — convenience factory wiring tools +
|
|
12
|
+
callback into ``langgraph.prebuilt.create_react_agent``. Requires
|
|
13
|
+
the ``[agent]`` extra.
|
|
14
|
+
|
|
15
|
+
The ``ContextWindow`` primitive is deferred in ``copass-core`` v0.1 —
|
|
16
|
+
until then, :class:`CopassWindowCallback` and :func:`create_copass_agent`
|
|
17
|
+
accept any object satisfying :class:`ContextWindowLike` (``get_turns()``
|
|
18
|
+
+ async ``add_turn()``). When ``copass-core`` v0.2 lands
|
|
19
|
+
``ContextWindow``, it will satisfy the protocol without changes here.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from copass_langchain.callback import CopassWindowCallback
|
|
23
|
+
from copass_langchain.tools import CopassTools, copass_tools
|
|
24
|
+
from copass_langchain.types import ContextWindowLike
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __getattr__(name: str) -> object:
|
|
30
|
+
"""Lazy-import :func:`create_copass_agent` so importing the
|
|
31
|
+
package does not pull ``langgraph`` unless the caller actually
|
|
32
|
+
uses the helper."""
|
|
33
|
+
if name == "create_copass_agent":
|
|
34
|
+
from copass_langchain.agent import create_copass_agent
|
|
35
|
+
|
|
36
|
+
return create_copass_agent
|
|
37
|
+
raise AttributeError(f"module 'copass_langchain' has no attribute {name!r}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"__version__",
|
|
42
|
+
"copass_tools",
|
|
43
|
+
"CopassTools",
|
|
44
|
+
"CopassWindowCallback",
|
|
45
|
+
"ContextWindowLike",
|
|
46
|
+
"create_copass_agent",
|
|
47
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Convenience factory for a Copass-wired LangGraph ReAct agent.
|
|
2
|
+
|
|
3
|
+
Python mirror of ``typescript/packages/langchain/src/agent.ts`` — wires
|
|
4
|
+
:func:`copass_tools` + :class:`CopassWindowCallback` (when a window is
|
|
5
|
+
supplied) into ``langgraph.prebuilt.create_react_agent``. Returns the
|
|
6
|
+
agent with the callback pre-bound via ``agent.with_config(...)``.
|
|
7
|
+
|
|
8
|
+
``langgraph`` is an **optional** dependency — install with
|
|
9
|
+
``pip install copass-langchain[agent]`` to use :func:`create_copass_agent`.
|
|
10
|
+
The core :func:`copass_tools` + :class:`CopassWindowCallback` exports
|
|
11
|
+
work without it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, List, Optional
|
|
17
|
+
|
|
18
|
+
from copass_core import CopassClient, SearchPreset
|
|
19
|
+
|
|
20
|
+
from copass_langchain.callback import CopassWindowCallback
|
|
21
|
+
from copass_langchain.tools import copass_tools
|
|
22
|
+
from copass_langchain.types import ContextWindowLike
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_copass_agent(
|
|
26
|
+
*,
|
|
27
|
+
client: CopassClient,
|
|
28
|
+
sandbox_id: str,
|
|
29
|
+
llm: Any,
|
|
30
|
+
window: Optional[ContextWindowLike] = None,
|
|
31
|
+
tools: Optional[List[Any]] = None,
|
|
32
|
+
project_id: Optional[str] = None,
|
|
33
|
+
preset: SearchPreset = "auto",
|
|
34
|
+
include_tool_messages: bool = False,
|
|
35
|
+
**react_agent_options: Any,
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Build a LangGraph ReAct agent pre-wired with Copass retrieval.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
client: Authenticated :class:`CopassClient`.
|
|
41
|
+
sandbox_id: Sandbox every retrieval call runs against.
|
|
42
|
+
llm: A LangChain chat model instance (``ChatAnthropic(...)``,
|
|
43
|
+
``ChatOpenAI(...)``, etc.).
|
|
44
|
+
window: Optional :class:`ContextWindowLike`. When supplied, a
|
|
45
|
+
:class:`CopassWindowCallback` is bound so conversation
|
|
46
|
+
history auto-mirrors into the window and retrieval
|
|
47
|
+
becomes window-aware.
|
|
48
|
+
tools: Additional tools to mix in alongside the three Copass
|
|
49
|
+
retrieval tools.
|
|
50
|
+
project_id: Optional project narrowing for retrieval.
|
|
51
|
+
preset: Preset for ``interpret`` / ``search``.
|
|
52
|
+
include_tool_messages: Whether :class:`CopassWindowCallback`
|
|
53
|
+
mirrors tool-result messages. Default ``False``.
|
|
54
|
+
**react_agent_options: Forwarded to
|
|
55
|
+
``langgraph.prebuilt.create_react_agent`` (e.g.,
|
|
56
|
+
``checkpointer``, ``state_schema``, ``prompt``).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A LangChain ``Runnable`` — call ``.ainvoke({"messages": [...]})``.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ImportError: If ``langgraph`` isn't installed. Install with
|
|
63
|
+
``pip install copass-langchain[agent]``.
|
|
64
|
+
|
|
65
|
+
Example::
|
|
66
|
+
|
|
67
|
+
from copass_core import ApiKeyAuth, CopassClient
|
|
68
|
+
from copass_langchain import create_copass_agent
|
|
69
|
+
from langchain_anthropic import ChatAnthropic
|
|
70
|
+
|
|
71
|
+
client = CopassClient(auth=ApiKeyAuth(key="olk_..."))
|
|
72
|
+
agent = create_copass_agent(
|
|
73
|
+
client=client,
|
|
74
|
+
sandbox_id="sb_...",
|
|
75
|
+
llm=ChatAnthropic(model="claude-opus-4-7"),
|
|
76
|
+
)
|
|
77
|
+
result = await agent.ainvoke(
|
|
78
|
+
{"messages": [("user", "why is checkout flaky?")]},
|
|
79
|
+
)
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
from langgraph.prebuilt import create_react_agent
|
|
83
|
+
except ImportError as e:
|
|
84
|
+
raise ImportError(
|
|
85
|
+
"create_copass_agent requires `langgraph`. Install with "
|
|
86
|
+
"`pip install copass-langchain[agent]`."
|
|
87
|
+
) from e
|
|
88
|
+
|
|
89
|
+
copass = copass_tools(
|
|
90
|
+
client=client,
|
|
91
|
+
sandbox_id=sandbox_id,
|
|
92
|
+
project_id=project_id,
|
|
93
|
+
window=window,
|
|
94
|
+
preset=preset,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
all_tools: List[Any] = copass.all() + list(tools or [])
|
|
98
|
+
agent = create_react_agent(model=llm, tools=all_tools, **react_agent_options)
|
|
99
|
+
|
|
100
|
+
if window is not None:
|
|
101
|
+
callback = CopassWindowCallback(
|
|
102
|
+
window=window,
|
|
103
|
+
include_tool_messages=include_tool_messages,
|
|
104
|
+
)
|
|
105
|
+
return agent.with_config({"callbacks": [callback]})
|
|
106
|
+
|
|
107
|
+
return agent
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["create_copass_agent"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""LangChain callback that mirrors chat-model messages into a Copass
|
|
2
|
+
Context Window.
|
|
3
|
+
|
|
4
|
+
Python mirror of
|
|
5
|
+
``typescript/packages/langchain/src/callback.ts``. Hooks
|
|
6
|
+
``on_chat_model_start`` (fired by every chat-model invocation with
|
|
7
|
+
the full message history) and walks messages, calling ``add_turn`` on
|
|
8
|
+
the window for any message we haven't seen before. Retrieval tools
|
|
9
|
+
invoked inside the same agent step then see a window that reflects
|
|
10
|
+
the actual conversation, not an empty buffer.
|
|
11
|
+
|
|
12
|
+
Works against any object satisfying :class:`ContextWindowLike` — the
|
|
13
|
+
v0.2 ``copass-core.ContextWindow`` will slot in without changes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from typing import Any, Dict, List, Optional, Set
|
|
20
|
+
from uuid import UUID
|
|
21
|
+
|
|
22
|
+
from copass_core import ChatMessage, ChatRole
|
|
23
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
24
|
+
from langchain_core.messages import (
|
|
25
|
+
AIMessage,
|
|
26
|
+
BaseMessage,
|
|
27
|
+
HumanMessage,
|
|
28
|
+
SystemMessage,
|
|
29
|
+
ToolMessage,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from copass_langchain.types import ContextWindowLike
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CopassWindowCallback(BaseCallbackHandler):
|
|
36
|
+
"""Auto-mirror a chat model's conversation into a Copass
|
|
37
|
+
:class:`ContextWindowLike`.
|
|
38
|
+
|
|
39
|
+
Usage::
|
|
40
|
+
|
|
41
|
+
from copass_langchain import CopassWindowCallback
|
|
42
|
+
agent = create_react_agent(llm=..., tools=...)
|
|
43
|
+
await agent.ainvoke(
|
|
44
|
+
{"messages": [...]},
|
|
45
|
+
config={"callbacks": [CopassWindowCallback(window=window)]},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
``include_tool_messages`` defaults to ``False``. Tool results tend
|
|
49
|
+
to be noisy; enable only if your agent's tool outputs carry
|
|
50
|
+
conceptual content you want retrieval to dedupe against.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
name = "copass-window"
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
window: ContextWindowLike,
|
|
59
|
+
include_tool_messages: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
self._window = window
|
|
63
|
+
self._include_tool_messages = include_tool_messages
|
|
64
|
+
self._seen: Set[str] = {
|
|
65
|
+
_hash_turn(turn) for turn in window.get_turns()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async def on_chat_model_start(
|
|
69
|
+
self,
|
|
70
|
+
serialized: Dict[str, Any],
|
|
71
|
+
messages: List[List[BaseMessage]],
|
|
72
|
+
*,
|
|
73
|
+
run_id: UUID,
|
|
74
|
+
parent_run_id: Optional[UUID] = None,
|
|
75
|
+
tags: Optional[List[str]] = None,
|
|
76
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Fires before every chat model call. ``messages`` is
|
|
80
|
+
``List[List[BaseMessage]]`` for batched-call support; in
|
|
81
|
+
single-agent loops it's always one conversation."""
|
|
82
|
+
flat: List[BaseMessage] = []
|
|
83
|
+
for conversation in messages:
|
|
84
|
+
flat.extend(conversation)
|
|
85
|
+
|
|
86
|
+
for msg in flat:
|
|
87
|
+
turn = _to_turn(msg, self._include_tool_messages)
|
|
88
|
+
if turn is None:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
key = _hash_turn(turn)
|
|
92
|
+
if key in self._seen:
|
|
93
|
+
continue
|
|
94
|
+
self._seen.add(key)
|
|
95
|
+
|
|
96
|
+
# Fire-and-forget. We don't block the model call on
|
|
97
|
+
# ingestion latency; missing a turn is recoverable
|
|
98
|
+
# (retrieval still sees already-added turns).
|
|
99
|
+
asyncio.create_task(_safe_add_turn(self._window, turn))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _safe_add_turn(window: ContextWindowLike, turn: ChatMessage) -> None:
|
|
103
|
+
try:
|
|
104
|
+
await window.add_turn(turn)
|
|
105
|
+
except Exception: # noqa: BLE001 — swallowed intentionally.
|
|
106
|
+
# Ingestion is best-effort. Future releases may surface via
|
|
107
|
+
# an optional ``on_error`` callback.
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _to_turn(msg: BaseMessage, include_tool_messages: bool) -> Optional[ChatMessage]:
|
|
112
|
+
role = _role_from_message(msg, include_tool_messages)
|
|
113
|
+
if role is None:
|
|
114
|
+
return None
|
|
115
|
+
content = _content_to_string(msg.content)
|
|
116
|
+
if not content.strip():
|
|
117
|
+
return None
|
|
118
|
+
return ChatMessage(role=role, content=content)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _role_from_message(msg: BaseMessage, include_tool_messages: bool) -> Optional[ChatRole]:
|
|
122
|
+
if isinstance(msg, HumanMessage):
|
|
123
|
+
return "user"
|
|
124
|
+
if isinstance(msg, AIMessage):
|
|
125
|
+
return "assistant"
|
|
126
|
+
if isinstance(msg, SystemMessage):
|
|
127
|
+
return "system"
|
|
128
|
+
if isinstance(msg, ToolMessage):
|
|
129
|
+
return "system" if include_tool_messages else None
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _content_to_string(content: Any) -> str:
|
|
134
|
+
if isinstance(content, str):
|
|
135
|
+
return content
|
|
136
|
+
if isinstance(content, list):
|
|
137
|
+
parts: List[str] = []
|
|
138
|
+
for part in content:
|
|
139
|
+
if isinstance(part, str):
|
|
140
|
+
parts.append(part)
|
|
141
|
+
elif isinstance(part, dict):
|
|
142
|
+
text = part.get("text")
|
|
143
|
+
if text:
|
|
144
|
+
parts.append(str(text))
|
|
145
|
+
return "\n".join(p for p in parts if p)
|
|
146
|
+
if content is None:
|
|
147
|
+
return ""
|
|
148
|
+
return str(content)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _hash_turn(turn: ChatMessage) -> str:
|
|
152
|
+
# Stable-ish hash: role + first 500 chars of content. Same policy
|
|
153
|
+
# as the TS sibling — collisions across different long messages
|
|
154
|
+
# starting identically are accepted as benign.
|
|
155
|
+
return f"{turn.role}:{turn.content[:500]}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = ["CopassWindowCallback"]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""LangChain tool adapters for Copass retrieval.
|
|
2
|
+
|
|
3
|
+
Python mirror of ``typescript/packages/langchain/src/tools.ts`` — uses
|
|
4
|
+
``StructuredTool.from_function`` (the LangChain-python equivalent of
|
|
5
|
+
the TS ``tool()`` factory) so tool names + descriptions come
|
|
6
|
+
programmatically from ``copass-config``.
|
|
7
|
+
|
|
8
|
+
The three returned tools — ``discover``, ``interpret``, ``search`` —
|
|
9
|
+
are window-aware when a ``window`` is supplied: the server will
|
|
10
|
+
exclude items already surfaced earlier in the conversation. Keeps
|
|
11
|
+
repeated ``discover`` calls cheap and productive.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from copass_config import (
|
|
20
|
+
DISCOVER_DESCRIPTION,
|
|
21
|
+
DISCOVER_QUERY_PARAM,
|
|
22
|
+
INTERPRET_DESCRIPTION,
|
|
23
|
+
INTERPRET_ITEMS_PARAM,
|
|
24
|
+
INTERPRET_QUERY_PARAM,
|
|
25
|
+
SEARCH_DESCRIPTION,
|
|
26
|
+
SEARCH_QUERY_PARAM,
|
|
27
|
+
)
|
|
28
|
+
from copass_core import CopassClient, SearchPreset
|
|
29
|
+
from langchain_core.tools import StructuredTool
|
|
30
|
+
from pydantic import BaseModel, Field
|
|
31
|
+
|
|
32
|
+
from copass_langchain.types import ContextWindowLike
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CopassTools:
|
|
37
|
+
"""Bundle of the three Copass retrieval tools as
|
|
38
|
+
``StructuredTool`` instances. Pass ``tools.discover``,
|
|
39
|
+
``tools.interpret``, ``tools.search`` (or ``tools.all()``) to the
|
|
40
|
+
agent framework."""
|
|
41
|
+
|
|
42
|
+
discover: StructuredTool
|
|
43
|
+
interpret: StructuredTool
|
|
44
|
+
search: StructuredTool
|
|
45
|
+
|
|
46
|
+
def all(self) -> List[StructuredTool]:
|
|
47
|
+
return [self.discover, self.interpret, self.search]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _DiscoverArgs(BaseModel):
|
|
51
|
+
query: str = Field(..., description=DISCOVER_QUERY_PARAM)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _InterpretArgs(BaseModel):
|
|
55
|
+
query: str = Field(..., description=INTERPRET_QUERY_PARAM)
|
|
56
|
+
items: List[List[str]] = Field(
|
|
57
|
+
...,
|
|
58
|
+
min_length=1,
|
|
59
|
+
description=INTERPRET_ITEMS_PARAM,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _SearchArgs(BaseModel):
|
|
64
|
+
query: str = Field(..., description=SEARCH_QUERY_PARAM)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def copass_tools(
|
|
68
|
+
*,
|
|
69
|
+
client: CopassClient,
|
|
70
|
+
sandbox_id: str,
|
|
71
|
+
project_id: Optional[str] = None,
|
|
72
|
+
window: Optional[ContextWindowLike] = None,
|
|
73
|
+
preset: SearchPreset = "auto",
|
|
74
|
+
) -> CopassTools:
|
|
75
|
+
"""Return a :class:`CopassTools` bundle wired to ``client``.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
client: Authenticated :class:`CopassClient`.
|
|
79
|
+
sandbox_id: Sandbox every retrieval call runs against.
|
|
80
|
+
project_id: Optional project narrowing.
|
|
81
|
+
window: Optional :class:`ContextWindowLike` — when provided,
|
|
82
|
+
every retrieval call is window-aware.
|
|
83
|
+
preset: Retrieval preset for ``interpret`` / ``search``.
|
|
84
|
+
Ignored by ``discover``. Defaults to ``"auto"`` —
|
|
85
|
+
``interpret`` requires ``auto`` in the current server
|
|
86
|
+
release (``fast`` silently returns
|
|
87
|
+
"No supporting context could be retrieved").
|
|
88
|
+
|
|
89
|
+
Example::
|
|
90
|
+
|
|
91
|
+
from copass_core import CopassClient, ApiKeyAuth
|
|
92
|
+
from copass_langchain import copass_tools
|
|
93
|
+
|
|
94
|
+
client = CopassClient(auth=ApiKeyAuth(key="olk_..."))
|
|
95
|
+
tools = copass_tools(client=client, sandbox_id="sb_...")
|
|
96
|
+
# Hand `tools.all()` to LangGraph's create_react_agent.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
async def _discover(query: str) -> Dict[str, Any]:
|
|
100
|
+
response = await client.retrieval.discover(
|
|
101
|
+
sandbox_id=sandbox_id,
|
|
102
|
+
query=query,
|
|
103
|
+
project_id=project_id,
|
|
104
|
+
window=window,
|
|
105
|
+
)
|
|
106
|
+
return {
|
|
107
|
+
"header": response.get("header", ""),
|
|
108
|
+
"items": [
|
|
109
|
+
{
|
|
110
|
+
"score": item.get("score"),
|
|
111
|
+
"summary": item.get("summary"),
|
|
112
|
+
"canonical_ids": item.get("canonical_ids", []),
|
|
113
|
+
}
|
|
114
|
+
for item in response.get("items", [])
|
|
115
|
+
],
|
|
116
|
+
"next_steps": response.get("next_steps", ""),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async def _interpret(query: str, items: List[List[str]]) -> Dict[str, Any]:
|
|
120
|
+
response = await client.retrieval.interpret(
|
|
121
|
+
sandbox_id=sandbox_id,
|
|
122
|
+
query=query,
|
|
123
|
+
items=items,
|
|
124
|
+
project_id=project_id,
|
|
125
|
+
window=window,
|
|
126
|
+
preset=preset,
|
|
127
|
+
)
|
|
128
|
+
return {"brief": response.get("brief", "")}
|
|
129
|
+
|
|
130
|
+
async def _search(query: str) -> Dict[str, Any]:
|
|
131
|
+
response = await client.retrieval.search(
|
|
132
|
+
sandbox_id=sandbox_id,
|
|
133
|
+
query=query,
|
|
134
|
+
project_id=project_id,
|
|
135
|
+
window=window,
|
|
136
|
+
preset=preset,
|
|
137
|
+
)
|
|
138
|
+
return {"answer": response.get("answer", "")}
|
|
139
|
+
|
|
140
|
+
return CopassTools(
|
|
141
|
+
discover=StructuredTool.from_function(
|
|
142
|
+
coroutine=_discover,
|
|
143
|
+
name="discover",
|
|
144
|
+
description=DISCOVER_DESCRIPTION,
|
|
145
|
+
args_schema=_DiscoverArgs,
|
|
146
|
+
),
|
|
147
|
+
interpret=StructuredTool.from_function(
|
|
148
|
+
coroutine=_interpret,
|
|
149
|
+
name="interpret",
|
|
150
|
+
description=INTERPRET_DESCRIPTION,
|
|
151
|
+
args_schema=_InterpretArgs,
|
|
152
|
+
),
|
|
153
|
+
search=StructuredTool.from_function(
|
|
154
|
+
coroutine=_search,
|
|
155
|
+
name="search",
|
|
156
|
+
description=SEARCH_DESCRIPTION,
|
|
157
|
+
args_schema=_SearchArgs,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = ["CopassTools", "copass_tools"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Structural types local to ``copass-langchain``.
|
|
2
|
+
|
|
3
|
+
Kept here (rather than in ``copass-core``) because they shim a
|
|
4
|
+
behavior ``copass-core`` v0.1.0 hasn't shipped yet —
|
|
5
|
+
``ContextWindow.add_turn(...)``. Once ``copass-core`` v0.2 ports the
|
|
6
|
+
``ContextWindow`` primitive from TS, the real class will satisfy this
|
|
7
|
+
protocol and this shim disappears.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Awaitable, List, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from copass_core import ChatMessage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class ContextWindowLike(Protocol):
|
|
19
|
+
"""Minimum surface ``CopassWindowCallback`` + ``copass_tools``
|
|
20
|
+
need from a "window" object.
|
|
21
|
+
|
|
22
|
+
A future ``copass-core.ContextWindow`` will satisfy this directly.
|
|
23
|
+
Callers holding any other turn-log structure can supply an adapter
|
|
24
|
+
that exposes these two methods.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def get_turns(self) -> List[ChatMessage]: ...
|
|
28
|
+
|
|
29
|
+
def add_turn(self, turn: ChatMessage) -> Awaitable[None]: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["ContextWindowLike"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""CopassWindowCallback — message → turn translation + dedup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from copass_core import ChatMessage
|
|
10
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from copass_langchain import CopassWindowCallback
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Window:
|
|
17
|
+
"""Minimal ContextWindowLike stub — captures add_turn calls."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, initial: List[ChatMessage] = None) -> None:
|
|
20
|
+
self._turns = list(initial or [])
|
|
21
|
+
self.added: List[ChatMessage] = []
|
|
22
|
+
|
|
23
|
+
def get_turns(self) -> List[ChatMessage]:
|
|
24
|
+
return list(self._turns)
|
|
25
|
+
|
|
26
|
+
async def add_turn(self, turn: ChatMessage) -> None:
|
|
27
|
+
self.added.append(turn)
|
|
28
|
+
self._turns.append(turn)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _trigger(cb: CopassWindowCallback, messages: List[List]) -> None:
|
|
32
|
+
await cb.on_chat_model_start(
|
|
33
|
+
serialized={},
|
|
34
|
+
messages=messages,
|
|
35
|
+
run_id=uuid4(),
|
|
36
|
+
)
|
|
37
|
+
# Callback schedules fire-and-forget tasks; yield so they run.
|
|
38
|
+
await asyncio.sleep(0)
|
|
39
|
+
await asyncio.sleep(0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_mirrors_human_and_ai_messages() -> None:
|
|
43
|
+
w = _Window()
|
|
44
|
+
cb = CopassWindowCallback(window=w)
|
|
45
|
+
await _trigger(
|
|
46
|
+
cb,
|
|
47
|
+
[
|
|
48
|
+
[
|
|
49
|
+
HumanMessage(content="hello"),
|
|
50
|
+
AIMessage(content="hi there"),
|
|
51
|
+
]
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
roles = [t.role for t in w.added]
|
|
55
|
+
contents = [t.content for t in w.added]
|
|
56
|
+
assert roles == ["user", "assistant"]
|
|
57
|
+
assert contents == ["hello", "hi there"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def test_deduplicates_previously_seen_turns() -> None:
|
|
61
|
+
existing = [ChatMessage(role="user", content="hello")]
|
|
62
|
+
w = _Window(initial=existing)
|
|
63
|
+
cb = CopassWindowCallback(window=w)
|
|
64
|
+
await _trigger(
|
|
65
|
+
cb,
|
|
66
|
+
[
|
|
67
|
+
[
|
|
68
|
+
HumanMessage(content="hello"), # already in window → skip
|
|
69
|
+
AIMessage(content="hi there"), # new
|
|
70
|
+
]
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
assert [t.role for t in w.added] == ["assistant"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_skips_tool_messages_by_default() -> None:
|
|
77
|
+
w = _Window()
|
|
78
|
+
cb = CopassWindowCallback(window=w)
|
|
79
|
+
await _trigger(
|
|
80
|
+
cb,
|
|
81
|
+
[
|
|
82
|
+
[
|
|
83
|
+
HumanMessage(content="query"),
|
|
84
|
+
ToolMessage(content="tool output", tool_call_id="t1"),
|
|
85
|
+
]
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
assert [t.role for t in w.added] == ["user"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_includes_tool_messages_when_opted_in() -> None:
|
|
92
|
+
w = _Window()
|
|
93
|
+
cb = CopassWindowCallback(window=w, include_tool_messages=True)
|
|
94
|
+
await _trigger(
|
|
95
|
+
cb,
|
|
96
|
+
[
|
|
97
|
+
[
|
|
98
|
+
HumanMessage(content="query"),
|
|
99
|
+
ToolMessage(content="tool output", tool_call_id="t1"),
|
|
100
|
+
]
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
assert [t.role for t in w.added] == ["user", "system"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def test_skips_empty_content() -> None:
|
|
107
|
+
w = _Window()
|
|
108
|
+
cb = CopassWindowCallback(window=w)
|
|
109
|
+
await _trigger(
|
|
110
|
+
cb,
|
|
111
|
+
[
|
|
112
|
+
[
|
|
113
|
+
HumanMessage(content=""),
|
|
114
|
+
HumanMessage(content=" "),
|
|
115
|
+
SystemMessage(content="you are helpful"),
|
|
116
|
+
]
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
assert [t.role for t in w.added] == ["system"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def test_content_parts_list_concatenated() -> None:
|
|
123
|
+
w = _Window()
|
|
124
|
+
cb = CopassWindowCallback(window=w)
|
|
125
|
+
multi = HumanMessage(
|
|
126
|
+
content=[
|
|
127
|
+
{"type": "text", "text": "first line"},
|
|
128
|
+
{"type": "text", "text": "second line"},
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
await _trigger(cb, [[multi]])
|
|
132
|
+
assert len(w.added) == 1
|
|
133
|
+
assert "first line" in w.added[0].content
|
|
134
|
+
assert "second line" in w.added[0].content
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def test_swallows_add_turn_errors() -> None:
|
|
138
|
+
class _FailingWindow:
|
|
139
|
+
def get_turns(self) -> List[ChatMessage]:
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
async def add_turn(self, turn: ChatMessage) -> None:
|
|
143
|
+
raise RuntimeError("graph write failed")
|
|
144
|
+
|
|
145
|
+
cb = CopassWindowCallback(window=_FailingWindow())
|
|
146
|
+
# Should NOT raise even though add_turn raises internally.
|
|
147
|
+
await _trigger(
|
|
148
|
+
cb,
|
|
149
|
+
[[HumanMessage(content="hello")]],
|
|
150
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""copass_tools — tool shape, argument schema, live-call wiring via
|
|
2
|
+
mocked ``CopassClient``."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, List
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
import respx
|
|
11
|
+
from copass_config import (
|
|
12
|
+
DISCOVER_DESCRIPTION,
|
|
13
|
+
INTERPRET_DESCRIPTION,
|
|
14
|
+
SEARCH_DESCRIPTION,
|
|
15
|
+
)
|
|
16
|
+
from copass_core import ApiKeyAuth, ChatMessage, CopassClient
|
|
17
|
+
|
|
18
|
+
from copass_langchain import CopassTools, copass_tools
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def client() -> CopassClient:
|
|
23
|
+
return CopassClient(
|
|
24
|
+
auth=ApiKeyAuth(key="olk_test"),
|
|
25
|
+
api_url="http://test",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_returns_copass_tools_bundle(client: CopassClient) -> None:
|
|
30
|
+
tools = copass_tools(client=client, sandbox_id="sb-1")
|
|
31
|
+
assert isinstance(tools, CopassTools)
|
|
32
|
+
assert tools.discover.name == "discover"
|
|
33
|
+
assert tools.interpret.name == "interpret"
|
|
34
|
+
assert tools.search.name == "search"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_descriptions_match_copass_config(client: CopassClient) -> None:
|
|
38
|
+
tools = copass_tools(client=client, sandbox_id="sb-1")
|
|
39
|
+
assert tools.discover.description == DISCOVER_DESCRIPTION
|
|
40
|
+
assert tools.interpret.description == INTERPRET_DESCRIPTION
|
|
41
|
+
assert tools.search.description == SEARCH_DESCRIPTION
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_tools_all_returns_three(client: CopassClient) -> None:
|
|
45
|
+
tools = copass_tools(client=client, sandbox_id="sb-1")
|
|
46
|
+
assert len(tools.all()) == 3
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@respx.mock
|
|
50
|
+
async def test_discover_hits_correct_endpoint(client: CopassClient) -> None:
|
|
51
|
+
route = respx.post("http://test/api/v1/query/sandboxes/sb-1/discover").mock(
|
|
52
|
+
return_value=httpx.Response(
|
|
53
|
+
200,
|
|
54
|
+
json={
|
|
55
|
+
"header": "hits",
|
|
56
|
+
"items": [
|
|
57
|
+
{"score": 0.9, "summary": "summary-1", "canonical_ids": ["a", "b"]},
|
|
58
|
+
],
|
|
59
|
+
"count": 1,
|
|
60
|
+
"sandbox_id": "sb-1",
|
|
61
|
+
"query": "auth",
|
|
62
|
+
"next_steps": "interpret one",
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
tools = copass_tools(client=client, sandbox_id="sb-1")
|
|
67
|
+
result = await tools.discover.ainvoke({"query": "auth"})
|
|
68
|
+
assert route.called
|
|
69
|
+
assert result["header"] == "hits"
|
|
70
|
+
assert len(result["items"]) == 1
|
|
71
|
+
assert result["items"][0]["canonical_ids"] == ["a", "b"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@respx.mock
|
|
75
|
+
async def test_interpret_forwards_items_and_preset(client: CopassClient) -> None:
|
|
76
|
+
route = respx.post("http://test/api/v1/query/sandboxes/sb-1/interpret").mock(
|
|
77
|
+
return_value=httpx.Response(
|
|
78
|
+
200,
|
|
79
|
+
json={
|
|
80
|
+
"brief": "synthesized answer",
|
|
81
|
+
"citations": [],
|
|
82
|
+
"items": [["a"]],
|
|
83
|
+
"sandbox_id": "sb-1",
|
|
84
|
+
"query": "q",
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
tools = copass_tools(client=client, sandbox_id="sb-1", preset="fast")
|
|
89
|
+
result = await tools.interpret.ainvoke(
|
|
90
|
+
{"query": "q", "items": [["a", "b"]]},
|
|
91
|
+
)
|
|
92
|
+
assert result == {"brief": "synthesized answer"}
|
|
93
|
+
import json as _json
|
|
94
|
+
|
|
95
|
+
body = _json.loads(route.calls.last.request.content)
|
|
96
|
+
assert body["items"] == [["a", "b"]]
|
|
97
|
+
assert body["preset"] == "fast"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@respx.mock
|
|
101
|
+
async def test_search_wraps_response(client: CopassClient) -> None:
|
|
102
|
+
respx.post("http://test/api/v1/query/sandboxes/sb-1/search").mock(
|
|
103
|
+
return_value=httpx.Response(
|
|
104
|
+
200,
|
|
105
|
+
json={
|
|
106
|
+
"answer": "Because the /checkout worker was pinned to 1 replica.",
|
|
107
|
+
"preset": "auto",
|
|
108
|
+
"execution_time_ms": 200,
|
|
109
|
+
"sandbox_id": "sb-1",
|
|
110
|
+
"query": "checkout flaky",
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
tools = copass_tools(client=client, sandbox_id="sb-1")
|
|
115
|
+
result = await tools.search.ainvoke({"query": "checkout flaky"})
|
|
116
|
+
assert "checkout worker" in result["answer"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@respx.mock
|
|
120
|
+
async def test_window_turns_sent_as_history(client: CopassClient) -> None:
|
|
121
|
+
class _Window:
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self._turns: List[ChatMessage] = [
|
|
124
|
+
ChatMessage(role="user", content="earlier user message"),
|
|
125
|
+
ChatMessage(role="assistant", content="earlier assistant reply"),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
def get_turns(self) -> List[ChatMessage]:
|
|
129
|
+
return list(self._turns)
|
|
130
|
+
|
|
131
|
+
async def add_turn(self, turn: ChatMessage) -> None: # pragma: no cover
|
|
132
|
+
self._turns.append(turn)
|
|
133
|
+
|
|
134
|
+
route = respx.post("http://test/api/v1/query/sandboxes/sb-1/discover").mock(
|
|
135
|
+
return_value=httpx.Response(
|
|
136
|
+
200,
|
|
137
|
+
json={
|
|
138
|
+
"header": "",
|
|
139
|
+
"items": [],
|
|
140
|
+
"count": 0,
|
|
141
|
+
"sandbox_id": "sb-1",
|
|
142
|
+
"query": "q",
|
|
143
|
+
"next_steps": "",
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
tools = copass_tools(client=client, sandbox_id="sb-1", window=_Window())
|
|
148
|
+
await tools.discover.ainvoke({"query": "q"})
|
|
149
|
+
import json as _json
|
|
150
|
+
|
|
151
|
+
body = _json.loads(route.calls.last.request.content)
|
|
152
|
+
assert body["history"] == [
|
|
153
|
+
{"role": "user", "content": "earlier user message"},
|
|
154
|
+
{"role": "assistant", "content": "earlier assistant reply"},
|
|
155
|
+
]
|