The idea: Tripper, on a bus
Tripper is the Embabel travel planner: one agent in one JVM that finds points of interest for a brief, researches them in parallel, writes a plan with a good model, and finds places to sleep. It is a lovely piece of software and it does everything itself. Party Bus keeps Tripper’s domain model and its planner and hands the work to a fleet. Every specialist Tripper would have been, the fleet has as an agent with a seat on the bus: a flight scout, a hotel scout, a restaurant scout, a photo curator, a review reader, a diet advisor for the friend with the allergy, an accessibility advisor for the friend who cannot do hills, a hospital locator for the day one of them needs it, three watchers reading the news, the video feeds, and the forums, and a sentiment analyst scoring what they find.
The agents coordinate through shared spaces and nothing else. There is no broker, no scheduler, and no one in charge, and that is the whole lesson of the project: each kind of coordination AgentSpaces offers appears once, on the job that fits it. Scouts drain a bag of typed requests under leases, so a scout that dies mid-search hands its request to the next. Activity planners bid for every day of the trip in an auction and the cheapest fit wins the day. A safety panel decides travel advisories by signed quorum vote, so a lurid video clip alone cannot cancel a holiday. The travelers themselves vote on where to go next and gossip how tired they are, and the fleet paces the week from the gossip. Booking clerks confirm reservations exactly once through an ordered log, because reservations are money. An operator watches and steers from a command-and-control turret, and a vacation simulator drives the fleet through a week that goes wrong on purpose.
This guide builds all of it, coordination type by coordination type, with the code from the project. Read it beside the developer guide, which explains each primitive; this one shows what happens when you use all of them at once on a domain everybody understands.
| what Tripper does | what Party Bus does with it | AgentSpaces feature |
|---|---|---|
@Action findPointsOfInterest | a SightseeingRequest in the research bag; the sightseeing scout takes it | @SpaceTake, LEASE_RACE |
parallelMap over points of interest | twelve specialists draining one bag, on five peers | replicated workers |
@Action proposeTravelPlan | the plan assembler joins the bundle when the last result lands | the join over the space |
@Action findPlacesToSleep | the stay finder reacts to the plan and orders the rooms | @SpaceNotify choreography |
| Airbnb MCP tool | the hotel scout with Brave search and the model; booking clerks over the ordered log | aspace:cap/ordered-log |
| the GOAP planner | the same planner, with every scout’s AgentCard as a typed action | remote-actions bridge (SPEC §10.6) |
| nothing | the safety panel, the auction, the party’s votes and energy, the turret, the simulator | vote, AUCTION, aggregate, console, choreography |
The week that goes wrong
Three friends fly Boston to Lisbon for a week on two hundred dollars a day. Marco eats for a living and has a shellfish allergy he ignores. Priya is an architecture historian with a camera. Dee wants a pool, a rooftop, and no steep hills. The bus researches, plans, and books the trip in about half a minute, and then the week happens. This is the recorded run, verbatim; a live run with the API keys swaps the recording for the real world and keeps every line of this story.
2026-10-03 pace EASY energy 70 -> PHOTO_WALK by photo-walk-planner: Golden-hour walk with a 4-shot list 2026-10-04 pace FULL energy 51 !! WEATHER: Atlantic squall parks over the city until mid-afternoon. -> DINING by dining-planner: Market morning, long lunch, dinner at Taberna da Rua das Flores 2026-10-05 pace EASY energy 32 !! ILLNESS: Marco ordered the clams. Marco regrets the clams. -> SIGHTS by sights-planner: Sights day: Sintra and Pena Palace and the streets around it 2026-10-06 pace REST energy 28 !! PROTEST: Transport unions march on the Assembleia; Baixa is sealed off. -> REST by rest-planner: Rest day: pool, siesta, one gentle dinner 2026-10-07 pace REST energy 24 !! STRIKE: Metro and ferry strike; the tram to Belém is a rumor. -> DINING by dining-planner: Market morning, long lunch, dinner at Taberna da Rua das Flores 2026-10-08 pace REST energy 25 -> REST by rest-planner: Rest day: pool, siesta, one gentle dinner 2026-10-09 pace REST energy 16 !! LOST_PASSPORT: Priya's passport is in a taxi. Somewhere. -> REST by rest-planner: Rest day: pool, siesta, one gentle dinner == the week, as the space remembers it == advisory [baseline] GO GO=3 CAUTION=0 AVOID=0 advisory [lisbon-2026-d3-protest] CAUTION GO=0 CAUTION=2 AVOID=1 booked lisbon-2026-stay-1 PB-FC5A3CA5 by clerk-c at log index 1 booked lisbon-2026-stay-2 PB-643B6984 by clerk-c at log index 3 next time: Porto and the Douro [Porto and the Douro=2, Seville=1, The Azores=0] plan: "Seven Hills, Three Friends, One Pool" advisory GO 2 stays
Read the week against the mechanisms. The storm on the second day re-auctioned the day and the dining planner took it from the sights planner, because a walking tour in a squall prices itself out. The clams knocked the party’s average energy under forty, so the simulator called a rest day and the rest planner’s bid collapsed to almost nothing. The protest sent the three watchers back to their sources under a new wave; the video seat voted AVOID after the livestreams, the news and forum seats voted CAUTION, and the advisory moved to CAUTION with the ballots on record for anyone to recount. Two stays were booked by one clerk each through the log, and the travelers voted Porto for next time, two ballots to one. Nobody dispatched any of it.
Who is on the bus
Fifteen peers, one group, nine replicated spaces on every peer. A peer hosts a few agents; the activity planners and the booking clerks are the exceptions, and the reason for each exception is a lesson in itself (chapters 6 and 8).
| seat | agents | coordination type |
|---|---|---|
| concierge | dispatcher, plan-assembler, stay-finder, safety-lead | notify choreography: the fan-out and the join |
| scouts-a, scouts-b | flight, hotel, car, sightseeing, restaurant, photo, review, next-destination | @SpaceTake on the research bag (LEASE_RACE) |
| advisors | diet, foodie, accessibility, hospital-locator | the same bag |
| watchers | news, video, social watchers; sentiment analyst; three panelists | @SpaceTake sweeps, @SpaceNotify scoring, aspace:cap/vote QUORUM |
| dining-, sights-, photo-walk-, rest-planner | one activity planner each | @BidFunction and @SpaceTake on slots (AUCTION) |
| the-bus | Marco, Priya, Dee, and the party lead | aspace:cap/aggregate push-sum, aspace:cap/vote |
| clerk-a, clerk-b, clerk-c | booking clerks | aspace:cap/ordered-log: exactly-once takes over Raft |
| planner | the Embabel planner’s seat | remote-actions bridge: every card is a typed action |
| turret | the console and the simulator | HAL+JSON, SSE, C2 directives with a signed audit trail |
| space | strategy | carries |
|---|---|---|
trip | LEASE_RACE | briefs, travelers, days, events, plans, advisories, decisions |
research | LEASE_RACE | typed requests in, typed results back: the bag |
signals | LEASE_RACE | watcher reports and the analyst’s assessments |
advisories | LEASE_RACE + vote | the safety panel’s proposals and signed ballots |
decisions | LEASE_RACE + vote | the travelers’ proposals and signed ballots |
slots | AUCTION | day slots and the activities that won them |
bookings | LEASE_RACE + ordered log | orders and confirmations |
fleet-control, c2-audit | LEASE_RACE | the turret’s directives and audit trail |
Start from Tripper’s domain model
Tripper’s design lesson is to center the agent on a domain model, and the fleet keeps it. Every Tripper type becomes a Java record that can live in a space: a TravelBrief is its JourneyTravelBrief, Travelers is its Travelers, ProposedTravelPlan and TravelPlan are its plan types. Two things change. Every record carries a tripId, the field the whole fleet correlates on, and dates travel as ISO-8601 strings so templates match them with plain equality. Party Bus adds needs to a traveler, the dietary and mobility requirements the advisors consume, and that one field is what makes the diet advisor and the accessibility advisor possible.
public record TravelBrief(String tripId, String from, String to, String departureDate, String returnDate, double dailyBudget, String brief, String transportPreference) { /** The trip's days as ISO-8601 strings, departure to return inclusive. */ public List<String> days() { LocalDate start = LocalDate.parse(departureDate); LocalDate end = LocalDate.parse(returnDate); return start.datesUntil(end.plusDays(1)).map(LocalDate::toString).toList(); } /** The brief as prompt text, the way Tripper's {@code contribution()} renders it. */ public String contribution() { return "Journey from " + from + " to " + to + "\nDates: " + departureDate + " to " + returnDate + "\nBrief: " + brief + "\nTransport preference: " + transportPreference + "\nDaily budget (USD): " + dailyBudget; } }
public record Traveler(String name, String about, List<String> needs) { }
The research bag needs a second family of types: one request and one result per specialist. Resist the temptation to make them one generic Finding. The planner conditions on types, so each (request, result) pair is one planner action; a template of the request type is one scout’s whole work queue; and the pair sharing tripId and topic is exactly the shared-field correlation the remote-actions bridge uses to pair a planner’s request with the answer some scout wrote back.
public record FlightRequest(String tripId, String topic, String from, String to, String departureDate, String returnDate, String preference) { }
public record FlightOptions(String tripId, String topic, List<FlightOption> options, String note, String author) { }
Lists, nested records, and enums serialize through the space’s CBOR codec as they are. A result record repeats the request’s tripId and topic and adds author, so the console can attribute every answer to the scout that wrote it.Board a peer
PartyBusFleet.startPeer is one peer: a fresh Ed25519 identity, a node listening on TLS, the partybus group joined from a seed, a replica of each of the nine spaces, an AgentBinder that knows every space by name, and the capabilities the peer will need (push-sum aggregation over the capability pipes, and vote capabilities over the two ballot spaces). The first peer has no seed and becomes everyone else’s. There is no server anywhere in this picture.
/** * Starts one peer: joins the group over TCP, builds every space, and wires * the capabilities. * * @param name the peer's name (its agents' host) * @param port the TCP port to listen on * @param seedPort an existing member's port, or 0 for the first member * @return the running peer * @throws Exception if the port cannot be bound */ public static Peer startPeer(String name, int port, int seedPort) throws Exception { Objects.requireNonNull(name, "name"); PeerIdentity identity = PeerIdentity.generate(); // Channel authentication (SPEC §5.6): TLS 1.3 with identity-endorsed // certificates, so the channel vouches for the sender and frames travel // unsigned. Fifteen peers times nine spaces is thousands of gossip frames // a second; signing each one would spend the machine on Ed25519. PeerNode node = PeerNode.builder(identity) .channelAuth(PeerNode.ChannelAuth.ATTESTED).build(); node.listen(new TlsTcpTransport(identity), HOST + ":" + port); List<PeerAdvertisement.Endpoint> seeds = seedPort == 0 ? List.of() : List.of(new PeerAdvertisement.Endpoint("tls", HOST + ":" + seedPort, 0)); GroupRuntime runtime = node.joinGroup(group(), new GroupMembership.Config(Duration.ofSeconds(30), Duration.ofSeconds(2), 2), seeds); CborCodec codec = CborCodec.defaultCodec(); InstantSource clock = InstantSource.system(); DiscoveryService discovery = new DiscoveryService(runtime, new AdCache(codec, clock), codec, identity.peerId()); Map<String, ReplicatedSpace> spaces = new LinkedHashMap<>(); for (String spaceName : SPACES) { ReplicatedSpace.Builder builder = ReplicatedSpace.builder(runtime, spaceName, identity, name).settleWindow(Duration.ofMillis(200)); if (spaceName.equals(SLOTS)) { // The bid comes from the planner's @BidFunction at bind time; a bid // function is single-assignment per space per peer (QA4 A4-8), so a // placeholder here would be the first bidder and the planner refused. builder.strategy(ConflictStrategyType.AUCTION); } spaces.put(spaceName, builder.build()); } AgentBinder binder = new AgentBinder(identity, runtime.id(), discovery, clock); spaces.forEach(binder::space); CapabilityPipes pipes = new CapabilityPipes(runtime, codec); PushSumAggregate aggregate = new PushSumAggregate(pipes, runtime.sampler(), identity.peerId(), codec, clock); VoteCapability advisoryVote = new VoteCapability(spaces.get(ADVISORIES), identity.agent(name), identity.peerId(), clock); VoteCapability partyVote = new VoteCapability(spaces.get(DECISIONS), identity.agent(name), identity.peerId(), clock); // What the annotations cast and watch with: @Ballot/@OnDecision on the two // vote spaces, and a Contribution return on the aggregate. binder.vote(ADVISORIES, advisoryVote).vote(DECISIONS, partyVote).aggregate(aggregate); node.startTicking(Duration.ofMillis(500)); return new Peer(name, identity, node, runtime, discovery, spaces, binder, codec, pipes, aggregate, advisoryVote, partyVote, port); }
<dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-capabilities</artifactId> <version>${agentspaces.version}</version> </dependency> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-agent</artifactId> <version>${agentspaces.version}</version> </dependency> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-console</artifactId> <version>${agentspaces.version}</version> </dependency> <!-- The Embabel extension reads Embabel's annotations reflectively and needs no Embabel artifact to compile. The planner bridge it carries generates the fleet agent the real Embabel planner plans over (see the embabel profile below and src/embabel/java). --> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>embabel-agentspaces</artifactId> <version>${agentspaces.version}</version> </dependency>
The one non-obvious line is the channel authentication. Fifteen peers times nine spaces is thousands of gossip frames a second, and the first cut of Party Bus ran on plain TCP with every frame Ed25519-signed and verified. On a two-core machine that spent the whole machine on cryptography, membership flapped, and the dispatcher’s reaction to the brief arrived tens of seconds late or never. SPEC §5.6 exists for exactly this: with ChannelAuth.ATTESTED and the TlsTcpTransport, the TLS handshake proves the sender through an identity-endorsed certificate and frames travel unsigned. The evidence layer (record signatures, state signatures, ballots, cards) stays signed and verified end to end. The fleet went from unusable to a ninety-second test on the same laptop with that one change.
@SpaceTake or @SpaceNotify shares a peer happily. Two things do not: an AUCTION bid function is per space per peer, so each bidder needs its own peer (chapter 6), and a Raft member is a peer (chapter 8).The research bag: scouts and advisors
A scout is one class with one @SpaceTake method on the research space. The binder turns the parameter type into the scout’s work queue, takes each request under a lease, invokes the method, and writes whatever it returns back into the space as the result, completing the take atomically. A scout that throws, or dies, never completes: the lease lapses and the request reappears for the next scout of that kind. Tripper researches its points of interest in a parallelMap inside one process; here the parallelism is the fleet, and the failure handling is the absence of a renewal.
public static final class FlightScout { private final Sources sources; public FlightScout(Sources sources) { this.sources = Objects.requireNonNull(sources, "sources"); } @SpaceTake(space = SPACE, lease = "2m", pollTimeout = "500ms") public FlightOptions scout(FlightRequest request) { List<Link> web = sources.search("flights " + request.from() + " to " + request.to() + " " + request.departureDate() + " return " + request.returnDate(), 8); FlightOptions found = sources.structured("flights", RESEARCHER + "\n" + "Propose up to three itineraries from " + request.from() + " to " + request.to() + " departing " + request.departureDate() + " returning " + request.returnDate() + ". Preference: " + request.preference() + ".\n" + digest("Search results", web), FlightOptions.class); return new FlightOptions(request.tripId(), request.topic(), found.options(), found.note(), "flight-scout"); } }
public static final class HospitalLocator { private final Sources sources; public HospitalLocator(Sources sources) { this.sources = Objects.requireNonNull(sources, "sources"); } @SpaceTake(space = Scouts.SPACE, lease = "1m", pollTimeout = "500ms") public HospitalList locate(HospitalRequest request) { List<Sources.Place> places = sources.places(request.location(), "hospital", 5); List<Hospital> hospitals = new ArrayList<>(); for (Sources.Place place : places) { hospitals.add(new Hospital(place.name(), place.address(), Math.round(distanceFromFirst(places, place) * 10.0) / 10.0, place.emergency(), place.phone())); } return new HospitalList(request.tripId(), request.topic(), request.location(), hospitals, emergencyNumber(request.location()), "hospital-locator"); } private static double distanceFromFirst(List<Sources.Place> places, Sources.Place place) { if (places.isEmpty()) { return 0.0; } Sources.Place origin = places.get(0); return ai.badmonkey.agentspaces.partybus.sources.LiveSources.distanceKm( origin.lat(), origin.lon(), place.lat(), place.lon()); } /** The emergency number: 112 across Europe, 911 in North America, else 112. */ static String emergencyNumber(String location) { String lower = location.toLowerCase(); if (lower.contains("usa") || lower.contains("united states") || lower.contains(", us") || lower.contains("canada") || lower.contains("mexico")) { return "911"; } return "112"; } }
Every scout follows the same three lines: build a query, ask the sources, bind the model’s structured answer to the result record. The hospital locator is the exception that proves the rule. It goes to the map (OpenStreetMap through Nominatim and Overpass) rather than to the model, because a language model guessing at the address of an emergency room is the wrong tool when someone has eaten the wrong clams. Twelve of these specialists share five peers; the flagship pattern from code-fleet applies unchanged, and running two flight scouts drains the flight queue twice as fast.
The world behind one interface
Tripper reaches the world through MCP tool groups and a model, and it insists on its API keys. Party Bus keeps the same posture with a smaller footprint: one Sources provider interface, a LiveSources that asks its model through a Spring AI ChatClient (over Spring AI’s OpenAiChatModel by default, any provider by constructor) and speaks REST/JSON to Brave Search, the YouTube Data API, and OpenStreetMap with java.net.http, and a RecordedSources that replays a recorded trip for the simulator and the tests. The scouts never learn which one they hold, so the coordination is orthogonal to the data provider, and the flow test runs the whole fleet with no network.
public interface Sources { /** A place from the map provider. */ record Place(String name, String address, double lat, double lon, String phone, boolean emergency) { } /** A short label for logs and the console: {@code live} or {@code recorded}. */ String name(); /** Web search results. */ List<Link> search(String query, int limit); /** News results, freshest first. */ List<Link> news(String query, int limit); /** Image results; each link is the image itself. */ List<Link> images(String query, int limit); /** Video results, YouTube first when a key is present. */ List<Link> videos(String query, int limit); /** * Places of one kind near a location, from the map provider. * * @param location a place name to geocode * @param amenity an OpenStreetMap amenity value, e.g. {@code hospital} * @param limit the maximum results * @return the places, nearest first */ List<Place> places(String location, String amenity, int limit); /** * Asks the language model for a structured answer and binds it to a record * type. The prompt describes the task; the implementation adds the shape of * the record and parses the JSON that comes back. * * @param tag a stable name for the kind of answer (the recorded key) * @param prompt the task * @param type the record type to bind * @param <T> the record type * @return the bound answer */ <T> T structured(String tag, String prompt, Class<T> type); /** * The sources the environment selects: {@link LiveSources} when the API keys * are present, and otherwise a fail-fast error naming what is missing, the * way Tripper insists on its keys. {@code PARTYBUS_SOURCES=recorded} selects * the recorded trip explicitly. * * @return the configured sources */ static Sources fromEnvironment() { String mode = System.getenv().getOrDefault("PARTYBUS_SOURCES", "live"); if ("recorded".equalsIgnoreCase(mode)) { return RecordedSources.lisbon(); } return LiveSources.fromEnvironment(); } }
public static LiveSources fromEnvironment(Map<String, String> env) { List<String> missing = new ArrayList<>(); for (String required : List.of(OPENAI_API_KEY, BRAVE_API_KEY)) { if (env.getOrDefault(required, "").isBlank()) { missing.add(required); } } if (!missing.isEmpty()) { throw new MissingApiKeyException(missing); } OpenAiChatModel model = OpenAiChatModel.builder() .options(OpenAiChatOptions.builder() .apiKey(env.get(OPENAI_API_KEY)) .model(env.getOrDefault(OPENAI_MODEL, "gpt-4.1-mini")) .temperature(0.2) .build()) .build(); return new LiveSources( HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build(), ChatClient.create(model), env.get(BRAVE_API_KEY), Optional.ofNullable(env.get(YOUTUBE_API_KEY)).filter(k -> !k.isBlank())); }
public <T> T structured(String tag, String prompt, Class<T> type) { // Spring AI's structured output: the response format comes from the // target type, and the answer binds straight into the record. T answer = chat.prompt() .system("You are a specialist on a travel-planning team. Use real, current" + " information from the material you are given; never invent URLs.") .user(prompt) .call() .entity(type); if (answer == null) { throw new IllegalStateException("the model returned no " + type.getSimpleName() + " for " + tag); } return answer; }
public <T> T structured(String tag, String prompt, Class<T> type) { count(tag); int colon = tag.indexOf(':'); String base = colon < 0 ? tag : tag.substring(0, colon); JsonNode node = lookup(recording.path("structured"), tag, base); if (node.isMissingNode()) { throw new IllegalStateException("recording has no structured answer for '" + tag + "' (nor '" + base + "')"); } return Json.bind(node, type); }
OPENAI_API_KEY and BRAVE_API_KEY, and takes YOUTUBE_API_KEY when offered. A missing key fails at startup with the variable named, never silently at the first search. PARTYBUS_SOURCES=recorded replays the bundled Lisbon week instead, which is what run-demo.sh does when it finds no keys.The fan-out and the join
Tripper’s planner expresses the trip as a chain of @Actions. With a fleet in the picture the chain becomes a fan-out into the bag and a join when the bundle is complete, and both are ordinary agents. The dispatcher reacts to a TravelBrief with @SpaceNotify and writes every specialist’s request, the three safety sweeps, and one DaySlot per day into the auction. Its @SpaceRef fields are the spaces it writes to; it never answers anything, which keeps the fan-out in one place and the specialists ignorant of each other.
public void dispatch(TravelBrief brief) { String id = brief.tripId(); Travelers party = trip.read(Template.of(Travelers.class).where("tripId", eq(id)), Duration.ofSeconds(10)).orElseThrow(() -> new IllegalStateException( "no travelers written for trip " + id)); List<String> needs = party.allNeeds(); String who = party.contribution(); double nightly = brief.dailyBudget() / 2.0; // Tripper's half-the-budget rule research.write(new FlightRequest(id, "flights " + brief.from() + " to " + brief.to(), brief.from(), brief.to(), brief.departureDate(), brief.returnDate(), brief.transportPreference()), REQUEST_LEASE); research.write(new HotelRequest(id, "hotels " + brief.to(), brief.to(), brief.departureDate(), brief.returnDate(), nightly, needs), REQUEST_LEASE); research.write(new CarRequest(id, "car " + brief.to(), brief.to(), brief.departureDate(), brief.returnDate(), party.travelers().size(), needs), REQUEST_LEASE); research.write(new SightseeingRequest(id, "sights " + brief.to(), brief.to(), brief.brief(), brief.departureDate(), brief.returnDate(), who), REQUEST_LEASE); research.write(new RestaurantRequest(id, "restaurants " + brief.to(), brief.to(), brief.dailyBudget() / 4.0, needs), REQUEST_LEASE); research.write(new DietRequest(id, "diet " + brief.to(), brief.to(), needs), REQUEST_LEASE); research.write(new FoodieRequest(id, "foodie " + brief.to(), brief.to(), brief.brief()), REQUEST_LEASE); research.write(new PhotoRequest(id, "photos " + brief.to(), "landmarks", brief.to()), REQUEST_LEASE); research.write(new ReviewRequest(id, "reviews " + brief.to(), "visiting", brief.to()), REQUEST_LEASE); research.write(new AccessibilityRequest(id, "accessibility " + brief.to(), brief.to(), needs), REQUEST_LEASE); research.write(new HospitalRequest(id, "hospitals " + brief.to(), brief.to(), "pre-trip: know the nearest emergency room"), REQUEST_LEASE); research.write(new NextDestinationRequest(id, "next after " + brief.to(), brief.to(), brief.brief(), who), REQUEST_LEASE); sweep(id, brief.to(), "travel safety", Safety.BASELINE); List<String> interests = List.of(brief.brief().split("[,;.]\\s*")); int day = 0; for (String date : brief.days()) { slots.write(new DaySlot(id, date, brief.to(), interests, day == 0 ? "EASY" : "FULL", 0), REQUEST_LEASE); day++; } }
public EventResponse react(TripEvent event) { String id = event.tripId(); switch (event.kind()) { case ILLNESS -> { research.write(new HospitalRequest(id, "hospitals " + event.eventId(), event.location(), event.detail()), REQUEST_LEASE); research.write(new DietRequest(id, "diet " + event.eventId(), event.location(), List.of("recovering from food poisoning", "bland food")), REQUEST_LEASE); return response(event, "asked the hospital locator for the nearest ER and the " + "diet advisor for a gentle menu"); } case PROTEST -> { sweep(id, event.location(), "protest unrest", event.eventId()); return response(event, "opened a fresh safety sweep on all three channels; " + "the panel re-votes the advisory"); } case STRIKE -> { reauction(id, event.date()); research.write(new CarRequest(id, "car " + event.eventId(), event.location(), event.date(), event.date(), 3, List.of()), REQUEST_LEASE); return response(event, "re-auctioned the day and re-scouted ground transport"); } case WEATHER -> { reauction(id, event.date()); return response(event, "re-auctioned the day; outdoor bids rise, indoor bids win"); } case LOST_PASSPORT -> { research.write(new AccessibilityRequest(id, "consulate " + event.eventId(), event.location(), List.of("emergency travel document", "nearest consulate")), REQUEST_LEASE); return response(event, "asked the accessibility advisor for the consulate route"); } default -> { return response(event, "a calm day; nothing to do"); } } }
The second reaction is the whole event model. A TripEvent from the simulator, or from the turret, gets exactly the requests it deserves: an illness sends the hospital locator to the map and the diet advisor to a gentle menu; a protest opens a fresh sweep under the event’s id; a strike or a storm re-auctions the day and re-scouts ground transport; a lost passport asks for the consulate route. The dispatcher returns an EventResponse, which the binder writes back so the turret can show what the fleet did about each event.
The join waits on five different entry types and fires once per trip when the last of them lands, so it is a loop over the space rather than a notify. It reads the sights, the hotels, the restaurants, the foodie list, and the flights, asks the model for Tripper’s ProposedTravelPlan, and writes it. The stay finder then does what Tripper’s findPlacesToSleep does: groups the days into stays, attaches the hotel scout’s options and the safety panel’s advisory, computes the map link in code because models get map links wrong, and writes one BookingOrder per stay for chapter 8.
public static AutoCloseable runPlanAssembler(Space research, Space trip, Sources sources) { Thread thread = Thread.ofVirtual().name("plan-assembler").start(() -> { Set<String> assembled = new HashSet<>(); while (!Thread.currentThread().isInterrupted()) { for (TravelBrief brief : trip.readAll(Template.of(TravelBrief.class), 20)) { String id = brief.tripId(); if (assembled.contains(id)) { continue; } Optional<SightseeingOptions> sights = first(research, SightseeingOptions.class, id); Optional<HotelOptions> hotels = first(research, HotelOptions.class, id); Optional<RestaurantOptions> tables = first(research, RestaurantOptions.class, id); Optional<FoodieOptions> foodie = first(research, FoodieOptions.class, id); Optional<FlightOptions> flights = first(research, FlightOptions.class, id); if (sights.isEmpty() || hotels.isEmpty() || tables.isEmpty() || foodie.isEmpty() || flights.isEmpty()) { continue; } Travelers party = trip.read(Template.of(Travelers.class) .where("tripId", eq(id))).orElse(new Travelers(id, List.of())); ProposedTravelPlan drafted = sources.structured("plan", PLANNER + "\n" + "Write the plan for this brief as HTML starting at <h4>, in 500 " + "words or fewer, with a catchy title, one Day entry per date in " + "'City,+Country' form, and the countries visited.\n<brief>" + brief.contribution() + "</brief>\n" + party.contribution() + "\nPoints of interest: " + sights.get().pointsOfInterest() + "\nHotels: " + hotels.get().options() + "\nRestaurants: " + tables.get().restaurants() + "\nMust eat: " + foodie.get().dishes() + "\nFlights: " + flights.get().options(), ProposedTravelPlan.class); List<Day> days = drafted.days().isEmpty() ? defaultDays(brief) : drafted.days(); trip.write(new ProposedTravelPlan(id, drafted.title(), drafted.plan(), days, drafted.countriesVisited(), "plan-assembler"), Lease.of(Duration.ofHours(12))); assembled.add(id); } SafetyPanel.sleep(250); } }); return thread::interrupt; }
public TravelPlan finish(ProposedTravelPlan plan) { Map<String, List<String>> byTown = new LinkedHashMap<>(); for (Day day : plan.days()) { byTown.computeIfAbsent(day.stayingAt(), k -> new ArrayList<>()).add(day.date()); } List<HotelOptions> hotels = research.readAll(Template.of(HotelOptions.class) .where("tripId", eq(plan.tripId())), 20); List<Stay> stays = new ArrayList<>(); int order = 0; for (Map.Entry<String, List<String>> e : byTown.entrySet()) { Optional<HotelOption> pick = hotels.stream() .filter(h -> h.location().toLowerCase().startsWith(e.getKey().toLowerCase()) || e.getKey().toLowerCase().startsWith(h.location().toLowerCase())) .flatMap(h -> h.options().stream()).findFirst() .or(() -> hotels.stream().flatMap(h -> h.options().stream()).findFirst()); Stay stay = new Stay(e.getKey(), e.getValue(), pick.map(HotelOption::name).orElse("lodging still being scouted"), pick.map(HotelOption::bookingUrl).orElse(""), pick.map(HotelOption::nightlyUsd).orElse(0.0)); stays.add(stay); if (pick.isPresent()) { bookings.write(new BookingOrder(plan.tripId(), plan.tripId() + "-stay-" + (++order), "HOTEL", stay.lodging(), e.getValue().get(0), stay.nightly() * e.getValue().size()), Lease.of(Duration.ofHours(12))); } } // The panel's baseline vote usually lands before the plan; give it a // moment when it has not, and ship PENDING rather than wait forever. String advisory = trip.read(Template.of(TravelAdvisory.class) .where("tripId", eq(plan.tripId())), Duration.ofSeconds(45)) .map(TravelAdvisory::verdict).orElse("PENDING"); return new TravelPlan(plan.tripId(), plan.title(), plan.plan(), plan.days(), stays, advisory, mapUrl(plan.days())); }
PENDING. The first version read the advisory once and shipped PENDING whenever the plan happened to beat the vote by a second. A bounded read(template, timeout) is the idiom for a join that usually has what it needs already.Watching the world, voting on it
The safety pipeline is three coordination types in a row. Three watchers take sweep requests from the bag and write SignalReports into the signals space, one per channel: the news watcher reads headlines, the video watcher reads YouTube and video search (which is where the front-line footage, the protest livestreams, and the “is it safe” vlogs live), and the social watcher reads the forums. The sentiment analyst reacts to every report with a returning @SpaceNotify and writes a DangerAssessment, a score from 0 to 100 with the themes behind it.
public static final class NewsWatcher { private final Sources sources; public NewsWatcher(Sources sources) { this.sources = Objects.requireNonNull(sources, "sources"); } @SpaceTake(space = Scouts.SPACE, resultSpace = SIGNALS, lease = "1m", pollTimeout = "500ms") public SignalReport sweep(NewsSweep sweep) { List<Link> news = sources.news(sweep.location() + " " + sweep.topic(), 10); return new SignalReport(sweep.tripId(), sweep.topic(), sweep.location(), "news", sweep.wave(), news.stream().map(Link::summary).toList(), news, "news-watcher"); } }
public static final class SentimentAnalyst { private final Sources sources; public SentimentAnalyst(Sources sources) { this.sources = Objects.requireNonNull(sources, "sources"); } @SpaceNotify(space = SIGNALS, lease = "12h") public DangerAssessment assess(SignalReport report) { DangerAssessment scored = sources.structured( "danger:" + report.channel() + ":" + report.topic(), "You assess danger to ordinary tourists, not to journalists or soldiers. " + "Score 0 (calm) to 100 (active conflict) and name the themes. Location: " + report.location() + ". Channel: " + report.channel() + ".\n" + digest("What the watcher found", report.sources()), DangerAssessment.class); return new DangerAssessment(report.tripId(), report.location(), report.channel(), report.wave(), scored.danger(), scored.themes(), "sentiment-analyst"); } }
Nothing so far decides anything, and that restraint is the design. The verdict belongs to the panel: three panelists, one seated per channel, deciding one advisory per place per sweep through aspace:cap/vote in QUORUM mode. Each panelist trusts the other channels a little less than its own, so a vivid video score alone cannot carry an AVOID that the news and the forums do not support. The lead opens the proposal when the first assessment of a sweep lands and records the TravelAdvisory when the vote reaches quorum. Every ballot stays in the advisories space signed by its panelist, so the advisory is an auditable decision anyone can recount rather than a number that fell out of a model.
public static double trust(String seat, String channel) { if (seat.equals(channel)) { return 1.0; } return switch (channel) { case "news" -> 0.9; case "social" -> 0.7; default -> 0.6; // video }; } public static String verdictFor(double weightedDanger) { if (weightedDanger >= AVOID_AT) { return "AVOID"; } return weightedDanger >= CAUTION_AT ? "CAUTION" : "GO"; }
public static final class Panelist { private final String seat; @SpaceRef(Watchers.SIGNALS) Space signals; public Panelist(String seat) { this.seat = Objects.requireNonNull(seat, "seat"); } @Ballot(space = ADVISORIES, prefix = PROPOSAL_PREFIX, lease = "12h") public String judge(VoteCapability.Proposal proposal) { // The proposal names the wave; read its assessments, waiting briefly // for our own channel's when it is still in flight. long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos(); while (System.nanoTime() < deadline) { List<DangerAssessment> wave = waveOf(proposal.proposalId()); boolean ownSeen = wave.stream().anyMatch(a -> a.channel().equals(seat)); if (ownSeen || wave.size() >= SEATS.size()) { double weighted = wave.stream() .mapToDouble(a -> a.danger() * trust(seat, a.channel())) .max().orElse(0.0); return verdictFor(weighted); } sleep(100); } return null; // the wave never arrived here: abstain } private List<DangerAssessment> waveOf(String proposalId) { return signals.readAll(Template.of(DangerAssessment.class), 500).stream() .filter(a -> Safety.proposalId(a.tripId(), a.location(), a.wave()).equals(proposalId)) .toList(); } }
public static final class Lead { private final VoteCapability vote; private final String leadName; private final Set<String> proposed = java.util.concurrent.ConcurrentHashMap.newKeySet(); private final Map<String, DangerAssessment> waves = new java.util.concurrent.ConcurrentHashMap<>(); @SpaceRef(Watchers.SIGNALS) Space signals; public Lead(VoteCapability vote, String leadName) { this.vote = Objects.requireNonNull(vote, "vote"); this.leadName = Objects.requireNonNull(leadName, "leadName"); } @SpaceNotify(space = Watchers.SIGNALS, lease = "12h") public void open(DangerAssessment assessment) { String proposalId = Safety.proposalId(assessment.tripId(), assessment.location(), assessment.wave()); waves.putIfAbsent(proposalId, assessment); if (proposed.add(proposalId) && vote.proposal(proposalId).isEmpty()) { vote.propose(proposalId, "Travel advisory for " + assessment.location() + " (" + assessment.wave() + ")", Safety.VERDICTS, SEATS.size(), Lease.of(Duration.ofHours(12))); } } @OnDecision(space = ADVISORIES, prefix = PROPOSAL_PREFIX, resultSpace = Party.TRIP, resultLease = "12h", lease = "12h") public TravelAdvisory publish(VoteCapability.Decision decision) { DangerAssessment wave = waves.get(decision.proposalId()); if (wave == null) { return null; // a vote opened elsewhere on a wave this lead never saw } Map<String, Integer> tally = decision.tally(); Set<String> reasons = new TreeSet<>(); signals.readAll(Template.of(DangerAssessment.class) .where("tripId", eq(wave.tripId())) .where("location", eq(wave.location())) .where("wave", eq(wave.wave())), 50) .forEach(a -> reasons.addAll(a.themes())); return new TravelAdvisory(wave.tripId(), wave.location(), wave.wave(), decision.winner(), tally.getOrDefault("GO", 0), tally.getOrDefault("CAUTION", 0), tally.getOrDefault("AVOID", 0), List.copyOf(reasons), leadName); } }
The panel votes again for every new sweep. The protest in the simulator triggers a sweep named by the event id; the analyst scores the fresh reports under that wave; and the panel decides that wave’s advisory. In the recorded week the video seat weighs its own 78 at full trust and votes AVOID, while the news seat weighs the video’s 78 at 0.6 and its own 55 at 1.0 and votes CAUTION, as does the social seat. Two to one, CAUTION, and the turret shows the fleet’s opinion moving with the news.
The itinerary auction
The slots space runs the AUCTION strategy. The dispatcher writes one DaySlot per day of the trip, carrying the party’s interests, the pace, and a round number. Four activity planners each carry a @BidFunction that prices the day from their own point of view (how well the day suits what they do, how the interests match, how the pace and the weather sit with it) and a @SpaceTake that fires when they win. The space runs the CBBA rounds among the bidders, awards the day to the lowest bid, and the winner’s method returns the ScheduledActivity the space writes back.
public record DaySlot(String tripId, String date, String location, List<String> interests, String pace, int round) { }
public static final class DiningPlanner { @SpaceRef(Scouts.SPACE) Space research; @BidFunction(space = SLOTS) public double bid(DaySlot slot) { double bid = 90; if (interested(slot, "food", "wine", "eat", "cook")) { bid -= 35; } if (slot.pace().equals("REST")) { bid += 20; } return bid + slot.round() * 5; // a strike barely touches a lunch } @SpaceTake(space = SLOTS, lease = "1m", pollTimeout = "500ms") public ScheduledActivity plan(DaySlot slot) { String table = research.readAll(Template.of(RestaurantOptions.class) .where("tripId", eq(slot.tripId())), 20).stream() .filter(r -> r.location().equalsIgnoreCase(slot.location())) .flatMap(r -> r.restaurants().stream()).map(r -> r.name()).findFirst() .orElse("a table the scouts are still finding"); return new ScheduledActivity(slot.tripId(), slot.date(), slot.location(), "DINING", "Market morning, long lunch, dinner at " + table, bid(slot), "dining-planner", slot.round()); } }
public static final class SightsPlanner { @SpaceRef(Scouts.SPACE) Space research; @BidFunction(space = SLOTS) public double bid(DaySlot slot) { double bid = 80; if (interested(slot, "history", "architecture", "museum", "art")) { bid -= 35; } if (slot.pace().equals("REST")) { bid += 80; // nobody wants a six-mile walk on a rest day } return bid + slot.round() * 45; // storms and strikes ruin an outdoor day } @SpaceTake(space = SLOTS, lease = "1m", pollTimeout = "500ms") public ScheduledActivity plan(DaySlot slot) { List<PointOfInterest> pois = research.readAll(Template.of(SightseeingOptions.class) .where("tripId", eq(slot.tripId())), 5).stream() .flatMap(s -> s.pointsOfInterest().stream()).toList(); String pick = pois.isEmpty() ? "the old town" : pois.get(Math.abs(slot.date().hashCode()) % pois.size()).name(); return new ScheduledActivity(slot.tripId(), slot.date(), slot.location(), "SIGHTS", "Sights day: " + pick + " and the streets around it", bid(slot), "sights-planner", slot.round()); } }
public static final class RestPlanner { @BidFunction(space = SLOTS) public double bid(DaySlot slot) { // Expensive to waste a good day; nearly free when the party is spent. return slot.pace().equals("REST") ? 5 : 140; } @SpaceTake(space = SLOTS, lease = "1m", pollTimeout = "500ms") public ScheduledActivity plan(DaySlot slot) { return new ScheduledActivity(slot.tripId(), slot.date(), slot.location(), "REST", "Rest day: pool, siesta, one gentle dinner", bid(slot), "rest-planner", slot.round()); } }
Nobody schedules. The dining planner wins the foodie’s days, the sights planner wins the historian’s, the photo walk takes the arrival day when the pace is EASY, and when the travelers’ energy turns a day to REST the rest planner’s bid collapses from 140 to 5 and it wins the day with a pool and a long lunch. A storm or a strike re-writes the day with round + 1, the outdoor planners’ bids climb by their round penalty, and the day changes hands to whoever minds the weather least. Each planner also holds a @SpaceRef to the research space so the day it schedules names a real table or sight the scouts found.
| day | dining | sights | photo walk | rest | winner |
|---|---|---|---|---|---|
| FULL, round 0, all interests | 55 | 45 | 50 | 140 | sights |
| EASY (arrival), round 0 | 55 | 45 | 30 | 140 | photo walk |
| REST, round 1 | 80 | 170 | 160 | 5 | rest |
| FULL, round 1 (storm) | 60 | 90 | 100 | 140 | dining |
The party votes and gossips
Tripper models travelers as data the planner writes for. Party Bus gives each traveler a seat. A TravelerAgent reacts to every TripDay with an EnergyReport and feeds the same number into the aspace:cap/aggregate push-sum epoch for that day, so the fleet learns the party’s average energy in a handful of gossip rounds with nobody collecting anything. The simulator reads the estimate and sets the next day’s pace, and the pace is what flips the auction toward the rest planner. Each traveler also casts one signed ballot when the party lead puts the next-destination advisor’s ideas to a quorum vote, preferring the idea that sounds most like them.
public static final class TravelerAgent { private final Traveler traveler; private final int stamina; private final PushSumAggregate aggregate; @SpaceRef(TRIP) Space trip; /** * @param traveler who * @param stamina baseline energy, 0 to 100 * @param aggregate the push-sum capability this traveler reports into * @param vote the vote capability over the decisions space */ public TravelerAgent(Traveler traveler, int stamina, PushSumAggregate aggregate) { this.traveler = Objects.requireNonNull(traveler, "traveler"); this.stamina = stamina; this.aggregate = Objects.requireNonNull(aggregate, "aggregate"); } /** The traveler's name. */ public String name() { return traveler.name(); } /** * How this traveler feels on a day: stamina minus wear, minus a lot when * the day's events hit them, and the same number goes into the aggregate. */ @SpaceNotify(space = TRIP, lease = "12h") public EnergyReport feel(TripDay day) { int energy = energyOn(day, trip.readAll(Template.of(TripEvent.class) .where("tripId", eq(day.tripId())).where("date", eq(day.date())), 20)); aggregate.start(Itinerary.energyEpoch(day.tripId(), day.date()), energy); double spent = 40 + (traveler.about().toLowerCase().contains("food") ? 45 : 20) + day.dayIndex() * 3; aggregate.start(Itinerary.spendEpoch(day.tripId(), day.date()), spent); return new EnergyReport(day.tripId(), day.date(), traveler.name(), energy, spent); } /** Pure and deterministic, so the simulator's story replays the same way. */ int energyOn(TripDay day, List<TripEvent> events) { int energy = stamina - day.dayIndex() * 9; for (TripEvent event : events) { if (event.kind() == EventKind.ILLNESS && event.detail().toLowerCase().contains(traveler.name().toLowerCase())) { energy -= 60; } else if (event.kind() == EventKind.WEATHER || event.kind() == EventKind.STRIKE) { energy -= 10; } else if (event.kind() == EventKind.PROTEST) { energy -= 15; } } return Math.max(0, Math.min(100, energy)); } /** * One signed ballot per proposal, for the option that sounds most like them. * The cue is the vote's own proposal, so the ballot is never refused as * unknown and no wait is needed. */ @Ballot(space = DECISIONS, prefix = "next:", lease = "12h") public String decide(VoteCapability.Proposal proposal) { return preference(proposal.options()); } String preference(List<String> options) { String about = traveler.about().toLowerCase(); for (String option : options) { for (String word : option.toLowerCase().split("[^a-z]+")) { if (word.length() > 3 && about.contains(word)) { return option; } } } // Fall back on a stable, name-derived choice so ties break differently per traveler. return options.get(Math.abs(traveler.name().hashCode()) % options.size()); } }
public static final class Lead { private final VoteCapability vote; private final int partySize; private final Set<String> proposed = java.util.concurrent.ConcurrentHashMap.newKeySet(); public Lead(VoteCapability vote, int partySize) { this.vote = Objects.requireNonNull(vote, "vote"); this.partySize = partySize; } @SpaceNotify(space = Scouts.SPACE, lease = "12h", resultSpace = DECISIONS, resultLease = "12h") public NextDestinationProposal open(NextDestinationIdeas ideas) { String proposalId = "next:" + ideas.tripId(); if (!proposed.add(proposalId)) { return null; } List<String> options = new ArrayList<>(); for (Destination idea : ideas.ideas()) { options.add(idea.name()); } vote.propose(proposalId, "Where next after this trip?", options, partySize, Lease.of(Duration.ofHours(12))); return new NextDestinationProposal(ideas.tripId(), proposalId, "Where next after this trip?", options, "party-lead"); } @OnDecision(space = DECISIONS, prefix = "next:", resultSpace = TRIP, resultLease = "12h", lease = "12h") public PartyDecision record(VoteCapability.Decision decision) { List<String> tally = new ArrayList<>(); for (Map.Entry<String, Integer> e : decision.tally().entrySet()) { tally.add(e.getKey() + "=" + e.getValue()); } return new PartyDecision(decision.proposalId().substring("next:".length()), decision.proposalId(), decision.winner(), tally); } }
Two collective decisions, two mechanisms, chosen by what each needs. The pace is quorum sensing: an approximate average is plenty, it must be cheap every day, and push-sum converges in O(log N) rounds over the capability pipes with no entries written at all. The destination is a ballot: it must be exact and auditable, it happens once, and the tally lives in the space. Each traveler sits on a peer of its own, so each contributes its own value to the push-sum epoch and carries its own ballot, and the party quorum is a quorum of members.
EnergyReports in the space when the estimate is empty, which is the right posture for a fleet: the space is the record, the aggregate is the fast path.Exactly once: the bookings
A reservation is paid for, so it must be confirmed once. LEASE_RACE promises at most one completion per entry and tolerates duplicate work during a partition; for money, Party Bus uses the ordered log. Three clerk peers form a Raft group over the capability pipes, each holds an OrderedTakes coordinator over its bookings replica, and each runs a clerk that takes BookingOrders through it. Three clerks race for every order; the log commits their claims in one order; and only the clerk whose claim committed first adopts the take and writes the BookingConfirmation, with the committed log index on it.
/** * Forms the booking quorum: a Raft group among the clerk peers, an * {@link OrderedTakes} coordinator per clerk over its {@code bookings}
public static final class BookingClerk { private final RaftLog raft; private final String clerk; public BookingClerk(RaftLog raft, String clerk) { this.raft = Objects.requireNonNull(raft, "raft"); this.clerk = Objects.requireNonNull(clerk, "clerk"); } @OrderedTake(space = BOOKINGS, lease = "30s", pollTimeout = "2s", resultLease = "12h") public BookingConfirmation confirm(BookingOrder order) { return new BookingConfirmation(order.tripId(), order.orderId(), confirmationCode(order, clerk), order.amountUsd(), clerk, raft.commitIndex()); } }
Two operational details are worth their lines. Raft’s election timer counts ticks, so the clerks tick their log on a slower cadence than the aggregate (300 ms rather than 100 ms) to keep elections calm under load. And the fleet flow test checks the bookings twice: once when the confirmations land, and once more after the simulated week, well past the thirty-second take lease, to prove no order ever reappeared.
That second check exists because the first Party Bus run showed each order confirmed three times, thirty seconds apart, by a different clerk each time. The ordered-log coordinator applies every committed claim on every member under that member’s own key, a placeholder; the holder’s own proof arrives later with its completion; and SignedClaim.merge kept the placeholder on a tie, so no member but the holder could authenticate the completion (SPEC §11a.4), the entry stayed live on the other replicas, and the next clerk took it when the lease lapsed. The merge now prefers the holder-attested proof for the same claim, SignedClaimMergeTest in agentspaces-space pins it, and a flagship earned its keep by finding a bug the unit tests had not.
The planner sees the fleet
This is the chapter Tripper’s author would read first. Tripper’s GOAP planner chains @Actions by their input and output types. Party Bus lets it keep doing that while the actions live on other peers. RemoteActions watches the group’s ad-cache and exposes one remote action per foreign AgentCard and (consumed, produced) type pair whose types resolve locally; the routes say which space carries which request type and where the result is awaited. Invoking an action writes the request entry into the bag and awaits the correlated result, so it is choreography over the space rather than RPC: whichever scout takes the request answers it, and a scout that dies hands the request to the next.
/** * The registry of remote actions over a peer's discovery and spaces, with * every request type routed to the research bag and every result awaited * there (sweeps excepted: watchers write their reports into {@code signals}).
/** * The planner's local repertoire: what a lone Tripper-style agent can do * with a brief before any scout is involved. Deliberately unable to reach a * {@code ReviewDigest} on its own.
public static Optional<List<Step>> plan(Class<?> start, Class<?> goal, List<Step> actions) { record Node(Class<?> type, List<Step> path) { } Deque<Node> frontier = new ArrayDeque<>(); frontier.add(new Node(start, List.of())); Set<Class<?>> visited = new HashSet<>(); visited.add(start); while (!frontier.isEmpty()) { Node node = frontier.poll(); if (goal.isAssignableFrom(node.type())) { return Optional.of(node.path()); } for (Step step : actions) { if (step.in().isAssignableFrom(node.type()) && visited.add(step.out())) { List<Step> path = new ArrayList<>(node.path()); path.add(step); frontier.add(new Node(step.out(), path)); } } } return Optional.empty(); }
EmbabelRemoteActions then generates a real @Agent class with one @Action method per remote action, because the planner conditions on method signatures, and deployTo(platform) hands it to Embabel’s AgentPlatform. Without Embabel on the classpath the same registry drives a small breadth-first planner, which is enough to show the property that matters. The planner’s local repertoire (turn a brief into a sightseeing request, turn the top sight into a review request) provably cannot reach a ReviewDigest. Add the fleet’s cards and the four-step plan exists, and executes, as two leased round-trips through two different peers.
planner alone reaches ReviewDigest: false no Embabel on the classpath (-Pembabel adds it): planning over 15 remote actions directly planner with the fleet: [askForSights, sightseeing-scout_SightseeingRequest, askForReviewsOfTheTopSight, review-reader_ReviewRequest] -> reviews of the top sight: 4.6/5, praise: [the light on the river at 6pm, the food, and the price of the food, how kind people are when you attempt Portuguese]
// 6. The planner: unreachable alone, reachable and executed through the fleet. // The bridge snapshots the fleet's cards when it is built, so wait for the // two actions this plan needs, not merely for a dozen cards: with the front // half of the trip now settling in seconds, "twelve of thirty" no longer // implies the sightseeing scout's and review reader's cards are among them. RemoteActions remote = PartyBusPlanner.remoteActions(bus.plannerPeer()); assertThat(PartyBusPlanner.awaitActions(remote, 12, Duration.ofSeconds(30))) .isGreaterThanOrEqualTo(12); List<String> needed = List.of("sightseeing-scout_SightseeingRequest", "review-reader_ReviewRequest"); assertThat(await(() -> { Set<String> names = remote.available().stream().map(RemoteAction::name) .collect(Collectors.toSet()); return names.containsAll(needed) ? Optional.of(names) : Optional.<Set<String>>empty(); }, Duration.ofSeconds(30))) .as("the scout and reader actions the plan needs are advertised; seen: " + remote.available().stream().map(RemoteAction::name).sorted().toList()) .isPresent(); List<Step> local = PartyBusPlanner.localSteps(PartyBus.party(TRIP)); assertThat(PartyBusPlanner.plan(TravelBrief.class, ReviewDigest.class, local)).isEmpty(); EmbabelRemoteActions bridge = PartyBusPlanner.bridge(remote); List<Step> withFleet = new ArrayList<>(local); withFleet.addAll(PartyBusPlanner.fleetSteps(bridge.agentInstance().orElseThrow())); List<Step> found = PartyBusPlanner.plan(TravelBrief.class, ReviewDigest.class, withFleet) .orElseThrow(); assertThat(found).extracting(Step::name).containsExactly("askForSights", "sightseeing_scout_SightseeingRequest", "askForReviewsOfTheTopSight", "review_reader_ReviewRequest"); ReviewDigest digest = (ReviewDigest) PartyBusPlanner.execute(found, new TravelBrief(TRIP + "-plan", brief.from(), brief.to(), brief.departureDate(), brief.returnDate(), brief.dailyBudget(), brief.brief(), brief.transportPreference())); assertThat(digest.author()).isEqualTo("review-reader"); assertThat(digest.averageRating()).isEqualTo(4.6);
Under the embabel Maven profile, src/embabel/java compiles against the same Embabel starter Tripper uses and boards the bus inside a Spring Boot application. The local agent supplies the two actions the fleet cannot, the bridge deploys the generated agent, and the real GOAP planner plans across the fleet. The profile runs on Embabel 1.5, Spring AI 2.0, and Spring Boot 4.1, and the workspace gate (verify-all.sh) builds and tests it, beside the core repository’s integration test that plans over fleet actions with the real Embabel planner.
package ai.badmonkey.agentspaces.partybus.embabel; import ai.badmonkey.agentspaces.agent.remote.RemoteActions; import ai.badmonkey.agentspaces.embabel.EmbabelRemoteActions; import ai.badmonkey.agentspaces.partybus.PartyBus; import ai.badmonkey.agentspaces.partybus.domain.Research.ReviewDigest; import ai.badmonkey.agentspaces.partybus.domain.Research.ReviewRequest; import ai.badmonkey.agentspaces.partybus.domain.Research.SightseeingOptions; import ai.badmonkey.agentspaces.partybus.domain.Research.SightseeingRequest; import ai.badmonkey.agentspaces.partybus.domain.Trip.PointOfInterest; import ai.badmonkey.agentspaces.partybus.domain.Trip.TravelBrief; import ai.badmonkey.agentspaces.partybus.fleet.PartyBusPlanner; import ai.badmonkey.agentspaces.partybus.sources.Sources; import com.embabel.agent.api.annotation.AchievesGoal; import com.embabel.agent.api.annotation.Action; import com.embabel.agent.api.annotation.Agent; import com.embabel.agent.config.annotation.EnableAgents; import com.embabel.agent.core.AgentPlatform; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import java.time.Duration; /** * Party Bus as a Tripper-style Embabel application. This source set compiles * only under the {@code embabel} Maven profile, against the same Embabel * starter Tripper uses, and it is the part of the project that cannot be * verified in a network-restricted build; treat it as the shape of the * integration and run it from a laptop with Maven Central and the API keys. * * <p>Two things happen here. First, the bus boards inside the Spring Boot * application, exactly as the standalone demo boards it. Second, the fleet's * AgentCards come back in as planner actions: {@link PartyBusPlanner#bridge} * generates an {@code @Agent} with one {@code @Action} per remote card and * {@code deployTo(platform)} hands it to Embabel's {@code AgentPlatform}, so * the GOAP planner sees the sightseeing scout, the review reader, and every * other specialist as actions it can chain. The local {@link PartyBusPlannerAgent} * supplies the two actions the fleet cannot: turning a brief into the first * request, and turning the top sight into a review request. The goal, a * {@code ReviewDigest} from a {@code TravelBrief}, is reachable only through * the fleet, which is the whole point. */ @SpringBootApplication @EnableAgents public class PartyBusEmbabelApplication { /** * Runs the application. * * @param args passed to Spring Boot */ public static void main(String[] args) { SpringApplication.run(PartyBusEmbabelApplication.class, args); } /** Boards the bus and bridges its cards into the platform once Spring is up. */ @Bean CommandLineRunner boardTheBus(AgentPlatform platform) { return args -> { Sources sources = Sources.fromEnvironment(); PartyBus.Seating bus = PartyBus.board(sources, 7700, 7590); RemoteActions remote = PartyBusPlanner.remoteActions(bus.plannerPeer()); PartyBusPlanner.awaitActions(remote, 12, Duration.ofSeconds(30)); EmbabelRemoteActions bridge = PartyBusPlanner.bridge(remote); if (!bridge.deployTo(platform)) { throw new IllegalStateException("the fleet agent did not deploy; are the cards in?"); } }; } /** * The planner's own two actions. Everything between them is a remote scout * the bridge generated from an AgentCard. */ @Agent(description = "Plans a trip with the Party Bus fleet's specialists") public static class PartyBusPlannerAgent { @Action public SightseeingRequest askForSights(TravelBrief brief) { return new SightseeingRequest(brief.tripId(), "planner sights " + brief.to(), brief.to(), brief.brief(), brief.departureDate(), brief.returnDate(), PartyBus.party(brief.tripId()).contribution()); } @Action public ReviewRequest askForReviewsOfTheTopSight(SightseeingOptions sights) { PointOfInterest top = sights.pointsOfInterest().isEmpty() ? new PointOfInterest("the old town", "", "", "", "") : sights.pointsOfInterest().get(0); return new ReviewRequest(sights.tripId(), "planner reviews " + top.name(), top.name(), top.location()); } @AchievesGoal(description = "Know what travelers say about the trip's top sight") @Action public ReviewDigest done(ReviewDigest digest) { return digest; } } }
<profile> <id>embabel</id> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-starter-openai</artifactId> <version>${embabel-agent.version}</version> </dependency> <dependency> <groupId>ai.badmonkey.agentspaces</groupId> <artifactId>agentspaces-spring-boot-starter</artifactId> <version>${agentspaces.version}</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> <version>3.4.0</version> <executions> <execution> <id>add-embabel-sources</id> <phase>generate-sources</phase> <goals> <goal>add-source</goal> </goals> <configuration> <sources> <source>src/embabel/java</source> </sources> </configuration> </execution> </executions> </plugin> </plugins> </build> </profile>
# Party Bus under Spring Boot (the embabel profile). The standalone demo reads # none of this; it boards the bus in code. With -Pembabel, the AgentSpaces # starter and the Embabel starter both read it. server: port: 8749 # Tripper flies 8747; the bus parks two doors down agentspaces: keystore: ./partybus-keys bind: 0.0.0.0:7700 transport: channel-auth: attested # SPEC §5.6: TLS vouches for the sender, frames travel bare tls: enabled: true groups: - name: partybus founding: partybus-v1 spaces: - { name: trip } - { name: research } - { name: signals } - { name: advisories } - { name: decisions } - { name: slots, strategy: AUCTION } - { name: bookings } - { name: fleet-control } - { name: c2-audit } embabel: remote-actions-enabled: true remote-action-timeout-millis: 60000 remote-agent-name: partyBusFleet console: enabled: true port: 7590 fleet-name: partybus command: enabled: true token: ${PARTYBUS_OPERATOR_TOKEN:partybus-operator} embabel: models: default-llm: gpt-4.1-mini # as Tripper's researcher; the plan itself deserves a better one
The command-and-control turret
The turret is the fleet console with Party Bus panels and commands, running as one more read-mostly peer. The panels are pure reads of the spaces: the itinerary (the winning activity per day, latest round first), the advisories with their ballot splits, the bookings with the clerk and the committed log index, the party’s energy per day beside the push-sum estimate, the events with the fleet’s responses, and the finished plan. The commands are writes: start a trip, throw an event at the fleet exactly as the simulator would, reroute the party to a new destination under a new trip id. Every command lands in c2-audit signed by the operator’s peer, so the trip’s audit trail includes the human, and the built-in PAUSE, RESUME, and DRAIN directives reach every worker through fleet-control.
public static Turret over(PartyBusFleet.Peer peer) { Objects.requireNonNull(peer, "peer"); ConsoleView.Builder view = ConsoleView.builder() .space(PartyBusFleet.RESEARCH, peer.space(PartyBusFleet.RESEARCH), Research.FlightRequest.class, Research.HotelRequest.class, Research.CarRequest.class, Research.SightseeingRequest.class, Research.RestaurantRequest.class, Research.DietRequest.class, Research.FoodieRequest.class, Research.PhotoRequest.class, Research.ReviewRequest.class, Research.AccessibilityRequest.class, Research.HospitalRequest.class, Research.NextDestinationRequest.class, Safety.NewsSweep.class, Safety.VideoSweep.class, Safety.SocialSweep.class) .space(PartyBusFleet.SLOTS, peer.space(PartyBusFleet.SLOTS), DaySlot.class) .results(PartyBusFleet.SLOTS, ScheduledActivity.class, a -> ((ScheduledActivity) a).plannedBy()) .space(PartyBusFleet.BOOKINGS, peer.space(PartyBusFleet.BOOKINGS), BookingOrder.class) .results(PartyBusFleet.BOOKINGS, BookingConfirmation.class, c -> ((BookingConfirmation) c).clerk()) .members(() -> peer.runtime().membership().allMembers()) .discovery(peer.discovery()); for (Class<?> result : RESULT_TYPES) { view.results(PartyBusFleet.RESEARCH, result, Turret::authorOf); } ConsoleView built = view.build(); FleetCommander commander = FleetCommander.builder(peer.spaces()::get) .controlSpace(PartyBusFleet.CONTROL) .auditSpace(PartyBusFleet.AUDIT) .view(built) .command(startTrip()) .command(throwEvent()) .command(reroute(peer.space(PartyBusFleet.TRIP))) .build(); FleetConsoleServer server = FleetConsoleServer.builder(built) .fleetName("partybus") .commandAndControl(commander, OPERATOR_TOKEN) .panel(itineraryPanel(peer)) .panel(safetyPanel(peer)) .panel(bookingsPanel(peer)) .panel(energyPanel(peer)) .panel(responsesPanel(peer)) .panel(planPanel(peer)) .build(); return new Turret(peer, built, server); }
static ConsoleCommand throwEvent() { return new ConsoleCommand() { @Override public String id() { return "throw-event"; } @Override public String title() { return "Throw an event"; } @Override public String description() { return "Storm, strike, protest, illness, or a lost passport; the fleet reacts."; } @Override public List<Field> fields() { return List.of(Field.text("tripId", "Trip id"), Field.text("date", "Date (ISO)"), Field.select("kind", "Kind", Arrays.stream(EventKind.values()) .map(Enum::name).toList()), Field.text("location", "Location"), Field.text("detail", "What happened"), Field.number("severity", "Severity 1-5")); } @Override public Outcome execute(Map<String, String> args, CommandContext ctx) { String eventId = args.get("tripId") + "-op-" + System.currentTimeMillis(); String entryId = ctx.dispatch(PartyBusFleet.TRIP, new TripEvent(args.get("tripId"), args.get("date"), EventKind.valueOf(args.get("kind")), args.get("location"), args.get("detail"), (int) Double.parseDouble(args.getOrDefault("severity", "3")), eventId), Duration.ofHours(12)); ctx.audit("throw-event", args.get("kind") + " at " + args.get("location")); return Outcome.dispatched(args.get("kind") + " thrown", entryId); } }; }
static ConsolePanel itineraryPanel(PartyBusFleet.Peer peer) { Space slots = peer.space(PartyBusFleet.SLOTS); return panel("itinerary", "Itinerary (auction winners)", () -> { Map<String, ScheduledActivity> byDay = new java.util.TreeMap<>(); for (ScheduledActivity a : slots.readAll(Template.of(ScheduledActivity.class), 200)) { ScheduledActivity current = byDay.get(a.date()); if (current == null || a.round() > current.round()) { byDay.put(a.date(), a); } } List<Map<String, Object>> rows = new ArrayList<>(); for (ScheduledActivity a : byDay.values()) { rows.add(row("date", a.date(), "kind", a.kind(), "title", a.title(), "plannedBy", a.plannedBy(), "bid", a.costUsd(), "round", a.round())); } return rows; }); }
The console’s three surfaces are open standards, so the turret works from a browser, from curl, or from a Spring HATEOAS client, and a panel is an id, a title, and a live JSON document.
curl -s localhost:7590/api/v1/panels/safety/data | jq . curl -s localhost:7590/api/v1/panels/itinerary/data | jq '.[] | {date, kind, plannedBy, round}' curl -N localhost:7590/api/v1/events # the activity stream, as Server-Sent Events curl -s -X POST localhost:7590/api/v1/commands/throw-event \ -H 'Authorization: Bearer partybus-operator' -H 'Content-Type: application/json' \ -d '{"operator":"al","args":{"tripId":"lisbon-2026","date":"2026-10-05","kind":"STRIKE", "location":"Lisbon, Portugal","detail":"taxi strike","severity":"3"}}' curl -s -X POST localhost:7590/api/v1/commands/directive \ -H 'Authorization: Bearer partybus-operator' -H 'Content-Type: application/json' \ -d '{"operator":"al","action":"PAUSE","target":"*"}' # every worker pauses; RESUME lifts it
The vacation simulator
The simulator is a clock and a script. It writes one TripDay per day of the brief, throws the script’s TripEvents at the fleet, and between days reads the party’s energy from the aggregate to set the next day’s pace; when the pace turns to REST it re-auctions the day. It never calls an agent. Everything it does is a write into the trip or slots space, and everything the fleet does about it is a reaction, so the same fleet handles the same events when the turret throws them by hand, or when the real world does. That is what makes the simulation a test of the fleet rather than of the simulator.
public static List<Scene> lisbonWeek() { return List.of( new Scene(0, EventKind.CALM, "Wheels down. Pastel de nata count: 3 before the hotel.", 1), new Scene(1, EventKind.WEATHER, "Atlantic squall parks over the city until mid-afternoon.", 2), new Scene(2, EventKind.ILLNESS, "Marco ordered the clams. Marco regrets the clams.", 3), new Scene(3, EventKind.PROTEST, "Transport unions march on the Assembleia; Baixa is sealed off.", 3), new Scene(4, EventKind.STRIKE, "Metro and ferry strike; the tram to Belém is a rumor.", 3), new Scene(5, EventKind.CALM, "Sun, a rooftop, and nothing on fire.", 1), new Scene(6, EventKind.LOST_PASSPORT, "Priya's passport is in a taxi. Somewhere.", 4)); }
/** * Lives the trip, day by day, on the calling thread. * * @param brief the brief that defines the days * @param script the scenes to play * @return one narration per day, in order * @throws InterruptedException if interrupted between days */ public List<Narration> live(TravelBrief brief, List<Scene> script) throws InterruptedException { List<Narration> story = new ArrayList<>(); List<String> days = brief.days(); String pace = "EASY"; for (int i = 0; i < days.size(); i++) { String date = days.get(i); // The day's event lands first, so the travelers wake up to it. TripEvent event = null; for (Scene scene : script) { if (scene.dayIndex() == i && scene.kind() != EventKind.CALM) { event = new TripEvent(brief.tripId(), date, scene.kind(), brief.to(), scene.detail(), scene.severity(), brief.tripId() + "-d" + i + "-" + scene.kind().name().toLowerCase()); trip.write(event, Lease.of(Duration.ofHours(12))); } } Thread.sleep(300); trip.write(new TripDay(brief.tripId(), date, i, brief.to(), pace), Lease.of(Duration.ofHours(12))); Thread.sleep(dayLength.toMillis()); OptionalDouble energy = aggregate.estimate(Itinerary.energyEpoch(brief.tripId(), date)); if (energy.isEmpty()) { // No share reached this peer yet: the reports in the space are the fallback. energy = trip.readAll(Template.of(EnergyReport.class) .where("tripId", eq(brief.tripId())).where("date", eq(date)), 20) .stream().mapToInt(EnergyReport::energy).average(); } List<ScheduledActivity> schedule = slots.readAll(Template.of(ScheduledActivity.class) .where("tripId", eq(brief.tripId())).where("date", eq(date)), 10); story.add(new Narration(date, pace, energy, event, schedule)); narrator.accept(story.get(story.size() - 1)); String next = paceFor(energy); if (i + 1 < days.size() && next.equals("REST")) { reauction(brief, days.get(i + 1), next); } pace = next; } return story; }
public static String paceFor(OptionalDouble energy) { if (energy.isEmpty()) { return "FULL"; } if (energy.getAsDouble() < 40) { return "REST"; } return energy.getAsDouble() < 60 ? "EASY" : "FULL"; }
TripDay, so a traveler’s feel reaction reads the illness that already happened. Written the other way round, Marco reported a fine morning and then ate the clams, and the rest day never came.Running, testing, going live
PartyBus.board is the seating plan: every peer started, every agent bound, the quorum formed, the turret up. The demo then writes the travelers and the brief, shows the planner alone and with the fleet, lives the week, and prints what the space remembers.
/** * Boards the bus: starts every peer, binds every agent, forms the booking * quorum, and starts the turret. * * @param sources the specialists' sources * @param basePort the first TCP port; peers take consecutive ports * @param consolePort the turret's HTTP port (0 for any) * @return the seating * @throws Exception on startup failure */ public static Seating board(Sources sources, int basePort, int consolePort) throws Exception { List<Peer> peers = new ArrayList<>(); int[] port = {basePort}; Function<String, Peer> seat = name -> { try { Peer peer = PartyBusFleet.startPeer(name, port[0]++, peers.isEmpty() ? 0 : basePort); peers.add(peer); return peer; } catch (Exception e) { throw new IllegalStateException("cannot seat " + name, e); } }; Peer turretPeer = seat.apply("turret"); // The concierge desk: the agents that watch the whole trip rather than one task. Peer concierge = seat.apply("concierge"); concierge.bind(new Dispatcher()); concierge.bind(new Assemblers.StayFinder()); concierge.run(Assemblers.runPlanAssembler(concierge.space(PartyBusFleet.RESEARCH), concierge.space(PartyBusFleet.TRIP), sources)); concierge.bind(new SafetyPanel.Lead(concierge.vote(PartyBusFleet.ADVISORIES), "safety-lead")); // The scouts and advisors: the research bag's workers, four to a peer. Peer scoutsA = seat.apply("scouts-a"); scoutsA.bind(new Scouts.FlightScout(sources)); scoutsA.bind(new Scouts.HotelScout(sources)); scoutsA.bind(new Scouts.CarScout(sources)); scoutsA.bind(new Scouts.SightseeingScout(sources)); Peer scoutsB = seat.apply("scouts-b"); scoutsB.bind(new Scouts.RestaurantScout(sources)); scoutsB.bind(new Scouts.PhotoCurator(sources)); scoutsB.bind(new Scouts.ReviewReader(sources)); scoutsB.bind(new Scouts.NextDestinationAdvisor(sources)); Peer advisors = seat.apply("advisors"); advisors.bind(new Advisors.DietAdvisor(sources)); advisors.bind(new Advisors.FoodieAdvisor(sources)); advisors.bind(new Advisors.AccessibilityAdvisor(sources)); advisors.bind(new Advisors.HospitalLocator(sources)); // The safety pipeline: watchers, the analyst, and the three-seat panel. Peer watchers = seat.apply("watchers"); watchers.bind(new Watchers.NewsWatcher(sources)); watchers.bind(new Watchers.VideoWatcher(sources)); watchers.bind(new Watchers.SocialWatcher(sources)); watchers.bind(new Watchers.SentimentAnalyst(sources)); // One peer per panelist: a ballot's voter is the peer's own agent, so // three seats sharing a peer would be one voter and the three-seat quorum // could never close (QA4 A4-4). for (String seatName : SafetyPanel.SEATS) { Peer panelist = seat.apply("panelist-" + seatName); panelist.bind(new SafetyPanel.Panelist(seatName)); } // The activity auction: one bid function per peer, so one planner per peer. seat.apply("dining-planner").bind(new Planners.DiningPlanner()); seat.apply("sights-planner").bind(new Planners.SightsPlanner()); seat.apply("photo-walk-planner").bind(new Planners.PhotoWalkPlanner()); seat.apply("rest-planner").bind(new Planners.RestPlanner()); // The party itself: one peer per traveler, so each carries its own ballot // and the party quorum is a quorum of members (QA4 A4-4). Travelers party = party("lisbon-2026"); int[] stamina = {85, 70, 55}; for (int i = 0; i < party.travelers().size(); i++) { Traveler traveler = party.travelers().get(i); Peer seatOf = seat.apply("traveler-" + traveler.name().toLowerCase()); seatOf.bind(new Party.TravelerAgent(traveler, stamina[i], seatOf.aggregate())); } Peer bus = seat.apply("the-bus"); bus.bind(new Party.Lead(bus.vote(PartyBusFleet.DECISIONS), party.travelers().size())); // The booking desk: three clerks, one Raft quorum. List<Peer> clerks = List.of(seat.apply("clerk-a"), seat.apply("clerk-b"), seat.apply("clerk-c")); PartyBusFleet.formBookingQuorum(clerks); // The planner is where Tripper's own agent would run; the simulator rides with the turret. Peer plannerPeer = seat.apply("planner"); Peer simulatorPeer = turretPeer; Turret turret = Turret.over(turretPeer); turretPeer.run(turret); int bound = turret.start(consolePort); System.out.println("turret: http://localhost:" + bound + "/ (operator token: " + Turret.OPERATOR_TOKEN + ")"); return new Seating(peers, turretPeer, plannerPeer, simulatorPeer); }
# from a clone of the AgentSpaces repository, once mvn -N install mvn install -DskipTests -pl agentspaces-common,agentspaces-api,agentspaces-identity,\ agentspaces-peering,agentspaces-discovery,agentspaces-space,agentspaces-capabilities,\ agentspaces-agent,agentspaces-a2a,agentspaces-connect-core,agentspaces-console,\ agentspaces-spring-stubs,agentspaces-spring-boot-autoconfigure,\ agentspaces-spring-boot-starter,embabel-agentspaces # in agentspaces-partybus mvn test # 6 unit tests + the fleet flow test over TLS, about 90 s ./run-demo.sh # the recorded week; open http://localhost:7590/ # live, exactly as Tripper asks export OPENAI_API_KEY=... export BRAVE_API_KEY=... export YOUTUBE_API_KEY=... # optional ./run-demo.sh # with the real Embabel planner (needs Maven Central) mvn -Pembabel spring-boot:run
The flow test is the project’s proof. It boards the whole bus on free ports with the recorded sources and asserts, in order: every one of the twelve result types answered its request; the baseline advisory is GO with three ballots; every day was auctioned and more than one planner won; the plan carries its stays and the advisory; each booking was confirmed by exactly one clerk and the orders drained; the planner reaches a ReviewDigest only with the fleet and executes the four-step plan; and after the simulated week the illness produced a hospital list, the protest produced a CAUTION advisory with one AVOID ballot, every event has a response on record, the storm day went to the dining planner, a rest day exists, the travelers voted, and the bookings are still exactly once.
Where to go next
Lift agentspaces-partybus into its own repository as it is; it builds on the published libraries alone. Swap RecordedSources for a recording of your own trip by pointing RecordedSources.of at any document with the same layout. Add a scout by writing one class with one @SpaceTake method and one request/result pair, and watch the planner grow an action. Seat a second flight scout and kill one mid-search. Then open the developer guide for the primitives you just used, and SPEC.md for the guarantees behind each one.