docent-python 0.1.12a0__tar.gz → 0.1.13a0__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.
Potentially problematic release.
This version of docent-python might be problematic. Click here for more details.
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/PKG-INFO +1 -1
- docent_python-0.1.13a0/docent/__init__.py +4 -0
- docent_python-0.1.13a0/docent/agent_run_writer.py +266 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/chat/tool.py +1 -1
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/pyproject.toml +1 -1
- docent_python-0.1.12a0/docent/__init__.py +0 -3
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/.gitignore +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/LICENSE.md +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/README.md +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/_log_util/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/_log_util/logger.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/_tiktoken_util.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/agent_run.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/chat/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/chat/content.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/chat/message.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/citation.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/metadata.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/regex.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/remove_invalid_citation_ranges.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/shared_types.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/data_models/transcript.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/loaders/load_inspect.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/py.typed +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/samples/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/samples/load.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/samples/log.eval +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/samples/tb_airline.json +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/sdk/__init__.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/sdk/client.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/trace.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/docent/trace_temp.py +0 -0
- {docent_python-0.1.12a0 → docent_python-0.1.13a0}/uv.lock +0 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import signal
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Callable, Coroutine, Optional
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
import backoff
|
|
11
|
+
import httpx
|
|
12
|
+
from backoff.types import Details
|
|
13
|
+
|
|
14
|
+
from docent._log_util.logger import get_logger
|
|
15
|
+
from docent.data_models.agent_run import AgentRun
|
|
16
|
+
from docent.sdk.client import Docent
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _giveup(exc: BaseException) -> bool:
|
|
22
|
+
"""Give up on client errors."""
|
|
23
|
+
|
|
24
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
25
|
+
status = exc.response.status_code
|
|
26
|
+
return status < 500 and status != 429
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print_backoff_message(e: Details):
|
|
31
|
+
logger.warning(
|
|
32
|
+
f"AgentRunWriter backing off for {e['wait']:.2f}s due to {e['exception'].__class__.__name__}" # type: ignore
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AgentRunWriter:
|
|
37
|
+
"""Background thread for logging agent runs.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
api_key (str): API key for the Docent API.
|
|
41
|
+
collection_id (str): ID of the collection to log agent runs to.
|
|
42
|
+
server_url (str): URL of the Docent server.
|
|
43
|
+
num_workers (int): Max number of concurrent tasks to run,
|
|
44
|
+
managed by anyio.CapacityLimiter.
|
|
45
|
+
queue_maxsize (int): Maximum size of the queue.
|
|
46
|
+
If maxsize is <= 0, the queue size is infinite.
|
|
47
|
+
request_timeout (float): Timeout for the HTTP request.
|
|
48
|
+
flush_interval (float): Interval to flush the queue.
|
|
49
|
+
batch_size (int): Number of agent runs to batch together.
|
|
50
|
+
max_retries (int): Maximum number of retries for the HTTP request.
|
|
51
|
+
shutdown_timeout (int): Timeout to wait for the background thread to finish
|
|
52
|
+
after the main thread has requested shutdown.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
_instance: Optional["AgentRunWriter"] = None
|
|
56
|
+
_instance_lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key: str,
|
|
61
|
+
collection_id: str,
|
|
62
|
+
server_url: str = "https://api.docent.transluce.org",
|
|
63
|
+
num_workers: int = 2,
|
|
64
|
+
queue_maxsize: int = 20_000,
|
|
65
|
+
request_timeout: float = 30.0,
|
|
66
|
+
flush_interval: float = 1.0,
|
|
67
|
+
batch_size: int = 1_000,
|
|
68
|
+
max_retries: int = 5,
|
|
69
|
+
shutdown_timeout: int = 60,
|
|
70
|
+
) -> None:
|
|
71
|
+
with self._instance_lock:
|
|
72
|
+
if AgentRunWriter._instance is not None:
|
|
73
|
+
return
|
|
74
|
+
AgentRunWriter._instance = self
|
|
75
|
+
|
|
76
|
+
# Request parameters
|
|
77
|
+
self._headers = {"Authorization": f"Bearer {api_key}"}
|
|
78
|
+
self._base_url = server_url.rstrip("/") + "/rest"
|
|
79
|
+
self._endpoint = f"{collection_id}/agent_runs"
|
|
80
|
+
|
|
81
|
+
self._num_workers = num_workers
|
|
82
|
+
self._request_timeout = request_timeout
|
|
83
|
+
self._flush_interval = flush_interval
|
|
84
|
+
self._batch_size = batch_size
|
|
85
|
+
self._max_retries = max_retries
|
|
86
|
+
self._shutdown_timeout = shutdown_timeout
|
|
87
|
+
|
|
88
|
+
self._queue: queue.Queue[AgentRun] = queue.Queue(maxsize=queue_maxsize)
|
|
89
|
+
self._cancel_event = threading.Event()
|
|
90
|
+
|
|
91
|
+
# Start background thread
|
|
92
|
+
self._thread = threading.Thread(
|
|
93
|
+
target=lambda: anyio.run(self._async_main),
|
|
94
|
+
name="AgentRunWriterThread",
|
|
95
|
+
daemon=True,
|
|
96
|
+
)
|
|
97
|
+
self._thread.start()
|
|
98
|
+
logger.info("AgentRunWriter thread started")
|
|
99
|
+
|
|
100
|
+
self._register_shutdown_hooks()
|
|
101
|
+
|
|
102
|
+
def _register_shutdown_hooks(self) -> None:
|
|
103
|
+
"""Register shutdown hooks for atexit and signals."""
|
|
104
|
+
|
|
105
|
+
# Register shutdown hooks
|
|
106
|
+
atexit.register(self.finish)
|
|
107
|
+
|
|
108
|
+
# Register signal handlers for graceful shutdown
|
|
109
|
+
signal.signal(signal.SIGINT, lambda s, f: self._shutdown()) # Ctrl+C
|
|
110
|
+
signal.signal(signal.SIGTERM, lambda s, f: self._shutdown()) # Kill signal
|
|
111
|
+
|
|
112
|
+
def log_agent_runs(self, agent_runs: list[AgentRun]) -> None:
|
|
113
|
+
"""Put a list of AgentRun objects into the queue.
|
|
114
|
+
|
|
115
|
+
If the queue is full, the method will block until the queue has space.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
agent_runs (list[AgentRun]): List of AgentRun objects to put into the queue.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
p_full = (
|
|
122
|
+
(self._queue.qsize() + len(agent_runs)) / self._queue.maxsize
|
|
123
|
+
if self._queue.maxsize > 0
|
|
124
|
+
else 0
|
|
125
|
+
)
|
|
126
|
+
if p_full >= 0.9:
|
|
127
|
+
logger.warning("AgentRunWriter queue is almost full (>=90%).")
|
|
128
|
+
|
|
129
|
+
for run in agent_runs:
|
|
130
|
+
try:
|
|
131
|
+
self._queue.put_nowait(run)
|
|
132
|
+
except queue.Full:
|
|
133
|
+
logger.warning("AgentRunWriter queue is full, blocking...")
|
|
134
|
+
self._queue.put(run, block=True)
|
|
135
|
+
|
|
136
|
+
def finish(self, force: bool = False) -> None:
|
|
137
|
+
"""Request shutdown and wait up to timeout for pending tasks to complete.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
force (bool): If True, shut down immediately. If False, wait for pending tasks to complete.
|
|
141
|
+
"""
|
|
142
|
+
if not force:
|
|
143
|
+
# Wait for background thread to finish up to timeout
|
|
144
|
+
logger.info("Waiting for pending tasks to complete")
|
|
145
|
+
|
|
146
|
+
for i in range(0, self._shutdown_timeout, 5):
|
|
147
|
+
if not self._thread.is_alive():
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
if self._queue.empty():
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
logger.info(
|
|
154
|
+
f"Waiting for pending tasks to complete " f"({i}/{self._shutdown_timeout})s"
|
|
155
|
+
)
|
|
156
|
+
time.sleep(5)
|
|
157
|
+
|
|
158
|
+
self._shutdown()
|
|
159
|
+
|
|
160
|
+
def _shutdown(self) -> None:
|
|
161
|
+
"""Shutdown the AgentRunWriter thread."""
|
|
162
|
+
if self._thread.is_alive():
|
|
163
|
+
logger.info("Cancelling pending tasks...")
|
|
164
|
+
self._cancel_event.set()
|
|
165
|
+
n_pending = self._queue.qsize()
|
|
166
|
+
logger.info(f"Cancelled ~{n_pending} pending tasks")
|
|
167
|
+
|
|
168
|
+
# Give a brief moment to exit
|
|
169
|
+
logger.info("Waiting for thread to exit...")
|
|
170
|
+
self._thread.join(timeout=1.0)
|
|
171
|
+
|
|
172
|
+
def get_post_batch_fcn(
|
|
173
|
+
self, client: httpx.AsyncClient
|
|
174
|
+
) -> Callable[[list[AgentRun], anyio.CapacityLimiter], Coroutine[Any, Any, None]]:
|
|
175
|
+
"""Return a function that will post a batch of agent runs to the API."""
|
|
176
|
+
|
|
177
|
+
@backoff.on_exception(
|
|
178
|
+
backoff.expo,
|
|
179
|
+
exception=httpx.HTTPError,
|
|
180
|
+
giveup=_giveup,
|
|
181
|
+
max_tries=self._max_retries,
|
|
182
|
+
on_backoff=_print_backoff_message,
|
|
183
|
+
)
|
|
184
|
+
async def _post_batch(batch: list[AgentRun], limiter: anyio.CapacityLimiter) -> None:
|
|
185
|
+
async with limiter:
|
|
186
|
+
payload = {"agent_runs": [ar.model_dump(mode="json") for ar in batch]}
|
|
187
|
+
resp = await client.post(
|
|
188
|
+
self._endpoint, json=payload, timeout=self._request_timeout
|
|
189
|
+
)
|
|
190
|
+
resp.raise_for_status()
|
|
191
|
+
|
|
192
|
+
return _post_batch
|
|
193
|
+
|
|
194
|
+
async def _async_main(self) -> None:
|
|
195
|
+
"""Main async function for the AgentRunWriter thread."""
|
|
196
|
+
|
|
197
|
+
limiter = anyio.CapacityLimiter(self._num_workers)
|
|
198
|
+
|
|
199
|
+
async with httpx.AsyncClient(base_url=self._base_url, headers=self._headers) as client:
|
|
200
|
+
async with anyio.create_task_group() as tg:
|
|
201
|
+
_post_batch = self.get_post_batch_fcn(client)
|
|
202
|
+
|
|
203
|
+
async def batch_loop() -> None:
|
|
204
|
+
while not self._cancel_event.is_set():
|
|
205
|
+
batch = await self._gather_next_batch_from_queue()
|
|
206
|
+
if not batch:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
tg.start_soon(_post_batch, batch, limiter)
|
|
210
|
+
|
|
211
|
+
tg.start_soon(batch_loop)
|
|
212
|
+
|
|
213
|
+
async def _gather_next_batch_from_queue(self) -> list[AgentRun]:
|
|
214
|
+
"""Gather a batch of agent runs from the queue.
|
|
215
|
+
|
|
216
|
+
Fetches items from the queue until the batch is full or the timeout expires.
|
|
217
|
+
"""
|
|
218
|
+
batch: list[AgentRun] = []
|
|
219
|
+
with anyio.move_on_after(self._flush_interval):
|
|
220
|
+
while len(batch) < self._batch_size:
|
|
221
|
+
try:
|
|
222
|
+
item = self._queue.get_nowait()
|
|
223
|
+
batch.append(item)
|
|
224
|
+
except queue.Empty:
|
|
225
|
+
await anyio.sleep(0.1)
|
|
226
|
+
|
|
227
|
+
return batch
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def init(
|
|
231
|
+
collection_name: str = "Agent Run Collection",
|
|
232
|
+
collection_id: str | None = None,
|
|
233
|
+
server_url: str = "https://api.docent.transluce.org",
|
|
234
|
+
web_url: str = "https://docent.transluce.org",
|
|
235
|
+
api_key: str | None = None,
|
|
236
|
+
):
|
|
237
|
+
"""Initialize the AgentRunWriter thread.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
collection_name (str): Name of the agent run collection.
|
|
241
|
+
collection_id (str): ID of the agent run collection.
|
|
242
|
+
server_url (str): URL of the Docent server.
|
|
243
|
+
web_url (str): URL of the Docent web UI.
|
|
244
|
+
api_key (str): API key for the Docent API.
|
|
245
|
+
"""
|
|
246
|
+
api_key = api_key or os.getenv("DOCENT_API_KEY")
|
|
247
|
+
|
|
248
|
+
if api_key is None:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
"api_key is required. Please provide an "
|
|
251
|
+
"api_key or set the DOCENT_API_KEY environment variable."
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
sdk = Docent(
|
|
255
|
+
server_url=server_url,
|
|
256
|
+
web_url=web_url,
|
|
257
|
+
api_key=api_key,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
collection_id = collection_id or sdk.create_collection(name=collection_name)
|
|
261
|
+
|
|
262
|
+
return AgentRunWriter(
|
|
263
|
+
api_key=api_key,
|
|
264
|
+
collection_id=collection_id,
|
|
265
|
+
server_url=server_url,
|
|
266
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|