9. Building cooperating agents
An agent can ask another agent to achieve a result without taking ownership of its implementation. Messages carry that request and the eventual outcome. The controller owns the light device; the client owns the decision to request a change.
This chapter combines agent creation, message syntax and a small request conversation. It uses LightDevice from Chapter 5 and the shared Lighting class from Chapter 8.
Agree on message meaning
A message has a performative, receiver and content. The performative states its intent: request asks for work, agree accepts it, refuse declines it, inform reports a result and failure reports an unsuccessful attempt.
send(request, "room", setLight("on")); sends a request. It completes after sending; it does not wait for the light to become on. A receiving plan, plan @message(request, string Sender, setLight(string Target)), matches the sender and content.
Receiving a message does not automatically adopt a goal or store its content as a belief. The receiving plan chooses what it means for this application.
Implement the controller
Create src/main/astra/RoomController.astra:
agent RoomController extends Lighting {
module example.LightDevice device;
plan @message(request, string Sender, setLight(string Target)) {
if (Target == "on" | Target == "off") {
send(agree, Sender, setLight(Target));
try {
!light(Target);
send(inform, Sender, light(Target));
} recover {
send(failure, Sender, setLight(Target));
}
} else {
send(refuse, Sender, setLight(Target));
}
}
plan +!light(string Target) : light(Target) { }
plan +!light(string Target) {
device.set(Target);
wait(light(Target), 2000);
}
}
The controller accepts only supported states. It reports success after its goal has confirmed an observation. An accepted operation that fails produces a failure response. Acceptance and completion are separate messages because they communicate different facts.
Create the controller and make a request
Create src/main/astra/LightClient.astra:
agent LightClient extends Lighting {
module Console console;
module System system;
types replies {
formula reported(string);
formula rejected(string);
formula failed(string);
}
plan +!main(list args) {
system.createAgent("room", "RoomController");
send(request, "room", setLight("on"));
try {
wait(reported("on") | rejected("on") | failed("on"), 3000);
if (reported("on")) {
console.println("room confirmed light = on");
} else {
console.println("room did not achieve the requested state");
}
} recover {
console.println("room response timed out");
}
system.exit();
}
plan @message(agree, "room", setLight(string Target)) {
console.println("room accepted target " + Target);
}
plan @message(inform, "room", light(string State)) {
+reported(State);
}
plan @message(refuse, "room", setLight(string Target)) {
+rejected(Target);
}
plan @message(failure, "room", setLight(string Target)) {
+failed(Target);
}
}
Run mvn clean compile astra:run -Dastra.main=LightClient. The final result reports that the room confirmed the light state. The controller needs no startup goal: its sensor and message plans supply its behavior. If a created agent needs initialization in a main plan, post that goal separately with system.setMainGoal(name, args).
The reply plans bind the expected sender to "room"; unrelated messages cannot satisfy this client’s wait. The client waits for completion, refusal or failure, not merely for acceptance.
Grow the conversation safely
This example has one request in flight and creates a fresh controller. Repeated or concurrent requests need more structure. Add a request identifier, match replies to it and clear conversation state when the exchange ends. A late reply after a timeout must not satisfy a later request accidentally.
Different target requests can also create competing intentions inside the controller. Define whether it queues, supersedes or rejects competing commands. A per-agent lock alone does not specify that policy, and a sender cannot infer successful execution merely because send succeeded.
Agent names identify instances, while class names identify the program to instantiate. Keep names unique and coordinate readiness explicitly when initialization is required. For transports beyond the local JVM, configure the messaging service separately using the Messaging API.
Further Details
String Assembler develops this pattern into a worker pipeline and a manager-coordinated design, using job identifiers to match replies.