Specify the wire protocol, and check it on every change - #1
Merged
Merged
Conversation
The repos shipped with no license at all, which makes them legally unusable and is a straight deduction in artifact review. Apache-2.0 matches openpi and lerobot, the two Apache-2.0 dependencies, and adds a patent grant. Also fills in the empty package descriptions and raises the setuptools floor to 77, the first release that understands the PEP 639 license fields used here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
plugrl-protocol had no CI, no pre-commit config and no tests at all, despite being the one package both sides depend on: every message between the training server and every env client goes through these four enum values and this serializer. A silent change here breaks both sides at once, so the tests pin the wire values and assert numpy arrays, uint8 image batches and the nested images/states/text observation shape all survive a round trip. Includes the ruff-format changes needed for the lint job to pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CITATION.cff validates against CFF schema 1.2.0. The author list is taken from pyproject.toml and lists one person; co-authors should be added before any submission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both drive a real plugrl-server through complete infer/action/feedback exchanges while sharing no code with PlugRL, which turns "the protocol is portable" from a claim into something a reader can check. The C++ one is the interesting case: 633 lines with no third-party libraries at all. It was written on a machine with no msgpack library, no WebSocket library and no OpenSSL, so SHA-1, base64, WebSocket framing and masking, and the msgpack subset the protocol needs are all in that one file. ldd shows libstdc++, libgcc_s, libc and libm and nothing else - the situation an embedded controller is actually in. The Python one needs msgpack and websockets and nothing else; notably no numpy, which matters because the dtype typestr is the only piece of numpy vocabulary on the wire and both clients parse it by hand in about ten lines. They belong here rather than with the server because this is the package they implement, and because it is public. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
raw_client.py imported struct but ended up using the array module; the docstring said struct too. Both fixed. Verified afterwards that both clients still complete exchanges against a real server - formatting should not change behaviour, but should-not is not did-not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/plugrl_protocol/msgpack_numpy.py is openpi's file, reformatted. Diffing it against third_party/openpi/packages/openpi-client/src/openpi_client/ msgpack_numpy.py shows only line-wrapping differences from ruff-format. openpi is Apache-2.0, Copyright 2024 Physical Intelligence. Apache-2.0 section 4 requires carrying attribution forward, and this repository's NOTICE claimed the whole work for PlugRL. That is a compliance defect, and this repository is already public, so it is fixed first. NOTICE now records the chain, which runs one step further than openpi: their file is itself adapted from lebedov/msgpack-numpy (BSD 3-Clause), as their docstring says. Worth stating plainly rather than burying: PlugRL's wire format IS openpi's wire format. That is not an accident to be embarrassed about - it means a PlugRL client and an openpi client speak the same bytes. But it does mean the transport was never PlugRL's invention, and nothing downstream should claim it as one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Until now the protocol existed only as two implementations that agreed with
each other. SPEC.md states it: transport settings, the msgpack ndarray
convention, the four messages, the exchange rules, the close reasons, and a
conformance checklist. It is written from the implementation and says so,
and where the implementation is under-specified it says that too rather
than inventing a rule.
Writing it down turned up five things worth knowing:
* The two servers disagree. WebSocketAgentServer sends env_ids alongside
the action; RayAgentServer sends no env_ids at all, ignores the
request's env_indices, and keeps one environment's state per
connection. It is an earlier single-environment dialect that was never
updated.
* The text field is the one place numpy leaks past the typestr. It ships
as a <U array - fixed-width UTF-32, NUL-padded, width set by the
longest string in that message - so a non-Python client has to
implement UTF-32 to read a prompt. Measured, not assumed.
* The two shipped clients disagree about how to write it.
plugrl-env-client sends the <U array; examples/raw_client.py sends a
plain msgpack string array. Both are accepted because the server never
validates observation contents.
* step_ids is required, sent, and read by nothing.
* The metadata message is always empty. Everything a client needs to know
out of band - action horizon, action shape, protocol version - would
naturally go there.
tests/test_spec_conformance.py makes the checkable half executable: bin
keys rather than str, C-order bytes from a non-contiguous view, byte-order
declarations, one byte per boolean, the rejected dtype kinds, and the text
encoding down to its padding bytes. A specification nobody runs is a
document that drifts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SPEC.md gave the protocol a written form. This gives it a way to be
checked. conformance_server.py speaks the protocol and validates every
clause visible from the server's side, then prints which one a client
broke and exits non-zero. plugrl-server cannot do this job: it validates
the message_type and hands the rest to the policy, which is right for a
training run and useless to someone bringing up a client in a new
language.
The report has two severities, and keeping them apart is the point. A
violation is something plugrl-server would reject or mishandle. A note is
something it accepts that differs from what plugrl-env-client sends - a
portability risk, not a breach. Collapsing the second into the first would
mean enforcing rules the protocol does not have, which is exactly how a
specification stops describing its implementation.
Both reference clients now pass, 24 clauses, with one note each: they send
text as a msgpack string array rather than a <U array. That is the section
3.4 Gap, reported rather than hidden.
Checking that the harness bites: four deliberately broken clients were run
against it. Two infers in a row, an observation with a fourth key, and
images and states with different leading dimensions were all caught. A
float64 reward was not - and that turned out to be the specification's
fault, not the harness's. SPEC.md said `<f4`; the server never inspects the
width. The spec now says float, notes that float32 is the convention, and
the harness reports the difference as a note. A specification written from
an implementation does not get to be stricter than it.
The C++ client changes are all conformance fixes found by reading it
against SPEC.md:
* pack_f8 and pack_i8 memcpy'd the host layout while declaring "<f8" and
"<i8". Right on x86, silently wrong on a big-endian controller, and
undetectable at the far end because the declared byte order would still
say little. Bytes are now emitted explicitly little-endian.
* The action decode assumed float32. The dtype is the environment's and
is never renegotiated, so it now parses the typestr. Verified: against
a server sending "<f8", the old binary printed 0 0 0 0 0 0 and the new
one prints 0 0 0 1 0 0, which is also the time-major layout of section
5.3 showing up in the values.
* A text frame where binary was expected is now fatal with the server's
message, per section 7.4, instead of being fed to the msgpack parser
and reported as "unsupported msgpack type 0x47".
* A frame length field is capped before allocating.
* The default port was 8123 while every document said 8000.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This repository asserts that the protocol can be spoken by something that is not this codebase. Until now that was demonstrated once, by hand, and would have stayed green in the README long after it stopped being true. The new job builds the C++ client, fails if ldd shows anything but the C++ and C runtimes, and drives conformance_server.py, which grades it against SPEC.md and exits non-zero on a violation. The Python reference client gets the same treatment. One step is subtler than the others. A client that hard-codes float32 sends perfectly valid messages and passes every clause a server can observe; it only misreads what comes back. So that step runs a server whose actions are float64 and whose values encode the horizon position, and asserts on the client's own output - which also pins the time-major layout of section 5.3. It is the regression test for a bug this client had until today. Verified locally against gcc 11.4: both clients pass, the ldd allowlist holds, and a deliberately misbehaving client makes the server exit 1. No -Werror: a newer compiler finding a new warning belongs in the log, not in a red build on an unrelated change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 5.1 described an extension point with nothing in it. plugrl-server fills it in as of today: protocol_version, server and version, algorithm and policy names, action_horizon and action_dim. The rule that makes the rest trustworthy is written down with them: an absent key means the server does not know, never that the value is zero or a default. A client written against the old empty message still works, which is why none of the keys are required. conformance_server.py sends the descriptive keys too, so a client that reads them is exercised and one that ignores them is proved not to break. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The abstract said PlugRL exists so that environments whose dependencies "cannot coexist with a modern deep learning stack" can still be used for reinforcement learning. plugrl-server's E1 experiment refutes exactly that, in four rounds, and keeps the refutation in the repository. Replaced with what survived and is checkable: separate processes joined by a small WebSocket protocol, independently installable and versionable, an env client that need not be Python, and openpi's serving format plus a feedback return channel - which is the difference between serving a policy and training one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README said `uv add plugrl-protocol` / `pip install plugrl-protocol`. None of the three PlugRL packages is on PyPI - all three return 404 - so following that instruction fails with no matching distribution. It now shows the pinned git install, which is how plugrl-server and plugrl-env-client actually depend on this package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The citation and package metadata named one author. The commit history of these three repositories names two: Chenhao Lu, and Zuo Gou, whose 31 commits here include the GRPO-diffusion policy and the policy-gradient base class that DPPO, FPO and PI0 all derive from - and who is the largest contributor to plugrl-protocol, at 12 commits to 9. Order is by who started the project, not by volume; reorder if you would rather it were otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tactino
marked this pull request as ready for review
September 11, 2026 15:14
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Gives the wire protocol a written specification, a way to check an
implementation against it, and CI that does so on every change. The licence
and packaging work this branch started with is now the smaller half.
The protocol is written down
SPEC.md(530 lines) is the first statement of what the bytes between thetraining server and an env client actually are: transport settings, the
msgpack ndarray convention, the four messages, the exchange rules, the close
reasons, a conformance checklist.
It is written from the implementation and says so. Where the
implementation is under-specified, or where two of our own servers disagree,
it says that in a box marked Gap rather than inventing a rule. Five of
those:
WebSocketAgentServersendsenv_idswith the action;RayAgentServersends none, ignoresenv_indices, and keeps one env's state per connection. An earlier single-env dialect that was never updated.textleaks numpy<Uarray — fixed-width UTF-32, NUL-padded, width set by the longest string in that message. Measured, not assumed.plugrl-env-clientsends the<Uarray;examples/raw_client.pysends a msgpack string array. Both accepted, because the server never validates observation contents.step_idsAuthorizationThe metadata message used to be a sixth: always
{}.plugrl-servernowfills it in, and section 5.1 documents the keys plus the rule that makes them
trustworthy — an absent key means the server does not know, never that the
value is a default.
It can be checked, not just read
examples/conformance_server.pyspeaks the protocol and grades a clientclause by clause, printing what it broke and exiting non-zero.
plugrl-servercannot do this job: it validates the
message_typeand hands the rest to thepolicy, which is right for a training run and useless to someone bringing up
a client in a new language.
The report has two severities and keeping them apart is the point. A
violation is something the real server would reject or mishandle. A
note is something it accepts that differs from what the Python client
sends — a portability risk, not a breach. Collapsing the second into the
first would mean enforcing rules the protocol does not have.
I checked that the harness bites: four deliberately broken clients were run
against it. Two infers in a row, a fourth key in the observation, and
mismatched leading dimensions were all caught. A float64 reward was not — and
that turned out to be the specification's fault. It said
<f4; the servernever inspects the width. The spec now says float, and the harness reports
the difference as a note.
CI checks the cross-language claim
This repository asserts that the protocol can be spoken by something that is
not this codebase. That was demonstrated once, by hand, and would have stayed
green in the README long after it stopped being true. The new job builds the
C++ client, fails if
lddshows anything but the C++ and C runtimes, anddrives the conformance server. The Python reference client gets the same.
One step is subtler: a client that hard-codes float32 sends perfectly valid
messages and passes every clause a server can observe — it only misreads what
comes back. So that step runs a server whose actions are
<f8and whosevalues encode the horizon position, and asserts on the client's own output.
It is the regression test for a bug the C++ client had until this branch.
Bugs this found in the C++ reference client
pack_f8andpack_i8memcpy'd the host layout while declaring<f8and<i8. Correct on x86, silently wrong on a big-endian controller, andundetectable at the far end because the declared byte order still said
little.
<f8server the old binaryprinted
0 0 0 0 0 0where the new one prints0 0 0 1 0 0.The rest
Apache-2.0 licence;
msgpack_numpy.pyattributed to openpi in the fileheader and in
NOTICE(it is openpi's file, reformatted only, and the wireformat is deliberately identical); CITATION.cff; pre-commit.
Tests: 30, from 0. The private-dependency CI problem this branch opened
with is resolved — the repository is public.
🤖 Generated with Claude Code