Repository navigation
feat: support RLM training over ACP #2386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 7 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
97d3062
feat: support RLM training over ACP
hallerite 6015f47
feat(v1): preserve ACP primary replies
hallerite 63aeea1
test(v1): assert concurrent RLM branches
hallerite 80372ab
ci(v1): track current nano-RLM contract head
hallerite bf5d432
fix(v1): canonicalize ACP primary replies
hallerite 79d2225
fix(v1): enforce ACP finalization contracts
hallerite 1730a00
fix(v1): bound exact idempotency replays
hallerite 7312127
fix(v1): preserve ACP shutdown failures
hallerite e4afc7b
test(v1): synchronize idempotent error retries
hallerite 9bb1633
fix(v1): keep ACP teardown best effort
hallerite abf76c2
ci(v1): pin merged nano-RLM contract
hallerite 21ad3d8
ci(v1): track merged nano-RLM main
hallerite 762650a
Merge main and track nano-RLM main
hallerite File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| import asyncio | ||
| import time | ||
|
|
||
| import httpx | ||
| import pytest | ||
|
|
||
| import verifiers.v1 as vf | ||
| from verifiers.v1.clients import ModelContext | ||
| from verifiers.v1.configs.client import EvalClientConfig | ||
| from verifiers.v1.errors import ProviderError | ||
| from verifiers.v1.interception import server as interception_server | ||
| from verifiers.v1.interception.server import InterceptionServer | ||
| from verifiers.v1.session import RolloutSession | ||
| from verifiers.v1.types import AssistantMessage, Response, Usage | ||
|
|
||
|
|
||
| class _BlockingClient: | ||
| def __init__(self) -> None: | ||
| self.calls = 0 | ||
| self.headers: dict[str, str] = {} | ||
| self.started = asyncio.Event() | ||
| self.release = asyncio.Event() | ||
|
|
||
| async def get_response(self, dialect, body, sampling, **kwargs) -> Response: | ||
| self.calls += 1 | ||
| self.headers = dict(kwargs["headers"]) | ||
| self.started.set() | ||
| await self.release.wait() | ||
| raw = { | ||
| "id": f"completion-{self.calls}", | ||
| "object": "chat.completion", | ||
| "created": 1, | ||
| "model": "test-model", | ||
| "choices": [ | ||
| { | ||
| "index": 0, | ||
| "message": {"role": "assistant", "content": "done"}, | ||
| "finish_reason": "stop", | ||
| } | ||
| ], | ||
| "usage": {"prompt_tokens": 2, "completion_tokens": 1}, | ||
| } | ||
| return Response( | ||
| id=raw["id"], | ||
| created=int(time.time()), | ||
| model="test-model", | ||
| message=AssistantMessage(content="done"), | ||
| finish_reason="stop", | ||
| usage=Usage(prompt_tokens=2, completion_tokens=1), | ||
| raw=raw, | ||
| ) | ||
|
|
||
|
|
||
| class _FailingBlockingClient: | ||
| def __init__(self) -> None: | ||
| self.calls = 0 | ||
| self.started = asyncio.Event() | ||
| self.release = asyncio.Event() | ||
|
|
||
| async def get_response(self, dialect, body, sampling, **kwargs) -> Response: | ||
| self.calls += 1 | ||
| self.started.set() | ||
| await self.release.wait() | ||
| raise ProviderError("rate limited", status_code=429) | ||
|
|
||
|
|
||
| def _session() -> tuple[vf.Trace, RolloutSession]: | ||
| trace = vf.Trace( | ||
| id="trace-1", | ||
| agent=vf.AgentInfo(config=vf.AgentConfig()), | ||
| task=vf.TraceTask( | ||
| type="Task", | ||
| data=vf.TaskData(idx=7, prompt="solve it"), | ||
| ), | ||
| ) | ||
| session = RolloutSession( | ||
| ctx=ModelContext( | ||
| model="test-model", | ||
| client=EvalClientConfig( | ||
| base_url="http://unused.invalid", api_key_var="NONE" | ||
| ), | ||
| ), | ||
| trace=trace, | ||
| ) | ||
| return trace, session | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_idempotency_key_coalesces_and_replays_one_model_call(): | ||
| trace, session = _session() | ||
| client = _BlockingClient() | ||
| session.client = client | ||
| body = {"model": "ignored", "messages": [{"role": "user", "content": "hi"}]} | ||
|
|
||
| async with InterceptionServer() as server: | ||
| secret = "rollout-secret" | ||
| server.sessions[secret] = session | ||
| headers = { | ||
| "Authorization": f"Bearer {secret}", | ||
| "idempotency-key": "call-1", | ||
| } | ||
| async with httpx.AsyncClient() as http: | ||
| first = asyncio.create_task( | ||
| http.post( | ||
| f"{server.base_url}/v1/chat/completions", json=body, headers=headers | ||
| ) | ||
| ) | ||
| await client.started.wait() | ||
| concurrent_retry = asyncio.create_task( | ||
| http.post( | ||
| f"{server.base_url}/v1/chat/completions", json=body, headers=headers | ||
| ) | ||
| ) | ||
| await asyncio.sleep(0) | ||
| client.release.set() | ||
| first_response, concurrent_response = await asyncio.gather( | ||
| first, concurrent_retry | ||
| ) | ||
| completed_retry = await http.post( | ||
| f"{server.base_url}/v1/chat/completions", json=body, headers=headers | ||
| ) | ||
| conflicting_retry = await http.post( | ||
| f"{server.base_url}/v1/chat/completions", | ||
| json={**body, "temperature": 0.5}, | ||
| headers=headers, | ||
| ) | ||
| distinct_call = await http.post( | ||
| f"{server.base_url}/v1/chat/completions", | ||
| json=body, | ||
| headers={**headers, "idempotency-key": "call-2"}, | ||
| ) | ||
|
|
||
| assert first_response.status_code == 200 | ||
| assert concurrent_response.json() == first_response.json() | ||
| assert completed_retry.json() == first_response.json() | ||
| assert conflicting_retry.status_code == 400 | ||
| assert distinct_call.status_code == 200 | ||
| assert client.calls == 2 | ||
| provider_keys = [ | ||
| value | ||
| for name, value in client.headers.items() | ||
| if name.lower() == "idempotency-key" | ||
| ] | ||
| assert len(provider_keys) == 1 | ||
| assert provider_keys[0] != "call-2" | ||
| assert len(trace.calls) == 2 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_idempotency_key_coalesces_original_error_without_caching_it(): | ||
| _, session = _session() | ||
| client = _FailingBlockingClient() | ||
| session.client = client | ||
| body = {"model": "ignored", "messages": [{"role": "user", "content": "hi"}]} | ||
|
|
||
| async with InterceptionServer() as server: | ||
| secret = "rollout-secret" | ||
| server.sessions[secret] = session | ||
| headers = { | ||
| "Authorization": f"Bearer {secret}", | ||
| "idempotency-key": "call-error", | ||
| } | ||
| async with httpx.AsyncClient() as http: | ||
| first = asyncio.create_task( | ||
| http.post( | ||
| f"{server.base_url}/v1/chat/completions", json=body, headers=headers | ||
| ) | ||
| ) | ||
| await client.started.wait() | ||
| concurrent_retry = asyncio.create_task( | ||
| http.post( | ||
| f"{server.base_url}/v1/chat/completions", json=body, headers=headers | ||
| ) | ||
| ) | ||
| while len(session.tasks) < 2: | ||
| await asyncio.sleep(0) | ||
| client.release.set() | ||
| first_response, concurrent_response = await asyncio.gather( | ||
| first, concurrent_retry | ||
| ) | ||
| assert client.calls == 1 | ||
| later_retry = await http.post( | ||
| f"{server.base_url}/v1/chat/completions", json=body, headers=headers | ||
| ) | ||
|
|
||
| assert first_response.status_code == 429 | ||
| assert concurrent_response.status_code == 429 | ||
| assert concurrent_response.content == first_response.content | ||
| assert later_retry.status_code == 429 | ||
| assert client.calls == 2 | ||
| assert "call-error" not in session.idempotent_requests | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_idempotency_cache_bounds_completed_responses(monkeypatch): | ||
| monkeypatch.setattr(interception_server, "IDEMPOTENCY_CACHE_MAX_COMPLETED", 1) | ||
| _, session = _session() | ||
| client = _BlockingClient() | ||
| client.release.set() | ||
| session.client = client | ||
| body = {"model": "ignored", "messages": [{"role": "user", "content": "hi"}]} | ||
|
|
||
| async with InterceptionServer() as server: | ||
| secret = "rollout-secret" | ||
| server.sessions[secret] = session | ||
| headers = {"Authorization": f"Bearer {secret}"} | ||
| async with httpx.AsyncClient() as http: | ||
| first = await http.post( | ||
| f"{server.base_url}/v1/chat/completions", | ||
| json=body, | ||
| headers={**headers, "idempotency-key": "call-1"}, | ||
| ) | ||
| await http.post( | ||
| f"{server.base_url}/v1/chat/completions", | ||
| json=body, | ||
| headers={**headers, "idempotency-key": "call-2"}, | ||
| ) | ||
| assert set(session.idempotent_requests) == {"call-2"} | ||
| evicted_retry = await http.post( | ||
| f"{server.base_url}/v1/chat/completions", | ||
| json=body, | ||
| headers={**headers, "idempotency-key": "call-1"}, | ||
| ) | ||
|
|
||
| assert evicted_retry.json() != first.json() | ||
| assert client.calls == 3 | ||
| assert set(session.idempotent_requests) == {"call-1"} | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.