copass-context-agents 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,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: copass-context-agents
3
+ Version: 0.1.0
4
+ Summary: Copass context-engineering primitives (retrieval tools, ingest tool, Context Window turn recorder) for copass-core-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: agents,context-window,copass,ingest,knowledge-graph,retrieval
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-agents>=0.1.0
18
+ Requires-Dist: copass-core>=0.1.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: mypy>=1.10; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.5; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # copass-context-agents
27
+
28
+ Copass context-engineering primitives for `copass-core-agents`.
29
+
30
+ Three provider-neutral pieces every Copass-aware agent uses:
31
+
32
+ - **`copass_retrieval_tools(...)`** — returns `discover` / `interpret` / `search` as `AgentTool` instances, window-aware when a `ContextWindow` is passed.
33
+ - **`copass_ingest_tool(...)`** — returns `ingest` as an `AgentTool` so agents can promote content into durable sandbox storage.
34
+ - **`CopassTurnRecorder`** — mirrors user / assistant turns into a `ContextWindow` with fire-and-forget pushes and author-prefix provenance.
35
+
36
+ These are the primitives the provider adapter packages — `copass-anthropic-agents`, `copass-google-agents` — compose into their `run()` / `stream()` loops. The descriptions come from `copass_config` so every Copass surface (TS adapters, MCP server, CLI) shows the LLM identical tool semantics.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install copass-context-agents
42
+ ```
43
+
44
+ Usually a transitive dep — `pip install copass-anthropic-agents` / `copass-google-agents` pulls it in.
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from copass_core import CopassClient
50
+ from copass_context_agents import (
51
+ copass_retrieval_tools,
52
+ copass_ingest_tool,
53
+ CopassTurnRecorder,
54
+ )
55
+
56
+ client = CopassClient(...)
57
+ window = await client.context_window.create(sandbox_id=sandbox_id)
58
+
59
+ tools = [
60
+ *copass_retrieval_tools(client=client, sandbox_id=sandbox_id, window=window),
61
+ copass_ingest_tool(
62
+ client=client,
63
+ sandbox_id=sandbox_id,
64
+ data_source_id=my_source_id,
65
+ default_source_type="decision",
66
+ author="agent:support-bot",
67
+ ),
68
+ ]
69
+
70
+ recorder = CopassTurnRecorder(window=window, author="agent:support-bot", include_author_prefix=True)
71
+ # ...wire into your provider's stream loop; see copass-anthropic-agents or
72
+ # copass-google-agents for the full wiring.
73
+ ```
74
+
75
+ See the provider adapter packages for the full `CopassManagedAgent` / `CopassGoogleAgent` integrations that wire these primitives into discover-as-step-1 + auto-record flows.
@@ -0,0 +1,50 @@
1
+ # copass-context-agents
2
+
3
+ Copass context-engineering primitives for `copass-core-agents`.
4
+
5
+ Three provider-neutral pieces every Copass-aware agent uses:
6
+
7
+ - **`copass_retrieval_tools(...)`** — returns `discover` / `interpret` / `search` as `AgentTool` instances, window-aware when a `ContextWindow` is passed.
8
+ - **`copass_ingest_tool(...)`** — returns `ingest` as an `AgentTool` so agents can promote content into durable sandbox storage.
9
+ - **`CopassTurnRecorder`** — mirrors user / assistant turns into a `ContextWindow` with fire-and-forget pushes and author-prefix provenance.
10
+
11
+ These are the primitives the provider adapter packages — `copass-anthropic-agents`, `copass-google-agents` — compose into their `run()` / `stream()` loops. The descriptions come from `copass_config` so every Copass surface (TS adapters, MCP server, CLI) shows the LLM identical tool semantics.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install copass-context-agents
17
+ ```
18
+
19
+ Usually a transitive dep — `pip install copass-anthropic-agents` / `copass-google-agents` pulls it in.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from copass_core import CopassClient
25
+ from copass_context_agents import (
26
+ copass_retrieval_tools,
27
+ copass_ingest_tool,
28
+ CopassTurnRecorder,
29
+ )
30
+
31
+ client = CopassClient(...)
32
+ window = await client.context_window.create(sandbox_id=sandbox_id)
33
+
34
+ tools = [
35
+ *copass_retrieval_tools(client=client, sandbox_id=sandbox_id, window=window),
36
+ copass_ingest_tool(
37
+ client=client,
38
+ sandbox_id=sandbox_id,
39
+ data_source_id=my_source_id,
40
+ default_source_type="decision",
41
+ author="agent:support-bot",
42
+ ),
43
+ ]
44
+
45
+ recorder = CopassTurnRecorder(window=window, author="agent:support-bot", include_author_prefix=True)
46
+ # ...wire into your provider's stream loop; see copass-anthropic-agents or
47
+ # copass-google-agents for the full wiring.
48
+ ```
49
+
50
+ See the provider adapter packages for the full `CopassManagedAgent` / `CopassGoogleAgent` integrations that wire these primitives into discover-as-step-1 + auto-record flows.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "copass-context-agents"
7
+ version = "0.1.0"
8
+ description = "Copass context-engineering primitives (retrieval tools, ingest tool, Context Window turn recorder) for copass-core-agents"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Olane Inc." }]
12
+ requires-python = ">=3.10"
13
+ keywords = [
14
+ "copass",
15
+ "knowledge-graph",
16
+ "agents",
17
+ "context-window",
18
+ "retrieval",
19
+ "ingest",
20
+ ]
21
+ classifiers = [
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ ]
28
+ dependencies = [
29
+ "copass-core>=0.1.0",
30
+ "copass-core-agents>=0.1.0",
31
+ "copass-config>=0.1.0",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ "pytest-asyncio>=0.23",
38
+ "mypy>=1.10",
39
+ "ruff>=0.5",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/olane-labs/copass-harness"
44
+ Repository = "https://github.com/olane-labs/copass-harness.git"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/copass_context_agents"]
48
+
49
+ [tool.pytest.ini_options]
50
+ asyncio_mode = "auto"
51
+ testpaths = ["tests"]
@@ -0,0 +1,28 @@
1
+ """Copass context-engineering primitives for the core agent runtime.
2
+
3
+ Three provider-neutral pieces every Copass-aware agent composes:
4
+
5
+ * :func:`copass_retrieval_tools` — ``discover`` / ``interpret`` /
6
+ ``search`` as :class:`AgentTool` instances.
7
+ * :func:`copass_ingest_tool` — ``ingest`` as an :class:`AgentTool`.
8
+ * :class:`CopassTurnRecorder` — mirror conversation turns into a
9
+ Copass :class:`ContextWindow`.
10
+
11
+ Sits between :mod:`copass_core_agents` (the ABCs / runtime) and the
12
+ per-provider adapter packages
13
+ (:mod:`copass_anthropic_agents`, :mod:`copass_google_agents`) which
14
+ compose these primitives into their ``run()`` / ``stream()`` loops.
15
+ """
16
+
17
+ from copass_context_agents.ingest_tool import copass_ingest_tool
18
+ from copass_context_agents.retrieval_tools import copass_retrieval_tools
19
+ from copass_context_agents.turn_recorder import CopassTurnRecorder
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "copass_retrieval_tools",
26
+ "copass_ingest_tool",
27
+ "CopassTurnRecorder",
28
+ ]
@@ -0,0 +1,181 @@
1
+ """Copass ingestion as a provider-neutral :class:`AgentTool`.
2
+
3
+ Python analogue of the ``ingest`` tool registered by
4
+ ``typescript/packages/mcp/src/tools/ingest.ts``. Same HTTP call
5
+ (``client.sources.ingest(...)``), same semantics — just shaped for
6
+ the core agent runtime's tool catalog.
7
+
8
+ Intent: let an agent **voluntarily commit** content worth preserving
9
+ into durable sandbox storage. Complements
10
+ :class:`CopassTurnRecorder`, which mirrors every turn into the
11
+ ephemeral Context Window. The LLM calls ``ingest`` when it learns
12
+ something that should outlive this conversation — architecture
13
+ decisions, user-shared framing, corrections, durable notes.
14
+
15
+ Not for turn-by-turn capture. Every agent turn is already pushed to
16
+ the Context Window by :class:`CopassTurnRecorder`. Ingest is the
17
+ intentional "promote this to durable knowledge" call. The LLM-facing
18
+ description says so explicitly.
19
+
20
+ Cache-safety: same story as the retrieval tools — :class:`ToolSpec`
21
+ is built from frozen strings + a constant JSON schema, so rebuilding
22
+ on each invocation yields an identical fingerprint and the
23
+ provider's agent cache keeps hitting.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Optional
29
+
30
+ from copass_core import CopassClient
31
+ from copass_core_agents.base_tool import AgentTool, ToolSpec
32
+ from copass_core_agents.invocation_context import AgentInvocationContext
33
+
34
+
35
+ # Canonical copy with `typescript/packages/mcp/src/tools/ingest.ts`. When
36
+ # `INGEST_DESCRIPTION` lands in `copass_config.tool_descriptions` (and
37
+ # `@copass/config`), switch this + the MCP server to an import.
38
+ _INGEST_DESCRIPTION = (
39
+ "Push content into the knowledge graph via a data source. Use for: "
40
+ 'architecture decisions (source_type: "decision"), user-shared context '
41
+ '("user_input"), corrections ("correction"), durable notes, any '
42
+ "significant new concept. Do NOT ingest trivial changes or ephemeral "
43
+ "debug context — those belong in the Context Window (every agent turn "
44
+ "is already mirrored there automatically). Call this only when the "
45
+ "content is worth preserving beyond the current conversation."
46
+ )
47
+
48
+ _CONTENT_PARAM = "The content to ingest. Non-empty."
49
+ _SOURCE_TYPE_PARAM = (
50
+ "Type tag: code, markdown, json, text, conversation, decision, correction, "
51
+ "user_input. Defaults to the tool's configured default_source_type when omitted."
52
+ )
53
+ _STORAGE_ONLY_PARAM = "If true, chunk and store but skip ontology ingestion."
54
+
55
+
56
+ class _CopassIngestTool(AgentTool):
57
+ def __init__(
58
+ self,
59
+ *,
60
+ client: CopassClient,
61
+ sandbox_id: str,
62
+ data_source_id: str,
63
+ project_id: Optional[str],
64
+ default_source_type: str,
65
+ author: Optional[str],
66
+ ) -> None:
67
+ self._client = client
68
+ self._sandbox_id = sandbox_id
69
+ self._data_source_id = data_source_id
70
+ self._project_id = project_id
71
+ self._default_source_type = default_source_type
72
+ self._author = author
73
+
74
+ @property
75
+ def spec(self) -> ToolSpec:
76
+ return ToolSpec(
77
+ name="ingest",
78
+ description=_INGEST_DESCRIPTION,
79
+ input_schema={
80
+ "type": "object",
81
+ "properties": {
82
+ "content": {
83
+ "type": "string",
84
+ "minLength": 1,
85
+ "description": _CONTENT_PARAM,
86
+ },
87
+ "source_type": {
88
+ "type": "string",
89
+ "description": _SOURCE_TYPE_PARAM,
90
+ },
91
+ "storage_only": {
92
+ "type": "boolean",
93
+ "description": _STORAGE_ONLY_PARAM,
94
+ },
95
+ },
96
+ "required": ["content"],
97
+ "additionalProperties": False,
98
+ },
99
+ )
100
+
101
+ async def invoke(
102
+ self,
103
+ arguments: dict,
104
+ *,
105
+ context: Optional[AgentInvocationContext] = None,
106
+ ) -> dict:
107
+ content = str(arguments.get("content", "")).strip()
108
+ if not content:
109
+ return {"error": "content is required and must be non-empty"}
110
+
111
+ # Provenance prefix — until the ingest API / ChatMessage grow
112
+ # a first-class author envelope, embed it as a structured
113
+ # header so downstream retrieval can see who committed this
114
+ # knowledge.
115
+ if self._author:
116
+ content = f"[author={self._author}]\n{content}"
117
+
118
+ source_type = arguments.get("source_type") or self._default_source_type
119
+ storage_only = arguments.get("storage_only")
120
+
121
+ response = await self._client.sources.ingest(
122
+ self._sandbox_id,
123
+ self._data_source_id,
124
+ text=content,
125
+ source_type=source_type,
126
+ storage_only=storage_only,
127
+ project_id=self._project_id,
128
+ )
129
+ return {
130
+ "job_id": response.get("job_id"),
131
+ "status": response.get("status"),
132
+ "data_source_id": self._data_source_id,
133
+ }
134
+
135
+
136
+ def copass_ingest_tool(
137
+ *,
138
+ client: CopassClient,
139
+ sandbox_id: str,
140
+ data_source_id: str,
141
+ project_id: Optional[str] = None,
142
+ default_source_type: str = "text",
143
+ author: Optional[str] = None,
144
+ ) -> AgentTool:
145
+ """Return the ``ingest`` :class:`AgentTool`.
146
+
147
+ Args:
148
+ client: An authenticated :class:`copass_core.CopassClient`.
149
+ sandbox_id: Sandbox the target data source lives in.
150
+ data_source_id: Target data source for durable ingestion.
151
+ Must be pre-registered — use
152
+ ``client.sources.register(sandbox_id, ...)`` or the
153
+ ``copass source register`` CLI to create one.
154
+ project_id: Optional project scoping.
155
+ default_source_type: Default ``source_type`` when the model
156
+ omits the argument. ``"text"`` matches the MCP server's
157
+ behavior; override to ``"conversation"`` for chat-style
158
+ agents or ``"decision"`` for agents whose primary job
159
+ is architecture capture.
160
+ author: Optional identifier of whoever is running the agent
161
+ (``"agent:support-bot"``, ``"user"``, etc.). When set,
162
+ ingested content is prefixed with ``"[author=...]\\n"``
163
+ so retrieval can distinguish provenance. Remove once the
164
+ ingest API / :class:`ChatMessage` grows a first-class
165
+ author field.
166
+
167
+ Returns:
168
+ One :class:`AgentTool` — register it via
169
+ ``AgentToolRegistry.add(...)``.
170
+ """
171
+ return _CopassIngestTool(
172
+ client=client,
173
+ sandbox_id=sandbox_id,
174
+ data_source_id=data_source_id,
175
+ project_id=project_id,
176
+ default_source_type=default_source_type,
177
+ author=author,
178
+ )
179
+
180
+
181
+ __all__ = ["copass_ingest_tool"]
@@ -0,0 +1,301 @@
1
+ """Copass retrieval as provider-neutral :class:`AgentTool` instances.
2
+
3
+ Python analogue of ``typescript/packages/langchain/src/tools.ts`` ·
4
+ ``copassTools(...)``. Returns the three context-engineering primitives
5
+ — ``discover``, ``interpret``, ``search`` — shaped for the core agent
6
+ runtime, so any :class:`BaseAgent` subclass (Anthropic managed agents,
7
+ Google Vertex AI Agent Engine, …) registers them with one call.
8
+
9
+ Why these three, and why always together:
10
+
11
+ * ``discover`` — window-aware ranked menu. Cheap, repeatable, and the
12
+ first call every agent should make when a new goal lands. Because
13
+ the server filters items already surfaced in this conversation's
14
+ window, subsequent calls always return NEW signal — agents can
15
+ lean on it freely without wasting tokens.
16
+ * ``interpret`` — paragraph brief pinned to specific items returned
17
+ by ``discover``. Lets the model drill down without paying for a
18
+ full retrieval round-trip.
19
+ * ``search`` — one-shot synthesized answer. Heavier; prefer
20
+ ``discover`` → ``interpret`` for exploration.
21
+
22
+ Descriptions come from :mod:`copass_config` — the single source of
23
+ truth the TypeScript ``@copass/config`` package mirrors. Editing here
24
+ is a bug; edit the shared module and rebuild all adapters.
25
+
26
+ Cache-safety note (Anthropic Managed Agents):
27
+
28
+ Provider adapters that cache managed-agent resources by a
29
+ fingerprint of ``(model, system_prompt, tool_specs)`` rely on
30
+ :class:`ToolSpec` being stable across invocations. Every spec
31
+ returned here is built from frozen module-level strings +
32
+ a constant JSON schema — so rebuilding the tool list on each
33
+ invocation produces an identical fingerprint. Re-creating the
34
+ tools does NOT invalidate the cache. Safe to call
35
+ :func:`copass_retrieval_tools` once at agent construction and
36
+ forget about it.
37
+
38
+ Example::
39
+
40
+ from copass_core import CopassClient
41
+ from copass_core_agents import AgentToolRegistry
42
+ from copass_context_agents import copass_retrieval_tools
43
+
44
+ copass = CopassClient(...)
45
+ window = await copass.context_window.create(sandbox_id=sandbox_id)
46
+
47
+ registry = AgentToolRegistry()
48
+ registry.extend(
49
+ copass_retrieval_tools(
50
+ client=copass,
51
+ sandbox_id=sandbox_id,
52
+ window=window,
53
+ )
54
+ )
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ from typing import List, Optional
60
+
61
+ from copass_config.param_descriptions import (
62
+ DISCOVER_QUERY_PARAM,
63
+ INTERPRET_ITEMS_PARAM,
64
+ INTERPRET_QUERY_PARAM,
65
+ SEARCH_QUERY_PARAM,
66
+ )
67
+ from copass_config.tool_descriptions import (
68
+ DISCOVER_DESCRIPTION,
69
+ INTERPRET_DESCRIPTION,
70
+ SEARCH_DESCRIPTION,
71
+ )
72
+ from copass_core import CopassClient
73
+ from copass_core.types import SearchPreset, WindowLike
74
+ from copass_core_agents.base_tool import AgentTool, ToolSpec
75
+ from copass_core_agents.invocation_context import AgentInvocationContext
76
+
77
+
78
+ class _CopassDiscoverTool(AgentTool):
79
+ def __init__(
80
+ self,
81
+ *,
82
+ client: CopassClient,
83
+ sandbox_id: str,
84
+ project_id: Optional[str],
85
+ window: Optional[WindowLike],
86
+ ) -> None:
87
+ self._client = client
88
+ self._sandbox_id = sandbox_id
89
+ self._project_id = project_id
90
+ self._window = window
91
+
92
+ @property
93
+ def spec(self) -> ToolSpec:
94
+ return ToolSpec(
95
+ name="discover",
96
+ description=DISCOVER_DESCRIPTION,
97
+ input_schema={
98
+ "type": "object",
99
+ "properties": {
100
+ "query": {
101
+ "type": "string",
102
+ "description": DISCOVER_QUERY_PARAM,
103
+ },
104
+ },
105
+ "required": ["query"],
106
+ "additionalProperties": False,
107
+ },
108
+ )
109
+
110
+ async def invoke(
111
+ self,
112
+ arguments: dict,
113
+ *,
114
+ context: Optional[AgentInvocationContext] = None,
115
+ ) -> dict:
116
+ response = await self._client.retrieval.discover(
117
+ self._sandbox_id,
118
+ query=str(arguments.get("query", "")),
119
+ project_id=self._project_id,
120
+ window=self._window,
121
+ )
122
+ return {
123
+ "header": response.get("header"),
124
+ "items": [
125
+ {
126
+ "score": item.get("score"),
127
+ "summary": item.get("summary"),
128
+ "canonical_ids": item.get("canonical_ids", []),
129
+ }
130
+ for item in response.get("items", [])
131
+ ],
132
+ "next_steps": response.get("next_steps"),
133
+ }
134
+
135
+
136
+ class _CopassInterpretTool(AgentTool):
137
+ def __init__(
138
+ self,
139
+ *,
140
+ client: CopassClient,
141
+ sandbox_id: str,
142
+ project_id: Optional[str],
143
+ window: Optional[WindowLike],
144
+ preset: SearchPreset,
145
+ ) -> None:
146
+ self._client = client
147
+ self._sandbox_id = sandbox_id
148
+ self._project_id = project_id
149
+ self._window = window
150
+ self._preset = preset
151
+
152
+ @property
153
+ def spec(self) -> ToolSpec:
154
+ return ToolSpec(
155
+ name="interpret",
156
+ description=INTERPRET_DESCRIPTION,
157
+ input_schema={
158
+ "type": "object",
159
+ "properties": {
160
+ "query": {
161
+ "type": "string",
162
+ "description": INTERPRET_QUERY_PARAM,
163
+ },
164
+ "items": {
165
+ "type": "array",
166
+ "items": {
167
+ "type": "array",
168
+ "items": {"type": "string"},
169
+ "minItems": 1,
170
+ },
171
+ "minItems": 1,
172
+ "description": INTERPRET_ITEMS_PARAM,
173
+ },
174
+ },
175
+ "required": ["query", "items"],
176
+ "additionalProperties": False,
177
+ },
178
+ )
179
+
180
+ async def invoke(
181
+ self,
182
+ arguments: dict,
183
+ *,
184
+ context: Optional[AgentInvocationContext] = None,
185
+ ) -> dict:
186
+ raw_items = arguments.get("items", []) or []
187
+ items: List[List[str]] = [
188
+ [str(x) for x in tup] for tup in raw_items if tup
189
+ ]
190
+ response = await self._client.retrieval.interpret(
191
+ self._sandbox_id,
192
+ query=str(arguments.get("query", "")),
193
+ items=items,
194
+ project_id=self._project_id,
195
+ window=self._window,
196
+ preset=self._preset,
197
+ )
198
+ return {"brief": response.get("brief")}
199
+
200
+
201
+ class _CopassSearchTool(AgentTool):
202
+ def __init__(
203
+ self,
204
+ *,
205
+ client: CopassClient,
206
+ sandbox_id: str,
207
+ project_id: Optional[str],
208
+ window: Optional[WindowLike],
209
+ preset: SearchPreset,
210
+ ) -> None:
211
+ self._client = client
212
+ self._sandbox_id = sandbox_id
213
+ self._project_id = project_id
214
+ self._window = window
215
+ self._preset = preset
216
+
217
+ @property
218
+ def spec(self) -> ToolSpec:
219
+ return ToolSpec(
220
+ name="search",
221
+ description=SEARCH_DESCRIPTION,
222
+ input_schema={
223
+ "type": "object",
224
+ "properties": {
225
+ "query": {
226
+ "type": "string",
227
+ "description": SEARCH_QUERY_PARAM,
228
+ },
229
+ },
230
+ "required": ["query"],
231
+ "additionalProperties": False,
232
+ },
233
+ )
234
+
235
+ async def invoke(
236
+ self,
237
+ arguments: dict,
238
+ *,
239
+ context: Optional[AgentInvocationContext] = None,
240
+ ) -> dict:
241
+ response = await self._client.retrieval.search(
242
+ self._sandbox_id,
243
+ query=str(arguments.get("query", "")),
244
+ project_id=self._project_id,
245
+ window=self._window,
246
+ preset=self._preset,
247
+ )
248
+ return {"answer": response.get("answer")}
249
+
250
+
251
+ def copass_retrieval_tools(
252
+ *,
253
+ client: CopassClient,
254
+ sandbox_id: str,
255
+ project_id: Optional[str] = None,
256
+ window: Optional[WindowLike] = None,
257
+ preset: SearchPreset = "auto",
258
+ ) -> List[AgentTool]:
259
+ """Return ``[discover, interpret, search]`` as :class:`AgentTool` instances.
260
+
261
+ Args:
262
+ client: An authenticated :class:`copass_core.CopassClient`.
263
+ sandbox_id: Sandbox all retrieval runs against.
264
+ project_id: Optional project scoping applied to every call.
265
+ window: Optional :class:`WindowLike` (typically a
266
+ :class:`ContextWindow`). When set, every retrieval call
267
+ is window-aware — repeated ``discover`` calls skip items
268
+ already surfaced earlier in this conversation.
269
+ preset: Preset for ``interpret`` / ``search``. Defaults to
270
+ ``"auto"`` — required for ``interpret`` (with ``"fast"``,
271
+ ``interpret`` silently returns "No supporting context").
272
+
273
+ Returns:
274
+ ``[discover, interpret, search]`` ready to pass to
275
+ :meth:`AgentToolRegistry.extend`.
276
+ """
277
+ return [
278
+ _CopassDiscoverTool(
279
+ client=client,
280
+ sandbox_id=sandbox_id,
281
+ project_id=project_id,
282
+ window=window,
283
+ ),
284
+ _CopassInterpretTool(
285
+ client=client,
286
+ sandbox_id=sandbox_id,
287
+ project_id=project_id,
288
+ window=window,
289
+ preset=preset,
290
+ ),
291
+ _CopassSearchTool(
292
+ client=client,
293
+ sandbox_id=sandbox_id,
294
+ project_id=project_id,
295
+ window=window,
296
+ preset=preset,
297
+ ),
298
+ ]
299
+
300
+
301
+ __all__ = ["copass_retrieval_tools"]
@@ -0,0 +1,224 @@
1
+ """CopassTurnRecorder — mirror agent turns into a Copass Context Window.
2
+
3
+ Python analogue of the TypeScript ``CopassWindowCallback``
4
+ (``typescript/packages/langchain/src/callback.ts``). Fire-and-forget
5
+ recorder for ``user`` / ``assistant`` turns emitted by provider
6
+ backends' :class:`AgentEvent` streams (Anthropic
7
+ :class:`ManagedAgentBackend`, Google :class:`GoogleAgentBackend`, …).
8
+
9
+ Event-driven: pass each event to :meth:`record_event`, or wrap the
10
+ stream with :meth:`record_stream` for a drop-in ``async for``.
11
+
12
+ Deduplication: ``role + sha256(content[:500])`` — stable across
13
+ restarts so an existing window's pre-seeded turns don't get
14
+ re-recorded.
15
+
16
+ Assistant-turn coalescing: :class:`AgentTextDelta` events are
17
+ buffered; one LLM response becomes one ``assistant`` turn in the
18
+ window, not N partial deltas. Flushes on :class:`AgentFinish` or when
19
+ the next user turn arrives.
20
+
21
+ Latency: pushes to the Context Window run in the background via
22
+ ``asyncio.create_task``, so the agent loop never blocks on the ingest
23
+ HTTP round-trip (~100–300 ms/call). Pending tasks are tracked on
24
+ ``self._pending_pushes``; call :meth:`flush` at end-of-session to
25
+ guarantee no turn is dropped. :meth:`record_stream` handles the flush
26
+ in its ``finally`` block, so the default path through a provider's
27
+ ``stream()`` override is leak-free without user work.
28
+
29
+ Envelope caveat: ``copass_core.types.ChatMessage`` currently carries
30
+ only ``{role, content}``. Richer provenance (agent_id, model,
31
+ tool_calls) can't be a first-class field yet — if you need that
32
+ today, pass ``author=...`` + ``include_author_prefix=True`` to embed
33
+ it as a ``[author=...]`` prefix in ``content``. Promote to a real
34
+ field in ``ChatMessage`` when the envelope is widened.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import hashlib
41
+ import logging
42
+ from typing import AsyncIterator, Optional
43
+
44
+ from copass_core.context_window import ContextWindow
45
+ from copass_core.types import ChatMessage
46
+ from copass_core_agents.events import (
47
+ AgentEvent,
48
+ AgentFinish,
49
+ AgentTextDelta,
50
+ AgentToolCall,
51
+ AgentToolResult,
52
+ )
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+
57
+ class CopassTurnRecorder:
58
+ """Push agent turns into a Copass :class:`ContextWindow`.
59
+
60
+ Args:
61
+ window: The :class:`ContextWindow` to mirror turns into.
62
+ include_tool_events: When True, record ``AgentToolCall`` /
63
+ ``AgentToolResult`` as ``system`` turns. Default False —
64
+ tool output is noisy and the retrieval graph already
65
+ indexes the underlying content, so recording it would
66
+ double-count.
67
+ author: Optional identifier for whoever is running the agent
68
+ (``"agent"``, ``"user"``, or something richer like
69
+ ``"agent:support-bot"``). Recorded as a prefix on
70
+ assistant turns when ``include_author_prefix`` is True.
71
+ Until :class:`ChatMessage` grows an ``author`` field this
72
+ is the only way to carry provenance into the window.
73
+ include_author_prefix: When True, assistant turns are stored
74
+ as ``"[author=...]\\n<content>"`` so downstream retrieval
75
+ can distinguish agent-authored vs. user-authored turns.
76
+ Default False — most callers don't need provenance, and
77
+ the prefix marginally pollutes the embedding.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ *,
83
+ window: ContextWindow,
84
+ include_tool_events: bool = False,
85
+ author: Optional[str] = None,
86
+ include_author_prefix: bool = False,
87
+ ) -> None:
88
+ self._window = window
89
+ self._include_tool_events = include_tool_events
90
+ self._author = author
91
+ self._include_author_prefix = include_author_prefix
92
+ self._seen: set[str] = set()
93
+ self._pending_assistant_text: list[str] = []
94
+ self._pending_pushes: set[asyncio.Task] = set()
95
+
96
+ # Seed dedupe set with whatever turns the window already has,
97
+ # so resuming a conversation doesn't double-record.
98
+ for turn in window.get_turns():
99
+ self._seen.add(self._dedupe_key(turn))
100
+
101
+ # ── Public recording API ───────────────────────────────────────
102
+
103
+ async def record_user(self, content: str) -> None:
104
+ """Record a user turn. Drops empty content. Flushes any
105
+ in-flight assistant text first — a new user turn finalizes
106
+ the prior assistant response."""
107
+ await self._flush_assistant()
108
+ await self._record(ChatMessage(role="user", content=content))
109
+
110
+ async def record_assistant_delta(self, text: str) -> None:
111
+ """Buffer an assistant text delta. Flushed by
112
+ :meth:`flush_assistant`, :meth:`record_event` (on
113
+ :class:`AgentFinish`), or :meth:`record_user`."""
114
+ if text:
115
+ self._pending_assistant_text.append(text)
116
+
117
+ async def flush_assistant(self) -> None:
118
+ """Coalesce buffered deltas into one assistant turn."""
119
+ await self._flush_assistant()
120
+
121
+ async def record_event(self, event: AgentEvent) -> None:
122
+ """Ingest one :class:`AgentEvent`. Convenience for drivers
123
+ that already iterate over the backend's event stream."""
124
+ if isinstance(event, AgentTextDelta):
125
+ await self.record_assistant_delta(event.text)
126
+ elif isinstance(event, AgentFinish):
127
+ await self._flush_assistant()
128
+ elif isinstance(event, AgentToolCall):
129
+ if self._include_tool_events:
130
+ await self._record(
131
+ ChatMessage(
132
+ role="system",
133
+ content=f"[tool_call name={event.name!r} args={event.arguments!r}]",
134
+ )
135
+ )
136
+ elif isinstance(event, AgentToolResult):
137
+ if self._include_tool_events:
138
+ await self._record(
139
+ ChatMessage(
140
+ role="system",
141
+ content=(
142
+ f"[tool_result name={event.name!r} "
143
+ f"result={event.result!r}]"
144
+ ),
145
+ )
146
+ )
147
+
148
+ async def record_stream(
149
+ self, stream: AsyncIterator[AgentEvent]
150
+ ) -> AsyncIterator[AgentEvent]:
151
+ """Wrap a backend event stream — records every event and
152
+ re-yields it unchanged. Drop-in for existing ``async for``
153
+ loops::
154
+
155
+ async for evt in recorder.record_stream(agent.stream(...)):
156
+ ...
157
+
158
+ Awaits :meth:`flush` in a ``finally`` block so pending
159
+ background pushes complete before the wrapper returns.
160
+ Callers that drive events through :meth:`record_event`
161
+ directly (without going through this wrapper) must call
162
+ ``await recorder.flush()`` themselves at end-of-session.
163
+ """
164
+ try:
165
+ async for event in stream:
166
+ await self.record_event(event)
167
+ yield event
168
+ finally:
169
+ await self.flush()
170
+
171
+ async def flush(self) -> None:
172
+ """Await every in-flight ingestion task. Call at
173
+ end-of-session to guarantee no turn is dropped. Idempotent;
174
+ safe to call even when nothing is pending."""
175
+ pending = list(self._pending_pushes)
176
+ if not pending:
177
+ return
178
+ await asyncio.gather(*pending, return_exceptions=True)
179
+
180
+ # ── Internals ─────────────────────────────────────────────────
181
+
182
+ async def _flush_assistant(self) -> None:
183
+ if not self._pending_assistant_text:
184
+ return
185
+ text = "".join(self._pending_assistant_text).strip()
186
+ self._pending_assistant_text.clear()
187
+ if not text:
188
+ return
189
+ if self._include_author_prefix and self._author:
190
+ text = f"[author={self._author}]\n{text}"
191
+ await self._record(ChatMessage(role="assistant", content=text))
192
+
193
+ async def _record(self, turn: ChatMessage) -> None:
194
+ if not turn.content.strip():
195
+ return
196
+ key = self._dedupe_key(turn)
197
+ if key in self._seen:
198
+ return
199
+ self._seen.add(key)
200
+ # Fire the push in the background so the agent loop doesn't
201
+ # pay ingestion latency (~100–300 ms) on every turn. Failures
202
+ # are logged inside ``_push``; callers can await
203
+ # :meth:`flush` to observe completion.
204
+ task = asyncio.create_task(self._push(turn))
205
+ self._pending_pushes.add(task)
206
+ task.add_done_callback(self._pending_pushes.discard)
207
+
208
+ async def _push(self, turn: ChatMessage) -> None:
209
+ try:
210
+ await self._window.add_turn(turn)
211
+ except Exception as err:
212
+ logger.warning(
213
+ "CopassTurnRecorder: add_turn failed (dropping turn)",
214
+ extra={"error": str(err), "role": turn.role},
215
+ )
216
+
217
+ @staticmethod
218
+ def _dedupe_key(turn: ChatMessage) -> str:
219
+ body = turn.content[:500].encode("utf-8", errors="replace")
220
+ digest = hashlib.sha256(body).hexdigest()[:16]
221
+ return f"{turn.role}:{digest}"
222
+
223
+
224
+ __all__ = ["CopassTurnRecorder"]