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.
- alpha_avatar_plugins_context-0.6.8.dist-info/METADATA +64 -0
- alpha_avatar_plugins_context-0.6.8.dist-info/RECORD +24 -0
- alpha_avatar_plugins_context-0.6.8.dist-info/WHEEL +4 -0
- alpha_avatar_plugins_context-0.6.8.dist-info/entry_points.txt +2 -0
- alpha_avatar_plugins_context-0.6.8.dist-info/licenses/LICENSE +201 -0
- alphaavatar/plugins/context/__init__.py +45 -0
- alphaavatar/plugins/context/builder/__init__.py +16 -0
- alphaavatar/plugins/context/builder/context_builder.py +193 -0
- alphaavatar/plugins/context/config.py +28 -0
- alphaavatar/plugins/context/history/__init__.py +16 -0
- alphaavatar/plugins/context/history/context_history.py +404 -0
- alphaavatar/plugins/context/manager.py +282 -0
- alphaavatar/plugins/context/prompts/avatar_system_prompts.py +137 -0
- alphaavatar/plugins/context/prompts/runtime_context_prompts.py +52 -0
- alphaavatar/plugins/context/prompts/xlm_protocol_prompts.py +33 -0
- alphaavatar/plugins/context/renderer/__init__.py +19 -0
- alphaavatar/plugins/context/renderer/base.py +35 -0
- alphaavatar/plugins/context/renderer/op.py +547 -0
- alphaavatar/plugins/context/renderer/realtime.py +31 -0
- alphaavatar/plugins/context/renderer/text.py +27 -0
- alphaavatar/plugins/context/renderer/vlm.py +27 -0
- alphaavatar/plugins/context/snapshot.py +67 -0
- alphaavatar/plugins/context/template.py +119 -0
- alphaavatar/plugins/context/version.py +14 -0
|
@@ -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)
|