Agent Development Guide¶
This guide shows how to build agents, behaviors, and message handlers with Agenor.
Prerequisites¶
- Java 21+
- Maven 3.9+
- Add dependencies:
dev.agenor:agenor-coredev.agenor:agenor-runtimedev.agenor:agenor-runtime-scanning— required if you usescanPackage(...)for classpath-based agent discovery (ADR-027); not needed if you register agents directly withregisterAgent(Agent).
Create Your First Agent¶
import dev.agenor.core.annotations.Agent;
import dev.agenor.core.annotations.Behavior;
import dev.agenor.core.annotations.AgenorMessageHandler;
import dev.agenor.core.Message;
import dev.agenor.runtime.agent.BaseAgent;
import static dev.agenor.core.BehaviorType.CYCLIC;
@Agent("hello-agent")
public class HelloAgent extends BaseAgent {
@Behavior(type = CYCLIC, interval = "5s")
public void sayHello() {
getMessageDispatcher().publish(Message.builder()
.topic("greetings")
.content("Hello from " + getAgentId())
.build());
}
@AgenorMessageHandler("greetings")
public void handleGreeting(Message message) {
log.info("Received: {}", message.getContent());
}
}
Bootstrapping the Runtime¶
import dev.agenor.runtime.AgenorRuntime;
public class App {
public static void main(String[] args) {
var runtime = AgenorRuntime.builder()
.scanPackage("com.example.agents")
.build();
runtime.start();
}
}
Agent Lifecycle¶
Every BaseAgent exposes two overridable hooks that are called by the runtime during startup and shutdown:
@Agent("my-agent")
public class MyAgent extends BaseAgent {
@Override
protected void onStart() {
// Called after services are injected and behaviors are registered.
// Safe to use getMessageDispatcher(), agentDirectory, memoryStore here.
log.info("Agent {} is starting", getAgentId());
}
@Override
protected void onStop() {
// Called before behaviors are stopped and the agent is unregistered.
// Use this to flush state, close connections, etc.
log.info("Agent {} is stopping", getAgentId());
}
}
onStart() is invoked after all services have been injected; onStop() is invoked before behavior teardown and directory unregistration.
LifecycleListener and LoggingLifecycleListener¶
The runtime uses a LifecycleManager that tracks status transitions (STARTING → RUNNING → STOPPING → STOPPED) and notifies registered LifecycleListener implementations:
// LifecycleListener functional interface
LifecycleListener listener = (agentId, oldStatus, newStatus) ->
System.out.printf("Agent %s: %s → %s%n", agentId, oldStatus, newStatus);
lifecycleManager.addLifecycleListener(listener);
// Built-in SLF4J-based listener
lifecycleManager.addLifecycleListener(LifecycleListener.logging());
// equivalent to:
lifecycleManager.addLifecycleListener(new LoggingLifecycleListener());
LoggingLifecycleListener logs every status change at INFO level via SLF4J. Use it during development or as a reference for custom implementations.
Behaviors¶
Supported behavior types:
- Cyclic: run at a fixed interval (agenor-runtime)
- One-shot: run once and complete (agenor-runtime)
- Event-driven: react to incoming messages (agenor-runtime)
- Waker: run once after a delay (agenor-runtime)
- Composite: sequential, parallel, FSM (agenor-runtime-ext, optional — see ADR-027)
- Advanced: conditional, throttled, batch, retry, circuit breaker, scheduled, pipeline (agenor-runtime-ext, optional — see ADR-027)
Annotate public methods on your agent class with @Behavior and use BehaviorType plus optional timing parameters like interval or delay.
Message Handling¶
Use @AgenorMessageHandler("topic") on public methods that accept a Message parameter. The in-memory message service will deliver matching topic messages within the JVM.
A handler returns void when its work is done by the time the method returns, or CompletableFuture<Void> when it is not — return the chain rather than starting it and returning void, or the message is acknowledged before the work finishes. See Delivery Semantics.
LLM Agents — LLMAgent¶
LLMAgent extends BaseAgent with conversation history management, context window budgeting, and long-term fact storage. Use it instead of BaseAgent when your agent needs to interact with an LLM.
When to prefer LLMAgent over BaseAgent:
- The agent must maintain a conversation history across turns.
- The agent needs to inject relevant past facts into the LLM prompt.
- You want auto-summarization of long conversations.
Minimal Example¶
@Agent("chat-bot")
public class ChatBot extends LLMAgent {
@Override
protected void onStart() {
// Add a system prompt at startup
if (hasLLMMemory()) {
addConversationMessage(LLMMessage.system("You are a helpful assistant.")).join();
}
}
@AgenorMessageHandler("user.message")
public void handleUserMessage(Message msg) {
String userInput = msg.getContent(String.class);
// Record the user turn
addConversationMessage(LLMMessage.user(userInput)).join();
// Build a prompt that respects the context window budget
List<LLMMessage> prompt = buildLLMPrompt(userInput, 2000).join();
// Call your LLM provider
String reply = myProvider.chat(LLMRequest.builder().messages(prompt).build())
.join().content();
// Record the assistant turn
addConversationMessage(LLMMessage.assistant(reply)).join();
getMessageDispatcher().publish(Message.builder().topic("bot.response").content(reply).build());
}
}
Key Methods¶
| Method | Description |
|---|---|
addConversationMessage(LLMMessage) |
Append a message to the conversation history |
buildLLMPrompt(query, maxTokens) |
Build a prompt list applying the configured context window strategy |
storeFact(key, content) |
Store a fact in long-term LLM memory |
retrieveFacts(query, maxTokens) |
Retrieve relevant facts by semantic similarity |
hasLLMMemory() |
Guard: returns true only if LLMMemoryManager was injected |
Configuring LLMMemoryManager¶
The runtime injects a LLMMemoryManager if one is registered. You can also pass it programmatically:
var memoryManager = new DefaultLLMMemoryManager(memoryStore);
chatBot.setLLMMemoryManager(memoryManager);
Tune the budgets and strategy inside onStart():
@Override
protected void onStart() {
setDefaultStrategy(ContextWindowStrategies.SLIDING);
setDefaultConversationBudget(3000); // tokens reserved for conversation
setDefaultContextBudget(800); // tokens reserved for retrieved facts
configureAutoSummarization(6000, 15); // summarize after 6000 tokens, batch 15 msgs
}
For the complete LLM guide see docs/llm-integration.md.
Message Filters¶
Filters allow fine-grained control over which messages an agent or subscription receives. All filter classes are in dev.agenor.runtime.filter and implement MessageFilter (a Predicate<Message>).
TopicFilter — filter by topic pattern¶
MessageFilter exact = TopicFilter.exact("orders.confirmed");
MessageFilter prefix = TopicFilter.startsWith("sensor.");
MessageFilter wild = TopicFilter.wildcard("sensor.alert.*"); // * = any chars
MessageFilter pattern = TopicFilter.regex("^orders\\.(confirmed|cancelled)$");
HeaderFilter — filter by header key/value¶
MessageFilter highPri = HeaderFilter.equals("priority", "HIGH");
MessageFilter hasAuth = HeaderFilter.exists("Authorization");
MessageFilter allowed = HeaderFilter.in("region", "EU", "US");
MessageFilter prefix = HeaderFilter.startsWith("tenant", "acme-");
MessageFilter regex = HeaderFilter.matches("version", "\\d+\\.\\d+");
ContentFilter — filter by message content¶
MessageFilter typed = ContentFilter.ofType(OrderData.class);
MessageFilter nonNull = ContentFilter.notNull();
MessageFilter custom = ContentFilter.matching(obj -> obj instanceof String s && s.length() > 0);
Any predicate¶
MessageFilter recent = MessageFilter.of(
msg -> msg.timestamp().isAfter(Instant.now().minusSeconds(60))
);
Combining filters¶
Every MessageFilter combines with any other:
MessageFilter combined = TopicFilter.startsWith("order.")
.and(HeaderFilter.equals("priority", "HIGH"));
MessageFilter either = TopicFilter.exact("alert")
.or(TopicFilter.exact("warning"));
MessageFilter notTest = HeaderFilter.equals("env", "test").negate();
Registering a filter on a subscription¶
Filters are applied at subscription time via FilterableSubscriber.subscribeFiltered(filter, handler). The in-memory dispatcher implements this capability; cast when needed:
@Override
protected void onStart() {
MessageFilter filter = TopicFilter.startsWith("orders.")
.and(HeaderFilter.equals("priority", "HIGH"));
((FilterableSubscriber) getMessageDispatcher()).subscribeFiltered(filter, msg -> handleHighPriorityOrder(msg));
}
Dialogue and Protocols¶
Agenor supports structured agent communication through DialogueCapability. Attach it to any BaseAgent via composition — it registers itself on the agent's start/stop hooks, so no lifecycle wiring is needed. An agent that implements Agent directly must call dialogue.initialize() from its start() and dialogue.shutdown() from its stop().
@Agent("coordinator")
public class CoordinatorAgent extends BaseAgent {
private final DialogueCapability dialogue = new DialogueCapability(this);
// Respond to incoming REQUEST performatives
@DialogueHandler(performatives = {Performative.REQUEST})
public void handleRequest(DialogueMessage msg) {
String content = (String) msg.content();
// ... process ...
dialogue.inform(msg, "Done: " + content).join();
}
// Initiate a request to another agent
public void askWorker(String workerId, String task) {
dialogue.request(workerId, task)
.thenAccept(reply -> log.info("Worker replied: {}", reply.content()));
}
}
Supported performatives: REQUEST, QUERY, INFORM, AGREE, REFUSE, FAILURE, PROPOSE, ACCEPT_PROPOSAL, REJECT_PROPOSAL, CFP.
For the full protocol reference see docs/dialog-protocol.md.
Persistence and Annotations¶
@Persist — mark fields for automatic persistence¶
@Agent("order-processor")
public class OrderProcessorAgent extends BaseAgent {
@Persist
private int processedCount = 0;
@Persist("customer_id") // explicit key in persisted state
private String customerId;
@Persist(required = true) // fail fast on restore if missing
private String sessionToken;
@Persist(encrypted = true) // encrypted at rest
private String apiKey;
}
The runtime reads all @Persist fields when saving state and restores them on reload. Use value to stabilise the schema key across renames.
@PersistenceConfig — configure save strategy at class level¶
@Agent("critical-agent")
@PersistenceConfig(
strategy = PersistenceStrategy.PERIODIC,
interval = "30s",
autoSnapshot = true,
snapshotInterval = "1h",
maxSnapshots = 24
)
public class CriticalAgent extends BaseAgent {
@Persist(required = true)
private String currentOrderId;
}
Available strategies: MANUAL, PERIODIC, ON_STOP, DEBOUNCED.
When the annotation is absent the default is MANUAL — the agent must call persistState() explicitly.
FilePersistenceService and PersistenceManager¶
For programmatic use outside the annotation system:
FilePersistenceService persistence = new FilePersistenceService(Path.of("data/agents"));
// Save any serializable state
persistence.save("agent-123", Map.of("count", 42, "status", "active"));
// Load state back
Map<String, Object> state = persistence.load("agent-123");
// Via the higher-level PersistenceManager
PersistenceManager manager = new PersistenceManager(persistence);
manager.persist(myAgent); // save @Persist fields
manager.restore(myAgent); // restore @Persist fields
Configuration¶
- Minimal code-based configuration via
AgenorRuntime.builder() - Optional YAML support via
ConfigurationLoader(see Configuration Reference)
Testing¶
- Use JUnit 5 and Mockito/AssertJ.
- For unit tests, instantiate your agent and invoke behavior methods directly.
- For integration-like tests, bootstrap a
AgenorRuntimewith a small package and verify message exchanges.
Examples¶
See agenor-examples:
- Ping/Pong basic messaging
- Weather Station cyclic producer
- Task Manager event-driven processing
- Advanced: Conditional and Throttled behaviors
- Filtering examples
- E-Commerce orchestration demo
- Discovery examples
Next Steps¶
- Read the Architecture Guide (
docs/architecture.md) - LLM integration:
docs/llm-integration.md - Dialogue protocol detail:
docs/dialog-protocol.md