ZB Field Notes

Blackboard vs Context: what an Embabel agent keeps between runs

Blackboard vs Context: what an Embabel agent keeps between runs

Every Embabel agent I have written so far has been a single shot: text in, typed result out, process over. That works right up until someone asks a follow-up question. Then you discover that an agent has no memory at all — and that the thing you assumed was memory, the blackboard, is deliberately not.

Embabel has a second concept for this, Context, and the distinction between the two is one of those design decisions that looks arbitrary until you see what it buys you. It is not "short-term vs long-term memory". It is sharper than that.

The agent that forgets

Here is a small order agent. Two actions: pull a customer name out of the user's question, then aggregate that customer's orders from Postgres.

@Agent(description = "Agent specialized in handling orders")
public class OrderAgent {

    @Action
    CustomerName extractCustomerName(UserInput userInput, Ai ai) {
        return ai.withLlmByRole("cheapest")
                .creating(CustomerName.class)
                .fromPrompt("Extract the customer's name from: " + userInput.getContent());
    }

    @Action
    @AchievesGoal(description = "Insights about the orders placed by a given customer")
    public CustomerInsights buildCustomerInsights(CustomerName customerName) {
        var orders = orderRepository.findByCustomerIgnoreCase(customerName.fullName())
                .orElseGet(List::of);
        // ... aggregate and return
    }
}

Nobody wired those two together. The planner infers the edge from the types: extractCustomerName produces a CustomerName, buildCustomerInsights requires one. Ask "how many did Alice order?" and the blackboard fills up in order:

blackboard: { UserInput }
  planner: need CustomerName -> run extractCustomerName   [LLM call]
blackboard: { UserInput, CustomerName("Alice") }
  planner: goal reachable -> run buildCustomerInsights     [DB query]
blackboard: { UserInput, CustomerName, CustomerInsights }
  process ends -> all of it discarded

Now ask "and what about her total?". New process, new blackboard, containing only the new UserInput. The extraction action runs again, the model has no idea who "her" is, and returns an empty string. The post-condition fails, no plan exists, the agent is stuck. The blackboard is scoped to exactly one AgentProcess and there is no mechanism to widen that scope.

Context is not a bigger blackboard

The temptation is to assume Context is the same structure with a longer lifetime. It isn't. Put the two interfaces side by side and Context is a strict subset:

interface Context {
    String getId();
    void bind(String key, Object value);
    void addObject(Object value);
    List<Object> getObjects();
    <T> T last(Class<T> clazz);
    void populate(Blackboard blackboard);   // its entire reason to exist
}

What Blackboard has and Context does not is the interesting part:

  • hasValue(variable, type, dataDictionary) — the method the GOAP planner calls to test whether an action's precondition holds.
  • hide(Object) — blackboard entries are append-only, so hiding is how you remove something from the planner's view without deleting it.
  • bindProtected(key, value) — survives the clear() that happens on state transitions.
  • lastResult(), getOrPut(...), thread-safety guarantees.

That first bullet is the whole design. Context has no way to answer a precondition query, so it is structurally incapable of participating in planning. It is a dumb list of objects with one method that matters. The blackboard is the only surface the planner ever reads.

The part that clicked: a Context changes the plan

Wiring it up is one field. ProcessOptions carries a contextId, and when the platform builds a blackboard for a new process it does this:

var blackboard = blackboardProvider.createBlackboard();
if (processOptions.getContextId() != null) {
    var context = contextRepository.findById(processOptions.getContextId().getValue());
    if (context != null) {
        context.populate(blackboard);   // Context --> Blackboard
    }
}
return blackboard;

The objects land on the blackboard before the planner runs for the first time. So if a previous run stashed CustomerName("Alice") in the context, the second run starts from a state where the precondition is already satisfied:

blackboard: { CustomerName("Alice"), UserInput("and what about her total?") }
  planner: goal already reachable -> run buildCustomerInsights
  extractCustomerName never enters the plan.  Zero LLM calls.

This is the framing I would give anyone hitting this for the first time. A Context does not "give the agent memory" in some woolly sense. It seeds the blackboard, the blackboard drives planning, and therefore a Context literally changes which actions execute. The second run got cheaper and faster because one object was already present.

Persistence is an SPI, not a feature

One thing worth knowing before you design around this: the default ContextRepository is in-memory and does not survive a restart. The docs say so plainly. What ships is the interface and an LRU-bounded in-memory implementation; durability is yours to supply.

The interface is five methods, so a JPA-backed one is a short afternoon. Since a Context is just a bag of objects, persisting it means persisting that bag — I store each entry as {"type": "<fqcn>", "value": {...}} and rebuild on load:

@Repository
@Primary
public class PostgresContextRepository implements ContextRepository {

    @Override
    public Context createWithId(String id) {
        return save(new InMemoryContext(id));
    }

    @Override
    public Context save(Context context) {
        rows.save(new ContextRow(context.getId(), serialize(context)));
        return context;
    }

    @Override
    public Context findById(String id) {
        return rows.findById(id).map(r -> deserialize(id, r.getPayload())).orElse(null);
    }
}

Two details that cost me time, so they may as well save you some. @Primary is mandatory — Embabel declares its default bean without @ConditionalOnMissingBean, so a second implementation is an ambiguity, not an override. And the flow is one-way: the platform hydrates the blackboard from the context, but nothing writes results back. If you want the next run to know something, you put it in the context and save it yourself.

The sharp edge

Pre-seeding cuts both ways. Once CustomerName("Alice") is in the context, asking "what about Bob?" in the same session still answers about Alice — the planner sees a satisfied precondition and never re-runs the extraction. That is not a bug in the wiring, it is the mechanism working exactly as designed.

Which is where hide() earns its place in the API. Handling a topic change means putting the fresh input on the blackboard and hiding the stale object so it drops out of the planner's view. Notably, that method exists only on Blackboard — there is nothing to hide on a Context, because nothing plans over it.

Where to reach for which

You wantUseShips durable?
Working state inside one runBlackboard (automatic)n/a
Continuity across runsContext + contextIdNo — implement ContextRepository
Resumable / human-in-the-loop processesAgentProcessRepositoryNo — extend AbstractAgentProcessRepository
Chat historyConversationStoreType.STOREDYes — via embabel-chat-store

The honest summary is that the blackboard is in-memory by design and stays that way. Durability is achieved by hydrating it from something that isn't. Two small SPIs, sensible defaults, and outside of chat history, no batteries included — which is a reasonable place for a framework at this stage to draw the line.

Verified against Embabel 0.3.5; the Context and ContextRepository interfaces are byte-for-byte identical in 1.0.0, so this carries over unchanged.