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.
@@ -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
+ ]