Language guide

AgentSpaces for Python

Write agents as decorated methods on plain Python classes and entries as dataclasses. A Python peer holds its own key, dials a Node or JVM seed over TCP or TLS, and takes work under the same lease rules as every other peer in the fleet.

Python 3.11 or newerone dependency, cryptographypackage agentspaces-client 0.5.0, installed from a clonedials tcp and tlsneeds a seed that listens (Node or JVM)
aspace://guide/python/intro

Introduction

This part takes you from an empty directory to two Python processes sharing work. It uses a few words throughout, so here they are first:

  • A peer is one running process that is a member of the fleet. It has its own Ed25519 key and signs everything it writes.
  • A group is the set of peers that work together. Its id is derived from a founding text, so every peer that knows the text knows the group.
  • A space is a named, replicated store inside a group. Every peer holds a copy.
  • An entry is one typed value in a space, such as an order or a shipment.
  • A lease is how long something lasts. An entry written with a 10 minute lease disappears after 10 minutes.
  • A take claims one entry for one peer. If the peer finishes, it completes the take. If it crashes, the take's lease lapses and the entry comes back for another peer.
  • An agent is a class whose methods react to entries. A capability is a fleet service an agent can use, such as a vote or an average.

Install

The client is the package agentspaces-client. It needs Python 3.11 or newer and has one dependency, cryptography. It is not on PyPI yet, so install it from a clone:

git clone https://github.com/badmonkeyai/agentspaces-python
python3 -m venv .venv
.venv/bin/pip install ./agentspaces-python

The package installs one module tree, agentspaces. You will import from agentspaces.peer, agentspaces.identity, agentspaces.space, agentspaces.agents, and agentspaces.capabilities.

Start a seed

A Python peer dials out and never listens. So it needs a seed: a peer that listens and that other peers dial to join. The easiest seed is the Node service host from the TypeScript client, which reads a fleet file and needs no code.

# seed.yaml
agentspaces:
  security:
    profile: dev-local          # signed frames on loopback TCP; never on a shared network
  bind: 127.0.0.1:7700
  tick-millis: 250
  groups:
    - name: shop
      founding: shop-v1
      spaces: [ orders ]

This file founds the group shop from the text shop-v1, opens the space orders, and listens on port 7700. The dev-local profile signs every frame over plain loopback TCP: use it on a laptop, never on a shared network. Production fleets use mtls.

The Node host is not on npm yet either. Build it from a clone (Node 22 or newer), then start it from the directory that holds seed.yaml:

git clone https://github.com/badmonkeyai/agentspaces-typescript
(cd agentspaces-typescript && npm install && npm run build)
(cd agentspaces-typescript/packages/node && npm install && npm run build)
node agentspaces-typescript/packages/node/build/src/cli.js run seed.yaml

The first run creates a keys/ directory beside seed.yaml for the seed's identity. A JVM peer with a bind address works as a seed too; the developer guide starts one.

Entries

An entry is a dataclass marked with @entry. The decorator names the entry's wire schema, the name every language in the fleet uses for this type. The field names go on the wire as written.

# shop.py
from dataclasses import dataclass

from agentspaces.space import entry


@entry("shop.Order#v1")
@dataclass
class Order:
    orderId: str
    item: str


@entry("shop.Shipment#v1")
@dataclass
class Shipment:
    orderId: str
    item: str
    by: str
  • Keep the wire's field names, orderId here, even where Python style would say order_id. A Java record or a TypeScript class with the same schema reads the same fields.
  • A reader ignores wire fields its dataclass does not declare, so a newer writer can add a field without breaking older readers.
  • Use integers for numbers that cross languages. The TypeScript encoder refuses an unwrapped fraction.

Your first agent

An agent is a method. Its parameter is the entry it takes, and its return value is the next entry written into the space. Put the method on a class, mark the class with @agent and the method with @space_take, and bind an instance to a peer.

# fulfiller.py
import sys
import threading
import time

from agentspaces.agents import Binder, agent, space_take
from agentspaces.identity import Identity, group_id_from_founding
from agentspaces.peer import Peer
from agentspaces.space import Space, Template, entry
# ...

@agent("fulfiller", description="Ships orders")
class Fulfiller:
    @space_take("orders", lease="30s")
    def ship(self, order: Order) -> Shipment:
        return Shipment(order.orderId, order.item, "fulfiller")


identity = Identity.generate()
peer = Peer(identity, group_id_from_founding(b"shop-v1"))
peer.connect("127.0.0.1", 7700)                     # the seed
time.sleep(1.0)
Binder(peer, agent=identity.renewing_subordinate("fulfiller", "PT24H")).bind(Fulfiller())

if len(sys.argv) > 1:                               # python fulfiller.py kite
    orders = Space(peer, "orders")
    orders.write(Order("o-1", sys.argv[1]))
    print(orders.read(Template(Shipment), timeout="30s"))
    peer.close()
else:
    threading.Event().wait()

Here is what each piece does:

  • Identity.generate() makes a fresh key. group_id_from_founding(b"shop-v1") derives the same group id the seed derived.
  • peer.connect dials the seed and introduces this peer to the group.
  • The binder reads the parameter annotation, Order, to know which entries to take. It takes one under a 30 second lease, calls ship, writes the returned Shipment, and completes the take.
  • Space(peer, "orders") is the procedural handle on the space: write, read, read_all, take, and watch.
  • renewing_subordinate gives the agent a key of its own, explained under Certified agents.

Run two instances

With the seed running, start the agent twice from two terminals:

.venv/bin/python fulfiller.py
.venv/bin/python fulfiller.py kite

The first instance only ships. The second also writes an order, prints the shipment, and exits. Either one can take the order, because both run the same agent.

Now try a crash. Add time.sleep(10) and a print at the top of ship, start both, and run kill -9 on the instance that prints. No code tells the other instance about the crash: the killed take's 30 second lease lapses, the order comes back, and the survivor ships it. Two fulfillers and a crash does the same thing in one program.

A second verb: bids

A take answers "who gets this entry". A bid lets agents compete for it. Add a @bid_function method for the same space: the binder asks it for a price before each take, and the lowest bidder gets the entry.

# step06.py
from agentspaces.agents import Binder, agent, bid_function, space_take
from agentspaces.space import Space, Template
from shop import Order, Shipment


@agent("pricer", description="Bids on orders and ships the ones it wins")
class Pricer:
    def __init__(self, peer_name, price=None):
        self.peer_name, self.price = peer_name, price

    @bid_function("auction")
    def bid(self, order: Order) -> float:
        return self.price if self.price else float(len(order.item))   # A bids the item's length

    @space_take("auction", lease="30s")
    def ship(self, order: Order) -> Shipment:
        return Shipment(order.orderId, order.item, self.peer_name)


Binder(peer_a).bind(Pricer("A"))
Binder(peer_b).bind(Pricer("B", price=5.0))                        # B always bids 5
auction = Space(peer_a, "auction")
for n, item in enumerate(["kite", "bicycle", "tent"], start=1):
    lot = auction.write(Order(f"o-{n}", item), lease="10m")
    won = auction.read(Template(Shipment, where={"item": item}), timeout="20s")

peer_a and peer_b are two peers connected as in the first agent. Peer A bids the length of the item's name and peer B always bids 5, so A gets the kite and the tent and B gets the bicycle. The seed needs no auction setting: each take carries its bid, and every peer settles on the claim with the lowest bid. The seed's fleet file must list the auction space beside orders.

Where to read next

aspace://guide/python/features

Key features

Decorators on plain classes

Python agents are ordinary classes. The decorators only attach metadata; Binder(peer).bind(obj) reads it and runs one loop per method. The entry type a method reacts to comes from its parameter annotation. A class without @agent is named after itself (Packer becomes packer).

DecoratorWhat the binder does
@agent(name, description, goals)Names the agent and publishes its card (name, what it consumes and produces) when you bind it
@space_take(space, lease, where, filters)Takes one matching entry under a lease, calls the method, writes the return, completes the take
@space_notify(space, on=...)Calls the method once per matching entry and event kind; never takes it
@bid_function(space)Prices each entry before a take; the lowest bidder gets it
@ballot(space, prefix)Casts one ballot per proposal whose id starts with prefix; None abstains
@on_decision(space, prefix)Calls the method once, when a proposal reaches its quorum
@propose(space, vote, options, quorum, key)Opens one vote per key from a cue entry; the method returns the question
@space_join(space, key, parts)Waits until every required part with the same key is present, then calls the method once
@on_estimate(epochs, ticks, tolerance)Calls the method once when an aggregate average settles

The return value decides what gets written. None writes nothing. A list or tuple is a fork: each element is written on its own. Tagged(value, {"region": "eu"}) writes the value with tags, and a Motion opens a vote. This program forks one order into two entries, joins them back, and watches the result from a second peer:

# step07.py
class Packer:                                                     # agent "packer", named after its class
    @space_take("orders", lease="30s", produces=[Label, Invoice])
    def pack(self, order: Order) -> list:
        print("packer forked", order.orderId, "into Label and Invoice")
        return [Label(order.orderId, "12 Vine St"), Invoice(order.orderId, 2500)]

class Assembler:
    @space_join("orders", key="orderId", parts=[Part(Label), Part(Invoice)])
    def assemble(self, joined: Joined) -> Parcel:
        label, invoice = joined.get(Label), joined.get(Invoice)
        print("assembler joined", joined.key, "into Parcel")
        return Parcel(joined.key, label.address, invoice.cents)

class Watchers:
    @space_notify("orders")
    def receipts(self, parcel: Parcel) -> None:
        print("receipts saw Parcel", parcel.orderId)
# ...

Binder(peer_a).bind(Packer())
Binder(peer_a).bind(Assembler())
Binder(peer_b).bind(Watchers())                                   # B watches; it takes nothing
  • produces=[...] tells the card what a fork writes, since a list annotation cannot.
  • where={"priority": gte(1)} filters with matchers from agentspaces.space (eq, ne, gt, gte, lt, lte, in_, contains, is_null, not_null, or any callable). filters=["priority>=3"] takes the same portable clause strings Java and TypeScript use.
  • @space_notify(on=[...]) can also watch TAKEN, COMPLETED, EXPIRED (a write lease lapsed), and REAPPEARED (a take's lease lapsed).

A dial-only peer

A Python peer opens one connection to a seed, over TCP or TLS, and reaches every other member through it. The seed relays frames for members that cannot reach each other directly. So you always need one listening peer in Node or the JVM, and the Python side only needs an address or a way to find one.

CallUse it when you have
peer.connect(host, port)One seed address and the group's founding text
peer.connect(host, port, scheme="tls") or peer.connect("tls://host:port")A seed that listens on TLS, as JVM peers do under the mtls profile. The peer presents a channel certificate its identity key signs and attests the seed the same way, so it joins fleets that require attestation
peer.connect_any([(host, port), ...])Several seeds, tcp or tls; it dials them in order and fails over to the next if the socket drops
peer.join_by_group_id(host, port, group_id)One seed and only the group id; it fetches and verifies the group's signed founding first
peer.bootstrap(url, group_id=..., beacon_peer=...)The URL of a beacon document, a signed file that lists a group's seeds
peer.join_invite(key_text)An invite key for a group of friends; returns (content_key, group_id)

For join_by_group_id, bootstrap, and join_invite, create the peer with Peer(identity, None); the call fills in the group. A seed that serves the wrong document is ignored, so a hostile seed can delay a join but cannot change the group. Joining by id works only for groups with a signed founding; a group founded from a plain text, as in this guide, is joined with group_id_from_founding and connect.

The peer recovers from a lost socket by itself. It re-dials every 5 seconds for 10 minutes, then introduces itself again and re-pulls what it had. A take in flight keeps waiting across the gap. Only your own peer.close() is final. The developer guide covers beacons and invite keys in depth.

Certified agents

By default the peer's key signs everything its agents write. Give an agent its own key instead, certified by the peer and renewed at half its lifetime, and the fleet records its work as that agent's:

# fulfiller.py
identity = Identity.generate()
peer = Peer(identity, group_id_from_founding(b"shop-v1"))
# ...
Binder(peer, agent=identity.renewing_subordinate("fulfiller", "PT24H")).bind(Fulfiller())
  • "PT24H" is the certificate's lifetime as an ISO 8601 duration.
  • For a peer that keeps its identity across restarts, use Identity.load_or_create("./keys"). It reads and writes the same peer.key and peer.pub layout as Java.
  • Identity.agent_keys(dir, peer_id, name) keeps an agent's key on disk the same way.
  • When a certified agent takes an entry, the agent's key must sign the completion too. The fleet refuses the peer's key for it, as Java does.

The security chapter explains certificates and revocation.

Capabilities

The fleet services live in agentspaces.capabilities and run over one multiplexer, Pipes(peer). Each speaks the same protocol as its Java provider, so Python members and JVM members take part in the same vote, average, or query.

A vote. Proposals and ballots are entries in a space, and every peer tallies them by Java's rule. vote.propose opens one; decorated councillors answer it:

# step09.py
@agent("councillor")
class Councillor:
    def __init__(self, answer, pause=0.0):
        self.answer, self.pause = answer, pause

    @ballot("votes", prefix="release-")
    def judge(self, proposal: Proposal) -> str:
        time.sleep(self.pause)
        return self.answer


@agent("clerk")
class Clerk:
    @on_decision("votes", prefix="release-")
    def announce(self, decision: Decision) -> None:
        print("decision", decision.proposal_id + ":", decision.winner)
# ...
propose(peer_a, "votes", "release-1.0", "Ship 1.0?", ["yes", "no"], quorum=2)

An average. Aggregate is push-sum gossip: each peer contributes a number and all of them converge on the fleet's average. await_settled returns it once it stops moving.

# demo_council_worker.py
pipes = Pipes(peer)
aggregate = Aggregate(pipes)
Semantic(pipes)  # answers the coordinator's semantic queries from this peer's cache
# ...
aggregate.start("load", value)
aggregate.run(0.25)
# ...
settled = aggregate.await_settled("load", Settle.after(3, 0.01), timeout_seconds=1.0)

Push-sum shares go only to members this peer reaches directly. Behind a relaying seed that is the seed alone, so run the average where the Python peer dials the peers that contribute.

More services:

  • Semantic(pipes) ranks agent cards against a text query (query), asks other members (remote_query), and answers their queries.
  • actions(peer) lists the methods other agents' cards declare, and invoke(peer, action, value, result_type=...) calls one through its space.
  • Content moves large artifacts between holders; see The content swarm.
  • OrderedLog takes an entry through a Raft log so exactly one peer gets it; see What Python cannot do yet.

The capabilities chapter explains each service and when to use it.

What Python cannot do yet

The Python client is a full member for spaces, agents, and the capabilities above. A few pieces need a process that listens or keeps a Raft log, and those run only in Java (and partly in TypeScript). Here is each gap and what to do instead:

Missing in PythonWhat to do instead
Listening, so being a seedRun a Node service host or a JVM peer as the seed
A Raft voter for the ordered logRun the voters on Node or JVM peers; take through them with through=OrderedLog(Pipes(peer))
The decorated ordered take (@OrderedTake)Call Space.take(..., through=OrderedLog(...)) from your own loop
LEASED and ORDERED join modes@space_join runs in LOCAL mode only; put a fleet-wide join on a JVM peer
@SpaceReduceRun the reduce on a JVM peer and take its result in Python
Transports other than tcp and tlsPoint Python at a seed's tcp or tls endpoint; the client skips ws, wss, http, https, webrtc, and dtls
Decrypting sealed spacesRead sealed spaces from a JVM peer; join_invite returns the content key for you to keep
The gossip-learning exchangeRun the learners on JVM peers
The connector runtimeRun connectors on the JVM or TypeScript hosts; Python has the client side (DataQueryClient, connect.invoke)

This is the ordered take from your own code. Three Node voters keep the payments log, the Python peers are its clients, and only the peer the log chose gets past if taken:

# step10.py
from agentspaces.capabilities import OrderedLog, Pipes
from agentspaces.space import Space, Template
from shop import Paid, Payment


def pay(peer, name):
    payments = Space(peer, "payments")
    log = OrderedLog(Pipes(peer))                                  # a client of the Node voters
    taken = payments.take(Template(Payment), lease="1m", timeout="20s", through=log)
    if taken:                                                      # only the log's choice gets here
        taken.complete(Paid(taken.value.paymentId, taken.value.cents, name))

The voters come from ordered-logs in the seed's fleet file, one entry per log listing every voter's PeerID. The ordered take section explains when you need it.

TakeContext

A take method sometimes runs long, for example while it waits on a model. Code deep inside that call may need the take it runs under, to renew its lease. The binder puts the current take in a context variable, so you can reach it without passing a parameter:

# test_layer34.py
    @space_take("tasks", lease="1m", poll_timeout="500ms", where={"priority": gte(1)})
    def research(self, task: Task) -> Finding:
        self.seen.append(task.topic)
        assert TakeContext.current() is not None
        return Finding(task.topic, "researched " + task.topic)

TakeContext.current() (from agentspaces.space) returns the Taken: its value, its entry_id, and renew() to extend the lease. Outside a take it returns None. A procedural Space.take returns the same Taken, which you complete(result) yourself.

aspace://guide/python/examples

Examples

The first two examples come from the Jungle Gym, a set of programs that run the same steps in Java, TypeScript, Python, and Clojure. Its repository is not published yet. Each program there also imports a small helper, wait.py: its until(check, why) calls check until it returns something, and fails the program at a deadline.

Two fulfillers and a crash

This program starts two peers in one process, binds the fulfiller on both, and ships six orders. Then it makes peer B hold order o-7 and stops B before it completes. B's 3 second lease lapses, o-7 comes back, and peer A ships it.

# step05.py
def start_peer():
    peer = Peer(Identity.generate(), group_id_from_founding(b"shop-v1"))
    peer.connect("127.0.0.1", 7700)       # every Python peer dials the seed
    return peer


peer_a, peer_b = start_peer(), start_peer()
# ...

@agent("fulfiller", description="Ships orders")
class Fulfiller:
    def __init__(self, peer_name, slow=False):
        self.peer_name, self.slow = peer_name, slow

    @space_take("orders", lease="3s")
    def ship(self, order: Order) -> Shipment:
        if self.slow and order.orderId == "o-7":
            holding.set()
            time.sleep(30)                                         # B holds o-7 and never finishes
        return Shipment(order.orderId, order.item, self.peer_name)


on_a = Binder(peer_a).bind(Fulfiller("A"))
Binder(peer_b).bind(Fulfiller("B", slow=True))
orders = Space(peer_a, "orders")
for n in range(1, 7):
    orders.write(Order(f"o-{n}", "kite"), lease="10m")
until(lambda: len(orders.read_all(Template(Shipment))) >= 6, "six orders were not shipped")

on_a.close()                          # only B's fulfiller is left
orders.write(Order("o-7", "tent"), lease="10m")
holding.wait(20)                      # B has taken o-7 ...
peer_b.close()                        # ... and B's peer stops before it completes
Binder(peer_a).bind(Fulfiller("A"))   # A is back; o-7 returns when B's 3 s lease lapses

What to notice:

  • Both peers dial the seed. The seed only relays; it runs no agent.
  • bind returns a Bound, and on_a.close() unbinds A's fulfiller so that only B can take o-7.
  • No code tells A about the crash; B's lapsed lease returns the order.

Run it with the seed from Start a seed on port 7700. The program prints 6 orders shipped, each exactly once and then o-7 shipped by A after peer B stopped.

The content swarm

Entries are small. For a model checkpoint or a dataset, use the content swarm: an artifact is cut into pieces, its signed manifest goes into the content space, and peers fetch pieces from every holder at once, checking each piece before they store it.

Here peer A publishes 4 MiB, peer B fetches it, A stops, and peer C fetches it from B, the only holder left:

# step12.py
import hashlib
from agentspaces.capabilities import Content, MemoryStore, Pipes
from agentspaces.space import Space


def swarm(peer):
    content = Content(Pipes(peer), Space(peer, "content"), MemoryStore())
    content.run()                                    # fetches pieces on a thread
    return content


on_a, on_b, on_c = swarm(peer_a), swarm(peer_b), swarm(peer_c)
artifact = bytes(i % 251 for i in range(4 * 1024 * 1024))                  # 4 MiB
digest = hashlib.sha256(artifact).hexdigest()
published = on_a.publish(artifact, name="checkpoint-1")
print(f"peer A published checkpoint-1: {published['size']} bytes")

# B finds the artifact's manifest by name, in the content space every peer replicates
found = until(lambda: [m for m in on_b.list_manifests() if m["name"] == "checkpoint-1"], "no manifest")
from_b = on_b.fetch(found[0]["root"])
if from_b.wait(30) and hashlib.sha256(from_b.read_bytes()).hexdigest() == digest:
    print("peer B fetched checkpoint-1, digest matches")

peer_a.close()
print("peer A stopped")
from_c = on_c.fetch(found[0]["root"])              # B is the only holder left
if from_c.wait(30) and hashlib.sha256(from_c.read_bytes()).hexdigest() == digest:
    print("peer C fetched checkpoint-1 from B, digest matches")
  • MemoryStore keeps pieces in memory, which suits a test. Use FileStore(path) in a real peer: it survives a restart and republishes what it holds.
  • publish takes bytes or a file path and returns the manifest. Its root is the artifact's content id.
  • The seed holds no pieces. It carries each one between the Python peers as a relayed frame.
  • Before the swarm starts, the full program sends each peer's first request to the seed, so that the peer learns the seed relays for it. The TypeScript client does this on joining; the Python client does not yet.

The content section covers piece sizes and escalation of large entries.

A Python councillor in a Java fleet

This example joins a fleet whose coordinator is Java. The Python worker takes research tasks, votes on the council's proposal, contributes its load to the push-sum average, answers semantic queries, and serves the remote action its card declares. It dials the Java coordinator directly, with no Node seed.

# demo_council_worker.py
@agent("py-researcher", description="Researches topics in Python and sits on the council", goals=["research", "vote"])
class Councillor:
    @space_take("tasks", lease="30s", poll_timeout="2s", description="Researches one topic in Python")
    def research(self, task: ResearchTask) -> Finding:
        State.completed += 1
        print(f"  [py] took and completed '{task.topic}' ({State.completed})", flush=True)
        return Finding(task.topic, "researched in python: " + task.topic, "py-worker")

    @ballot("votes", prefix="council:")
    def judge(self, proposal: Proposal) -> str | None:
        print(f"  [py] ballot on {proposal.proposal_id}: yes", flush=True)
        return "yes"

    @on_decision("votes", prefix="council:")
    def record(self, decision: Decision) -> None:
        State.decided = decision
        print(f"  [py] decision {decision.proposal_id} -> {decision.winner} {decision.tally}", flush=True)
        return None

The entries use the Java records' schema names, such as ai.badmonkey.agentspaces.examples.fleet.ResearchFleet$ResearchTask#v1, so both sides read the same tasks and findings.

To run it, build the AgentSpaces repository (mvn install in agentspaces/): the library is on Maven Central, but the example modules are not published. Start the Java coordinator from that directory, then the worker from the agentspaces-python clone with the client installed:

mvn -q -pl examples/example-02-research-fleet exec:java \
    -Dexec.mainClass=ai.badmonkey.agentspaces.examples.fleet.CouncilFleet \
    -Dexec.args="7470 2 20"
python3 demo_council_worker.py 127.0.0.1 7470 10

The worker prints each task it completes, its ballot, the decision, and the settled average. The full program is demo_council_worker.py, and the client's tests show every capability without a socket.