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.
- copass_context_agents-0.1.0/.gitignore +38 -0
- copass_context_agents-0.1.0/PKG-INFO +75 -0
- copass_context_agents-0.1.0/README.md +50 -0
- copass_context_agents-0.1.0/pyproject.toml +51 -0
- copass_context_agents-0.1.0/src/copass_context_agents/__init__.py +28 -0
- copass_context_agents-0.1.0/src/copass_context_agents/ingest_tool.py +181 -0
- copass_context_agents-0.1.0/src/copass_context_agents/retrieval_tools.py +301 -0
- copass_context_agents-0.1.0/src/copass_context_agents/turn_recorder.py +224 -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,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"]
|