Wire schemas
The protobuf messages that define every wire and state format the runtime uses.
StableImplemented, specified, and covered by tests in the repository.
Seven protobuf messages, defined once in protos/beam_agents.proto under
package beam_agents.v1, describe every byte this runtime persists or puts on a
wire: MemoryBlob, ToolIntent, ToolResult, TraceEvent, AgentEnvelope,
Continuation, and LlmCacheBlob.
Generated Python bindings are committed under src/beam_agents/_protos/, and
regenerating them with scripts/gen_proto.sh must produce no diff. Committing
generated code and then gating it on a drift check is the trade this repository
makes repeatedly: the artifact is readable and importable without a build step,
and it cannot silently disagree with its source.
Why a schema at all
A stateful agent runtime persists two kinds of thing — state that outlives a bundle, and messages that cross a process boundary — and both need a format that survives a redeploy. Python pickle survives neither: it is not stable across library versions, it is not readable by the effector (a separate process), and it is not safe to load from a queue.
Declaring the formats as protobuf makes the state layout an explicit, reviewable artifact and makes cross-language consumers possible. It is also what lets the proto coders advertise determinism to Beam, which is the precondition for using these types as keys.
What each message is for
AgentEnvelope— the single keyed input type. One entry point means the transform's signature does not multiply as event kinds are added.MemoryBlob— versioned working memory, ordered least-recently-used first, withtotal_value_bytescarried alongside so size accounting does not require a rescan. See the memory facade.Continuation— framework-opaque resume state for a suspended activation. The runtime stores the bytes the agent handed it and gives them back, without interpreting them; that opacity is what lets an adapted LangGraph graph suspend without the runtime knowing anything about graphs.ToolIntent— a declarative request to perform a side effect, carrying a deterministicintent_idand anexpires_at_ms. Deterministic identity is what makes effectively-once execution possible downstream.ToolResult— the correlated outcome, with a terminal status.TraceEvent— shaped to the OpenTelemetry GenAI conventions, so traces are consumable by tooling that has never heard of this project.LlmCacheBlob— versioned replay-cache entries, also LRU-orderable.
Evolution
Schema changes are additive, and guarded by golden blobs: a stored blob written by an older version must still decode. Field numbers are not reused and fields are not repurposed, because the state in a running pipeline is older than the code reading it — always.
Related
- Proto coders — how these messages become Beam coders.
- Architecture — where each message travels.
- Traces — the
TraceEventattribute set. - The four outputs —
ToolIntentandToolResultin a pipeline.
Published verbatim from openspec/specs/wire-schemas/spec.md — 11 requirements, 31 scenarios. Each scenario is the source a test is derived from and named after.
Purpose
TBD - created by archiving change add-wire-schemas-and-coders. Update Purpose after archive.
Requirements
Requirement: Proto package and committed generation
The project SHALL define all wire and state message schemas in protos/beam_agents.proto with syntax = "proto3" and package beam_agents.v1. Generated Python bindings SHALL be committed under src/beam_agents/_protos/ and regenerating them via scripts/gen_proto.sh MUST produce no diff against the committed files.
Scenario: Regeneration is diff-clean
- WHEN
scripts/gen_proto.shis run on a clean checkout - THEN
git diff --exit-codeoversrc/beam_agents/_protos/reports no changes
Scenario: Bindings are importable from the installed package
- WHEN a test imports the message classes from
beam_agents._protos - THEN all eight top-level message classes (
MemoryBlob,ToolIntent,ToolResult,TraceEvent,AgentEnvelope,Continuation,LlmCacheBlob,ActivationErrorRecord) are available without anysys.pathmanipulation
Requirement: MemoryBlob carries versioned, LRU-orderable working memory
MemoryBlob SHALL contain a state_schema_version field (uint32, set to 1 for this schema), a repeated MemoryEntry field where each entry has a string key, bytes value, and int64 last_access_ms, and a total_value_bytes field caching the sum of entry value sizes. Entry order SHALL be preserved exactly as written so LRU ordering is representable without a map field.
Scenario: Entries round-trip in insertion order
- WHEN a
MemoryBlobis built with entries["a", "b", "c"]in that order, serialized, and parsed back - THEN the parsed entries appear in the order
["a", "b", "c"]with identical keys, values, andlast_access_ms
Scenario: Schema version defaults are explicit
- WHEN a new
MemoryBlobis constructed by runtime code - THEN
state_schema_versionis set to 1, and a parsed blob withstate_schema_version0 is distinguishable as pre-versioned/uninitialized
Requirement: ToolIntent carries deterministic identity and expiry
ToolIntent SHALL contain intent_id (string), entity_key (bytes), seq (int64), step_index (uint32), tool_name (string), args_json (string holding canonical JSON), created_at_ms, expires_at_ms, attempt (uint32), kind (a Kind enum with values TOOL_KIND_UNSPECIFIED = 0, TOOL, APPROVAL), and trace_id (bytes, field 11). The schema SHALL NOT compute or default intent_id; it only transports the caller-computed uuid5 value. trace_id is likewise transported, not computed: it carries the emitting activation's 16-byte trace identifier so the effector can execute the intent inside the pipeline's trace. trace_id is additive under state_schema_version = 1 — an intent written before the field existed decodes with trace_id empty, which readers treat as "no trace correlation available" rather than an error. The kind field SHALL be additive under the existing state_schema_version: a reader SHALL treat TOOL_KIND_UNSPECIFIED and TOOL identically, so an intent written before kind existed is read as a tool call, and only an explicit APPROVAL SHALL be routed to a human approval channel rather than executed.
ToolIntent SHALL additionally carry a signature envelope as three additive fields taking the next free field numbers, above every existing field: signature_scheme (an enum with SIGNATURE_SCHEME_UNSPECIFIED = 0 and HMAC_SHA256 = 1), signing_key_id (string), and signature (bytes). Like intent_id, these fields are transported, never computed by the schema; the signature is defined over the deterministic serialization of the message with all three signature fields cleared. Any future ToolIntent field SHALL take a new field number above all existing ones (the project's additive-evolution rule), which keeps the cleared-fields signing input stable across schema skew: an older verifier preserves the newer field as an unknown field and re-serializes it after the known fields, matching the signer's sorted-by-number layout. An intent whose signature_scheme is unspecified and whose signature is empty SHALL read as unsigned. This is an additive change under state_schema_version = 1; no version bump.
Scenario: All identity fields round-trip
- WHEN a
ToolIntentwith every field populated is serialized and parsed back - THEN every field compares equal, including exact preservation of the
args_jsonstring, thekindvalue, and the 16 bytes oftrace_id
Scenario: Expiry is representable for fail-closed enforcement
- WHEN an intent is created with
expires_at_msset - THEN the parsed message exposes
expires_at_msso the effector can refuse expired intents without consulting external state
Scenario: An intent written without trace_id still decodes
- WHEN a golden
ToolIntentblob predating thetrace_idfield is parsed with the current bindings - THEN parsing succeeds,
trace_idreads as empty bytes, and every other field compares equal to its expected value
Scenario: Approval intents are distinguishable on the wire
- WHEN an intent created for a human approval request is serialized and parsed back
- THEN its
kindisAPPROVAL, distinguishable from aTOOLintent without inspectingtool_name
Scenario: An intent written before kind existed reads as a tool call
- WHEN a
ToolIntentblob serialized without thekindfield is parsed - THEN
kindreads asTOOL_KIND_UNSPECIFIEDand is treated as a tool call, not as an approval request
Scenario: Signature fields round-trip
- WHEN a
ToolIntentcarryingsignature_scheme = HMAC_SHA256, a non-emptysigning_key_id, and 32 signature bytes is serialized and parsed back - THEN the scheme, key id, and exact signature bytes compare equal
Scenario: A pre-signature intent decodes as unsigned
- WHEN a blob serialized before the signature fields existed is parsed with the current bindings
- THEN parsing succeeds,
signature_schemereads asSIGNATURE_SCHEME_UNSPECIFIED,signatureis empty, and the intent is treated as unsigned
Scenario: A future-field intent still yields a stable signing input
- WHEN a blob carrying an unknown field with a number above the signature fields is parsed, its signature fields cleared, and the message re-serialized deterministically
- THEN the re-encoded bytes match the signer's cleared-fields serialization, so verification succeeds across the schema skew
Requirement: ToolResult correlates outcomes with terminal statuses
ToolResult SHALL contain intent_id (string), entity_key (bytes), seq (int64), a Status enum with values STATUS_UNSPECIFIED = 0, OK, ERROR, EXPIRED, REJECTED, a payload (bytes), error_message (string), and completed_at_ms.
Scenario: Every status value is representable
- WHEN a
ToolResultis constructed with each declared status value in turn and round-tripped - THEN each parses back to the same status, and an unset status reads as
STATUS_UNSPECIFIED
Requirement: TraceEvent aligns with OTel GenAI conventions
TraceEvent SHALL contain trace_id, span_id, parent_span_id (bytes), entity_key (bytes), seq (int64), step_index (uint32), an EventType enum (EVENT_TYPE_UNSPECIFIED = 0, ACTIVATION_START, LLM_CALL, TOOL_CALL, INTENT_EMITTED, ACTIVATION_END, ERROR, SUSPENDED = 7), a map<string, string> attributes field for OTel GenAI semantic-convention attributes, and start_ms/end_ms. trace_id SHALL be 16 bytes and span_id/parent_span_id 8 bytes, matching the W3C trace-context and OTel wire formats. SUSPENDED is additive under state_schema_version = 1: a reader that predates it decodes the value as an unrecognized enum number rather than failing.
Scenario: GenAI attributes survive round-trip
- WHEN a
TraceEventcarrying attributes such asgen_ai.request.modelandgen_ai.usage.input_tokensis serialized and parsed back - THEN all attribute keys and values compare equal
Scenario: Correlation identifiers round-trip at their wire widths
- WHEN a
TraceEventis built with a 16-bytetrace_idand 8-bytespan_idandparent_span_id, serialized, and parsed back - THEN all three compare equal byte-for-byte at their original widths
Scenario: The suspension event type round-trips
- WHEN a
TraceEventwithevent_type = SUSPENDEDis serialized and parsed back - THEN the parsed event reports
SUSPENDED, and the same bytes parsed by bindings that predate the value expose it as an unrecognized enum number without a parse error
Requirement: Continuation persists framework-opaque resume state
Continuation SHALL contain state_schema_version (uint32, set to 1), seq (int64), step_index (uint32), repeated pending_intent_ids (string), adapter (string discriminator), snapshot (opaque bytes), suspended_at_ms, deadline_ms, and escalations (uint32, the number of timeout escalations already performed for this suspension). The runtime SHALL treat snapshot as opaque — no parsing, no schema. The escalations field SHALL be additive under the existing state_schema_version, defaulting to 0 for a blob written before it existed.
Scenario: Suspension state round-trips
- WHEN a
Continuationwith multiplepending_intent_ids, a non-emptysnapshot, and adeadline_msis serialized and parsed back - THEN all fields compare equal and
snapshotbytes are byte-identical
Scenario: Escalation count round-trips and defaults to zero
- WHEN a
Continuationblob serialized withoutescalationsis parsed, and aContinuationwithescalationsset is serialized and parsed back - THEN the first reads
escalations == 0and the second preserves its value exactly
Requirement: LlmCacheBlob carries versioned, LRU-orderable replay-cache entries
LlmCacheBlob SHALL contain a state_schema_version field (uint32, set to 1 for this schema), a repeated LlmCacheEntry field where each entry has a string cache_key, bytes response, bytes response_digest, int64 created_at_ms, int64 last_access_ms, and bool digest_only, and a total_response_bytes field caching the sum of stored response sizes. Entry order SHALL be preserved exactly as written so LRU ordering is representable without a map field.
Scenario: Cache entries round-trip in insertion order
- WHEN an
LlmCacheBlobis built with entries keyed["a", "b", "c"]in that order, serialized, and parsed back - THEN the parsed entries appear in the order
["a", "b", "c"]with identical field values for every entry field
Scenario: Digest-only entries are representable
- WHEN an entry is written with
digest_onlyTrue, an emptyresponse, and a 32-byteresponse_digest, then round-tripped - THEN the parsed entry preserves
digest_onlyTrue, emptyresponsebytes, and the exact digest bytes
Requirement: Schema evolution is additive and golden-blob guarded
Schema changes SHALL be additive by default (new fields with new numbers; removals use reserved statements). A breaking change is permitted ONLY for the three versioned keyed-state messages (MemoryBlob, Continuation, LlmCacheBlob) and ONLY through a state_schema_version bump accompanied by a registered migration function for every versioned message and a frozen golden corpus entry for the outgoing version (see state-migration); the unversioned wire messages (ToolIntent, ToolResult, TraceEvent, AgentEnvelope, ActivationErrorRecord) remain additive-only with no bump escape hatch. Every schema change, breaking or not, MUST keep previously written bytes parseable by the current descriptor: an existing field number SHALL never be retyped or reused, because migration operates on decoded messages and runs only after a successful parse. Golden serialized blobs SHALL be committed per schema version under tests/core/golden/v<N>/, and a compat test SHALL decode every golden blob with the current bindings, migrate historical versions to current, and assert field-level equality against expected values. Golden tests SHALL NOT require byte-identical re-encoding.
Scenario: Golden blobs decode with current bindings
- WHEN the compat test parses each committed golden blob under
tests/core/golden/v<N>/ - THEN every blob decodes without error and, after migration to the current version, all expected field values compare equal
Scenario: Golden corpus is laid out per version
- WHEN the compat suite collects its fixtures
- THEN every committed blob lives under a
v<N>directory matching thestate_schema_versionregime its builders were frozen at, and no fixture sits outside a version directory
Scenario: Unknown fields are tolerated on decode
- WHEN a blob containing an unknown (future) field number is parsed with the current bindings
- THEN parsing succeeds and all known fields compare equal, demonstrating forward-compatible decode
Scenario: Unknown fields survive re-encode
- WHEN a blob containing an unknown (future) field number is parsed with the current bindings and then re-serialized
- THEN the re-encoded bytes still contain the unknown field's data, so an older reader rewriting state during pipeline
--updatenever drops fields written by a newer schema
Requirement: ActivationErrorRecord carries dead-letter triage fields
ActivationErrorRecord SHALL be a top-level message in protos/beam_agents.proto containing entity_key (bytes), reason (string), detail (string), and event_time_ms (int64). It is the wire form of a .errors dead letter; when published to a message-bus errors sink it SHALL travel inside AgentEnvelope.external_event as deterministically serialized bytes. The AgentEnvelope schema itself SHALL NOT change: external_event remains opaque bytes, and carrying an ActivationErrorRecord there is a documented convention, not a schema constraint.
Scenario: All fields round-trip
- WHEN an
ActivationErrorRecordwith every field populated is serialized and parsed back - THEN
entity_key,reason,detail, andevent_time_msall compare equal
Scenario: Deterministic serialization is byte-stable
- WHEN the same
ActivationErrorRecordis serialized twice with deterministic serialization - THEN both byte strings are identical
Requirement: AgentEnvelope is the single keyed input type with four payload variants
AgentEnvelope SHALL contain entity_key (bytes), event_time_ms (int64), and a oneof payload with exactly four variants: external_event (opaque bytes), tool_result (ToolResult), approval (a nested Approval message with intent_id, approved bool, approver string, decided_at_ms), and export_request (a nested StateExportRequest message carrying a request_id string). The runtime SHALL NOT impose any schema on external_event bytes. export_request is additive under state_schema_version = 1: an envelope written before the variant existed decodes unchanged, and a reader that predates it sees the new variant as an unknown field rather than a parse failure.
Scenario: Exactly one payload variant is set
- WHEN an
AgentEnvelopeis constructed with atool_resultpayload and then assigned anapprovalpayload - THEN the
oneofreports onlyapprovalas set and thetool_resultfield is cleared
Scenario: All four variants round-trip
- WHEN one envelope of each payload variant is serialized and parsed back
- THEN each parsed envelope reports the correct
oneofcase with field-equal contents
Scenario: An envelope written before export_request still decodes
- WHEN a golden
AgentEnvelopeblob predating theexport_requestvariant is parsed with the current bindings - THEN parsing succeeds and every field compares equal to its expected value
Requirement: StateSnapshot carries a versioned per-key state image
StateSnapshot SHALL contain state_schema_version (uint32, set to 1), entity_key (bytes), seq (int64, the key's SEQ counter at export), snapshot_at_ms (int64, the export request's event_time_ms), memory (MemoryBlob), llm_cache (LlmCacheBlob), continuation (Continuation, present only when the key is suspended), repeated pending (ToolIntent), and request_id (string, echoed from the originating StateExportRequest). Embedded blobs SHALL be carried verbatim as committed — the snapshot SHALL NOT re-version, migrate, or reorder their contents at export time.
Scenario: A populated snapshot round-trips
- WHEN a
StateSnapshotwith a populated memory blob, cache blob, continuation, and two pending intents is serialized deterministically and parsed back - THEN every field compares equal, including entry order within the embedded blobs
Scenario: Embedded blobs keep their own schema versions
- WHEN a snapshot embeds a
MemoryBlobwhosestate_schema_versiondiffers from the snapshot envelope's - THEN the round-tripped blob reports its original version unchanged, leaving migration to the loader
What backs this page
- Specification
- openspec/specs/wire-schemas/spec.md
- Test
- tests/core/test_wire_schemas.py