Agent topics

Give an AI character knowledge that outlives its context window. Compaction topics divide an agent's compaction summary into a general summary and named topics — a person, place, thing or matter, each with a one-line description and a summary. Open topics sit in the agent's context; closed ones are listed by name and description until the agent calls `open_topic`. The compactor writes every topic, and at each compaction decides which stay open. Covers the model and what the agent sees, `AgentConfig.topics` and `maxOpenTokens`, defining relevance in the summarization prompt (with an RPG and a poker example), seeding with `initialTopics`, the `open_topic` message part, transient drafts, limits and cost.

An agent's conversation history is finite. Once it passes its token budget the platform compacts it: older exchanges are replaced by a summary, and their details are gone. Three hours into a game, the character has forgotten the player's sister's name.

Topics are the fix. With topics on, the compaction summary is divided in two:

  • a general summary — what belongs to no topic in particular;
  • topics — each a name, a one-line description (who or what it is, and when it becomes relevant) and a summary of what the conversation has established about it.

Only some topics are open: their summaries are in the agent's context. The rest are closed: listed by name and description only, as cues.

  • The agent only opens. It gets one tool, open_topic({ name }), which shows a closed topic's summary. It never writes, closes or deletes a topic.
  • The compactor does all the writing. At each compaction it decides, for every topic that was open at some point in the span being compacted, whether it is still relevant (it stays open) or not (it closes), and whether its summary needs rewriting. It may also start new topics and delete open ones. Topics that stayed closed throughout are left untouched.

Lisa, a friend the player meets in the first scene:

  1. A compaction while she's in the scene starts a topic for her, open: lisa — "Lisa, the player's childhood friend; relevant when she appears or is mentioned".
  2. She dyes her hair blue, then leaves town. At the next compaction the scene has moved on: the compactor rewrites her summary with the hair, and closes her topic.
  3. Chapters later the player asks about her. The agent sees the lisa cue, calls open_topic({ name: "lisa" }), and knows her hair is blue.

Turning it on

Topics are a per-agent capability, and every option has a default, so topics: {} is the whole setup:

ts
// Once, where the game starts; later handlers reach it with io.getAgent('dm').
const dm = io.createAgent('dm', {
  systemPrompt: DM_PROMPT,
  topics: {}, // and say what makes a topic relevant — see below
})

| Option | Default | What it does | | --- | --- | --- | | maxOpenTokens | 4000 | Budget for the summaries of the topics a compaction leaves open. The compactor is held to it. |

Raise maxOpenTokens freely — it protects the agent's own context, not the platform. Its one hard rule: it must be below half the agent's compaction budget, so open topics never crowd out the recent history beside them. It is checked whenever the config is set:

ts
// Throws: 4000 is not below 8000 / 2.
io.createAgent('dm', { topics: {}, compaction: { type: 'summarize', maxTokens: 8000 } })
// Either give the agent more room, or take less of it for topics.
io.createAgent('dm', { topics: {}, compaction: { type: 'summarize', maxTokens: 16000 } })
io.createAgent('dm', { topics: { maxOpenTokens: 1000 }, compaction: { type: 'summarize', maxTokens: 8000 } })

Topics need the summarize compaction (the default). compaction: { type: 'sliding-window' } makes no compaction call, so nothing would write topics: combining the two throws.

Create the agent once, where the game (or that agent) starts, and reach it afterwards with io.getAgent(name). io.updateAgent(name, config) is for a deliberate change: history and topics are kept, but a real change costs one full re-read of the history. Once an agent has topics, it keeps them: updating it without topics throws, even before it holds any.

What the agent sees

Beyond its verbatim history, the agent's context opens with one platform-written block, rebuilt at each compaction:

[Context — written by the platform, not said by anyone. Play continues from the messages after this one.]

## Summary of earlier conversation

The player (Mara) reached the drowned chapel and bargained with the ferryman…

## Open topics — current as of this point

### ferryman — The ferryman of the drowned chapel; relevant while Mara is on the river
Wants a silver coin per crossing. Hinted he knew Mara's sister.

## Closed topics — call open_topic to open one

- lisa — Lisa, Mara's childhood friend; relevant when she appears or is mentioned
- ashmere — Mara's home town; relevant when the story returns there

[End of context]

Between compactions the block doesn't change. An open_topic call answers with the topic's summary; with "already visible" if the topic is open in the block or was opened earlier; or with "doesn't exist" for an unknown name. The topic joins the block's open section at the next compaction, if the compactor still finds it relevant.

Defining relevance — in the summarization prompt

The engine's instruction to the compactor is deliberately generic: a topic is relevant while "the conversation is still with" it, and it should close one when unsure, since reopening is cheap. The rule ends: "Your instructions may say more about what makes a topic relevant." Saying what relevance means in your game is the biggest lever you have — without it, compactors keep most topics open; with a concrete rule, they close the ones the scene has left.

Put the rule in compaction.summarizationPrompt. It opens the compaction's closing instruction, so the compactor reads it right where it decides. Tested on a story RPG, the same rule kept 92% of the topics that should stay open from there, against 85% from the system prompt — the misses there were people mentioned inside a matter the scene was discussing.

Your prompt replaces the default, so keep the default's two points — a general summary of the messages and the previous general summary, of a given length, and what belongs to a topic left to that topic — then add the rule, written per kind of subject: when it becomes relevant, and when it stops. For a story RPG (the tested wording):

ts
io.createAgent('dm', {
  systemPrompt: DM_PROMPT,
  topics: {},
  compaction: {
    type: 'summarize',
    summarizationPrompt: `This conversation is too long and needs to be compacted. Please write a general summary of the messages above, including the previous general summary, in about 200 words. What belongs to a topic goes in that topic rather than the general summary. Preserve key facts, decisions, and context needed for continuing the story.

A topic is relevant while the story is still with it: a place while the story is in it;
a person while the player is speaking with them, they are with the player, or the current
scene is talking about them, even as part of a matter it is discussing; a thing while it
is in hand, being used, being talked about in the current scene, or has just been given or
received in it (a thing simply carried is not); a matter — a promise, errand, request or
plan — while the current scene is discussing or acting on it. It stops being relevant once
the scene has moved on: the player has left the place, a conversation has ended and the
person stayed behind, the thing has been put away or left, or the scene has turned from
the person, thing or matter it was talking about, even if that is still unresolved — its
topic keeps it, and it can be opened again.`,
  },
})

Whether a carried item stays open is your call — the rule above closes it.

A game with no places or inventory needs different kinds. Poker:

ts
const DEALER_POLICY = `…the general summary, as above…

Keep a topic for each opponent (their tells and betting patterns), for each side bet, and
for each running grudge or rivalry at the table. An opponent is relevant while they are
in the current hand or the table is talking about them; a side bet while it is being made,
argued over or settled; a grudge while it is flaring up. An opponent who has folded, a
settled bet, a grudge nobody has mentioned since the last hand — those have moved on.`

The same prompt is also where to say which subjects deserve a topic — the compactor is told to start one for "each subject likely to come up again", and your game knows what that means (a passing shopkeeper doesn't need one) — and how long a topic's summary should run.

Don't describe the mechanics in it — the compact call, open and closed, the budget: the platform's lines that follow say all that. Relevance is judged at the end of the span being compacted, not at the latest message, so it can lag the scene a little; the agent reopens what it needs, which is cheap. agent-prompts covers the rest of the agent's prompting, including when the agent itself should open a topic.

Seeding topics

To start a world in progress — an RPG the player has "been playing for a long time", a character with a past — give createAgent the topics the agent starts with:

ts
// In the start handler: the agent begins with these, as topics it already had.
io.createAgent('dm', {
  systemPrompt: DM_PROMPT,
  topics: {},
  initialTopics: [
    { name: 'mara', description: 'Mara, the player character', summary: maraNotes, open: true },
    { name: 'ashmere', description: "Mara's home town; relevant when the story returns there", summary: townNotes, open: false },
  ],
})

From the first send, the context opens with the block above: the open topics' summaries and the closed topics' index (no general summary yet). From there they are ordinary topics.

  • Write descriptions as cues. A closed topic is nothing but its name and description, so the description must say who or what it is and when it becomes relevant — "Lisa, the player's childhood friend; relevant when she appears or is mentioned", not "Lisa".
  • One topic per subject. lisa, ferryman, ashmere — not a bundled npcs or world. Separate topics let the agent hold open exactly what the scene is about.
  • initialTopics requires topics, and is createAgent-only. Names must be non-empty and unique, the open ones must fit maxOpenTokens, and the first compaction must be able to answer for every seed, were they all opened: roughly, the seeds' summaries plus 500 tokens apiece must fit in ~24,000 tokens at the default budget (49 one-line topics). A violation throws.
  • Keep them proportionate. The closed index rides the head of every request.

Opening a topic from game code

Only the compactor writes topics, but game code can open one with an open_topic part — for example when the player walks into a place the game knows the agent has a topic for:

ts
dm.addMessages({ role: 'assistant', content: [{ type: 'open_topic', name: 'ashmere' }] })

It is kept as an open_topic call: the agent reads it as an open it made itself, at that point in the conversation. It throws on an agent without topics, or naming a topic the agent doesn't have.

Topics are for what the character knows; state is for what the game runs on. Anything the frontend renders, the reducer branches on, or must be exactly right (hit points, inventory counts, the secret number) belongs in game state. Topics hold the soft, narrative knowledge — the promise the player made, why this NPC is angry.

Transient drafts

open_topic is offered on every send, transient: true or not. A transient reply's opens are part of the reply, so they are kept exactly when it is:

ts
import type { UserMessage } from '@aichatgames/sdk'

// Draft a ruling without keeping it; keep it — topic opens included — only if accepted.
const user: UserMessage = { role: 'user', content: prompt }
const reply = await gm.send(`ruling-${turn}`, { messages: [user], transient: true })
if (accepted(reply.text)) gm.addMessages(user, reply)

(self-review is the full pattern.) A compaction a send triggers compacts kept history only, and its topic writes persist whether that send is kept or not. A kept open answers against the topics as they stand where it lands, so a draft kept after a compaction reads correctly — "already visible" if the compaction left the topic open, "doesn't exist" if it deleted it.

Limits and cost

An agent's whole topic store is capped at ~1 MB — hundreds of pages of prose. It protects the platform; a compaction that would exceed it is retried.

A compaction rewrites every topic open over the span it compacts, in one answer of at most 32,768 tokens: 4,096 for the general summary, maxOpenTokens, and each open topic's summary plus 500. Open topics with very long summaries — or very many of them — between two compactions can exceed that, and the compaction then throws, naming the cause. Keep summaries short (ask for it in the summarization prompt), and keep maxOpenTokens at most 28,672.

Each open_topic call costs a small flat credit fee, shown in the turn's cost breakdown. What the compactor writes rides the compaction call's tokens — and because the agent writes no topic content itself, topics cost less than having it take notes.

See also

  • multi-character-scenes — several agents in one scene; each has its own topics.
  • structured-output — typed data out of an agent, often the right alternative when the thing you want back belongs in game state.