Agents¶
An agent is a component that carries out a task by talking to a model. Its handler describes one interaction — the instructions, the user's message, the tools the model may call, the checks to apply, which memory to use — and returns that description as an effect. The runtime runs the loop: it calls the model, runs the tools the model asks for, feeds their results back, repeats until the model answers, applies the guardrails, writes the session's memory and counts the tokens.
An agent is addressed by a session id, not an entity id. Every call with the same session id belongs to one conversation, and the runtime handles one request per session at a time. Two overlapping requests to one session would otherwise read the same history, both append to it, and produce a conversation in which neither turn acknowledges the other. Several agents can share a session, which is how agents collaborate; see Agents and sessions.
An agent in Scala¶
An agent is a class extending Agent whose handlers return an Effect[R], and a companion that
registers them:
final class WeatherAgent extends Agent:
def consult(destination: String): Effect[String] =
effects
.systemMessage(
"You are a concise weather specialist. Use your tools, then answer in one sentence."
)
.userMessage(s"What is the weather like in $destination?")
.tools(WeatherAgent.forecast)
.thenReply()
object WeatherAgent extends Agent.Companion[WeatherAgent](ComponentId("weather-agent")):
override val role: String = Specialist.Weather
/** A stand-in forecast service, deterministic so the sample behaves the same each run. */
val forecast = FunctionTool
.named("get_forecast")
.describedAs("Returns a short weather forecast for a destination.")
.param[String]("destination", "The city or region to forecast.")
.handle { destination =>
val outlook =
if destination.toLowerCase.contains("reykjav") then "cold and windy"
else if destination.toLowerCase.contains("cairo") then "hot and dry"
else "mild with occasional rain"
s"$destination: $outlook"
}
def create(context: AgentContext) = new WeatherAgent
val consult = command("consult")(_.consult)
The companion's command("consult")(_.consult) declares the handler under its wire name, as on an
entity. role names the agent in shared memory; it defaults to the component id. A handler that takes
no argument is declared from a method with no parameters.
Agents live in the ankka-agent module, and import com.thinkmorestupidless.ankka.agent.* brings in
Agent, FunctionTool, MemoryProvider, Guardrail and the forAgent method on the component client.
Describing an interaction¶
A handler starts from effects and chains what the interaction needs. Nothing runs until the handler has
returned.
| Scala | Meaning |
|---|---|
systemMessage(text) |
The instruction that frames the conversation. |
userMessage(text) |
What the user said. This is what memory records as the user's turn. |
withContext(text) |
Context the user did not type: an entity's state, a retrieved document. Sent to the model, but kept out of memory. |
tools(tool, …) |
Functions the model may call. |
guardrails(guardrail, …) |
Checks on the input before the model is called and on the output before it is returned. |
memory(provider) |
Which of the session's history to read and whether to write to it. The default reads and writes the whole session. |
model(provider) |
A different model for this interaction than the service default. |
thenReply() |
Reply with the model's text. |
thenReplyAs[T] |
Reply with the model's JSON, decoded into T. |
thenStream() |
Stream the reply as it is produced. See Streaming responses. |
effects.error(message, code) |
Refuse without calling a model. |
Use withContext, not string concatenation, for anything retrieved. Memory then records the question
the user asked, not the whole assembled prompt, and the next turn's history is not filled with documents
the user never saw:
final class ActivityAgent extends Agent:
def consult(request: ActivityAgent.Request): Effect[String] =
// `componentClient` is inherited from `Agent`; no constructor plumbing needed.
val preferences = componentClient
.forKeyValueEntity(EntityId(request.userId))
.call(PreferencesEntity.get)
.invoke()
effects
.systemMessage(
"You are a concise activity specialist. Suggest two activities, in one sentence."
)
.userMessage(s"What should I do in ${request.destination}?")
// Preferences are context, not something the user said — so memory records the
// question, not the whole assembled prompt.
.withContext(s"Traveller preferences: ${preferences.summary}")
.thenReply()
An agent reads what it needs through componentClient rather than being handed it, so the code that
calls it does not have to know what each agent wants.
Structured replies¶
thenReplyAs[T] decodes the model's reply as JSON into T with the JsonValueCodec[T] in scope. The
schema is not sent to the model; say what you want in the system message. A reply that does not decode
fails the call with an error naming the type.
/** Same interaction, but the reply is parsed into a `Forecast`. */
def askStructured(question: String): Effect[Forecast] =
effects
.systemMessage(WeatherAgent.SystemMessage + " Reply with JSON.")
.userMessage(question)
.thenReplyAs[Forecast]
Declare the codec at the top level of the file, beside the case class, not in the agent's companion:
thenReplyAs is called inside the agent class, which does not see its companion's givens.
final case class Forecast(location: String, summary: String, degreesCelsius: Int)
given JsonValueCodec[Forecast] = Codecs.make[Forecast]
given Serializer[Forecast] = Codecs.serializer[Forecast]("forecast")
The Serializer is needed as well because Forecast is the handler's reply type, which crosses the wire
to whoever called the agent.
Tools¶
A tool is declared with FunctionTool: a name, a description the model decides by, typed parameters
each with a description, and the function to run.
val getWeather = FunctionTool
.named("get_weather")
.describedAs("Returns the weather forecast for a given city.")
.param[String]("location", "A location or city name.")
.param[Option[String]]("date", "Forecast date, in yyyy-MM-dd format.")
.handle { (location, date) =>
toolCalls.add(s"get_weather($location,${date.getOrElse("-")})"): Unit
if location == "Nowhere" then throw RuntimeException("unknown location")
else s"$location: 18C, sunny"
}
val currentDate = FunctionTool
.named("current_date")
.describedAs("Returns today's date in yyyy-MM-dd format.")
.handle { () =>
toolCalls.add("current_date()"): Unit
"2026-09-06"
}
The same SchemaType instance produces a parameter's JSON Schema and decodes the value the model sends,
so a parameter cannot be described as one type and read as another. Mistakes in arity or types are
compile errors: the handler's parameters are the declared parameters, in order.
| Parameter type | JSON Schema |
|---|---|
String |
string |
Int, Long |
integer |
Double |
number |
Boolean |
boolean |
Option[A] |
A, not required |
List[A] |
array of A |
A tool takes up to three parameters. For more, take one parameter holding an id and look the rest up, or
split the tool. A tool returns a String, a number, a Boolean, a Json value, or any type with a
JsonValueCodec, which is rendered as JSON for the model.
A failing tool is an answer, not an exception. When a tool throws, or the model's arguments do not
decode, the model receives an error tool result saying why. That is what lets it recover, usually by
fixing its arguments. The loop allows 100 tool round trips per request by default; override
maxToolCallSteps in the companion to change it.
Tools run after the handler has returned, inside the runtime's loop. Read anything a tool needs from the session — its id, for instance — in the handler, and capture it in the tool's closure.
Guardrails¶
A guardrail checks the text going into the model and the text coming out. Input guardrails run before any
model is called, so a rejection there costs nothing. Output guardrails run before memory is written, so a
rejected reply leaves no trace in the conversation. A rejection fails the call with Forbidden, naming
the guardrail.
/** Guarded, to exercise rejection before a model is called. */
def guarded(question: String): Effect[String] =
effects
.systemMessage(WeatherAgent.SystemMessage)
.userMessage(question)
.guardrails(Guardrail.maxInputLength(40))
.thenReply()
Guardrail.maxInputLength(n) and Guardrail.forbidding(name, regex) are provided. A guardrail of your
own implements name and either or both of checkInput and checkOutput, returning Left(reason) to
reject:
val noKeys: Guardrail = new Guardrail:
val name = "no-keys"
override def checkOutput(text: String): Either[String, Unit] =
if text.contains("sk-") then Left("the reply contains what looks like a key") else Right(())
Guardrails run in the order they are declared and stop at the first rejection, so put cheap checks first. On a streaming handler, output guardrails run after the tokens have been sent; see Streaming responses.
Memory¶
Session memory is an event-sourced entity keyed by session id. It is durable — a conversation survives a
restart — and it is shared by every agent that uses the session. memory(provider) decides what one
interaction reads and writes:
| Provider | Meaning |
|---|---|
MemoryProvider.limitedWindow |
Read and write the whole session. The default. |
MemoryProvider.none |
No memory at all. Right for a one-shot classification or routing decision, which a shared conversation makes worse. |
….readLast(n) |
Read only the most recent n messages. |
….readOnly |
Read the history, contribute nothing. |
….writeOnly |
Contribute to the history without being influenced by it. |
….filtered(filter) |
Read only some agents' messages. |
….withInterceptor(i) |
Rewrite messages on their way into memory — the place to redact. |
/** Reads only the last two messages, to exercise the memory window. */
def askWithShortMemory(question: String): Effect[String] =
effects
.systemMessage(WeatherAgent.SystemMessage)
.userMessage(question)
.memory(MemoryProvider.limitedWindow.readLast(2))
.thenReply()
Every stored message carries the role of the agent that wrote it, which is what a MemoryFilter
selects on. MemoryFilter.includeFromAgentId(role) adds an agent; filters combine with OR;
withoutToolResults drops tool traffic and keeps the conversation. The
multi-agent orchestration guide shows a summariser that reads only its
specialists' contributions.
Registering agents and choosing a model¶
Agents are hosted by the AgentRuntime extension, which supplies the service's default model. Register
the agents, the session memory entity (AgentRuntime.descriptors), and the extension:
val model = AnthropicProvider.fromEnv()
val service = Ankka.service
.register(PreferencesEntity.descriptor)
.register(PlannerWorkflow.descriptor)
.register(SelectorAgent.descriptor)
.register(WeatherAgent.descriptor)
.register(ActivityAgent.descriptor)
.register(BudgetAgent.descriptor)
.registerAll(AgentRuntime.descriptors)
.register(SummaryAgent.descriptor)
.withExtension(AgentRuntime.withDefaultModel(model))
.withExtension(HttpServer.of(clients => PlannerEndpoint(clients.componentClient)))
.start()
AnthropicProvider.fromEnv() reads ANTHROPIC_API_KEY from the environment and uses claude-opus-5
unless given another model name. Its defaults can be changed:
val model = AnthropicProvider.fromEnv(
modelName = "claude-opus-5",
defaults = AnthropicProvider.Defaults(maxTokens = 16000L, thinking = true, effort = Some(Effort.High))
)
| Setting | Default | Meaning |
|---|---|---|
maxTokens |
16000 | The most the model may produce in one turn. |
thinking |
on | Adaptive thinking: the model decides how much to reason. There is no token budget to set. |
effort |
the provider's | Low, Medium, High, XHigh or Max: how hard the model works, trading cost against quality. |
Sampling parameters such as temperature are not sent to Claude models, which reject them; use effort.
AnthropicProvider.withApiKey(key) takes the key directly, and AnthropicProvider(client) takes a
configured Anthropic client, for a proxy, a custom timeout, Bedrock or Vertex. Any other model is one
class implementing ModelProvider, whose complete and stream methods are the whole contract: the
loop, tools, memory and guardrails are above it.
AgentRuntime.withDefaultModel(model, modelTimeout) waits two minutes for a model call by default,
because a model working through a multi-step task can legitimately take that long. An AgentRuntime()
with no default model requires every handler to name one with effects.model(provider).
Compaction¶
A long session eventually outgrows a model's context window. With compaction on, the oldest messages are replaced by a summary once the session's text passes a size:
val agents = AgentRuntime
.withDefaultModel(model)
.withCompaction(CompactionSettings(maxHistoryBytes = 100_000, keepRecentMessages = 10))
Ankka.service
.registerAll(agents.descriptors) // session memory and the compactor
.withExtension(agents)
.withExtension(ProjectionRuntime()) // the compactor is a consumer
.start()
| Setting | Default | Meaning |
|---|---|---|
maxHistoryBytes |
100000 | Compact once the session's text exceeds this many characters. A coarse ceiling, not a token count. |
keepRecentMessages |
10 | The most recent messages, left verbatim. |
minMessagesToCompact |
4 | Do not summarise fewer than this many messages. |
Use agents.descriptors, not AgentRuntime.descriptors, when compaction is on: it adds the compactor.
The compactor is a consumer over session memory's events, so it runs after the turn that crossed the
limit rather than making that turn wait, and it needs a ProjectionRuntime registered. A second
compaction folds the earlier summary into the new one rather than accumulating summaries.
The summariser calls the model directly rather than through an agent, because an agent would append its
own turns to the history it is trying to shrink. Pass your own Summariser to
withCompaction(settings, Some(summariser)) to change how summaries are written. Compaction is
best-effort: a summarisation that fails skips that compaction and moves on, rather than stalling
compaction for every session.
Calling an agent¶
Agents are called through the component client, addressed by session:
import com.thinkmorestupidless.ankka.agent.*
val answer: String =
componentClient.forAgent(SessionId("session-42")).call(WeatherAgent.consult).invoke("Lisbon")
Choose session ids to match conversations: one per chat, or one per workflow instance when several agents collaborate on one task. The multi-agent orchestration guide uses the workflow's id.
An agent in Python¶
A Python agent declares its tools and guardrails as class attributes and returns an AgentEffect — a
plan, as data. The loop runs in the sidecar. The sidecar calls the model, keeps the session, and
calls back into your process to run a tool or check a guardrail, and for nothing else. The process never
calls a model and never holds the model's key.
"""An agent that answers questions about a cart. It declares its instructions, one tool and one
guardrail; the sidecar runs the loop — the model, memory, compaction — and asks this process to run
the tool and check the guardrail. No model key lives here."""
from __future__ import annotations
from dataclasses import dataclass
from ankka.agent import Agent, Guardrail, Tool, stream
from ankka.effects.agent import AgentEffect
from ankka.event_sourced_entity import command
from examples.shopping_cart.domain import ShoppingCart
@dataclass(frozen=True)
class CartLookup:
cartId: str
async def _lookup(agent: Agent, arguments: CartLookup) -> str:
assert agent.client is not None
cart = await agent.client.for_event_sourced_entity("shopping-cart", arguments.cartId).call("get-cart").invoke(reply=ShoppingCart)
if not cart.items:
return f"cart {arguments.cartId} is empty"
return ", ".join(f"{i.quantity} x {i.name}" for i in cart.items)
class CartAssistant(Agent):
component_id = "assistant"
tools = {"lookup": Tool("Looks up what is in a cart by its id.", _lookup, CartLookup)}
guardrails = {"no-secrets": Guardrail(lambda stage, text: "a key leaked" if stage == "output" and "sk-" in text else None)}
def _describe(self, question: str) -> AgentEffect[str]:
return (
self.effects.system_message("You help shoppers with their carts. Use the lookup tool before answering about a cart.")
.user_message(question)
.tools("lookup")
.guardrails("no-secrets")
.then_reply()
)
@command("ask")
def ask(self, question: str) -> AgentEffect[str]:
return self._describe(question)
@stream("chat")
def chat(self, question: str) -> AgentEffect[str]:
return self._describe(question)
A Tool is a description, an async or plain function taking the agent and the decoded input, and an
input dataclass whose fields are the JSON Schema the model sees. An exception from the function is fed
back to the model as a tool error. A Guardrail wraps a function of (stage, text), where stage is
"input" or "output", returning a reason to block or None.
The Python effect has the same shape as the Scala one:
| Python | Meaning |
|---|---|
system_message(text), user_message(text), with_context(text) |
As in Scala. |
tools(*names), guardrails(*names) |
By their keys in the class's tools and guardrails. |
memory(False) |
No session memory, for a one-shot task. The default reads and writes the session. |
with_model(name) |
A model the sidecar configured, by name: anthropic or scripted. |
then_reply() |
Reply with the text. |
then_reply_json() |
Reply with the model's JSON, decoded by the handler's reply type. |
self.effects.error(message, code) |
Refuse without calling a model. |
A handler declared with @stream streams its reply; see Streaming responses. The class
attributes role and max_tool_call_steps match the Scala companion's.
The model is configured on the sidecar, through the descriptor's environment, which the platform routes to the sidecar container rather than to your process:
| Variable | Meaning |
|---|---|
ANTHROPIC_API_KEY |
Enables the anthropic model and makes it the default. |
ANKKA_MODEL_NAME |
The Claude model to use; claude-opus-5 when unset. |
ANKKA_MODEL_SCRIPT |
A scripted model for tests: a JSON array of turns, or the path of a file holding one. See Testing. |
A sidecar with neither a key nor a script refuses agent calls, naming both variables. Compaction is not yet configurable for a Python service's sessions.
What to read next¶
- Streaming responses sends tokens to the caller as the model produces them.
- Multi-agent orchestration coordinates several agents from a workflow.
- Testing scripts the model so a test asserts on behaviour, not on prose.