verifiers 0.2.2.dev88__py3-none-any.whl → 0.2.2.dev89__py3-none-any.whl

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.
verifiers/v1/__init__.py CHANGED
@@ -43,7 +43,7 @@ from verifiers.v1.errors import (
43
43
  TunnelError,
44
44
  )
45
45
  from verifiers.v1.graph import MessageNode
46
- from verifiers.v1.harness import Harness
46
+ from verifiers.v1.harness import Harness, HarnessSession
47
47
  from verifiers.v1.judge import Judge, JudgeResponse, JudgeView
48
48
  from verifiers.v1.judges import (
49
49
  Criterion,
@@ -64,6 +64,7 @@ from verifiers.v1.runtimes import (
64
64
  Runtime,
65
65
  RuntimeConfig,
66
66
  RuntimeInfo,
67
+ RuntimeProcess,
67
68
  SubprocessConfig,
68
69
  )
69
70
  from verifiers.v1.state import State, StateT
@@ -241,10 +242,12 @@ __all__ = [ # noqa: RUF022 - grouped by public API area
241
242
  "TasksetConfig",
242
243
  "BaseConfig",
243
244
  "Harness",
245
+ "HarnessSession",
244
246
  "HarnessConfig",
245
247
  "ACP",
246
248
  "ModelContext",
247
249
  "Runtime",
250
+ "RuntimeProcess",
248
251
  "RuntimeConfig",
249
252
  "RuntimeInfo",
250
253
  "ProgramResult",
@@ -1,28 +1,68 @@
1
1
  """Public Agent Client Protocol support for harness programs."""
2
2
 
3
+ import asyncio
4
+ import contextlib
3
5
  import json
4
6
  import secrets
7
+ from collections.abc import AsyncIterator
5
8
  from pathlib import Path
6
9
 
10
+ from verifiers.v1.clients import ModelContext
7
11
  from verifiers.v1.dialects.chat import message_to_wire
8
- from verifiers.v1.harness import Harness
9
- from verifiers.v1.runtimes import ProgramResult, Runtime
12
+ from verifiers.v1.errors import HarnessError
13
+ from verifiers.v1.harness import Harness, HarnessSession
14
+ from verifiers.v1.runtimes import ProgramResult, Runtime, RuntimeProcess
15
+ from verifiers.v1.task import TaskData
16
+ from verifiers.v1.trace import Trace
10
17
  from verifiers.v1.types import Messages
11
18
  from verifiers.v1.utils.aio import run_shielded
12
19
 
13
- ACP_SOURCE = (Path(__file__).resolve().parent / "_runner.py").read_text()
20
+ ACP_SOURCE = (Path(__file__).resolve().parent / "runner.py").read_text()
21
+ MAX_PACKET_BYTES = 128 * 1024 * 1024
14
22
 
15
23
  __all__ = ["ACP"]
16
24
 
17
25
 
18
26
  class ACP:
19
- """Run an ACP agent."""
27
+ """Run one-shot ACP agents or create rollout-scoped ACP sessions."""
20
28
 
21
29
  async def setup(self, harness: Harness, runtime: Runtime) -> None:
22
30
  await runtime.prepare_uv_script(
23
31
  ACP_SOURCE, {**harness.config.resolved_env, "UV_FROZEN": "false"}
24
32
  )
25
33
 
34
+ def session(
35
+ self,
36
+ harness: Harness,
37
+ ctx: ModelContext,
38
+ trace: Trace,
39
+ runtime: Runtime,
40
+ endpoint: str,
41
+ secret: str,
42
+ mcp_urls: dict[str, str],
43
+ data: TaskData,
44
+ *,
45
+ env: dict[str, str],
46
+ command: list[str],
47
+ prompt: str | Messages | None,
48
+ system_prompt: str | None = None,
49
+ ) -> "ACPHarnessSession":
50
+ """Create a persistent ACP-backed handle owned by one rollout."""
51
+ return ACPHarnessSession(
52
+ harness,
53
+ ctx,
54
+ trace,
55
+ runtime,
56
+ endpoint,
57
+ secret,
58
+ mcp_urls,
59
+ data,
60
+ env=env,
61
+ command=command,
62
+ prompt=prompt,
63
+ system_prompt=system_prompt,
64
+ )
65
+
26
66
  async def run(
27
67
  self,
28
68
  runtime: Runtime,
@@ -34,6 +74,30 @@ class ACP:
34
74
  system_prompt: str | None = None,
35
75
  session_path: str | None = None,
36
76
  allow_empty_tool_reply: bool = False,
77
+ ) -> ProgramResult:
78
+ """Run one ACP segment without retaining its process."""
79
+ return await self._run(
80
+ runtime,
81
+ env,
82
+ command,
83
+ prompt,
84
+ mcp_urls=mcp_urls,
85
+ system_prompt=system_prompt,
86
+ session_path=session_path,
87
+ allow_empty_tool_reply=allow_empty_tool_reply,
88
+ )
89
+
90
+ async def _run(
91
+ self,
92
+ runtime: Runtime,
93
+ env: dict[str, str],
94
+ command: list[str],
95
+ prompt: str | Messages | None,
96
+ *,
97
+ mcp_urls: dict[str, str] | None = None,
98
+ system_prompt: str | None = None,
99
+ session_path: str | None = None,
100
+ allow_empty_tool_reply: bool = False,
37
101
  ) -> ProgramResult:
38
102
  if prompt is None:
39
103
  raise ValueError("ACP requires a prompt")
@@ -60,7 +124,180 @@ class ACP:
60
124
  path = f"{directory}/config.json"
61
125
  try:
62
126
  await runtime.write(path, json.dumps(config).encode())
63
- result = await runtime.run_program([*program, path], env)
64
- return result
127
+ return await runtime.run_program([*program, "once", path], env)
65
128
  finally:
66
129
  await run_shielded(runtime.run(["rm", "-rf", directory], {}))
130
+
131
+
132
+ def _packet(value: dict) -> bytes:
133
+ data = json.dumps(value, ensure_ascii=False).encode()
134
+ if len(data) > MAX_PACKET_BYTES:
135
+ raise ValueError(f"ACP session packet is too large: {len(data)} bytes")
136
+ return len(data).to_bytes(8, "big") + data
137
+
138
+
139
+ class _PacketReader:
140
+ def __init__(self, source: AsyncIterator[bytes]) -> None:
141
+ self._source = source.__aiter__()
142
+ self._buffer = bytearray()
143
+
144
+ async def _readexactly(self, size: int) -> bytes:
145
+ while len(self._buffer) < size:
146
+ try:
147
+ self._buffer.extend(await anext(self._source))
148
+ except StopAsyncIteration as e:
149
+ raise EOFError("ACP process closed its stdout") from e
150
+ data = bytes(self._buffer[:size])
151
+ del self._buffer[:size]
152
+ return data
153
+
154
+ async def read(self) -> dict:
155
+ size = int.from_bytes(await self._readexactly(8), "big")
156
+ if size > MAX_PACKET_BYTES:
157
+ raise ValueError(f"ACP session packet is too large: {size} bytes")
158
+ return json.loads((await self._readexactly(size)).decode())
159
+
160
+
161
+ class ACPHarnessSession(HarnessSession):
162
+ """A live ACP process, connection, and native session for one rollout."""
163
+
164
+ def __init__(
165
+ self,
166
+ harness: Harness,
167
+ ctx: ModelContext,
168
+ trace: Trace,
169
+ runtime: Runtime,
170
+ endpoint: str,
171
+ secret: str,
172
+ mcp_urls: dict[str, str],
173
+ data: TaskData,
174
+ env: dict[str, str],
175
+ command: list[str],
176
+ prompt: str | Messages | None,
177
+ system_prompt: str | None,
178
+ ) -> None:
179
+ super().__init__(harness, ctx, trace, runtime, endpoint, secret, mcp_urls, data)
180
+ self.env = env
181
+ self.command = command
182
+ self.prompt = prompt
183
+ self.system_prompt = system_prompt
184
+ self._process: RuntimeProcess | None = None
185
+ self._reader: _PacketReader | None = None
186
+ self._stderr_tail = bytearray()
187
+ self._stderr_task: asyncio.Task[None] | None = None
188
+ self._lock = asyncio.Lock()
189
+
190
+ async def _start(self) -> None:
191
+ self._stderr_tail.clear()
192
+ program = await self.runtime.prepare_uv_script(
193
+ ACP_SOURCE, {**self.env, "UV_FROZEN": "false"}
194
+ )
195
+ process = await self.runtime.open_process([*program, "stream"], self.env)
196
+ self._process = process
197
+ self._reader = _PacketReader(process.stdout)
198
+ self._stderr_task = asyncio.create_task(self._drain_stderr(process.stderr))
199
+
200
+ async def _drain_stderr(self, stream: AsyncIterator[bytes]) -> None:
201
+ async for chunk in stream:
202
+ self._stderr_tail.extend(chunk)
203
+ if len(self._stderr_tail) > 4000:
204
+ del self._stderr_tail[:-4000]
205
+
206
+ def _stderr(self) -> str:
207
+ return self._stderr_tail.decode(errors="replace").strip()
208
+
209
+ async def _run(self, messages: Messages | None) -> ProgramResult:
210
+ prompt = self.prompt if messages is None else messages
211
+ if prompt is None:
212
+ raise ValueError("ACP requires a prompt")
213
+ wire_messages = (
214
+ [{"role": "user", "content": prompt}]
215
+ if isinstance(prompt, str)
216
+ else [message_to_wire(message) for message in prompt]
217
+ )
218
+ config = {
219
+ "command": self.command,
220
+ "messages": wire_messages,
221
+ "mcp_urls": self.mcp_urls,
222
+ "system_prompt": self.system_prompt or "",
223
+ "session_path": None,
224
+ }
225
+ async with self._lock:
226
+ if self._closed:
227
+ raise HarnessError(
228
+ f"harness {self.harness.config.id!r} session is already closed"
229
+ )
230
+ if self._process is None:
231
+ await self._start()
232
+ assert self._process is not None
233
+ assert self._reader is not None
234
+ try:
235
+ await self._process.write(
236
+ _packet({"operation": "prompt", "config": config})
237
+ )
238
+ response = await self._reader.read()
239
+ except BaseException:
240
+ await run_shielded(self._stop(graceful=False))
241
+ raise
242
+ if not response.get("ok"):
243
+ detail = response.get("error") or "ACP session request failed"
244
+ if stderr := self._stderr():
245
+ detail = f"{detail}\n\nACP process stderr:\n{stderr}"
246
+ raise RuntimeError(detail)
247
+ return ProgramResult(exit_code=0, stdout=response.get("reply", ""), stderr="")
248
+
249
+ async def _stop(self, *, graceful: bool) -> None:
250
+ process, self._process = self._process, None
251
+ reader, self._reader = self._reader, None
252
+ stderr_task, self._stderr_task = self._stderr_task, None
253
+ if process is None:
254
+ return
255
+ failure: BaseException | None = None
256
+ try:
257
+ if graceful and reader is not None:
258
+ try:
259
+ await process.write(_packet({"operation": "shutdown"}))
260
+ response = await asyncio.wait_for(reader.read(), timeout=10)
261
+ if not response.get("ok"):
262
+ raise RuntimeError(
263
+ response.get("error") or "ACP session shutdown failed"
264
+ )
265
+ except BaseException as error: # noqa: BLE001 - finish teardown if cancelled
266
+ failure = error
267
+ try:
268
+ await asyncio.wait_for(process.wait(), timeout=10 if graceful else 0.1)
269
+ except BaseException: # noqa: BLE001 - cancellation still requires termination
270
+ with contextlib.suppress(Exception):
271
+ await asyncio.wait_for(process.terminate(), timeout=5)
272
+ try:
273
+ await asyncio.wait_for(process.wait(), timeout=5)
274
+ except BaseException: # noqa: BLE001 - cancellation still requires a kill
275
+ with contextlib.suppress(Exception):
276
+ await asyncio.wait_for(process.kill(), timeout=5)
277
+ with contextlib.suppress(BaseException):
278
+ await asyncio.wait_for(process.wait(), timeout=5)
279
+ finally:
280
+ if stderr_task is not None:
281
+ if not stderr_task.done():
282
+ stderr_task.cancel()
283
+ with contextlib.suppress(BaseException):
284
+ await stderr_task
285
+ if failure is not None:
286
+ detail = str(failure)
287
+ if stderr := self._stderr():
288
+ detail = f"{detail}\n\nACP process stderr:\n{stderr}"
289
+ raise RuntimeError(detail) from failure
290
+
291
+ async def close(self) -> None:
292
+ if self._closed:
293
+ return
294
+ # Publish closure before waiting for the process lock. A turn that
295
+ # already passed HarnessSession.turn()'s fast check rechecks under the
296
+ # same lock in _run(), so it cannot restart after teardown.
297
+ await super().close()
298
+
299
+ async def close_process() -> None:
300
+ async with self._lock:
301
+ await self._stop(graceful=True)
302
+
303
+ await run_shielded(close_process())