verifiers 0.2.2.dev88__py3-none-any.whl → 0.2.2.dev90__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,70 @@
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
+ session_meta: dict | None = None,
50
+ ) -> "ACPHarnessSession":
51
+ """Create a persistent ACP-backed handle owned by one rollout."""
52
+ return ACPHarnessSession(
53
+ harness,
54
+ ctx,
55
+ trace,
56
+ runtime,
57
+ endpoint,
58
+ secret,
59
+ mcp_urls,
60
+ data,
61
+ env=env,
62
+ command=command,
63
+ prompt=prompt,
64
+ system_prompt=system_prompt,
65
+ session_meta=session_meta,
66
+ )
67
+
26
68
  async def run(
27
69
  self,
28
70
  runtime: Runtime,
@@ -33,6 +75,33 @@ class ACP:
33
75
  mcp_urls: dict[str, str] | None = None,
34
76
  system_prompt: str | None = None,
35
77
  session_path: str | None = None,
78
+ session_meta: dict | None = None,
79
+ allow_empty_tool_reply: bool = False,
80
+ ) -> ProgramResult:
81
+ """Run one ACP segment without retaining its process."""
82
+ return await self._run(
83
+ runtime,
84
+ env,
85
+ command,
86
+ prompt,
87
+ mcp_urls=mcp_urls,
88
+ system_prompt=system_prompt,
89
+ session_path=session_path,
90
+ session_meta=session_meta,
91
+ allow_empty_tool_reply=allow_empty_tool_reply,
92
+ )
93
+
94
+ async def _run(
95
+ self,
96
+ runtime: Runtime,
97
+ env: dict[str, str],
98
+ command: list[str],
99
+ prompt: str | Messages | None,
100
+ *,
101
+ mcp_urls: dict[str, str] | None = None,
102
+ system_prompt: str | None = None,
103
+ session_path: str | None = None,
104
+ session_meta: dict | None = None,
36
105
  allow_empty_tool_reply: bool = False,
37
106
  ) -> ProgramResult:
38
107
  if prompt is None:
@@ -48,6 +117,7 @@ class ACP:
48
117
  "mcp_urls": mcp_urls or {},
49
118
  "system_prompt": system_prompt or "",
50
119
  "session_path": session_path,
120
+ "session_meta": session_meta or {},
51
121
  "allow_empty_tool_reply": allow_empty_tool_reply,
52
122
  }
53
123
  program = await runtime.prepare_uv_script(
@@ -60,7 +130,183 @@ class ACP:
60
130
  path = f"{directory}/config.json"
61
131
  try:
62
132
  await runtime.write(path, json.dumps(config).encode())
63
- result = await runtime.run_program([*program, path], env)
64
- return result
133
+ return await runtime.run_program([*program, "once", path], env)
65
134
  finally:
66
135
  await run_shielded(runtime.run(["rm", "-rf", directory], {}))
136
+
137
+
138
+ def _packet(value: dict) -> bytes:
139
+ data = json.dumps(value, ensure_ascii=False).encode()
140
+ if len(data) > MAX_PACKET_BYTES:
141
+ raise ValueError(f"ACP session packet is too large: {len(data)} bytes")
142
+ return len(data).to_bytes(8, "big") + data
143
+
144
+
145
+ class _PacketReader:
146
+ def __init__(self, source: AsyncIterator[bytes]) -> None:
147
+ self._source = source.__aiter__()
148
+ self._buffer = bytearray()
149
+
150
+ async def _readexactly(self, size: int) -> bytes:
151
+ while len(self._buffer) < size:
152
+ try:
153
+ self._buffer.extend(await anext(self._source))
154
+ except StopAsyncIteration as e:
155
+ raise EOFError("ACP process closed its stdout") from e
156
+ data = bytes(self._buffer[:size])
157
+ del self._buffer[:size]
158
+ return data
159
+
160
+ async def read(self) -> dict:
161
+ size = int.from_bytes(await self._readexactly(8), "big")
162
+ if size > MAX_PACKET_BYTES:
163
+ raise ValueError(f"ACP session packet is too large: {size} bytes")
164
+ return json.loads((await self._readexactly(size)).decode())
165
+
166
+
167
+ class ACPHarnessSession(HarnessSession):
168
+ """A live ACP process, connection, and native session for one rollout."""
169
+
170
+ def __init__(
171
+ self,
172
+ harness: Harness,
173
+ ctx: ModelContext,
174
+ trace: Trace,
175
+ runtime: Runtime,
176
+ endpoint: str,
177
+ secret: str,
178
+ mcp_urls: dict[str, str],
179
+ data: TaskData,
180
+ env: dict[str, str],
181
+ command: list[str],
182
+ prompt: str | Messages | None,
183
+ system_prompt: str | None,
184
+ session_meta: dict | None,
185
+ ) -> None:
186
+ super().__init__(harness, ctx, trace, runtime, endpoint, secret, mcp_urls, data)
187
+ self.env = env
188
+ self.command = command
189
+ self.prompt = prompt
190
+ self.system_prompt = system_prompt
191
+ self.session_meta = session_meta or {}
192
+ self._process: RuntimeProcess | None = None
193
+ self._reader: _PacketReader | None = None
194
+ self._stderr_tail = bytearray()
195
+ self._stderr_task: asyncio.Task[None] | None = None
196
+ self._lock = asyncio.Lock()
197
+
198
+ async def _start(self) -> None:
199
+ self._stderr_tail.clear()
200
+ program = await self.runtime.prepare_uv_script(
201
+ ACP_SOURCE, {**self.env, "UV_FROZEN": "false"}
202
+ )
203
+ process = await self.runtime.open_process([*program, "stream"], self.env)
204
+ self._process = process
205
+ self._reader = _PacketReader(process.stdout)
206
+ self._stderr_task = asyncio.create_task(self._drain_stderr(process.stderr))
207
+
208
+ async def _drain_stderr(self, stream: AsyncIterator[bytes]) -> None:
209
+ async for chunk in stream:
210
+ self._stderr_tail.extend(chunk)
211
+ if len(self._stderr_tail) > 4000:
212
+ del self._stderr_tail[:-4000]
213
+
214
+ def _stderr(self) -> str:
215
+ return self._stderr_tail.decode(errors="replace").strip()
216
+
217
+ async def _run(self, messages: Messages | None) -> ProgramResult:
218
+ prompt = self.prompt if messages is None else messages
219
+ if prompt is None:
220
+ raise ValueError("ACP requires a prompt")
221
+ wire_messages = (
222
+ [{"role": "user", "content": prompt}]
223
+ if isinstance(prompt, str)
224
+ else [message_to_wire(message) for message in prompt]
225
+ )
226
+ config = {
227
+ "command": self.command,
228
+ "messages": wire_messages,
229
+ "mcp_urls": self.mcp_urls,
230
+ "system_prompt": self.system_prompt or "",
231
+ "session_path": None,
232
+ "session_meta": self.session_meta,
233
+ }
234
+ async with self._lock:
235
+ if self._closed:
236
+ raise HarnessError(
237
+ f"harness {self.harness.config.id!r} session is already closed"
238
+ )
239
+ if self._process is None:
240
+ await self._start()
241
+ assert self._process is not None
242
+ assert self._reader is not None
243
+ try:
244
+ await self._process.write(
245
+ _packet({"operation": "prompt", "config": config})
246
+ )
247
+ response = await self._reader.read()
248
+ except BaseException:
249
+ await run_shielded(self._stop(graceful=False))
250
+ raise
251
+ if not response.get("ok"):
252
+ detail = response.get("error") or "ACP session request failed"
253
+ if stderr := self._stderr():
254
+ detail = f"{detail}\n\nACP process stderr:\n{stderr}"
255
+ raise RuntimeError(detail)
256
+ return ProgramResult(exit_code=0, stdout=response.get("reply", ""), stderr="")
257
+
258
+ async def _stop(self, *, graceful: bool) -> None:
259
+ process, self._process = self._process, None
260
+ reader, self._reader = self._reader, None
261
+ stderr_task, self._stderr_task = self._stderr_task, None
262
+ if process is None:
263
+ return
264
+ failure: BaseException | None = None
265
+ try:
266
+ if graceful and reader is not None:
267
+ try:
268
+ await process.write(_packet({"operation": "shutdown"}))
269
+ response = await asyncio.wait_for(reader.read(), timeout=10)
270
+ if not response.get("ok"):
271
+ raise RuntimeError(
272
+ response.get("error") or "ACP session shutdown failed"
273
+ )
274
+ except BaseException as error: # noqa: BLE001 - finish teardown if cancelled
275
+ failure = error
276
+ try:
277
+ await asyncio.wait_for(process.wait(), timeout=10 if graceful else 0.1)
278
+ except BaseException: # noqa: BLE001 - cancellation still requires termination
279
+ with contextlib.suppress(Exception):
280
+ await asyncio.wait_for(process.terminate(), timeout=5)
281
+ try:
282
+ await asyncio.wait_for(process.wait(), timeout=5)
283
+ except BaseException: # noqa: BLE001 - cancellation still requires a kill
284
+ with contextlib.suppress(Exception):
285
+ await asyncio.wait_for(process.kill(), timeout=5)
286
+ with contextlib.suppress(BaseException):
287
+ await asyncio.wait_for(process.wait(), timeout=5)
288
+ finally:
289
+ if stderr_task is not None:
290
+ if not stderr_task.done():
291
+ stderr_task.cancel()
292
+ with contextlib.suppress(BaseException):
293
+ await stderr_task
294
+ if failure is not None:
295
+ detail = str(failure)
296
+ if stderr := self._stderr():
297
+ detail = f"{detail}\n\nACP process stderr:\n{stderr}"
298
+ raise RuntimeError(detail) from failure
299
+
300
+ async def close(self) -> None:
301
+ if self._closed:
302
+ return
303
+ # Publish closure before waiting for the process lock. A turn that
304
+ # already passed HarnessSession.turn()'s fast check rechecks under the
305
+ # same lock in _run(), so it cannot restart after teardown.
306
+ await super().close()
307
+
308
+ async def close_process() -> None:
309
+ async with self._lock:
310
+ await self._stop(graceful=True)
311
+
312
+ await run_shielded(close_process())