Conversation History¶
History retains successful turns. It is not response memoization: a new prompt still invokes the model.
Stateful Conversation History¶
Kedi model calls are stateless by default. Enable history in a lexical scope when later template or raw-invoke blocks must receive complete successful turns from earlier calls:
> history: enabled
>> Remember [project_name].
>> A concise tagline for <project_name> is [tagline].
= <tagline>
History is valid at top level, in procedures, and in profiles. A nested
> history: disabled scope neither reads nor mutates enabled outer history.
Failed and cancelled calls do not commit partial turns.
Prompt hooks run before a turn enters history. When user_prompt_submit edits a
prompt, transport, budgeting, telemetry, cache identity, and history all use the
same edited content. A successful prompt/result pair, native continuation,
cleanup ownership, and cache-epoch effect commit atomically. Parallel calls
sharing one conversation run these complete transactions in source order;
separate conversation sessions remain independent.
History is partitioned by concrete adapter and compatible model, settings, MCP, and tool-contract lane. Each lane keeps its native or portable message sequence, tool lifecycle, and stable cache identity. Changing an incompatible input starts a fresh native continuation rather than replaying the previous checkpoint. Kedi does not translate private provider messages between frameworks, and tool calls/results remain native causal messages rather than being flattened into a user prompt. Artifact release and expiry do not delete or reorder existing messages, so a cached prefix remains append-only within its cache epoch.
User-Defined History Processing¶
PydanticAdapter and LangChainAdapter accept an optional
history_processor= callback when an application needs deterministic retention,
redaction, or its own summarization policy. It runs before every logical model
request, including later requests in a tool loop, and receives detached native
messages plus validated editing groups:
from typing import TypeVar
from kedi.agent_adapter import (
HistoryProcessorContext,
LangChainAdapter,
PydanticAdapter,
)
MessageT = TypeVar("MessageT")
def keep_recent_cycles(ctx: HistoryProcessorContext[MessageT]) -> list[MessageT]:
return ctx.keep_recent_groups(4)
pydantic_adapter = PydanticAdapter(history_processor=keep_recent_cycles)
langchain_adapter = LangChainAdapter(history_processor=keep_recent_cycles)
Native Configuration¶
Select the same callback from a Kedi program, procedure, or profile:
@keep_recent(ctx: Any) -> list[Any]:
= `ctx.keep_recent_groups(4)`
> history:
enabled: true
processor: `keep_recent`
>> The proposed next step is [next_step: str].
= <next_step>
Use Any for a native callback's framework-specific context and messages.
An unannotated parameter means str; object is not a supported native Kedi
type name. Python callbacks can use the generic HistoryProcessorContext type.
processor is a callable reference, not its result. The expanded directive still
requires enabled: true|false; compaction_mode and compaction_threshold remain
sibling fields. Python callers can use
AgentProfile(history_processing=HistoryProcessingSettings(processor=callback))
from kedi.agent_profile for the same lexical override.
Omitting processor inherits the captured policy or adapter constructor default.
An explicit processor: `None` disables the callback in that scope; leaving
the scope restores the previous policy. Shared adapter fields are not mutated,
so concurrent invocations can use different processors safely.
Native procedure callbacks bind to their selected definition; a deeper procedure
with the same name cannot replace the captured callback.
Conditional Processing¶
Use processor_condition to evaluate whether the processor is needed before
each logical model request, including requests after tool calls:
@keep_recent(ctx: Any) -> list[Any]:
= `ctx.keep_recent_groups(4)`
@needs_compaction(state: Any) -> bool:
= `state.estimated_tokens >= 80_000`
> history:
enabled: true
processor: `keep_recent`
processor_condition: `needs_compaction`
>> A concise next step for this project is [next_step].
= <next_step>
The predicate receives a frozen HistoryProcessorState, not editable messages.
It has adapter_shortname, model_id, one-based request_index, cache_epoch,
estimated_tokens, and an opaque history_version. The estimate covers the
candidate history, including any system messages it contains. It does not include
separately injected instructions or tool schemas and is not an exact wire-request
budget. A true result builds HistoryProcessorContext for the same history
revision; false skips the processor's detached copy and edit validation. Normal
request validation remains active. The predicate may be synchronous or async
and must return exactly bool; errors and other return types stop the request
before model transport. It is level-triggered, with no cooldown. Cancellation
does not commit a pending rewrite or cache generation.
Python adapters use history_processor_condition=needs_compaction alongside
history_processor=keep_recent_cycles. A condition alone does not choose a
processor. A condition-only lexical override can use an outer or constructor
processor. Replacing a processor without specifying a condition makes the new
processor unconditional; an explicit processor_condition: `None` clears an
inherited condition while retaining its processor. An effective condition
without a processor is a configuration error.
The LSP warns at active processor declarations, including external callables. Python constructors warn once per configuration, not per request. A processor may change earlier history and reduce prefix-cache reuse. A condition reduces how often the processor runs, but it does not guarantee prefix preservation or a provider cache hit. A semantic no-op preserves native history and cache identity. Evaluating the predicate and estimating tokens still cost work.
enabled: false disables conversation continuity, not processing of the current
invocation's ephemeral native tool loop. It does not expose an outer conversation
to the callback. Unsupported adapters reject an active processor or condition
before model I/O; the declared capability is history_processing.
Recent Groups and Origins¶
ctx.keep_recent_groups(n) keeps the last n closed, unprotected groups and all
protected, unfinished, and current-request groups, in their original order.
It returns a native message list without changing the context. n must be a
nonnegative integer, not a boolean. n=0 still preserves required history; the
total retained group count may exceed n. No summary model is called and no
token-budget fit is promised. Normal edit validation still applies.
Protected groups do not count toward n. For example, if the newest completed
cycle contains provider-required reasoning, keep_recent_groups(1) retains it
and the newest unprotected completed cycle. Retention can therefore differ
between providers even for similar visible text. Inspect group.protected and
group.protected_reasons when building a more selective policy; do not remove
opaque replay data to force a smaller history.
Each group.origins is a tuple of frozen HistoryOrigin records:
| Field | Meaning |
|---|---|
kind |
Existing history kind, such as user, assistant, tool-call, or tool-result |
run_id |
Runtime-known run identity, or None |
source_location |
Detached Kedi source span, or None for unknown/native-only locations |
tool_name |
Exact native or execution-boundary tool name, when known |
tool_call_id |
Native tool lifecycle identifier, when known |
Origins are out-of-band metadata, never extra prompt text. They survive supported edits and successful in-process conversation continuation, including turns where the condition skips the processor. Imported history, ambiguous copied duplicates, generated summaries, and calls made while processing is disabled may lack lineage; Kedi does not infer it from message text. This is not a durable provenance export protocol, a truth score, or proof of tool side effects. Metadata edits inside a callback cannot change the retained lineage.
Read origins while returning list(ctx.messages) to inspect without rewriting.
A no-op preserves cache identity. Retaining fewer groups can remove an early
prefix and reduce cache reuse; this policy is opt-in, not a universal speedup.
For example, inspect known tool origins without changing the outgoing history:
from kedi.agent_adapter import HistoryProcessorContext
def inspect_origins(ctx: HistoryProcessorContext) -> list:
for group in ctx.groups:
for origin in group.origins:
if origin.tool_name is not None:
print(origin.run_id, origin.tool_name, origin.tool_call_id)
return list(ctx.messages)
This logs identifiers, not tool arguments or results. Treat identifiers and source locations as application metadata when choosing a logging destination.
For executable, deterministic coverage of provider-native tool loops, see the
history-processor regression tests.
From the checkout, run uv run pytest tests/test_pydantic_user_history_processor.py --record-mode=none.
These checks need no API key and make no network requests.
Callback Contract¶
The callback may be synchronous or asynchronous. Synchronous callbacks run in a
worker thread rather than blocking the adapter event loop. It must return a
non-empty sequence in the owning framework's native message type. Returning the
input or an equivalent fresh sequence is a no-op. Do not slice an arbitrary last
N messages: a boundary can split a tool call from its result. ctx.groups
exposes the atomic ranges that can be selected safely.
Leaving history_processor=None disables this work. An enabled no-op still
copies and inspects messages; it preserves history and continuation rather than
eliminating processing overhead. Native message classes and typed tool payloads
are deep-copied, so the callback must not depend on sharing object identity with
the caller. Serialized dictionaries are not accepted in place of native messages.
The frozen context also provides adapter_shortname, model_id, one-based
request_index, cache_epoch, a conservative estimated_tokens value, and
per-entry metadata. It does not expose the complete wire request: system
instructions, tool schemas, routing, approvals, and model settings remain owned
by the adapter.
Editing Boundaries¶
Context Budget Accounting¶
The host request budget is separate from the callback's inspection estimate. After a successful Pydantic AI or LangChain run, Kedi can bind the last native response's measured input plus output usage to the exact saved history digest. Tool-result messages appended after that response are added conservatively. This avoids treating serialized history bytes as model tokens on an unchanged continuation. New instructions, schemas and prompt content still enter the next request's reservation; request and cumulative-input limits are unchanged.
If history is edited, reordered or extended outside the captured run, or native usage is absent, Kedi falls back to its conservative content estimate. Local lineage and usage-checkpoint metadata are not model-visible text. Unknown state is still counted. This uses provider-reported usage, not a tokenizer guarantee. A2A and subagent persistence retain and validate the digest-bound checkpoint; older checkpoints without this optional field remain supported.
Atomic Groups¶
group.entry_start and group.entry_end are half-open indices into
ctx.messages. Groups cover complete user/assistant cycles, including their
tool exchanges. Keep or replace an eligible group as a whole. Redacting one
message while retaining the rest of its original group is rejected; replace
the entire eligible group with native text summary messages instead.
Use group.closed, group.protected, and group.protected_reasons to decide
what is editable. Retained groups may be reordered within the same protected
boundaries, but their internal message order must remain intact. Keep the last
request message exactly, and preserve system messages, checkpoints, unfinished
work, and provider-required records. A signed reasoning block can therefore
protect its entire group.
This conservative grouping does not expose individual completed tool exchanges
inside an ongoing user cycle for arbitrary editing. Use history_archive= for
the existing exact archival of long tool loops. Combining it with this callback
does not allow the callback to remove the resulting checkpoints.
Kedi clones the callback input and accepted result. Before transport it rejects orphan tool results, partial lifecycle groups, an altered current request, fabricated provider/checkpoint records, and removal of pending or signed native state. Callback errors propagate; Kedi never silently sends the old history.
HistoryProcessingError wraps callback/copy failures;
HistoryProcessingValidationError identifies invalid edits. Cancellation
propagates normally. A running synchronous callback's worker thread cannot be
forcibly stopped: its late result is discarded, but application-owned side
effects still need their own cancellation policy. Callback state shared between
concurrent runs is also the application's responsibility. Kedi keeps its request
counters and pending transport resets separate, including nested native runs on
the same adapter. Calling that same configured adapter recursively from inside
the processor raises an error; use a separate processor-free model for a summary.
Persistence and Continuation¶
An accepted edit replaces Pydantic AI's native run history or LangChain's
durable graph state, so later tool steps and successful future turns see it.
Stale response-ID continuation is disabled before an incompatible request and
the conversation's cache generation is staged atomically. Failure or cancellation
does not commit the edit. When history_archive= is also enabled, exact archival
runs first and the callback sees the checkpointed native history.
Each actual rewrite stages a generation; two rewrites in one run can advance it twice. Requests without another rewrite reuse the current generation, and successful commit does not advance it again. An old server-owned conversation ID remains disabled on later turns of the same Kedi conversation lane: otherwise the server could reintroduce removed history. Automatic response-ID continuation may resume from a response produced after the edit; a fixed old response ID is not reused. Caller-owned model settings are not mutated.
These cross-turn guarantees require Kedi's conversation state. With native
Pydantic message_history= alone, carry forward result.all_messages() and
appropriate model settings yourself. Do not append new_messages() to the old
unprocessed list, because that would restore the history you removed.
A separately supplied Pydantic ProcessHistory capability is rejected when
history_processor is configured. Compose the policies in one callback instead.
Other capabilities must also respect these message boundaries.
The Python callback and native processor field share one implementation.
Neither introduces a second DSL compaction mode or a built-in summarizer.