alpha-avatar-plugins-context 0.6.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ # Copyright 2026 AlphaAvatar project
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ from collections import OrderedDict
20
+ from dataclasses import dataclass, field
21
+ from types import MappingProxyType
22
+ from typing import TYPE_CHECKING
23
+
24
+ from alphaavatar.agents.avatar.provider.enums import ModelRole
25
+ from alphaavatar.agents.avatar.provider.errors import ModelProtocolError
26
+ from alphaavatar.agents.avatar.provider.records import MODEL_RECORD_SCHEMA
27
+ from alphaavatar.agents.avatar.provider.schemas import ModelInput, ModelInputMessage, ModelTextPart
28
+ from alphaavatar.agents.avatar.provider.schemas.model_input import (
29
+ ModelFunctionCall,
30
+ ModelFunctionOutput,
31
+ )
32
+ from alphaavatar.core.cleanup import wait_for_cleanup
33
+
34
+ if TYPE_CHECKING:
35
+ from alphaavatar.agents.avatar.provider.schemas.model_input import ModelInputItem
36
+ from alphaavatar.core.output import OutputRuntime
37
+ from alphaavatar.core.output.schemas import OutputRecordBatch
38
+ from alphaavatar.core.turn import TurnSnapshot
39
+
40
+ from ..config import ContextConfig
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class _Turn:
47
+ context_id: str
48
+ turn_id: str
49
+ input_id: str
50
+ cutoff: int
51
+ seed: ModelInputMessage
52
+ size_bytes: int
53
+ records: list[tuple[str | None, ModelInputItem]] = field(default_factory=list)
54
+ batch_digests: dict[str, str] = field(default_factory=dict)
55
+
56
+ @property
57
+ def count(self) -> int:
58
+ return 1 + len(self.records)
59
+
60
+ def project(self) -> tuple[ModelInputItem, ...]:
61
+ # A retiring tool may settle after another query begins. Never expose half a pair.
62
+ calls = {
63
+ (run_id, item.call_id)
64
+ for run_id, item in self.records
65
+ if isinstance(item, ModelFunctionCall)
66
+ }
67
+ outputs = {
68
+ (run_id, item.call_id)
69
+ for run_id, item in self.records
70
+ if isinstance(item, ModelFunctionOutput)
71
+ }
72
+ paired = calls & outputs
73
+ return (
74
+ self.seed,
75
+ *(
76
+ item
77
+ for run_id, item in self.records
78
+ if not isinstance(item, ModelFunctionCall | ModelFunctionOutput)
79
+ or (run_id, item.call_id) in paired
80
+ ),
81
+ )
82
+
83
+
84
+ class ContextHistory:
85
+ """Bounded canonical query history, independently acknowledging Output.records.
86
+
87
+ User inputs come from committed Turns. Assistant/tool records come only from
88
+ the lossless journal, not playout, LoopResult or private provider continuation.
89
+ Byte accounting covers serialized content, not process RSS or shared media caches.
90
+ """
91
+
92
+ CONSUMER_ID = "context.history"
93
+
94
+ def __init__(self, *, output: OutputRuntime, config: ContextConfig) -> None:
95
+ self._journal = output.records
96
+ self._max_turns = config.history_max_turns
97
+ self._max_items = config.history_max_items
98
+ self._max_bytes = config.history_max_bytes
99
+ self._turns: OrderedDict[tuple[str, str], _Turn] = OrderedDict()
100
+ self._bytes = self._items = self._latest_cutoff = 0
101
+ self._registered = self._stopping = False
102
+ self._worker: asyncio.Task[None] | None = None
103
+ self._close_task: asyncio.Task[None] | None = None
104
+ self._failure: Exception | None = None
105
+
106
+ def _ensure_open(self) -> None:
107
+ if self._close_task is not None:
108
+ raise RuntimeError("Context history is closing or closed")
109
+ if self._failure is not None:
110
+ raise RuntimeError("Context history consumer has failed") from self._failure
111
+
112
+ def _fail(self, error: BaseException) -> None:
113
+ if isinstance(error, asyncio.CancelledError):
114
+ failure = RuntimeError("Context history consumer cancelled without owner shutdown")
115
+ failure.__cause__ = error
116
+ elif isinstance(error, Exception):
117
+ failure = error
118
+ else:
119
+ raise error
120
+ if self._failure is None:
121
+ self._failure = failure
122
+ if self._registered:
123
+ self._journal.fail(self.CONSUMER_ID, failure)
124
+ logger.error("Context history consumer failed", exc_info=failure)
125
+
126
+ def _observe(self, task: asyncio.Task[None]) -> None:
127
+ if task.cancelled():
128
+ if not self._stopping:
129
+ self._fail(asyncio.CancelledError())
130
+ elif (error := task.exception()) is not None:
131
+ self._fail(error)
132
+
133
+ def _start(self) -> None:
134
+ self._ensure_open()
135
+ if not self._registered:
136
+ self._journal.register(self.CONSUMER_ID, required=True)
137
+ self._registered = True
138
+ self._worker = asyncio.create_task(self._run(), name=self.CONSUMER_ID)
139
+ self._worker.add_done_callback(self._observe)
140
+
141
+ def _evict(self, key: tuple[str, str]) -> None:
142
+ removed = self._turns.pop(key)
143
+ self._bytes -= removed.size_bytes
144
+ self._items -= removed.count
145
+
146
+ def _make_room(
147
+ self, *, target: tuple[str, str], add_bytes: int, add_items: int, add_turn: bool = False
148
+ ) -> bool:
149
+ turn = self._turns.get(target)
150
+ target_bytes = (turn.size_bytes if turn else 0) + add_bytes
151
+ target_items = (turn.count if turn else 0) + add_items
152
+ if target_bytes > self._max_bytes or target_items > self._max_items:
153
+ raise RuntimeError("A single turn exceeds canonical context history capacity")
154
+
155
+ victims = []
156
+ size = self._bytes + add_bytes
157
+ count = self._items + add_items
158
+ turns = len(self._turns) + int(add_turn)
159
+ for key, value in self._turns.items():
160
+ if size <= self._max_bytes and count <= self._max_items and turns <= self._max_turns:
161
+ break
162
+
163
+ # Whole-turn eviction never leaves an orphan tool record in the projected input.
164
+ if key == target:
165
+ if key == next(reversed(self._turns)):
166
+ raise RuntimeError("The current turn cannot fit in context history")
167
+
168
+ victims.append(key)
169
+ break
170
+
171
+ victims.append(key)
172
+ size, count, turns = size - value.size_bytes, count - value.count, turns - 1
173
+
174
+ for key in victims:
175
+ self._evict(key)
176
+
177
+ return target not in victims
178
+
179
+ def _ingest(self, batch: OutputRecordBatch) -> None:
180
+ if batch.schema != MODEL_RECORD_SCHEMA:
181
+ return
182
+
183
+ key = batch.scope.context_id, batch.scope.turn_id
184
+ turn = self._turns.get(key)
185
+ if turn is None:
186
+ # A late result stays in canonical storage; it cannot resurrect an evicted query.
187
+ return
188
+
189
+ previous = turn.batch_digests.get(batch.batch_id)
190
+ if previous is not None:
191
+ if previous != batch.digest:
192
+ raise ModelProtocolError("Conflicting canonical batch replay")
193
+ return
194
+
195
+ if not batch.items or type(batch.size_bytes) is not int or batch.size_bytes < 1:
196
+ raise ModelProtocolError("Canonical history requires a nonempty measured batch")
197
+
198
+ existing = {
199
+ item.id
200
+ for value in self._turns.values()
201
+ if value.context_id == turn.context_id
202
+ for item in (value.seed, *(item for _, item in value.records))
203
+ }
204
+ run_id = batch.scope.run_id
205
+ if not isinstance(run_id, str) or not run_id:
206
+ raise ModelProtocolError("Canonical model records require their owning run")
207
+
208
+ calls = {
209
+ (owner, item.call_id): item
210
+ for owner, item in turn.records
211
+ if isinstance(item, ModelFunctionCall)
212
+ }
213
+ output_ids = {
214
+ (owner, item.call_id)
215
+ for owner, item in turn.records
216
+ if isinstance(item, ModelFunctionOutput)
217
+ }
218
+ records = []
219
+ for item in batch.items:
220
+ if item.id in existing:
221
+ raise ModelProtocolError("Canonical model item identity was reused")
222
+
223
+ existing.add(item.id)
224
+ if isinstance(item, ModelInputMessage):
225
+ if item.role != ModelRole.ASSISTANT:
226
+ raise ModelProtocolError(
227
+ "Output history cannot contain user or policy messages"
228
+ )
229
+ elif isinstance(item, ModelFunctionCall):
230
+ if (run_id, item.call_id) in calls:
231
+ raise ModelProtocolError("Canonical tool call identity was reused")
232
+ calls[run_id, item.call_id] = item
233
+ elif isinstance(item, ModelFunctionOutput):
234
+ call = calls.get((run_id, item.call_id))
235
+ if call is None or call.name != item.name or (run_id, item.call_id) in output_ids:
236
+ raise ModelProtocolError("Canonical tool output has no unique owning call")
237
+ output_ids.add((run_id, item.call_id))
238
+ else:
239
+ raise ModelProtocolError("Private continuation cannot enter canonical history")
240
+
241
+ records.append((run_id, item))
242
+
243
+ if not self._make_room(target=key, add_bytes=batch.size_bytes, add_items=len(records)):
244
+ return
245
+
246
+ turn.records.extend(records)
247
+ turn.batch_digests[batch.batch_id] = batch.digest
248
+ turn.size_bytes += batch.size_bytes
249
+ self._bytes, self._items = self._bytes + batch.size_bytes, self._items + len(records)
250
+
251
+ def drain(self) -> None:
252
+ if not self._registered:
253
+ return
254
+
255
+ if self._failure is not None:
256
+ raise RuntimeError("Context history consumer failed") from self._failure
257
+
258
+ try:
259
+ while True:
260
+ read = self._journal.read_pending(consumer_id=self.CONSUMER_ID)
261
+ if read.has_gap:
262
+ raise RuntimeError("Context history missed canonical records")
263
+
264
+ for event in read.items:
265
+ if event.scope != event.payload.scope:
266
+ raise ModelProtocolError("Canonical batch scope changed")
267
+
268
+ self._ingest(event.payload)
269
+ self._journal.commit(consumer_id=self.CONSUMER_ID, cursor_seq=event.sequence)
270
+
271
+ if read.cursor_seq >= read.latest_seq:
272
+ return
273
+ except BaseException as error:
274
+ self._fail(error)
275
+ raise
276
+
277
+ def accept_turn(self, snapshot: TurnSnapshot, context_id: str) -> ModelInput:
278
+ self._start()
279
+ key = context_id, snapshot.turn_id
280
+ previous = self._turns.get(key)
281
+ if previous is not None:
282
+ if (previous.input_id, previous.cutoff) != (
283
+ snapshot.input_id,
284
+ snapshot.cutoff.sequence,
285
+ ):
286
+ raise ModelProtocolError("Committed turn identity changed")
287
+
288
+ return ModelInput(items=(previous.seed,))
289
+
290
+ if snapshot.cutoff.sequence < self._latest_cutoff:
291
+ raise RuntimeError("This committed input is outside the retained context history")
292
+
293
+ metadata = MappingProxyType(
294
+ {
295
+ "context_id": context_id,
296
+ "turn_id": snapshot.turn_id,
297
+ "input_modality": snapshot.modality.value,
298
+ }
299
+ )
300
+ text = (
301
+ snapshot.text
302
+ if snapshot.text is not None
303
+ else (
304
+ '<historical_media omitted="true" current="false">'
305
+ "Visual input belongs to this earlier turn; do not infer its contents from this placeholder."
306
+ "</historical_media>"
307
+ )
308
+ )
309
+ seed = ModelInputMessage(
310
+ id=snapshot.input_id,
311
+ role=ModelRole.USER,
312
+ parts=(ModelTextPart(text),),
313
+ created_at=snapshot.committed_at.unix_seconds,
314
+ metadata=metadata,
315
+ )
316
+ size = len(
317
+ json.dumps(
318
+ {
319
+ "id": seed.id,
320
+ "text": text,
321
+ "metadata": dict(metadata),
322
+ "created_at": seed.created_at,
323
+ },
324
+ ensure_ascii=False,
325
+ allow_nan=False,
326
+ ).encode()
327
+ )
328
+
329
+ if any(
330
+ value.context_id == context_id
331
+ and (value.input_id == seed.id or any(item.id == seed.id for _, item in value.records))
332
+ for value in self._turns.values()
333
+ ):
334
+ raise ModelProtocolError("Context input identity was reused")
335
+
336
+ self._make_room(target=key, add_bytes=size, add_items=1, add_turn=True)
337
+ self._turns[key] = _Turn(
338
+ context_id, snapshot.turn_id, snapshot.input_id, snapshot.cutoff.sequence, seed, size
339
+ )
340
+ self._bytes, self._items = self._bytes + size, self._items + 1
341
+ self._latest_cutoff = max(self._latest_cutoff, snapshot.cutoff.sequence)
342
+ return ModelInput(items=(seed,))
343
+
344
+ def capture(self, snapshot: TurnSnapshot, context_id: str, current: ModelInput) -> ModelInput:
345
+ self._ensure_open()
346
+ if (
347
+ len(current.items) != 1
348
+ or not isinstance(current.items[0], ModelInputMessage)
349
+ or (current.items[0].id != snapshot.input_id or current.items[0].role != ModelRole.USER)
350
+ ):
351
+ raise ValueError(
352
+ "Context preparation requires exactly the current committed input seed"
353
+ )
354
+
355
+ self.drain()
356
+ key = context_id, snapshot.turn_id
357
+ if key not in self._turns:
358
+ raise RuntimeError("Current turn is not retained in context history")
359
+ if current.realtime is not None or current.items != (self._turns[key].seed,):
360
+ raise ValueError("Current input differs from its canonical committed Turn seed")
361
+
362
+ items = []
363
+ for entry_key, turn in self._turns.items():
364
+ if entry_key == key:
365
+ items.extend(current.items)
366
+ break
367
+ if turn.context_id == context_id:
368
+ items.extend(turn.project())
369
+
370
+ ids = [item.id for item in items]
371
+ call_ids = [item.call_id for item in items if isinstance(item, ModelFunctionCall)]
372
+ if len(ids) != len(set(ids)) or len(call_ids) != len(set(call_ids)):
373
+ raise ModelProtocolError("Captured canonical history has duplicate identities")
374
+
375
+ return ModelInput(items=tuple(items))
376
+
377
+ async def _run(self) -> None:
378
+ while await self._journal.wait_for_pending(consumer_id=self.CONSUMER_ID):
379
+ self.drain()
380
+
381
+ async def _close(self) -> None:
382
+ errors = []
383
+ try:
384
+ self.drain()
385
+ except Exception as error:
386
+ errors.append(error)
387
+ if self._worker is not None:
388
+ if not self._worker.done() and not self._worker.cancelling():
389
+ self._stopping = True
390
+ self._worker.cancel()
391
+ await asyncio.gather(self._worker, return_exceptions=True)
392
+ self._observe(self._worker)
393
+ if self._registered and self._failure is None:
394
+ self._journal.unregister(self.CONSUMER_ID)
395
+ self._registered = False
396
+ if self._failure is not None and self._failure not in errors:
397
+ errors.append(self._failure)
398
+ if errors:
399
+ raise ExceptionGroup("Context history cleanup failed", errors)
400
+
401
+ async def aclose(self) -> None:
402
+ if self._close_task is None:
403
+ self._close_task = asyncio.create_task(self._close(), name="context_history_close")
404
+ await wait_for_cleanup(self._close_task)
@@ -0,0 +1,282 @@
1
+ # Copyright 2026 AlphaAvatar project
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ from dataclasses import replace
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from alphaavatar.agents.avatar.context import ContextManager
22
+ from alphaavatar.agents.avatar.context.schemas import ContextBuildRequest, ContextPrepareRequest
23
+ from alphaavatar.agents.avatar.provider.enums import ModelInputType
24
+ from alphaavatar.agents.avatar.provider.errors import ModelProtocolError
25
+ from alphaavatar.agents.avatar.provider.schemas import ModelInput
26
+ from alphaavatar.agents.avatar.provider.schemas.model_input import ModelInputItem
27
+ from alphaavatar.agents.avatar.vision import VisualFrameSelector
28
+ from alphaavatar.agents.constants import DEFAULT_SYSTEM_VALUE
29
+ from alphaavatar.agents.utils.time import ParticipantTimeContext, format_user_time
30
+ from alphaavatar.core.cleanup import wait_for_cleanup
31
+ from alphaavatar.core.env.enums import ObservationKind
32
+ from alphaavatar.core.perception import PerceptionTemporalAligner, TemporalAlignmentMode
33
+ from alphaavatar.core.time import RuntimeTimeRange
34
+
35
+ from .builder import ContextBuilder
36
+ from .config import ContextConfig
37
+ from .history import ContextHistory
38
+ from .snapshot import snapshot_input
39
+ from .template import AvatarSysPromptTemplate, RuntimeStateTemplate
40
+
41
+ if TYPE_CHECKING:
42
+ from alphaavatar.agents.configs import AvatarConfig
43
+ from alphaavatar.agents.memory import MemoryBase
44
+ from alphaavatar.agents.persona import PersonaBase
45
+ from alphaavatar.agents.runtime import AvatarRuntime, StateRuntime
46
+ from alphaavatar.core.turn import TurnSnapshot
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ class AvatarContextManager(ContextManager):
52
+ """Own canonical query history and snapshots, not execution handles or SDK state."""
53
+
54
+ _OBSERVATION_KINDS = frozenset(
55
+ {
56
+ ObservationKind.VIDEO_FRAME,
57
+ ObservationKind.SCREEN_FRAME,
58
+ ObservationKind.SPEECH_SEGMENT,
59
+ ObservationKind.TRANSCRIPT_SEGMENT,
60
+ ObservationKind.TEXT_INPUT,
61
+ ObservationKind.IMAGE_INPUT,
62
+ }
63
+ )
64
+
65
+ def __init__(
66
+ self, *, runtime: AvatarRuntime, avatar_config: AvatarConfig, config: ContextConfig
67
+ ) -> None:
68
+ self._runtime = runtime
69
+ self._config = config
70
+ self._vision = avatar_config.vision.model_copy(deep=True)
71
+ self._alignment_policy = avatar_config.runtime.perception.temporal_alignment.build_policy()
72
+ self._introduction = avatar_config.avatar.introduction
73
+ self._memory: MemoryBase | None = None
74
+ self._persona: PersonaBase | None = None
75
+ self._history = ContextHistory(output=runtime.output, config=config)
76
+ self._pending = 0
77
+ self._work: set[asyncio.Task[ModelInput]] = set()
78
+ self._close_task: asyncio.Task[None] | None = None
79
+
80
+ @property
81
+ def initial_instructions(self) -> str:
82
+ self._ensure_open()
83
+ persona = self._persona.persona_content if self._persona is not None else None
84
+ return self._system_template(self._runtime.state).instructions(
85
+ stable_persona=persona or self._runtime.state.user_persona or DEFAULT_SYSTEM_VALUE
86
+ )
87
+
88
+ @staticmethod
89
+ def _observe(task: asyncio.Task[Any]) -> None:
90
+ if not task.cancelled():
91
+ task.exception()
92
+
93
+ def _ensure_open(self) -> None:
94
+ if self._close_task is not None:
95
+ raise RuntimeError("Context manager is closing or closed")
96
+
97
+ def bind_sources(self, *, memory: MemoryBase, persona: PersonaBase) -> None:
98
+ self._ensure_open()
99
+ different = self._memory is not memory or self._persona is not persona
100
+ if self._memory is not None and different:
101
+ raise RuntimeError("Context sources are already bound to another Engine")
102
+ if self._pending or self._work:
103
+ raise RuntimeError("Cannot bind Context sources during preparation")
104
+ self._memory, self._persona = memory, persona
105
+
106
+ def accept_turn(self, snapshot: TurnSnapshot) -> ModelInput:
107
+ self._ensure_open()
108
+ context_id = self._runtime.state.context_id
109
+ if self._runtime.turn.get(snapshot.input_id) is not snapshot:
110
+ raise ValueError("Context can only accept a Turn owned by this runtime")
111
+ if context_id not in snapshot.context_ids:
112
+ raise ValueError("Committed input belongs to another context")
113
+ return self._history.accept_turn(snapshot, context_id)
114
+
115
+ def _system_template(self, state: StateRuntime) -> AvatarSysPromptTemplate:
116
+ return AvatarSysPromptTemplate(
117
+ self._introduction,
118
+ interaction_method=state.interaction_method,
119
+ internal_capabilities=self._runtime.capability_registry.capabilities,
120
+ stable_behavior_rules=self._config.behavior_rules,
121
+ )
122
+
123
+ def _state_snapshot(self) -> StateRuntime:
124
+ state = self._runtime.state
125
+ times = {}
126
+ for identity, participant in self._runtime.session.participants.items():
127
+ time = format_user_time(
128
+ participant.user_time.timezone, participant.user_time.timezone_source
129
+ )
130
+ times[identity] = ParticipantTimeContext(
131
+ participant_id=identity, user_id=participant.effective_user_id, user_time=time
132
+ )
133
+
134
+ # Read sources once. Preparing a query must not write to shared State or participants.
135
+ memory = self._memory.memory_content if self._memory is not None else state.memory_content
136
+ persona = self._persona.persona_content if self._persona is not None else state.user_persona
137
+ return replace(
138
+ state,
139
+ participant_time=times,
140
+ interaction_method=replace(
141
+ state.interaction_method, notes=list(state.interaction_method.notes)
142
+ ),
143
+ memory_content=memory or DEFAULT_SYSTEM_VALUE,
144
+ user_persona=persona or DEFAULT_SYSTEM_VALUE,
145
+ turn_behavior_rules="\n".join((state.global_behavior_rules, state.turn_behavior_rules)),
146
+ )
147
+
148
+ def _compose(
149
+ self,
150
+ request: ContextPrepareRequest,
151
+ snapshot: TurnSnapshot,
152
+ system_prompt: str,
153
+ runtime_context: str,
154
+ ) -> ModelInput:
155
+ events = snapshot.perception_events
156
+ if self._vision.input_mode != ModelInputType.REALTIME:
157
+ events = tuple(
158
+ event
159
+ for event in events
160
+ if event.source_state is not None
161
+ or (
162
+ event.observation is not None
163
+ and event.observation.kind in self._OBSERVATION_KINDS
164
+ )
165
+ )
166
+
167
+ # Request-local helpers avoid races between concurrent preparation workers.
168
+ alignment = PerceptionTemporalAligner(self._alignment_policy).align(
169
+ events=events,
170
+ time_range=RuntimeTimeRange(start=snapshot.started_at, end=snapshot.committed_at),
171
+ source_states_at_start=snapshot.source_states_at_start,
172
+ source_states_at_end=snapshot.source_states_at_commit,
173
+ has_event_gap=snapshot.perception_gap,
174
+ missed_event_count=snapshot.missed_perception_events,
175
+ mode=TemporalAlignmentMode.FIXED
176
+ if self._vision.input_mode == ModelInputType.REALTIME
177
+ else None,
178
+ )
179
+ selection = VisualFrameSelector().select(alignment=alignment, config=self._vision)
180
+ result = ContextBuilder().build(
181
+ request=ContextBuildRequest(
182
+ base_input=request.input,
183
+ input_id=request.input_id,
184
+ alignment=alignment,
185
+ visual_selection=selection,
186
+ model_input_type=self._vision.input_mode,
187
+ ),
188
+ system_prompt=system_prompt,
189
+ runtime_context=runtime_context,
190
+ contributions=request.contributions,
191
+ query_scope=f"{request.context_id}:{request.turn_id}",
192
+ )
193
+
194
+ return snapshot_input(result.model_input)
195
+
196
+ async def prepare(self, request: ContextPrepareRequest) -> ModelInput:
197
+ self._ensure_open()
198
+ if not isinstance(request, ContextPrepareRequest):
199
+ raise TypeError("ContextManager requires ContextPrepareRequest")
200
+
201
+ if self._pending + len(self._work) >= self._config.max_pending_preparations:
202
+ raise RuntimeError("Context preparation capacity exhausted")
203
+
204
+ self._pending += 1
205
+ reserved = True
206
+ try:
207
+ request = replace(request, input=snapshot_input(request.input))
208
+ snapshot = self._runtime.turn.get(request.input_id)
209
+ if snapshot is None or snapshot.turn_id != request.turn_id:
210
+ raise ValueError("Context input does not identify the requested committed turn")
211
+ if (
212
+ request.context_id not in snapshot.context_ids
213
+ or request.context_id != self._runtime.state.context_id
214
+ ):
215
+ raise ValueError("Committed turn does not belong to the requested context")
216
+
217
+ # Standalone native Loop callers use the same acceptance path as the Engine.
218
+ self.accept_turn(snapshot)
219
+ ready = await self._runtime.turn.wait_context_ready(snapshot)
220
+ self._ensure_open()
221
+ if request.context_id != self._runtime.state.context_id:
222
+ raise ValueError("State no longer belongs to the requested context")
223
+ if not ready:
224
+ logger.debug("Context readiness timed out turn_id=%s", request.turn_id)
225
+
226
+ request = replace(
227
+ request, input=self._history.capture(snapshot, request.context_id, request.input)
228
+ )
229
+ state = self._state_snapshot()
230
+ system = self._system_template(state).instructions(stable_persona=state.user_persona)
231
+ text = RuntimeStateTemplate().render(state_runtime=state)
232
+ task = asyncio.create_task(
233
+ asyncio.to_thread(self._compose, request, snapshot, system, text),
234
+ name=f"context_prepare:{request.turn_id}",
235
+ )
236
+ self._work.add(task)
237
+ self._pending -= 1
238
+ reserved = False
239
+ task.add_done_callback(self._work.discard)
240
+ task.add_done_callback(self._observe)
241
+
242
+ # Cancellation drops this request's delivery, not ownership of an in-flight worker.
243
+ result = await asyncio.shield(task)
244
+ self._ensure_open()
245
+ return result
246
+ finally:
247
+ if reserved:
248
+ self._pending -= 1
249
+
250
+ def build(
251
+ self, prefix: ModelInput, *, continuation: tuple[ModelInputItem, ...] = ()
252
+ ) -> ModelInput:
253
+ self._ensure_open()
254
+ if not isinstance(prefix, ModelInput):
255
+ raise TypeError("ContextManager requires a captured ModelInput")
256
+
257
+ identities = {item.id for item in prefix.items}
258
+ added = {item.id for item in continuation}
259
+
260
+ if len(identities) != len(prefix.items) or len(added) != len(continuation):
261
+ raise ModelProtocolError("Context contains duplicate item identities")
262
+
263
+ if identities.intersection(added):
264
+ raise ModelProtocolError("Context continuation overlaps its captured prefix")
265
+
266
+ if any(ContextBuilder._is_runtime_context_item(item) for item in continuation):
267
+ raise ModelProtocolError("Runtime context is not canonical execution history")
268
+
269
+ return replace(prefix, items=(*prefix.items, *continuation))
270
+
271
+ async def _close(self) -> None:
272
+ await asyncio.gather(*tuple(self._work), return_exceptions=True)
273
+ self._work.clear()
274
+ try:
275
+ await self._history.aclose()
276
+ finally:
277
+ self._memory = self._persona = None
278
+
279
+ async def aclose(self) -> None:
280
+ if self._close_task is None:
281
+ self._close_task = asyncio.create_task(self._close(), name="context_manager_close")
282
+ await wait_for_cleanup(self._close_task)