ADR-0007: Canonical turn-request compilation boundary¶
- Status: Proposed
- Kind: Aspirational
- Area: messages-context
- Date: 2026-07-09
- Relations: extends ADR-0006
Context¶
LionAGI currently distributes provider-request preparation across durable message content, a raw projection, an unused general preparation helper, and the active chat/run preparation path. The result works for ordinary turns but does not give one place an exact request contract.
P1 — Instruction currently mixes durable and one-call lifetimes. InstructionContent contains stable user material (instruction, guidance, prompt_context, plain_content, and images) alongside current-call tool schemas, response-format objects, structure objects, and a rendering instance (lionagi/protocols/messages/instruction.py). Serialization conditionally strips the objects that cannot round-trip, and in-memory copies carry special cases to keep them alive. Replay therefore depends on remembering which fields are transient rather than making that lifetime explicit.
P2 — Three surfaces project history with different semantics.
MessageManager.to_chat_msgs()is a raw orderedchat_msgprojection. It does no prompt shaping (lionagi/protocols/messages/manager.py).prepare_messages_for_chat()extracts a leading system record, folds action results, clears historical schemas, merges assistant messages, and can render round notifications. It has no production caller (lionagi/protocols/messages/prepare.py)._prepare_run_kwargs()is the active chat and run path. It performs a different action fold, assistant merge, system fold, context-provider injection, filtering, and final rendering (lionagi/operations/chat/_prepare.py).
Two helpers can therefore assign different provider-visible meanings to the same record sequence.
P3 — History selection and malformed-history behavior are implicit. The active path chooses param.progression or branch.progression, looks up every UUID, ignores some message kinds, and requires the first retained item to be an instruction only when a system message exists. Invalid IDs surface through collection lookup; adjacent non-assistant retained records can be silently dropped by the current merge loop. Those are control-flow accidents, not a named contract.
P4 — Ephemeral context needs attribution and syntax boundaries. ADR-0008 records that current provider blocks are joined and concatenated directly between system text and guidance. The request compiler needs a typed block input and an exact encoding so source attribution survives to the provider request and retrieved text cannot become adjacent to policy merely because delimiters were omitted. This is a data-boundary improvement, not a claim that retrieved text becomes trusted.
P5 — Chat and run must agree before transport-specific execution begins. Both paths already call _prepare_run_kwargs(), but they then mutate call arguments differently: chat invokes an API event, while run adds streaming, resume, deadline, and persistence behavior. Compilation belongs before that fork so transport mechanics cannot redefine history.
P6 — Migration must preserve normal provider payloads while removing duplicate authority. The existing Branch.chat, communicate, operate, and run signatures cannot all change at once. They need an adapter into one typed request, characterization tests for current successful histories, and one removal/delegation plan for legacy helpers.
| Concern | Decision |
|---|---|
| Durable versus transient turn data | D1: Introduce immutable TurnRequest, ContextBlock, CompiledMessage, and CompiledChatRequest contracts. |
| History selection and folding | D2: Make compile_chat_request() the sole owner of validation, action folding, assistant normalization, system placement, and rendering. |
| Context attribution and boundaries | D3: Encode context blocks as canonical JSON in a separately delimited guidance section. |
| Service boundary | D4: Keep compiled messages provider-neutral; adapters translate them only at model invocation. |
| Chat/run parity | D5: Require chat and run to consume the same compiled result before adding transport-only keyword arguments. |
| Migration and old helpers | D6: Adapt existing Branch arguments into TurnRequest, characterize payloads, then delegate or remove every competing preparation path. |
This ADR deliberately does not decide:
- how context providers retrieve or prioritize blocks; ADR-0008 owns provider execution and supplies
ContextBlockvalues to this boundary; - tool execution, action concurrency, or action-result writeback; compilation only represents recorded results in model context;
- response parsing or Pydantic validation after the model call;
- provider transport, retry, rate-limit, streaming, or persistence behavior;
- a new public
Branch.chatorBranch.runsignature. Existing call surfaces adapt into the new contract first; or - a new system-role wire message. The target keeps the shipped system-into-first-user-message fold so this refactor does not also change the role sequence.
Decision¶
D1 — Separate record-ready instruction data from transient call data¶
The operation layer introduces the following exact Python-native contract in lionagi/operations/types.py:
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any
from uuid import UUID
from pydantic import BaseModel
from lionagi.operations.schema.structure import Structure
from lionagi.protocols.messages import Instruction, MessageRole
@dataclass(frozen=True, slots=True)
class ContextBlock:
provider_name: str
content: str
@dataclass(frozen=True, slots=True)
class CompiledMessage:
role: MessageRole
content: str | list[dict[str, Any]]
@dataclass(frozen=True, slots=True)
class TurnRequest:
instruction: Instruction
progression: tuple[UUID, ...] | None = None
tool_schemas: tuple[dict[str, Any], ...] = ()
response_format: type[BaseModel] | dict[str, Any] | BaseModel | None = None
structure: type[Structure] | str | None = None
context_blocks: tuple[ContextBlock, ...] = ()
provider_kwargs: Mapping[str, Any] = field(
default_factory=lambda: MappingProxyType({})
)
@dataclass(frozen=True, slots=True)
class CompiledChatRequest:
instruction: Instruction
messages: tuple[CompiledMessage, ...]
provider_kwargs: Mapping[str, Any]
def compile_chat_request(
branch: Branch,
request: TurnRequest,
) -> CompiledChatRequest: ...
The per-instance mapping factory avoids sharing mutable call state and exposes a read-only empty default.
After migration, the durable instruction content contract is reduced to provider-neutral fields:
# lionagi/protocols/messages/instruction.py
@dataclass(slots=True)
class InstructionContent(MessageContent):
instruction: str | None = None
guidance: str | None = None
prompt_context: list[Any] = field(default_factory=list)
plain_content: str | None = None
images: list[str] = field(default_factory=list)
image_detail: Literal["low", "high", "auto"] | None = None
tool_schemas, response_format, structure, and _structure_instance no longer remain fields on the target durable dataclass. During the compatibility window, InstructionContent.from_dict() accepts those keys from legacy serialized content and ignores them; replay cannot reconstruct a current-call option. The Branch call adapters continue accepting their existing arguments and place them on TurnRequest.
The lifetime split is normative:
| Data | Durable CompiledChatRequest.instruction | Transient TurnRequest / compiled messages |
|---|---|---|
| instruction text | yes | rendered from durable copy |
| stable guidance and prompt context | yes | rendered from durable copy |
| plain content and images | yes | rendered from durable copy |
| sender, recipient, identity, metadata | yes | used only as needed for compilation |
| tool schemas | no | TurnRequest.tool_schemas |
| response format and structure | no | TurnRequest.response_format / structure |
| selected progression | no | TurnRequest.progression |
| context-provider blocks | no | TurnRequest.context_blocks |
| model-call keyword arguments | no | TurnRequest.provider_kwargs |
Exact construction semantics:
TurnRequest.instructionis anInstruction, not raw text. Existing Branch entry points first build it through the current instruction factory so sender/recipient, context, images, and content validation retain their public behavior.- The compiler makes a deep model copy of the supplied instruction, preserving its
id,created_at, sender, recipient, and metadata values. It replaces that copy's content with the targetInstructionContentbuilt from only instruction, guidance, prompt context, plain content, images, and image detail. The suppliedInstructionis never mutated. - The compiler builds a private ephemeral rendering view for the current call. That view combines durable content with
tool_schemas,response_format, andstructurefromTurnRequestand preserves the current minimal-YAML Tools, ResponseSchema, and ResponseFormat sections. - Historical instruction copies use only the target durable fields, so an old in-memory record created before migration cannot replay a schema into a later call.
CompiledChatRequest.instructionis the only instruction object that chat/communicate/run may record after compilation. It contains no context blocks or current-call model kwargs.- The frozen dataclasses prevent field reassignment but do not make nested dictionaries or multimodal lists immutable. The compiler uses
copy.deepcopy()for schema dictionaries, provider kwargs, and rendered list content, then exposes compiled kwargs throughMappingProxyType. Caller-owned mutable containers are not retained by the compiled request. provider_kwargsmay not contain the reserved key"messages". A collision raisesValueError("provider_kwargs must not contain reserved key 'messages'")rather than silently overwriting either value.
This decision removes the need for serialization to be the mechanism that distinguishes durable from transient state. Serialization remains a compatibility safeguard, not the lifetime boundary.
D2 — One deterministic compiler owns selection, folding, and rendering¶
compile_chat_request() is a pure compilation step with respect to branch history: it reads the branch and returns new values without mutating any stored message, progression, or content object. Its algorithm is normative.
1. Resolve and validate the history view¶
request.progression is Noneselectsbranch.progression, including anymetadata["current_progression"]override described by ADR-0006.- An explicit empty tuple selects no historical messages.
- Existing public adapters preserve the current falsy-list behavior during migration by mapping a legacy
progression=[]argument toNone. Direct internalTurnRequest(progression=())is the unambiguous empty-history form. - Every UUID must exist in
branch.msgs.messages. Missing IDs are collected and reported in oneValueErrorbefore any compilation output is produced. - Duplicate UUIDs are rejected with
ValueError; replaying the same record twice is not an implicit feature. - Order is exactly tuple/progression order, not creation time.
2. Project provider-relevant history¶
The compiler walks the selected records once:
Systemrecords are not emitted from the selected progression; the canonicalbranch.msgs.systemis handled in the system fold below.ActionRequestrecords are not emitted independently. Their function and arguments are already present on the correlatedActionResponsecontent.ActionResponserecords are accumulated until the next historicalInstruction.AssistantResponsebecomes an ephemeral copy ofAssistantResponseContent.Instructionbecomes an ephemeral copy withtool_schemas=(),response_format=None, and no structure instance. Pending action responses are appended to its prompt context.- A base
Messageor other record whose role isunsetis ignored; routed traffic is not a provider conversation turn. - Any future fixed-role conversational subtype not covered above raises
TypeErrorso adding a subtype cannot silently change prompt semantics.
Action responses use the current provider-visible projection:
{
"function": response.content.function,
"arguments": response.content.arguments,
"output": response.content.output,
}
For each target instruction, projected dictionaries append in action-response order. A dictionary already equal to an existing prompt-context item is not appended again. Pending responses after the last historical instruction append to the current ephemeral instruction.
3. Validate conversation ordering and merge assistant runs¶
- Consecutive
AssistantResponseContentobjects merge into one assistant message by joining their text with exactly"\n\n". - Adjacent retained instructions are rejected with
ValueError("Consecutive instructions require an intervening assistant response"). The compiler does not preserve the current accidental silent drop. - If a system message exists and retained history is non-empty, the first retained content must be an instruction. Otherwise compilation raises
ValueError("First message in progression must be an Instruction or System"), preserving the active path's explicit error. - With no retained history, the current ephemeral instruction is the first content.
- The current instruction is appended exactly once after retained history.
4. Fold system, context, and guidance¶
The compiler keeps the shipped role sequence: it does not emit a provider system message. It builds one guidance prefix and applies it to the first instruction content:
<system.rendered, when present>
<context section from D3, when blocks are present>
<that instruction's existing guidance, when present>
Each non-empty section is joined with exactly two newline characters. Non-string guidance renders through the existing minimal-YAML renderer before joining.
When retained history exists, the fold applies to its first instruction, matching current system placement. Otherwise it applies to the current instruction. Context blocks can compile without a system message; whether the provider registry runs on a systemless branch remains a migration decision in ADR-0008.
5. Render immutable provider-neutral messages¶
- Each instruction uses the existing structured instruction renderer and has role
user. Historical views render only durable sections; the current ephemeral view additionally renders the TurnRequest's Tools, ResponseSchema, and ResponseFormat sections. - Each assistant response renders its normalized text and has role
assistant. - A falsy rendered value is omitted, matching the active path.
- Multimodal instruction content remains a list containing a text part and
image_urlparts. - The returned
messagestuple preserves the final content order. CompiledMessagecontains onlyroleand renderedcontent; message UUIDs and metadata do not enter the provider request.
The algorithm has no retry, timeout, token cap, or provider call. It is deterministic for the branch snapshot and TurnRequest supplied. Callers that require a stable snapshot must avoid concurrent history mutation while compiling; cross-task branch locking is outside this ADR.
D3 — Context blocks use a canonical source-attributed JSON section¶
ContextBlock is data, not preformatted policy text. The compiler renders all blocks as one canonical JSON array:
from lionagi.ln import json_dumps
payload = [
{"provider_name": block.provider_name, "content": block.content}
for block in request.context_blocks
]
encoded = json_dumps(payload, sort_keys=True)
section = "LIONAGI_CONTEXT_BLOCKS_JSON\n" + encoded + "\nEND_LIONAGI_CONTEXT_BLOCKS_JSON"
The exact provider-visible shape for two blocks is:
LIONAGI_CONTEXT_BLOCKS_JSON
[{"content":"first retrieved value","provider_name":"catalog"},{"content":"second retrieved value","provider_name":"memory"}]
END_LIONAGI_CONTEXT_BLOCKS_JSON
Exact semantics:
- block order is
TurnRequest.context_blocksorder, which ADR-0008 defines as retained registration order; - empty
provider_nameorcontentis rejected withValueError; the registry should omit empty results before constructing blocks; - JSON escaping preserves quotes, newlines, and control characters inside the
contentfield; - system text, the context section, and guidance are separate two-newline-delimited sections;
- context content remains untrusted model input. Attribution and syntactic encoding prevent accidental string adjacency; they do not authorize instructions found in retrieved data or guarantee resistance to semantic prompt injection; and
- the context section exists only in compiled content. Neither the JSON text nor the source blocks are copied into the record-ready instruction.
This target is the concrete source-attributed envelope required by ADR-0008 delta 1.
D4 — Provider translation occurs after neutral compilation¶
The operation compiler returns CompiledMessage, not OpenAI-, Anthropic-, or CLI-specific request objects. The default service projection is mechanically:
provider_messages = [
{"role": message.role.value, "content": message.content}
for message in compiled.messages
]
Provider adapters may translate multimodal part names or other service-specific shapes at the iModel boundary. They may not re-read Branch.messages, refold actions, move system policy, re-run context providers, or mutate the compiled instruction. If a provider needs a native tools field or response-format field, that translation is driven from explicit operation parameters, not reconstructed from durable history.
An adapter error occurs before model invocation and leaves history unchanged. Recording happens only after compilation succeeds and follows each operation's existing behavior: direct chat() does not automatically record, communicate() records its instruction/response, and run() records streamed messages.
D5 — Chat and run share compilation and diverge only for execution¶
Both operation paths use the same sequence:
sequenceDiagram
participant API as Branch call surface
participant P as Context providers
participant C as compile_chat_request
participant A as Provider adapter
participant M as iModel
API->>P: gather source-attributed blocks
P-->>API: tuple[ContextBlock, ...]
API->>C: Branch + TurnRequest
C-->>API: CompiledChatRequest
API->>A: CompiledMessage tuple
A-->>API: provider messages
API->>M: invoke or stream After compilation:
- chat selects the model from its existing
ChatParam/Branch operation state, adds the deprecatedinclude_token_usage_to_modelvalue only when explicitly present, and invokes once; - run verifies a CLI model, adds resume state,
stream=True, an optional stream deadline, and optional persistence callbacks; and - neither path changes
compiled.messagesor compiles a second time.
There is no separate compile_run_request(). Run-only settings such as stream_persist, persist_dir, and snapshot_dir remain execution parameters and never enter TurnRequest.
D6 — Migrate by adaptation, characterization, and removal of competing policy¶
The target module ownership is:
lionagi/operations/
├── types.py TurnRequest, ContextBlock, CompiledMessage,
│ CompiledChatRequest
└── chat/
├── _compile.py compile_chat_request and pure helpers
├── _prepare.py temporary compatibility adapter; then removed/delegated
└── chat.py provider invocation only
lionagi/protocols/messages/
├── manager.py ordered raw records and factories only
└── prepare.py compatibility delegation, then deprecation/removal
Migration order is part of the decision:
- Capture current successful provider-visible payloads for chat and run: empty history, systemful and systemless history, action results, consecutive assistant responses, images, tools, response formats, selected progressions, and context-provider blocks.
- Add the typed contracts and compiler without changing public Branch signatures.
- Adapt
ChatParam/RunParamplus the newly built instruction intoTurnRequest. Preserve the legacy empty-progression behavior in that adapter. - Route both chat and run through the compiler. Any intentional difference—canonical context delimiters and explicit malformed-history errors in this ADR—gets a focused expectation rather than an accidental snapshot update.
- Make
MessageManager.to_chat_msgs()explicitly raw and keep it only for callers that want a literal record projection. - Delegate
prepare_messages_for_chat()to the compiler where compatible, deprecate unsupported option combinations, then remove independent prompt policy at the declared compatibility boundary. - Remove
_prepare_run_kwargs()after all production callers use the compiler.
Completion requires one payload test matrix shared by chat and run. A change that updates only one side fails the boundary contract.
Consequences¶
Positive¶
- Durable instructions no longer retain objects or schemas that exist only for one call.
- One deterministic compiler becomes the regression surface for history, actions, structured output instructions, tools, images, system policy, and injected context.
- Chat and run cannot drift in prompt semantics while retaining independent execution mechanics.
- Source attribution survives into provider-visible context without persisting retrieved text as a conversation record.
- Provider adapters consume a small frozen-field intermediate shape instead of depending on
MessageManagerinternals.
Negative¶
- Compilation allocates record-ready and ephemeral content copies. This is deliberate isolation overhead; no performance rationale or benchmark currently justifies in-place mutation.
- The migration touches a compatibility-sensitive request boundary and must distinguish intended changes from regressions in payload snapshots.
- Canonical JSON context encoding adds prompt characters compared with direct concatenation.
TurnRequestintroduces another internal type alongsideChatParamduring migration. The overlap must be temporary; leaving both as prompt authorities would recreate P2.
Maintenance and reversal cost¶
- Adding a new conversational message kind requires an explicit compiler case and shared chat/run tests. Silent fallback is forbidden.
- Adding a transient call option requires a
TurnRequestfield or explicit provider-adapter input; putting it back on durableInstructionContentviolates D1. - Reversing to provider-specific compilers would duplicate the history algorithm and invalidate the parity test matrix. Reversing the JSON context envelope would change provider-visible prompt text and requires a documented compatibility decision.
- The compiler is highly testable without a live model: branch records and a request are sufficient to assert the complete output.
Alternatives considered¶
Keep current-call configuration in InstructionContent and strip it on replay¶
This preserves existing factories and keeps all rendering inputs on one object. It lost because the object then has two lifetimes: record storage and request execution. Type/BaseModel response formats cannot round-trip, dictionary formats behave differently, and every replay path must remember to clear schemas. The current to_dict() and with_updates() exceptions are direct evidence of that burden.
Move all compilation into MessageManager¶
The manager already owns messages and could expose one prepare() method. This would reduce the number of modules a caller touches. It lost because the manager would need current model kwargs, tool schemas, response formats, provider blocks, and system-fold policy—operation inputs unrelated to durable membership and order. It would reverse ADR-0006's manager boundary.
Let each provider adapter compile raw history¶
Provider-local compilation could use native system, tool, and multimodal features directly. It lost because action folding, historical schema stripping, progression validation, and assistant normalization would be repeated for every provider. Equivalent turns could then change meaning when only the model provider changes.
Keep _prepare_run_kwargs() as the canonical boundary without typed intermediates¶
This is the smallest code change because chat and run already call it. It lost because its input is a branch plus broad ChatParam, its output mixes messages into a mutable kwargs dictionary, and it uses a private branch slot for context. The signature does not separate durable data, transient data, compiled messages, or reserved provider keys.
Emit a native provider system message¶
A dedicated system-role item would avoid folding policy into the first user message and maps to many chat APIs. It lost for this migration because it changes the provider-visible role sequence, and not every CLI or provider surface treats native system messages identically. This ADR keeps the current fold; a future role-sequence change needs its own characterization and decision.
Preserve context as direct string concatenation¶
This is shortest and has the lowest token overhead. It lost because source names disappear and empty delimiters let system text, retrieved text, and guidance become one syntactic run. Canonical JSON costs characters but preserves source/content fields and escaping deterministically.
Mutate historical content in place to avoid copies¶
In-place clearing of old schemas and folding of action outputs would allocate less. It lost because compilation failure or concurrent inspection could observe partially rewritten history, and later turns would inherit previous call policy. The compiler's primary invariant is non-mutation.
Notes¶
Current implementation anchors used to define migration behavior: lionagi/protocols/messages/instruction.py, lionagi/protocols/messages/manager.py, lionagi/protocols/messages/prepare.py, lionagi/operations/types.py, lionagi/operations/chat/_prepare.py, lionagi/operations/chat/chat.py, lionagi/operations/communicate/communicate.py, and lionagi/operations/run/run.py.