Skip to content

Docs / Core Concepts

Context Window

The agent loop can only grow its history: every tool batch appends an assistant message and one tool result per call. AgentCore owns the loop but ships no automatic compression policy — nothing compacts unless you supply a context_compactor. It is invoked once per loop iteration, before the model call:

agent = AgentCore(bindings, context_compactor=my_compactor) # constructor default
response = await agent.chat(prompt) # uses the default
response = await agent.chat(prompt, context_compactor=other) # per-call override
response = await agent.chat(prompt, context_compactor=None) # disabled for this call

The compactor is a sync or async callable receiving a CompactionContext. It can return:

ReturnMeaning
CompactionPlanRecommended. Declare what to compact; core materializes the replacement history
list[CLIverMessage]Escape hatch: return a complete, already-validated history
NoneLeave the history unchanged — the steady-state path

chat(), stream(), run_skill(), and chat_structured() all accept the same per-call parameter.

from cliver_core import CompactionContext
async def my_compactor(context: CompactionContext):
used = context.last_usage.input_tokens if context.last_usage else 0
if used < 60_000:
return None
return compact(context)
Field / methodMeaning
messagesThe run history excluding the core-owned system message — a fresh list; treat the message objects as read-only
iterationZero-based loop iteration about to call the model
run_idThe run’s correlation id (same one on events and the response)
modelThe active llm binding’s model before failover for this call
last_usageThe previous iteration’s usage for this run; None on the first iteration
record_usage(usage)Fold usage from a model call the compactor made (for example a summarization call) into the run total — the plan path does this for you
split_at_user_turn(keep_turns)Split the history before the Nth-from-last user turn; returns a CompactionSplit
render(messages)Format messages as compact text for a summarizer prompt

last_usage.input_tokens is the previous model call’s input — the best available signal that the next call may exceed the window. It is per call, not cumulative.

A plan describes the change; core performs it. That keeps the structural invariants — cutting at a safe boundary, preserving tool call/result pairs, framing the summary, and accounting for its usage — in one place instead of in every application’s compactor.

from cliver_core import CompactionContext, CompactionPlan
SUMMARIZE = (
"Summarize the earlier part of this agent run for the same assistant. "
"Keep facts, decisions, tool results that still matter, and open tasks. "
"Be compact and do not invent."
)
async def my_compactor(context: CompactionContext) -> CompactionPlan | None:
used = context.last_usage.input_tokens if context.last_usage else 0
if used < 60_000:
return None
split = context.split_at_user_turn(keep_turns=2)
if not split.older: # nothing to drop yet
return None
response = await summarizer.chat(f"{SUMMARIZE}\n\n{context.render(split.older)}")
if not response.message.content:
return None
return split.plan(summary=response.message.content, usage=response.usage_total)
CompactionPlan fieldMeaning
keep_fromIndex of the first retained message in context.messages; must be positive and point at a user message
summaryOptional replacement text; core prepends it as one user message beginning with [Compacted earlier context]
usageOptional usage from your summarization call; core adds it to the run’s usage_total once the resulting history is accepted

What core does with a plan:

  1. Rejects a keep_from outside the history, or one that does not point at a user message — ValueError before the model call.
  2. Builds [summary_message, *messages[keep_from:]] (the summary message is omitted when summary is None).
  3. Validates the result (tool pairs intact, ends with user or tool).
  4. Records plan.usage into the run’s usage total.

split_at_user_turn(keep_turns) returns a CompactionSplit with keep_from, older, and recent. Its boundary is always a user message, so a suffix that starts there can never contain a dangling tool call/result pair. split.plan(...) binds that same boundary to the summary, so the plan cannot drift from the text the summarizer actually saw. It raises ValueError when the split drops nothing (keep_from == 0) — check split.older first. keep_turns must be at least 1.

cliver-core ships the pattern above as an opt-in class. Core never installs it; pass it explicitly:

from cliver_core import AgentCore, LlmCompactor, ModelBindings, presets
# One summarizer per worker / event loop, like any other AgentCore.
summarizer = AgentCore(ModelBindings(llm=presets.deepseek(api_key="...")))
compactor = LlmCompactor(summarizer, threshold_tokens=60_000, keep_turns=2)
agent = AgentCore(bindings, context_compactor=compactor)
  • Triggers when last_usage.input_tokens >= threshold_tokens. It is silent on the first iteration and whenever the history holds no complete older turn.
  • Keeps the last keep_turns user turns verbatim; the summarized prefix is exactly split.older, rendered with context.render(...).
  • Returns a plan, so the summarization call’s usage_total is recorded automatically. An empty or missing summary leaves the history unchanged (its usage is still recorded).
  • The summarizer is anything with an async chat(prompt) returning a CLIverResponse — usually a second AgentCore bound to a cheaper model. Its run is a separate run with its own run_id and events.
  • Import as from cliver_core import LlmCompactor; CompactionPlan and CompactionSplit are exported alongside it.

Returning list[CLIverMessage] still works — it is the path for a deterministic trim, a non-LLM policy, or a history loaded from your own store. A returned list is validated before it can reach a provider. ValueError is raised — before the model call — when it:

  • is empty, or contains a system message;
  • has a tool result with no matching assistant tool call;
  • leaves an assistant tool call without all of its tool results;
  • does not end with a user or tool message, so the model has a turn to answer.

Core re-attaches the system message to whatever history you return. If you take this path and make your own model call, record its usage with context.record_usage(...); the plan path is the one that does it for you.

  • Once per loop iteration, before the model call. Failover retries inside that iteration reuse the compacted history; they do not re-run the compactor.
  • The first iteration included. A caller-supplied conversation that is already too large can be trimmed before the first call, but last_usage is None there — trigger on a message or character budget if that matters to you.
  • Inside the run deadline. run_timeout covers compaction model calls too. Exceptions propagate; a slow summarizer consumes the run’s budget.
  • The system message stays core-owned. It is never passed to the compactor and is re-attached to whatever history results.
  • Streaming uses the same placement. The replacement is visible to the next model call; the terminal trace’s entries carry a usage_total that includes recorded compaction usage.
  • Non-streaming: usage recorded through context.record_usage(...) — and plan.usage on the plan path — is included in response.usage_total.
  • Streaming: each terminal trace entry has a usage_total as of that iteration.
  • The compactor’s own AgentCore run (the summarizer) is a separate run with its own events and usage; LlmCompactor attaches its usage_total to the plan so the caller’s meter stays complete.
  • The context window is application knowledge. Core does not maintain a model → window-size table. max_tokens caps output, not the window; set the threshold per model in your application.
  • Bound tool output separately. A single huge tool result can blow the window before any turn boundary is available to cut at; cap it at the tool boundary.
  • Persist summaries yourself. Core is stateless. If the next request should start from a compacted history, store the summary in your conversation store and pass it as conversation=.
  • Decide the failure policy. A summarizer failure propagates and fails the run. If you prefer a fallback, catch it inside the compactor and return a deterministic trim (or None) instead.
  • Keep the steady-state path cheap. The compactor runs before every model call; a token check that returns None should not make a network request.
  • Do not store per-run state on the agent. Use the context, run_id, or application-owned storage keyed by your conversation id. The agent stays stateless and loop-safe, not thread-safe — see Concurrency & Lifecycle.