alpha-avatar-plugins-loop 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,594 @@
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
20
+ from uuid import uuid4
21
+
22
+ from alphaavatar.agents.avatar.context.schemas import ContextPrepareRequest
23
+ from alphaavatar.agents.avatar.loop import LoopHandle
24
+ from alphaavatar.agents.avatar.loop.base import ToolAuthorizer
25
+ from alphaavatar.agents.avatar.loop.enums import LoopState, ToolOutcome
26
+ from alphaavatar.agents.avatar.loop.schemas import LoopIdentity, LoopRequest, LoopResult
27
+ from alphaavatar.agents.avatar.provider.enums import ModelMessagePhase, ModelRole
28
+ from alphaavatar.agents.avatar.provider.errors import ModelIncompleteError, ModelProtocolError
29
+ from alphaavatar.agents.avatar.provider.records import model_record_batch
30
+ from alphaavatar.agents.avatar.provider.schemas import (
31
+ ModelFunctionCall,
32
+ ModelInput,
33
+ ModelInputMessage,
34
+ ModelRefusalPart,
35
+ ModelRequest,
36
+ ModelTextPart,
37
+ )
38
+ from alphaavatar.agents.avatar.provider.schemas.model_input import ModelInputItem
39
+ from alphaavatar.agents.avatar.provider.schemas.stream import (
40
+ ModelItemCompleted,
41
+ ModelRefusalDelta,
42
+ ModelResponseCompleted,
43
+ ModelTextDelta,
44
+ )
45
+ from alphaavatar.core.cleanup import wait_for_cleanup
46
+ from alphaavatar.core.log.diagnostics import format_error
47
+ from alphaavatar.core.log.events import log_event
48
+ from alphaavatar.core.output.enums import ExecutionSignalKind
49
+
50
+ from .config import RealtimeLoopConfig
51
+ from .feedback import LoopFeedback
52
+ from .policy import ExecutionBudget
53
+ from .tools import LoopTools
54
+
55
+ if TYPE_CHECKING:
56
+ from alphaavatar.agents.avatar.provider.gateway import ProviderGateway
57
+ from alphaavatar.agents.runtime import AvatarRuntime
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ class LoopCommitError(RuntimeError):
63
+ """Output admission failed; never continue as though model records were retained."""
64
+
65
+
66
+ class LoopExecution(LoopHandle):
67
+ def __init__(
68
+ self,
69
+ request: LoopRequest,
70
+ config: RealtimeLoopConfig,
71
+ *,
72
+ runtime: AvatarRuntime,
73
+ gateway: ProviderGateway,
74
+ unsafe_lock: asyncio.Lock,
75
+ authorize: ToolAuthorizer | None = None,
76
+ ) -> None:
77
+ self._identity = LoopIdentity(uuid4().hex, request.turn_id, request.context_id)
78
+ self._request = request
79
+ self._config = config
80
+ self._runtime = runtime
81
+ self._gateway = gateway
82
+ self._context_manager = runtime.foundation.context
83
+ self._budget = ExecutionBudget(request, asyncio.get_running_loop().time())
84
+ self._items: list[ModelInputItem] = list(request.input.items)
85
+ self._new_items: list[ModelInputItem] = []
86
+ self._commits = 0
87
+ self._pending_calls: tuple[ModelFunctionCall, ...] = ()
88
+ self._context: ModelInput | None = None
89
+ self._generation_options = self._budget.options(config.reasoning, finalizing=False)
90
+ self._cancel_reason: str | None = None
91
+ self._started = False
92
+ self._settled = False
93
+ self._close_task: asyncio.Task[None] | None = None
94
+ self._feedback = LoopFeedback(
95
+ self.identity,
96
+ runtime,
97
+ timeout=config.feedback_timeout,
98
+ reasoning_effort=self._generation_options.reasoning_effort,
99
+ query_difficulty=request.query_difficulty,
100
+ )
101
+ self._tools = LoopTools(
102
+ self.identity,
103
+ runtime,
104
+ self._budget,
105
+ self._feedback,
106
+ unsafe_lock,
107
+ cancelled=lambda: self._cancel_reason is not None,
108
+ policies=config.tool_policies,
109
+ authorize=authorize,
110
+ )
111
+ self._definitions = self._tools.definitions
112
+ self.task = asyncio.create_task(self._run(), name=f"avatar_loop:{self.identity.run_id}")
113
+ self.task.add_done_callback(self._observe)
114
+
115
+ @property
116
+ def identity(self) -> LoopIdentity:
117
+ return self._identity
118
+
119
+ @staticmethod
120
+ def _observe(task: asyncio.Task[LoopResult]) -> None:
121
+ if not task.cancelled():
122
+ task.exception()
123
+
124
+ @staticmethod
125
+ def _answers(response: ModelResponseCompleted) -> tuple[ModelInputMessage, ...]:
126
+ return tuple(
127
+ replace(
128
+ item,
129
+ parts=tuple(
130
+ part
131
+ for part in item.parts
132
+ if isinstance(part, ModelTextPart | ModelRefusalPart)
133
+ ),
134
+ )
135
+ for item in response.items
136
+ if isinstance(item, ModelInputMessage)
137
+ and item.phase != ModelMessagePhase.COMMENTARY
138
+ and any(
139
+ isinstance(part, ModelTextPart | ModelRefusalPart) and part.text.strip()
140
+ for part in item.parts
141
+ )
142
+ )
143
+
144
+ def cancel(self, *, reason: str = "interrupted") -> None:
145
+ if self.task.done() or self._settled or self._cancel_reason is not None:
146
+ return
147
+ self._cancel_reason = reason
148
+ self._runtime.output.revoke_run(self.identity.run_id)
149
+ if self._started:
150
+ self.task.cancel()
151
+
152
+ async def wait(self) -> LoopResult:
153
+ return await asyncio.shield(self.task)
154
+
155
+ async def _commit(self, items: tuple[ModelInputItem, ...]) -> None:
156
+ if not items:
157
+ return
158
+
159
+ existing = {item.id for item in self._items}
160
+ if self._context is not None:
161
+ existing.update(item.id for item in self._context.items)
162
+
163
+ identities = {item.id for item in items}
164
+ if len(identities) != len(items) or existing.intersection(identities):
165
+ raise ModelProtocolError("Execution history contains duplicate item identities")
166
+
167
+ calls = tuple(item for item in items if isinstance(item, ModelFunctionCall))
168
+ if calls and self._pending_calls:
169
+ raise ModelProtocolError("Previous tool calls have not been settled")
170
+
171
+ # Freeze and admit atomically before changing local history or dispatching tools.
172
+ records = tuple(
173
+ replace(
174
+ item, metadata={**item.metadata, "output_id": self._feedback.output_id(item.id)}
175
+ )
176
+ if isinstance(item, ModelInputMessage)
177
+ else item
178
+ for item in items
179
+ )
180
+ batch = model_record_batch(
181
+ batch_id=f"loop:{self.identity.run_id}:{self._commits + 1}",
182
+ source="avatar.loop",
183
+ scope=self._feedback.scope,
184
+ items=records,
185
+ )
186
+ if batch is not None:
187
+ try:
188
+ self._runtime.output.publish_records(batch)
189
+ except Exception as exc:
190
+ raise LoopCommitError("Output record batch was not accepted") from exc
191
+
192
+ self._items.extend(items)
193
+ self._new_items.extend(items)
194
+ if calls:
195
+ self._pending_calls = calls
196
+
197
+ self._commits += 1
198
+ for item in items:
199
+ if (
200
+ batch is not None
201
+ and isinstance(item, ModelInputMessage)
202
+ and item.role == ModelRole.ASSISTANT
203
+ and any(isinstance(part, ModelTextPart | ModelRefusalPart) for part in item.parts)
204
+ ):
205
+ self._log(
206
+ "assistant.message",
207
+ item_id=item.id,
208
+ output_id=self._feedback.output_id(item.id),
209
+ stage="recorded",
210
+ model_step=self._budget.model_steps,
211
+ phase=item.phase.value if item.phase is not None else "unspecified",
212
+ interrupted=item.interrupted,
213
+ refusal=any(isinstance(part, ModelRefusalPart) for part in item.parts),
214
+ text_parts=(
215
+ part.text
216
+ for part in item.parts
217
+ if isinstance(part, ModelTextPart | ModelRefusalPart)
218
+ ),
219
+ )
220
+
221
+ async def _settle_pending(self, *, reason: str) -> None:
222
+ if not self._pending_calls:
223
+ return
224
+
225
+ async def settle() -> None:
226
+ calls = self._pending_calls
227
+ records = self._tools.settle(calls, reason=reason)
228
+ await self._commit(tuple(record.output for record in records))
229
+ self._pending_calls = ()
230
+
231
+ await wait_for_cleanup(asyncio.create_task(settle(), name="loop_call_settlement"))
232
+
233
+ async def _model(self, *, finalizing: bool, reason: str | None) -> ModelResponseCompleted:
234
+ if self._context is None:
235
+ raise RuntimeError("Query context was not prepared")
236
+ if self._pending_calls:
237
+ raise ModelProtocolError("Unsettled calls cannot enter another model request")
238
+
239
+ budget = self._budget
240
+ tools_allowed = (
241
+ not finalizing
242
+ and budget.tool_rounds < self._request.limits.max_tool_rounds
243
+ and budget.tool_calls < self._request.limits.max_tool_calls
244
+ )
245
+ options = self._generation_options
246
+ if finalizing:
247
+ options = options.model_copy(
248
+ update={"max_output_tokens": self._request.limits.final_output_tokens}
249
+ )
250
+
251
+ request = ModelRequest(
252
+ input=self._context_manager.build(self._context, continuation=tuple(self._new_items)),
253
+ # Preserve schemas/order even when tool use is disabled for finalization.
254
+ tools=self._definitions,
255
+ tool_choice="auto" if tools_allowed and self._definitions else "none",
256
+ parallel_tool_calls=(
257
+ bool(self._definitions) and self._request.limits.max_parallel_tools > 1
258
+ ),
259
+ options=options,
260
+ metadata={
261
+ **self._request.metadata,
262
+ "run_id": self.identity.run_id,
263
+ "turn_id": self.identity.turn_id,
264
+ "context_id": self.identity.context_id,
265
+ },
266
+ )
267
+
268
+ budget.model_steps += 1
269
+ self._feedback.model_step = budget.model_steps
270
+ self._feedback.tool_round = budget.tool_rounds
271
+ state = LoopState.FINALIZING if finalizing else LoopState.MODEL
272
+ self._feedback.begin_model(request.request_id)
273
+ await self._feedback.change(state, reason=reason)
274
+ started_at = asyncio.get_running_loop().time()
275
+ first_event_ms: float | None = None
276
+ first_text_ms: float | None = None
277
+ first_text = False
278
+ self._log(
279
+ "model.started",
280
+ request_id=request.request_id,
281
+ backend=self._config.model.backend,
282
+ provider=self._config.model.provider,
283
+ model=self._config.model.model,
284
+ requested_reasoning_effort=options.reasoning_effort,
285
+ query_difficulty=self._request.query_difficulty,
286
+ model_step=budget.model_steps,
287
+ state=state.value,
288
+ )
289
+ terminal = None
290
+ partial: dict[str, tuple[ModelMessagePhase | None, dict[int, list[str]]]] = {}
291
+ completed_messages: dict[str, ModelInputMessage] = {}
292
+ order: dict[str, int] = {}
293
+ try:
294
+ async with self._gateway.stream(task_name="avatar.loop", request=request) as events:
295
+ async for event in events:
296
+ if self._cancel_reason is not None:
297
+ raise asyncio.CancelledError
298
+ if event.request_id != request.request_id:
299
+ raise ModelProtocolError("Loop received another request's output")
300
+ if terminal is not None:
301
+ raise ModelProtocolError("Loop received output after completion")
302
+
303
+ if first_event_ms is None:
304
+ first_event_ms = (asyncio.get_running_loop().time() - started_at) * 1000
305
+ self._log(
306
+ "model.first_event",
307
+ request_id=request.request_id,
308
+ event_kind=type(event).__name__,
309
+ elapsed_ms=first_event_ms,
310
+ )
311
+
312
+ if isinstance(event, ModelTextDelta):
313
+ if not first_text and event.text:
314
+ first_text = True
315
+ first_text_ms = (asyncio.get_running_loop().time() - started_at) * 1000
316
+ self._log(
317
+ "model.first_text",
318
+ request_id=request.request_id,
319
+ response_id=event.response_id,
320
+ elapsed_ms=first_text_ms,
321
+ )
322
+ order[event.item_id] = event.output_index
323
+ entry = partial.setdefault(event.item_id, (event.phase, {}))
324
+ entry[1].setdefault(event.content_index, []).append(event.text)
325
+ await self._feedback.text(event)
326
+ elif isinstance(event, ModelRefusalDelta):
327
+ pass # Refusal is delivered as a complete typed message.
328
+ elif isinstance(event, ModelItemCompleted):
329
+ if not isinstance(event.item, ModelInputMessage):
330
+ continue
331
+ visible = replace(
332
+ event.item,
333
+ parts=tuple(
334
+ part
335
+ for part in event.item.parts
336
+ if isinstance(part, ModelTextPart | ModelRefusalPart)
337
+ ),
338
+ )
339
+ if visible.parts:
340
+ order[visible.id] = event.output_index
341
+ completed_messages[visible.id] = visible
342
+ await self._feedback.message(visible)
343
+ elif isinstance(event, ModelResponseCompleted):
344
+ terminal = event
345
+
346
+ if terminal is None:
347
+ raise ModelProtocolError("Loop model stream ended without completion")
348
+
349
+ calls = tuple(item for item in terminal.items if isinstance(item, ModelFunctionCall))
350
+ if request.tool_choice == "none" and calls:
351
+ raise ModelProtocolError("Model returned tools when invocation was disabled")
352
+
353
+ prior_calls = {
354
+ item.call_id for item in self._items if isinstance(item, ModelFunctionCall)
355
+ }
356
+ call_ids = [item.call_id for item in calls]
357
+ if len(set(call_ids)) != len(call_ids) or prior_calls.intersection(call_ids):
358
+ raise ModelProtocolError("Model reused a tool call identity")
359
+ except BaseException:
360
+ # Complete visible messages include refusal/commentary; only truncated text is partial.
361
+ visible = []
362
+ for item_id in sorted(order, key=order.__getitem__):
363
+ if item_id in completed_messages:
364
+ visible.append(completed_messages[item_id])
365
+ elif item_id in partial:
366
+ phase, chunks = partial[item_id]
367
+ if chunks:
368
+ visible.append(
369
+ ModelInputMessage(
370
+ id=item_id,
371
+ role=ModelRole.ASSISTANT,
372
+ parts=tuple(
373
+ ModelTextPart("".join(chunks[i])) for i in sorted(chunks)
374
+ ),
375
+ phase=phase,
376
+ interrupted=True,
377
+ )
378
+ )
379
+
380
+ if visible:
381
+ await self._commit(tuple(visible))
382
+
383
+ await self._feedback.interrupt_incomplete("model_stream_stopped")
384
+ raise
385
+
386
+ for item in terminal.items:
387
+ if isinstance(item, ModelInputMessage):
388
+ await self._feedback.message(item)
389
+
390
+ await self._commit(terminal.items)
391
+ if self._cancel_reason is not None:
392
+ raise asyncio.CancelledError
393
+
394
+ self._log(
395
+ "model.completed",
396
+ request_id=request.request_id,
397
+ response_id=terminal.response_id,
398
+ model_step=budget.model_steps,
399
+ output_items=len(terminal.items),
400
+ requested_reasoning_effort=options.reasoning_effort,
401
+ query_difficulty=self._request.query_difficulty,
402
+ first_event_ms=first_event_ms,
403
+ first_visible_text_ms=first_text_ms,
404
+ input_tokens=terminal.usage.input_tokens if terminal.usage else None,
405
+ output_tokens=terminal.usage.output_tokens if terminal.usage else None,
406
+ reasoning_output_tokens=terminal.usage.reasoning_output_tokens
407
+ if terminal.usage
408
+ else None,
409
+ cached_input_tokens=terminal.usage.cached_input_tokens if terminal.usage else None,
410
+ elapsed_ms=(asyncio.get_running_loop().time() - started_at) * 1000,
411
+ )
412
+ return terminal
413
+
414
+ async def _drive(self) -> tuple[LoopState, tuple[ModelInputMessage, ...], str | None]:
415
+ budget = self._budget
416
+ reason = None
417
+ while reason is None:
418
+ if self._cancel_reason is not None:
419
+ raise asyncio.CancelledError
420
+
421
+ reason = budget.should_finalize(asyncio.get_running_loop().time())
422
+ if reason is not None:
423
+ break
424
+
425
+ try:
426
+ async with asyncio.timeout_at(budget.exploration_deadline):
427
+ response = await self._model(finalizing=False, reason=None)
428
+ calls = tuple(
429
+ item for item in response.items if isinstance(item, ModelFunctionCall)
430
+ )
431
+ if calls:
432
+ budget.tool_rounds += 1
433
+ self._feedback.tool_round = budget.tool_rounds
434
+ await self._feedback.change(LoopState.TOOLS)
435
+ results = await self._tools.batch(calls)
436
+ progressed = any(r.outcome == ToolOutcome.SUCCEEDED for r in results)
437
+ budget.no_progress = 0 if progressed else budget.no_progress + 1
438
+ elif answers := self._answers(response):
439
+ refused = any(
440
+ isinstance(part, ModelRefusalPart)
441
+ for answer in answers
442
+ for part in answer.parts
443
+ )
444
+ return LoopState.REFUSED if refused else LoopState.COMPLETED, answers, None
445
+ else:
446
+ budget.no_progress += 1
447
+ except TimeoutError:
448
+ reason = "time_budget"
449
+ except ModelIncompleteError:
450
+ reason = "incomplete_response"
451
+ finally:
452
+ await self._settle_pending(
453
+ reason=reason or self._cancel_reason or "execution_stopped"
454
+ )
455
+
456
+ if self._cancel_reason is not None:
457
+ raise asyncio.CancelledError
458
+
459
+ # One answer-only request, using the same prefix, tools and reasoning profile.
460
+ async with asyncio.timeout_at(budget.deadline):
461
+ response = await self._model(finalizing=True, reason=reason)
462
+
463
+ answers = self._answers(response)
464
+ if not answers:
465
+ return LoopState.PARTIAL, (), "no_final_answer"
466
+
467
+ refused = any(isinstance(p, ModelRefusalPart) for a in answers for p in a.parts)
468
+ return LoopState.REFUSED if refused else LoopState.PARTIAL, answers, reason
469
+
470
+ def _log(self, name: str, **fields) -> None:
471
+ log_event(
472
+ logger,
473
+ name,
474
+ session_id=self._runtime.session.session_id,
475
+ context_id=self.identity.context_id,
476
+ turn_id=self.identity.turn_id,
477
+ run_id=self.identity.run_id,
478
+ **fields,
479
+ )
480
+
481
+ def _diagnose(self, error: BaseException, *, stage: str) -> None:
482
+ # Keep exception chains and code locations, never prompts, payloads or tool arguments.
483
+ logger.warning(
484
+ "Avatar loop failed run_id=%s stage=%s\n%s",
485
+ self.identity.run_id,
486
+ stage,
487
+ format_error(error, details=True),
488
+ )
489
+
490
+ async def _run(self) -> LoopResult:
491
+ self._started = True
492
+ state = LoopState.FAILED
493
+ answers: tuple[ModelInputMessage, ...] = ()
494
+ reason = error_type = None
495
+ stage = "prepare_context"
496
+ try:
497
+ if self._cancel_reason is not None:
498
+ raise asyncio.CancelledError
499
+
500
+ await self._feedback.change(LoopState.ACCEPTED)
501
+ self._log("run.started", input_id=self._request.input_id, state="accepted")
502
+ async with asyncio.timeout_at(self._budget.exploration_deadline):
503
+ preparation = ContextPrepareRequest(
504
+ input=self._request.input,
505
+ turn_id=self.identity.turn_id,
506
+ context_id=self.identity.context_id,
507
+ input_id=self._request.input_id,
508
+ contributions=(self._budget.contribution(),),
509
+ )
510
+ self._context = await self._context_manager.prepare(preparation)
511
+
512
+ if not isinstance(self._context, ModelInput):
513
+ raise TypeError("ContextManager.prepare must return ModelInput")
514
+ if self._cancel_reason is not None:
515
+ raise asyncio.CancelledError
516
+
517
+ stage = "model_and_tools"
518
+ state, answers, reason = await self._drive()
519
+ except asyncio.CancelledError as exc:
520
+ state, reason = LoopState.CANCELLED, self._cancel_reason or "cancelled"
521
+ if exc.__cause__ is not None:
522
+ error_type = type(exc.__cause__).__name__
523
+ self._diagnose(exc, stage=stage)
524
+ except Exception as exc:
525
+ error_type = type(exc).__name__
526
+ reason = "timeout" if isinstance(exc, TimeoutError) else "execution_failed"
527
+ self._diagnose(exc, stage=stage)
528
+
529
+ finally:
530
+ try:
531
+ await self._settle_pending(
532
+ reason=self._cancel_reason or reason or "execution_stopped"
533
+ )
534
+ except asyncio.CancelledError as exc:
535
+ state, reason = LoopState.CANCELLED, self._cancel_reason or "cancelled"
536
+ if exc.__cause__ is not None:
537
+ error_type = type(exc.__cause__).__name__
538
+ self._diagnose(exc, stage="record_settlement")
539
+ except Exception as exc:
540
+ state, reason, error_type = LoopState.FAILED, "history_failed", type(exc).__name__
541
+ answers = ()
542
+ self._diagnose(exc, stage="record_settlement")
543
+
544
+ try:
545
+ await self._feedback.aclose(
546
+ interrupted=state
547
+ not in {LoopState.COMPLETED, LoopState.PARTIAL, LoopState.REFUSED}
548
+ )
549
+ except asyncio.CancelledError:
550
+ state, reason = LoopState.CANCELLED, self._cancel_reason or "cancelled"
551
+ except Exception as exc:
552
+ state, reason = LoopState.FAILED, "output_cleanup_failed"
553
+ error_type = type(exc).__name__
554
+ answers = ()
555
+ self._diagnose(exc, stage="output_cleanup")
556
+ if self._cancel_reason is not None:
557
+ state, reason, answers = LoopState.CANCELLED, self._cancel_reason, ()
558
+
559
+ self._settled = True
560
+ result = LoopResult(
561
+ identity=self.identity,
562
+ state=state,
563
+ items=tuple(self._new_items),
564
+ final_messages=answers,
565
+ tools=tuple(self._tools.records),
566
+ model_steps=self._budget.model_steps,
567
+ tool_rounds=self._budget.tool_rounds,
568
+ elapsed_ms=(asyncio.get_running_loop().time() - self._budget.started_at) * 1000,
569
+ reason=reason,
570
+ error_type=error_type,
571
+ )
572
+ self._feedback.state = state
573
+ self._feedback.signal(ExecutionSignalKind.FINISHED, reason=reason)
574
+ self._log(
575
+ "run.finished",
576
+ state=state.value,
577
+ reason=reason,
578
+ error_type=error_type,
579
+ model_steps=result.model_steps,
580
+ tool_rounds=result.tool_rounds,
581
+ elapsed_ms=result.elapsed_ms,
582
+ level=logging.WARNING if error_type is not None else logging.INFO,
583
+ )
584
+ return result
585
+
586
+ async def aclose(self) -> None:
587
+ if self._close_task is None:
588
+ self.cancel(reason="closed")
589
+
590
+ async def close() -> None:
591
+ await asyncio.gather(self.task, return_exceptions=False)
592
+
593
+ self._close_task = asyncio.create_task(close(), name="loop_execution_close")
594
+ await wait_for_cleanup(self._close_task)