Observed since 20 Oct, 23:29 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Framework-agnostic message reducer for AI agent state management. Works with LangGraph, CrewAI, and plain dicts.
Framework-agnostic message reducer for AI agent state management. Works with LangGraph, CrewAI, and plain dicts.
Automatically prunes message history when it exceeds a threshold, keeping conversations manageable:
max_messages, retain min_messagesmax_tokens, prune down to target_tokens — whole messages only, never truncatedon_prune) — new in 0.4.0: pruned messages are handed to any callable the moment they leave the window, with the app's memory namespace, exactly once. Wire them to a LangGraph BaseStore, LangMem, a Strands MemoryStore, or your own engine — the reducer imports none of them. See Long-Term Memory Hooks.user/assistant/agent in addition to human/ai — works with OpenAI-format dicts and agent framework outputs out of the boxBaseMessage subclasses, or any duck-typed message object — zero dependenciespip install agentstate-reducer
Zero dependencies. Works with Python 3.10+.
from agentstate_reducer import MessageReducer
reducer = MessageReducer(min_messages=10, max_messages=20)
# Works with plain dicts
result = reducer.reduce(
existing=[
{"role": "system", "content": "You are helpful"},
{"role": "human", "content": "Hello"},
{"role": "ai", "content": "Hi there!"},
],
new=[{"role": "human", "content": "New message"}],
)
print(result.surviving) # Messages that remain
print(result.pruned) # Messages that were removed
# Works with LangChain BaseMessage objects too
from langchain_core.messages import HumanMessage, AIMessage
result = reducer.reduce(
existing=[HumanMessage(content="Hello")],
new=[AIMessage(content="Hi!")],
)
Use directly as a state annotation — the reducer runs automatically on every state update:
from agentstate_reducer import MessageReducer
from typing_extensions import Annotated, TypedDict
reducer = MessageReducer(min_messages=10, max_messages=20)
class MyState(TypedDict):
messages: Annotated[list, reducer.as_langgraph_reducer()]
as_langgraph_reducer() returns a (existing, new) -> list function that LangGraph calls on every state merge.
ReducingSaver)Checkpoint storage grows on two axes: how many checkpoints you keep (prune, TTL) and how big each one is. The messages channel is stored whole on every step, so a thread's blobs grow quadratically with its length, and keep-latest never shrinks the latest one. ReducingSaver runs the reducer inside any saver's put, so every checkpoint holds at most max_messages messages and the pruned turns go to on_prune once each:
pip install "agentstate-reducer[langgraph]"
from langgraph.checkpoint.postgres import PostgresSaver
from agentstate_reducer import MessageReducer, ReducerConfig
from agentstate_reducer.langgraph import ReducingSaver
saver = ReducingSaver(PostgresSaver.from_conn_string(url), MessageReducer(config=ReducerConfig(max_messages=20)))
graph = builder.compile(checkpointer=saver)
Works with PostgresSaver, SQLite, MongoDB, Redis, in-memory. Wrapping one of the agentstate checkpointers (DynamoDB, Cosmos DB, Firestore) hands the reducer to the saver instead, so it still runs once; wrapping a saver that already has a different reducer raises. Optional capabilities (copy_thread, delete_for_runs, prune) are delegated only when the inner saver implements them. Not for DeltaChannel-backed message channels. Docs: Any Checkpointer, Bounded.
When using langgraph-checkpoint-cosmosdb, pass a MessageReducer to reduce messages at the persistence layer — useful when you don't control the state definition:
from agentstate_reducer import MessageReducer
from langgraph_checkpoint_cosmosdb import CosmosDBSaver
reducer = MessageReducer(min_messages=10, max_messages=20)
saver = CosmosDBSaver(
database_name="mydb",
container_name="checkpoints",
reducer=reducer, # applied before each checkpoint is stored
messages_key="messages", # which channel holds the message list (default)
)
Provide a summarize_fn to generate a summary of pruned messages (e.g. via an LLM):
from agentstate_reducer import MessageReducer, ReducerConfig
def summarize(pruned_messages):
# Call your LLM here
return f"Summary of {len(pruned_messages)} pruned messages"
config = ReducerConfig(
min_messages=10,
max_messages=20,
summarize_fn=summarize,
)
reducer = MessageReducer(config=config)
result = reducer.reduce(existing=messages)
print(result.summary) # "Summary of 5 pruned messages"
result.summary is always returned when a summarize_fn is set — you can store or display it however you like.
By default the summary is only returned, not added to the surviving messages. Set inject_summary=True to also insert it in place of the pruned block, so the model sees it on the next turn:
config = ReducerConfig(
min_messages=10,
max_messages=20,
summarize_fn=summarize,
inject_summary=True,
)
The default injection is a human + ai pair — a human message carrying the summary followed by a short ai acknowledgement ("OK"):
[system] → [human: "Here is the summary of previous N messages: ..."] → [ai: "OK"] → [recent tail]
Why a pair? It keeps the conversation well-formed for providers that require strict role alternation (system → human → ai → …). Because both messages are ordinary human/ai messages at the front of the window, the next prune sweeps them into the new summary automatically — so there is always exactly one summary block, with no accumulation and no bookkeeping.
Custom shape. Override summary_message_factory — a Callable[[summary_text, pruned_count], list[message]] — to inject a single message or a provider-specific shape:
config = ReducerConfig(
summarize_fn=summarize,
inject_summary=True,
# inject just one system message instead of the human/ai pair
summary_message_factory=lambda text, n: [{"role": "system", "content": f"Summary ({n} msgs): {text}"}],
)
The summary is placed where the pruned messages were — after
preserve_first, before the retained recent tail — never at index 0 (the system prompt is preserved). When used with a checkpoint/persistence saver that storesresult.surviving, the injected summary is persisted automatically with no saver changes.
on_prune)Messages leaving the window are exactly the ones worth deciding about for long-term memory. on_prune hands them to any callable the moment they are pruned — the reducer never imports a store, a framework, or an LLM, so it stays zero-dependency and the same hook body works for every framework that runs the reducer (LangGraph checkpointers, CrewAI persistence, ...).
from agentstate_reducer import MessageReducer, ReducerConfig, Background
def remember(pruned, namespace):
# namespace is whatever the framework integration forwarded (see below)
for m in pruned:
store.put(namespace, key=str(uuid4()), value={"role": m.type, "content": m.content})
reducer = MessageReducer(config=ReducerConfig(
max_messages=20,
on_prune=[remember], # cheap hook: runs inline
# on_prune=[Background(remember)] # slow hook (LLM extraction): runs off the request path
))
Where the namespace comes from. reduce(..., namespace=X) forwards X untouched to every hook. Framework integrations look up ReducerConfig.namespace_key (default "memory_namespace") in their per-call config and pass that — e.g. in LangGraph:
graph.invoke(input, config={"configurable": {
"thread_id": uuid4().hex, # short-term scope (checkpoint)
"memory_namespace": ("memories", user.id), # long-term scope (store)
}})
If the app never sets it, integrations fall back to a per-thread namespace. Old callers that pass no namespace get None.
Exactly once. Persistence layers often call reduce() several times per turn on overlapping lists (LangGraph writes a checkpoint per super-step). With dedupe_on_prune=True (default) each message id reaches the hooks once per reducer instance; ReducerResult.pruned is never filtered.
Background(fn, workers=2, max_pending=1000) runs a hook on a bounded worker pool: copies the list, drops (with a warning) when the backlog is full, swallows hook exceptions, drains at exit. On serverless runtimes call close() at the end of the handler.
Instead of counting messages, you can prune to a token budget — useful when you want to stay within a model's context window or control cost. Set max_tokens and pruning switches from message-count mode to token mode.
from agentstate_reducer import MessageReducer, ReducerConfig
# Prune when the conversation exceeds 4000 tokens, down to ~2000
config = ReducerConfig(max_tokens=4000, target_tokens=2000)
reducer = MessageReducer(config=config)
result = reducer.reduce(existing=messages, new=new_messages)
# result.surviving stays within ~2000 tokens; whole messages only — never truncated
Whole messages only. The reducer never truncates message content — it drops whole messages, keeping the most recent ones that fit the budget, plus the preserved first message. This guarantees you never send a model a half-cut message.
max_tokens vs target_tokens. Pruning triggers when the total exceeds max_tokens, and reduces down to target_tokens (defaults to max_tokens if not set). Setting target_tokens lower than max_tokens creates hysteresis — prune at 4000, down to 2000 — so pruning runs less often.
The counter is resolved in three layers (highest priority first):
token_counter — a Callable[[message], int] you pass on the config. Use this for exact, model-specific counting:
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o")
config = ReducerConfig(
max_tokens=4000,
token_counter=lambda m: len(enc.encode(m.get("content", ""))),
)
pip install "agentstate-reducer[tokens]"), the cl100k_base encoding is used automatically. Accurate for OpenAI-family models.len(content) / 4 plus a small per-message overhead. Dependency-free fallback, fine for approximate budgeting.Install with tiktoken support:
pip install "agentstate-reducer[tokens]"
Token mode takes precedence over message-count mode: if
max_tokensis set,max_messages/min_messagesare ignored.preserve_firstandcascade_tool_messagesapply in both modes.
ai/agent/assistant and human/user messages are candidates for pruning.system, tool, and function messages are never pruned directly.preserve_first=True) — typically the system prompt.ai message is pruned, all tool messages linked to it via tool_call_id are also pruned (cascade_tool_messages=True).len(existing + new) > max_messages, oldest eligible messages are removed until min_messages remain.The reducer normalises common role name variants before applying pruning rules, so you don't need to convert message formats:
| Input role | Treated as | Common source |
|---|---|---|
human | human | LangChain canonical |
user | human | OpenAI API format |
ai | ai | LangChain canonical |
assistant | ai | OpenAI API format |
agent | ai | LangGraph task outputs, agent frameworks |
system | system | preserved, never pruned |
tool | tool | preserved unless cascade-pruned |
This means OpenAI-format message lists (role: "user" / role: "assistant") work without any preprocessing.
The adapter layer uses duck typing and class-name inspection — no langchain_core import required:
| Message format | Supported |
|---|---|
{"role": "ai", "content": "..."} | ✓ plain dict with role key |
{"role": "user", "content": "..."} | ✓ OpenAI-format dict (normalised to human) |
{"role": "agent", "content": "..."} | ✓ agent framework dict (normalised to ai) |
{"type": "ai", "content": "..."} | ✓ plain dict with type key (LangChain serialized) |
AIMessage, HumanMessage, etc. | ✓ LangChain BaseMessage subclasses |
Any object with .type attribute | ✓ duck typing fallback |
MessageReducer(min_messages, max_messages, *, config)| Param | Default | Description |
|---|---|---|
min_messages | 0 | Messages to retain after pruning |
max_messages | None | Threshold to trigger pruning (None = never prune) |
config | None | ReducerConfig object (overrides min_messages/max_messages) |
ReducerConfig| Field | Default | Description |
|---|---|---|
min_messages | 10 | Messages to retain after pruning (message-count mode) |
max_messages | 20 | Threshold to trigger pruning (message-count mode) |
max_tokens | None | Token threshold to trigger pruning. When set, enables token mode (takes precedence over message-count mode) |
target_tokens | None | Prune down to at or below this token count. Defaults to max_tokens |
token_counter | None | Callable[[message], int]. When omitted: tiktoken if installed, else char heuristic |
preserve_first | True | Never prune index 0 (system message) |
cascade_tool_messages | True | Also prune ToolMessages linked to a pruned AIMessage |
summarize_fn | None | Callable[[list], str] called with pruned messages |
inject_summary | False | Insert the summary back into surviving in place of the pruned block |
on_prune | [] | RememberFn list: (pruned, namespace) -> Any, called after pruning |
namespace_key | "memory_namespace" | Key integrations read from per-call config to find the namespace |
dedupe_on_prune | True | Deliver each message id to hooks at most once per reducer instance |
dedupe_window | 10000 | Bounded memory of delivered ids |
reducer.reduce(existing, new, namespace=None) -> ReducerResult| Field | Type | Description |
|---|---|---|
surviving | list | Messages that remain |
pruned | list | Messages that were removed |
summary | str | None | Summary from summarize_fn if configured |
reducer.as_langgraph_reducer() -> CallableReturns a function with signature (existing, new) -> list for use with LangGraph's Annotated[list, fn] pattern.
pypistats · 76 observations shown. Missing days are unknown. Source category: overall: without mirrors.
| Date / source period | Value | Observed at |
|---|---|---|
| 2026-10-20 – 2026-10-20 | 3 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-19 – 2026-10-19 | 7 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-18 – 2026-10-18 | 13 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-17 – 2026-10-17 | 15 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-16 – 2026-10-16 | 3 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-15 – 2026-10-15 | 8 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-14 – 2026-10-14 | 7 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-13 – 2026-10-13 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-12 – 2026-10-12 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-11 – 2026-10-11 | 5 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-10 – 2026-10-10 | 10 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-09 – 2026-10-09 | 10 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-08 – 2026-10-08 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-07 – 2026-10-07 | 13 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-06 – 2026-10-06 | 15 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-05 – 2026-10-05 | 9 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-04 – 2026-10-04 | 4 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-02 – 2026-10-02 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-10-01 – 2026-10-01 | 5 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-30 – 2026-09-30 | 12 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-29 – 2026-09-29 | 10 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-28 – 2026-09-28 | 8 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-27 – 2026-09-27 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-26 – 2026-09-26 | 3 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-25 – 2026-09-25 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-24 – 2026-09-24 | 7 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-23 – 2026-09-23 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-22 – 2026-09-22 | 10 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-21 – 2026-09-21 | 103 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-20 – 2026-09-20 | 11 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-19 – 2026-09-19 | 9 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-18 – 2026-09-18 | 94 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-16 – 2026-09-16 | 4 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-14 – 2026-09-14 | 7 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-13 – 2026-09-13 | 9 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-12 – 2026-09-12 | 6 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-11 – 2026-09-11 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-10 – 2026-09-10 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-09 – 2026-09-09 | 48 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-08 – 2026-09-08 | 206 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-07 – 2026-09-07 | 42 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-06 – 2026-09-06 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-04 – 2026-09-04 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-03 – 2026-09-03 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-09-01 – 2026-09-01 | 5 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-31 – 2026-08-31 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-28 – 2026-08-28 | 19 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-27 – 2026-08-27 | 8 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-25 – 2026-08-25 | 5 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-24 – 2026-08-24 | 5 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-23 – 2026-08-23 | 3 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-21 – 2026-08-21 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-20 – 2026-08-20 | 4 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-19 – 2026-08-19 | 6 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-18 – 2026-08-18 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-17 – 2026-08-17 | 4 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-16 – 2026-08-16 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-15 – 2026-08-15 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-14 – 2026-08-14 | 5 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-13 – 2026-08-13 | 34 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-12 – 2026-08-12 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-10 – 2026-08-10 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-09 – 2026-08-09 | 4 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-07 – 2026-08-07 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-06 – 2026-08-06 | 3 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-04 – 2026-08-04 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-03 – 2026-08-03 | 3 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-02 – 2026-08-02 | 1 | 2026-10-21T10:29:26.7027563Z |
| 2026-08-01 – 2026-08-01 | 6 | 2026-10-21T10:29:26.7027563Z |
| 2026-07-31 – 2026-07-31 | 4 | 2026-10-21T10:29:26.7027563Z |
| 2026-07-30 – 2026-07-30 | 4 | 2026-10-21T10:29:26.7027563Z |
| 2026-07-29 – 2026-07-29 | 2 | 2026-10-21T10:29:26.7027563Z |
| 2026-07-28 – 2026-07-28 | 10 | 2026-10-21T10:29:26.7027563Z |
| 2026-07-27 – 2026-07-27 | 13 | 2026-10-21T10:29:26.7027563Z |
| 2026-07-26 – 2026-07-26 | 100 | 2026-10-21T10:29:26.7027563Z |
| 2026-07-23 – 2026-07-23 | 5 | 2026-10-21T10:29:26.7027563Z |
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 0.5.0 | 21 Sept 2026 | 9 |
This describes our collected data; it is not a security assessment.
Shared repository data · GitHub
Observed since 20 Oct, 23:29 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:2835aec3c9cd0080314901dcdb1510cb82ab076ea040fa374e8885a696fb40f1