Intermediate: Ping-pong agents
This tutorial builds a two-agent ping-pong system. Along the way it shows how to:
- define agents, reusable or inline, and group them in nodes;
- run a MAS made of several nodes;
- use your own Kotlin types as beliefs and goals, without any incarnation;
- turn incoming messages into beliefs;
- react to belief additions with plans;
- give agents a skill — here, the ability to send messages.
It runs in the project from Getting Started: put the code in App.kt, as in Hello world,
and run it with ./gradlew run. The complete program is also the
ping-pong example in the JaKtA repository.
Beliefs and goals are just types
The engine is generic over the belief and goal types.
Here goals are Strings, and a belief is a ToldBelief: the text another agent told, and who told it.
It is a type of your own, not the message itself: the agent decides which beliefs it gets from a message.
Imports
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import it.unibo.jakta.agent.AgentID
import it.unibo.jakta.agent.BaseAgentID
import it.unibo.jakta.dsl.agent
import it.unibo.jakta.dsl.agent.AgentBuilder
import it.unibo.jakta.dsl.mas
import it.unibo.jakta.dsl.mas.runLocally
import it.unibo.jakta.dsl.node.NodeBuilders
import it.unibo.jakta.dsl.plan.triggers
import it.unibo.jakta.event.AgentUpdate
import it.unibo.jakta.skills.MessagingSkill
import it.unibo.jakta.skills.sendTo
import kotlinx.coroutines.runBlocking
private data class ToldBelief(val text: String, val sender: AgentID)
private val alice = BaseAgentID("Alice")
private val bob = BaseAgentID("Bob")
From messages to beliefs
A message sent to an agent is an external event. The agent decides how to turn it into updates of its
own state with handlesMessageEvents: return an AgentUpdate to accept it, or null to ignore it.
Both agents behave in the same way, so let's write it once as an extension of the agent builder:
private fun AgentBuilder<ToldBelief, String, Any>.receivesTextMessages() {
embodiedAs { Any() }
handlesMessageEvents { message ->
when (val payload = message.payload) {
is String -> AgentUpdate.Belief(setOf(ToldBelief(payload, message.sender)), emptySet())
else -> null
}
}
}
AgentUpdate.Belief(additions, removals) adds the belief ToldBelief("Ping!", sender), which in turn generates
a belief-addition event that plans can react to. Perceptions work the same way through handlesPerceptionEvents.
A reusable agent
Bob only answers pings, so he is defined once at top level with agent(id) { } and can be added to any node.
To answer he needs the messaging skill, which sends messages through the node hosting him. So Bob does not create it:
ponger() asks for it as a context parameter,
and the node he is added to provides it:
context(_: MessagingSkill)
private fun ponger() = agent<ToldBelief, String, Any>(bob) {
receivesTextMessages()
hasPlanLibrary {
adding.belief {
takeIf { it.text == "Ping!" }
} triggers {
val (text, sender) = context
agent.print("Received \"$text\" from ${sender.displayName}")
agent.sendTo(sender, "Pong!")
node.terminateNode()
}
}
}
Two nodes
A node is a part of the MAS that lives on its own: it hosts its agents, delivers their perceptions and messages,
gives them their skills, and runs and stops independently of the other nodes. Nodes stand for logically different devices, such as a robot,
a server or a phone, each running several agents of the same MAS. Here every node runs in the same process
(runLocally() connects them through shared memory), but agents on different nodes still interact only by
messages, as they would across a network. See Nodes.
Alice and Bob live on separate nodes, so each of them can stop when done: node.terminateNode() stops only the
node of the agent calling it. On a shared node, Bob stopping after his answer would stop Alice too, before she
gets the pong.
Alice is defined inline, directly on her node, and Bob lives on another node, added with withAgents.
Each node gives its agents a MessagingSkill of its own, and messages cross nodes, so Alice reaches Bob on his node:
fun main(): Unit = runBlocking {
Logger.setMinSeverity(Severity.Assert)
mas(NodeBuilders.baseNode()) {
node {
context(MessagingSkill(node)) {
withAgents(ponger())
}
}
node {
context(MessagingSkill(node)) {
agent<ToldBelief, String>(alice) {
receivesTextMessages()
hasInitialGoals {
!"sendPing"
}
hasPlanLibrary {
adding.goal {
takeIf { it == "sendPing" }
} triggers {
agent.sendTo(bob, "Ping!")
}
adding.belief {
takeIf { it.text == "Pong!" }
} triggers {
agent.print("Got the pong, stopping.")
node.terminateNode()
}
}
}
}
}
}.runLocally()
}
It prints:
Received "Ping!" from Alice
Got the pong, stopping.
A few things to notice:
- Triggers are functions.
adding.belief { ... }receives the new belief and returns eithernull(the plan is not relevant) or a context value. HeretakeIfreturns the belief itself. - The context flows into the body. Inside
triggers { },contextis the value returned by the trigger, so Bob can destructure it to find out who sent the ping. Incarnations provide richer matching: in the Prolog incarnation the context is the substitution produced by unification. - Skills are given by the node.
context(MessagingSkill(node)) { ... }makes the messaging skill available to every agent defined or added inside the block, andagent.sendTo(...)only compiles there. A reusable agent like Bob declares the skills it needs withcontext(...), and the node hosting it provides them. See Skills to write your own. - The MAS ends with its last node.
runLocally()returns when every node has terminated.
The Prolog incarnation ships KQML messaging:
tell, achieve, askOne, ... with [source(X)] annotations on received beliefs.
Next: Advanced: Thermostat, an agent interacting with an external environment.