Skip to content

fix(policies/groot): a reply the client cannot read names the peer, not the codec - #3677

Merged
cagataycali merged 2 commits into
strands-labs:mainfrom
cagataycali:groot-unreadable-reply
Sep 15, 2026
Merged

cagataycali merged 2 commits into
strands-labs:mainfrom
cagataycali:groot-unreadable-reply

Conversation

@cagataycali

@cagataycali cagataycali commented Sep 15, 2026

Copy link
Copy Markdown
Member

What

Gr00tInferenceClient.call_endpoint decoded every reply frame ungraded. One wire fault - something on the port that is not the msgpack REQ/REP GR00T policy server - reached the caller as three different outcomes, two of them silent, none naming the host, the port or the request answered. Measured through the public client against main at 53e37a1:

Reply frame on the port Before After
HTTP error page from a proxy ExtraData: unpack(b) received extra data. ConnectionError: GR00T policy server at tcp://127.0.0.1:5555 answered 'get_action' with an unreadable reply: not msgpack (ExtraData: unpack(b) received extra data.); it begins b'<html>502 Bad Gateway</html>' ...
JSON (another policy server) ExtraData ... expected wire format named, frame quoted
the reference client's legacy b"ERROR" sentinel ExtraData ... same door, same report
msgpack int 42 TypeError: argument of type 'int' is not iterable ... expected a msgpack map or list, got int
msgpack str 'ok' returned str 'ok' as the declared dict ... expected a msgpack map or list, got str
msgpack str 'internal error' TypeError: string indices must be integers ... expected a msgpack map or list, got str
msgpack list [1, 2] through get_action returned 1 as the action dict ConnectionError: ... answered 'get_action' with a list this client cannot read as an action chunk: expected (action, info) ... got a list of 2 whose elements are ['int', 'int']

This is the GR00T sibling of #3676 (MoveIt2), found by reading the other MsgSerializer.from_bytes(self.socket.recv()) site in the package. Same seam, same report shape, one contract difference the reference server forces (below).

Why

The reference client (gr00t.policy.server_client.PolicyClient) already refuses one wrong-peer frame by name - a bare b"ERROR" gets "Make sure we are running the correct policy server" - and reads the error field only off a reply that isinstance(response, dict). Our client had neither guard: "error" in response is a membership test, so a string answered it False without raising and was returned as the declared dict, to fail one frame later in Gr00tPolicy._unpack_service_actions with AttributeError: 'str' object has no attribute 'items'; an int raised TypeError from the test itself; a string containing "error" took the server-error branch and raised TypeError: string indices must be integers.

Why a list is admitted here where #3676 refuses one. PolicyServer.run packs each handler's return value as-is, and get_action returns (action, info), which msgpack carries as a 2-element list - so the GR00T reply contract is map or list, and the reference client's isinstance(response, dict) and "error" in response is what lets that list through. _decode_reply grades the wire (one msgpack value, map or list); get_action then grades the envelope it alone understands (2 elements, first a map, or a bare legacy action map) instead of returning whatever list arrived. That second door was the same silent half at a different depth: [1, 2] returned 1, and any other length returned the list itself, each to fail on .items() later.

ConnectionError needs no private subclass: nothing between the seam and the caller catches OSError. ping still absorbs it (any failure means "not reachable"), and Gr00tPolicy.reset still logs it and continues - what changes is that the INFO line now names the peer and 'reset' where it logged unpack(b) received extra data.

MsgSerializer.from_bytes is annotated -> Any, which is what unpackb returns.

Tests

New tests/policies/groot/test_unreadable_reply_names_the_peer.py: 21 fail pre-fix, 33 pass after. Tables over 6 undecodable frames (including the legacy b"ERROR" sentinel), 5 decodable scalars and 4 malformed action envelopes; cells pin that both wire doors report in one class, that the endpoint is named rather than assumed, that the codec's own words are quoted, that the cause is preserved, and that the envelope report names element types rather than rendering an array. Controls pin the unchanged contract: a map round-trips, {"error": ...} still raises RuntimeError, (action, info) still unpacks, a bare action map still returns, a list reply is not indexed for error, ping still returns False, and reset still continues.

10/10 mutants caught (map-or-list guard removed, cause dropped, endpoint / uri / frame preview / codec detail / decoded type / remedy dropped, envelope refusal removed, dict-guard on the error test dropped), 1-14 cells each.

Gate: ruff check + format --check clean, mypy 0 issues in groot/client.py (0 on main too), 653 passed across tests/policies/groot + the two ZMQ-domain siblings. Whole-tree roster (145 graders): 4169 passed; every failure/error is mujoco / cv2 / serial absent from the venv or the utf8 subprocess cell unable to import the uninstalled package - the same 3 fail on unmodified main in the same venv, delta zero.

Composition with #3577. check_merge_base_overlap.py --all-open pairs this with #3577 by name (its test reaches strands_robots.policies.groot.policy, which #3577 edits; neither edits a path the other does). git merge-tree --write-tree of the two heads: 0 conflicts; tests/policies/groot/ on the composed tree: 425 passed, 2 skipped, including all 33 cells here.

LOC: +148/-10 production, +250 tests.

…ot the codec

Gr00tInferenceClient.call_endpoint decoded every reply frame ungraded. One
wire fault - something on the port that is not the msgpack REQ/REP GR00T
policy server - reached the caller as three different outcomes, two of them
silent, none naming the host, the port or the request answered: an
undecodable frame raised the codec's own ExtraData; a msgpack str was
returned as the declared dict and failed one frame later in the policy on
.items(); an int died on the 'error' in response membership test.

Grade the reply at a _decode_reply seam that knows the peer and the
endpoint, in the shape the moveit2 client now uses, with one difference the
reference server forces: a list is admitted, because PolicyServer packs
get_action's (action, info) tuple as a 2-element list. get_action then
grades that envelope itself rather than returning whatever list arrived as
the action dict. The 'error' field is read only off a map, which is the
reference client's own guard.

MsgSerializer.from_bytes is annotated Any, which is what unpackb returns.
@cagataycali
cagataycali marked this pull request as ready for review September 15, 2026 12:49
@cagataycali cagataycali moved this from Backlog to In review in Strands Labs - Robots Sep 15, 2026

@yinsong1986 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Grades the GR00T ZMQ client's reply direction at a single _decode_reply seam: bytes that are not msgpack and values that decode to neither a map nor a list are both refused with a ConnectionError naming the peer URI, the endpoint answered, the problem in the codec's own words, and the frame's opening bytes - replacing three divergent pre-fix outcomes (a bare ExtraData, a TypeError from the membership test, and a scalar silently returned as the declared dict). Unlike the MoveIt2 sibling (#3676), a list is deliberately admitted at the seam because the reference server's get_action returns (action, info) as a 2-element list; get_action then grades that envelope itself instead of returning whatever list arrived. The error-field read is now guarded by isinstance(response, dict), matching the reference client, and MsgSerializer.from_bytes is honestly annotated -> Any.

What's good

  • Verified locally at head 64ea1062: the new suite (33 cells) and the full tests/policies/groot directory (427 passed) are green, ruff is clean, and the relevant whole-tree graders (docstring xrefs, Raises: completeness, test-name, ASCII) all pass.
  • Controls pin the unchanged contract precisely: {"error": ...} keeps its RuntimeError (and is asserted not to be a ConnectionError), (action, info) still unpacks, the legacy bare action map still returns, a list reply is not indexed for error, ping still absorbs and returns False, and Gr00tPolicy.reset still continues while logging the peer.
  • ConnectionError without a private subclass is justified in the docstring by the actual catch topology (ping and reset absorb by contract; nothing else between the seam and the caller catches OSError), and the except (TypeError, ValueError) tuple has no member covering another, per AGENTS.md > Review Learnings (#86) > Exception Clauses Must Be Narrow.
  • Changelog fragment named for the PR number, ASCII-only user-facing strings, no host paths, scope disciplined to the one seam plus its tests.

@cagataycali
cagataycali enabled auto-merge (squash) September 15, 2026 13:10
@cagataycali
cagataycali merged commit 536e3da into strands-labs:main Sep 15, 2026
12 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in Strands Labs - Robots Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants