Skip to content

M2-29 Python SDK with hand layer and FakeServer

No Date Ruling Reason
D-M2-29-0 2026-10-23 Built on the old integration line (base int/m1-wave53) with M2-09, M2-10 and M2-14 stacked, and ported to main after they landed: only the ticket's own commits were brought over as one change. The spec commit that ported the inbox acknowledgement of 04 15.2 and 09 4.3, 4.9, 15, 16.2 and 19 is dropped, because main already carries it; the additions to the German chapter and ADR files of the old base go to their English files on main, and the references in the SDK's docstrings name the English files; changelog entries and rulings are fragments (PL/24). The rules of the landing replace the integration branches; main is the reference.
D-M2-29-1 2026-10-21 Module set of the hand layer: the modules of 11 11.2 except webhooks, plus adapter (acceptance and run loop), inbox (durable inbox), context (trust marks) and the infrastructure modules settings, clock, ids, auth, errors, retry, client, sync (the synchronous hull) and sources. 4.3, 4.6 and 4.9 of 09 are duties of every adapter, so the SDK builds them once (ADR 0026, ADR 0054 "Consequences").
D-M2-29-2 2026-10-21 SdkSettings holds one field per named value with its key in the field metadata; the server-side limits (component.*, command.*, agent.run_update_min_interval) are only defaults of the SDK's early checks, the server stays the authority and its 400 validation_failed reaches the caller. New key sdk.poll_interval (SDK, 2s). Every time decision goes through Clock; jitter through an injectable random source. A REST polling adapter needs a named wait, far below ratelimit.rl.agent_runtime.per_minute; tests drive time with FakeClock.
D-M2-29-3 2026-10-21 DPoP keys are ES256 (P-256) in a PKCS#8 file of mode 0600 written with O_EXCL, or any DpopKeySource; jkt is the RFC 7638 thumbprint. Clear text (http, ws) only for a loopback host. The gateway proof's htu is the HTTPS form of the gateway URL (scheme https, host and port kept, a default port dropped), not ws turned into http as the brief says. 08 7.3 names ES256; a key store plugs in without a new dependency; a token never travels in clear text off the host; GatewayIdentify.dpop in openapi.yaml and the Go handler (HTU) require the HTTPS form, the brief's form fails with 4004.
D-M2-29-4 2026-10-21 Retries: GET, HEAD, PUT, DELETE always; POST and PATCH only with Idempotency-Key or on a route of x-idempotency-exempt with reason client_id or agent_runtime (EXEMPT_ROUTES, held equal to openapi.yaml). Repeated: network errors and timeouts, 5xx except 503 setup_required, 429, 409 request_in_progress; wait the larger of Retry-After and full jitter; at most sdk.retry_max_attempts. The client adds a UUIDv7 Idempotency-Key once per logical call to every non-exempt POST and PATCH; every attempt gets a fresh DPoP proof. 06 7 point 3; a DPoP jti is single use, so the proof is rebuilt per attempt while the key stays the same.
D-M2-29-5 2026-10-21 Close codes for agents: stop without reconnect on 1000, 4004, 4007, 4011, 4012, 4015, 4016, 4017, 4018; resume after backoff on 1001, 4000, 4009, 4013 (first try after a random part of sync.reconnect_backoff_base), 4014; identify on 4006; 4008 waits the retry_after of the close reason; log and reconnect on 4001, 4002, 4003, 4005 and unknown codes. Adapter.run() returns the CloseReason. An agent cannot renew its API token; only the owner issues a new one (04 6.3 step 6).
D-M2-29-6 2026-10-21 Acknowledgement: the acknowledged seq is the highest one up to which every delivered event is settled (accepted, refused with a stored run.finish, a duplicate, an event without token handed to on_event, an unknown type, a cursor). After every (re)connect the window starts over and acknowledges again, because a server restart can set the cursor back. ack is cumulative (04 15.2 point 1); without the restart the redelivery after a rewound cursor came once more after agent.ack_timeout.
D-M2-29-7 2026-10-21 Verifier: PyJWT with cryptography, algorithms=["EdDSA"] only for the signature; header, base64url, duplicate members, iss, aud (one string) and times are compared by the SDK itself, in the precedence of M2-06's reference verifier. Payload identifiers must parse as UUIDs. A token whose triggering object has no counterpart in the payload is INVALID (also agent.invocation with trigger_kind = command until M2-16 adds command_id). A sub missing from the allow list after a reload is INVALID. check takes the actual state as a function of the verified claims, so the handler is asked with verified claims at the place of points 8 and 10. 09 4.5 names exactly three refusal reasons; fail closed; the precedence holds without a second check that would see the jti as a replay.
D-M2-29-8 2026-10-21 A refused token on an event that would start or resume a run (agent.invocation, or agent.followup and interaction.created for a waiting run) ends the run with finishRun (failed and invalid_invocation_token, billing_mode_mismatch or tool_class_mismatch); on any other event it is logged and skipped, the run is not ended. An unknown key leaves the event unsettled; a replayed jti under a new event ID is ignored and not settled. 09 4.5 refuses a start; a forged event must not let anyone end a running run.
D-M2-29-9 2026-10-21 The inbox is a SQLite file of mode 0600 (synchronous = FULL, rollback journal) in the state directory; it holds tokens that expire after agent.invocation_token_ttl and the jti store. A redelivery of an accepted entry replaces the token when its iat is not older; equal iat takes the later arrival. An adapter stop keeps accepted entries for the next process; a restart closes started and waiting entries without a call. 04 15.2 point 4 says "the new token"; 09 4.9: no run is retried after its runtime died.
D-M2-29-10 2026-10-21 The patcher keeps the desired state per path and sends at most one patch per agent.run_update_min_interval; a later value replaces an earlier one, an add_step with later updates goes out as one add. Transport REST patchRun (new version and current in the answer); use_gateway sends run.update, assumes base_version + 1 and loads getRun after base_version_conflict. run_not_active and run_inactive stop the patcher and end the work on the run. Set semantics of M2-10; the gateway has no success reply.
D-M2-29-11 2026-10-21 Context format: a fixed notice, then channel and thread context in ascending thread_seq, each with a header line (names quoted as JSON strings); unverified content and every file text inside <<<DATA <boundary> source=... id=...>>> blocks with a fixed notice line and a 16-hex boundary from secrets chosen again on a collision; a trust value the SDK does not know counts as data. ADR 0054 spotlighting; an author cannot forge the end of a block without the boundary.
D-M2-29-12 2026-10-21 Approval card custom IDs approval.once, approval.session, approval.always, approval.deny. Commands are reconciled after every ready only when declared: own commands from listChannelCommands over listServedChannels, then register, update with If-Match, remove; a 409 conflict on register is logged and skipped. 09 11.5; 09 10.2 asks for one request that the API does not offer (spec gap).
D-M2-29-13 2026-10-21 Heartbeat forms as M2-20 decodes them: names ^[a-z0-9][a-z0-9._-]*$, versions ^[0-9A-Za-z][0-9A-Za-z.+_-]*$, codes ^[a-z0-9][a-z0-9_.:-]*$, matched in full; an unknown key or a value outside its form raises naming the field, never the value. A heartbeat must never carry a secret, content or a path (09 4.8).
D-M2-29-14 2026-10-21 Recorded fixtures: agent.invocation, run and discovery are recorded from the real server with BOTHY_SDK_RECORD_FIXTURES=1 (IDs and times normalized, seq 1, no delivery fields); run_context (the server answers getRunContext only with M2-13) and the events the scenarios do not receive from the server of this tree (agent.followup, interaction.created, command.invoked, command.autocomplete_requested, run.cancel_requested, run.state_changed) stay hand-made from the schemas until a later recording replaces them. 11 11.2 "recorded fixtures"; the FakeServer builds its envelopes from them.
D-M2-29-15 2026-10-21 services/server/test/sdkserver is a test binary (go test -c -tags sdkserver) speaking JSON lines; a TCP forwarder is the stable origin across restarts; the real-server tests run on real time with agent.invocation_token_ttl 5s, auth.clock_leeway 1s, agent.ack_timeout 2s, agent.ack_persist_interval 100ms, sync.heartbeat_interval 2s and the SDK leeway 1s. pgtest and the harness need a testing.TB; the SDK and the server share no clock.
D-M2-29-16 2026-10-21 Dependencies: runtime PyJWT, cryptography, websockets (compatible ranges from the tested versions), extra testing with aiohttp; async tests on the anyio plugin of httpx; mkdocstrings and mkdocstrings-python pinned in tools/. certifi moves to the release that fixes PYSEC-2026-4320 in both locks. 11 11.3: few dependencies; every license is on the allowlist.
D-M2-29-17 2026-10-21 The reference page docs/sdk/python.md renders every public hand-layer module with mkdocstrings and leaves bothy_sdk.generated out. The core is documented by openapi.yaml; its generated docstrings would only repeat it.
D-M2-29-18 2026-10-21 Names beside the brief's: Adapter takes a further keyword http_transport (tests) and offers make_source(); Client offers call_as, call_detailed, set_run_token and idempotency_key; RunHandle exists before the start (for prepare) and gets its patcher at started(); InvocationVerifier.check takes the actual state or a function of the claims; stored_claims reads a token the adapter accepted before; AutocompleteTooManyChoicesError; the error classes keep the brief's names without Error suffix. RunHandle.finish sends costs only when given: the server refuses them with 400 validation_failed and the field code not_enabled until cost reports land. Each addition keeps a rule of the brief provable; the brief allows private helpers.
D-M2-29-19 2026-10-21 Outbox and SR9: an entry goes out under the binding of the run it was stored in (the run passed as run_id, else the run the client was bound to), with the newest token the adapter knows for that run (also after the run ended, so the server answers run_inactive and the entry fails); an entry stored outside any run goes out unbound; an entry of a run without a known token fails with run_token_unavailable and is never sent unbound. MessageCreate.run_id is set only when the caller passes it. 09 4.7: every request of the adapter inside a run carries Invocation-Token; a later flush after a reconnect runs outside the run's context.
D-M2-29-20 2026-10-21 The scenario adapters of bothy_sdk.testing.scenarios send their heartbeat without reported_tool_class. The server keeps the reported class of the last heartbeat; a reported host makes it refuse the next trigger of a person who is not the owner (tool_class_owner_only, SR2) before the adapter sees a token, so the adapter-side AZ-P6 case raced the server-side rule.
D-M2-29-21 2026-10-23 The SDK tests wait for a condition with bothy_sdk.testing._patience (eventually, within_patience), whose only bound is quality.test.patience (180 s, like the Go testkit.Patience); the scenario helper fails only after its server-time limit and the patience both passed; tests/test_no_fixed_waits.py refuses wait_for with a literal timeout and polling loops of fixed turns in the tests and in bothy_sdk.testing. test_rate_limited_close_waits_retry_after failed under load because its wait gave up after 100 turns before the gateway slept on the clock; the same pattern was in seven other places.
D-M2-29-22 2026-10-23 From the pull request review: the inbox and outbox refuse a state directory that group or others can write (POSIX); the JWKS of the discovery is fetched only from the origin of the API base (Client.origin_url); a second use_dpop_nonce makes the polling source poll again after the backoff instead of stopping; 409 request_in_progress keeps an outbox entry pending; the run patcher schedules the changes set while a timed patch was on its way; SdkSettings.from_mapping accepts only positive values; command reconciliation compares options and control with their defaults when the declaration leaves them out. Whoever can write the directory could swap the checked database file before SQLite opens it; every request of the shared HTTP client carries the API token; a nonce rotation is transient (06 3.1); 06 7 keeps a key in progress waiting; without the reschedule a change could stay unsent until the next one.