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,426 @@
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 hashlib
18
+ import json
19
+ import logging
20
+ import time
21
+ from collections.abc import Callable, Mapping
22
+ from math import isfinite
23
+ from typing import TYPE_CHECKING, Any
24
+ from uuid import uuid4
25
+
26
+ from pydantic import BaseModel
27
+
28
+ from alphaavatar.agents.avatar.loop.base import ToolAuthorizer
29
+ from alphaavatar.agents.avatar.loop.enums import ToolOutcome
30
+ from alphaavatar.agents.avatar.loop.schemas import LoopIdentity, ToolCallContext, ToolRecord
31
+ from alphaavatar.agents.avatar.provider.schemas import (
32
+ ModelFunctionCall,
33
+ ModelFunctionOutput,
34
+ ModelMediaPart,
35
+ ModelTextPart,
36
+ ModelToolDefinition,
37
+ )
38
+ from alphaavatar.agents.runtime.capability.result import CapabilityResult
39
+ from alphaavatar.core.cleanup import wait_for_cleanup
40
+ from alphaavatar.core.output.enums import ExecutionSignalKind
41
+
42
+ from .config import ToolPolicy
43
+ from .feedback import LoopFeedback
44
+ from .policy import ExecutionBudget
45
+
46
+ if TYPE_CHECKING:
47
+ from alphaavatar.agents.runtime import AvatarRuntime
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ class ToolInputError(ValueError):
53
+ pass
54
+
55
+
56
+ def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
57
+ result: dict[str, Any] = {}
58
+ for key, value in pairs:
59
+ if key in result:
60
+ raise ToolInputError("Duplicate argument key")
61
+ result[key] = value
62
+ return result
63
+
64
+
65
+ def _constant(value: str) -> Any:
66
+ raise ToolInputError(f"Non-finite JSON number: {value}")
67
+
68
+
69
+ def _number(value: str) -> float:
70
+ number = float(value)
71
+ if not isfinite(number):
72
+ raise ToolInputError("Non-finite JSON number")
73
+ return number
74
+
75
+
76
+ def parse_arguments(value: str, *, max_bytes: int) -> dict[str, Any]:
77
+ if len(value.encode("utf-8")) > max_bytes:
78
+ raise ToolInputError("Tool arguments exceed the execution limit")
79
+ try:
80
+ result = json.loads(
81
+ value, object_pairs_hook=_object, parse_constant=_constant, parse_float=_number
82
+ )
83
+ except (ValueError, RecursionError) as exc:
84
+ raise ToolInputError("Expected finite JSON arguments with unique keys") from exc
85
+ if not isinstance(result, dict):
86
+ raise ToolInputError("Tool arguments must be a JSON object")
87
+ return result
88
+
89
+
90
+ def result_parts(value: Any, *, max_bytes: int) -> tuple[tuple[Any, ...], bool]:
91
+ if isinstance(value, CapabilityResult):
92
+ parts, is_error = value.parts, value.is_error
93
+ else:
94
+ if isinstance(value, BaseModel):
95
+ value = value.model_dump(mode="json", by_alias=True)
96
+ text = value if isinstance(value, str) else json.dumps(value, allow_nan=False)
97
+ parts, is_error = (ModelTextPart(text),), False
98
+
99
+ size = 0
100
+ for part in parts:
101
+ if isinstance(part, ModelTextPart):
102
+ size += len(part.text.encode("utf-8"))
103
+ elif isinstance(part, ModelMediaPart):
104
+ size += len(part.data or b"") + len((part.uri or "").encode("utf-8"))
105
+ else:
106
+ raise TypeError("Tool results must use explicit text or media parts")
107
+ if size > max_bytes:
108
+ raise ValueError("Tool result exceeds the execution limit")
109
+ return tuple(parts), is_error
110
+
111
+
112
+ class LoopTools:
113
+ """Scheduling and call records only. Invocation always uses the existing registry."""
114
+
115
+ def __init__(
116
+ self,
117
+ identity: LoopIdentity,
118
+ runtime: AvatarRuntime,
119
+ budget: ExecutionBudget,
120
+ feedback: LoopFeedback,
121
+ unsafe_lock: asyncio.Lock,
122
+ cancelled: Callable[[], bool],
123
+ *,
124
+ policies: Mapping[str, ToolPolicy],
125
+ authorize: ToolAuthorizer | None,
126
+ ) -> None:
127
+ self._identity = identity
128
+ self._registry = runtime.capability_registry
129
+ self._authorize = authorize
130
+ self._budget = budget
131
+ self._feedback = feedback
132
+ self._unsafe_lock = unsafe_lock
133
+ self._cancelled = cancelled
134
+
135
+ allowed = set(budget.request.allowed_capabilities)
136
+ capabilities = {c.id: c for c in self._registry.capabilities if c.callable}
137
+ if unknown := allowed - capabilities.keys():
138
+ raise ValueError(f"Unknown or description-only capabilities: {sorted(unknown)}")
139
+ self._capabilities = {capabilities[name].tool_name: capabilities[name] for name in allowed}
140
+
141
+ if unknown := set(policies) - capabilities.keys():
142
+ raise ValueError(f"Tool policies require registered capability IDs: {sorted(unknown)}")
143
+ self._policies = dict(policies)
144
+ self._signatures: dict[str, int] = {}
145
+ self._records: dict[str, ToolRecord] = {}
146
+ self.records: list[ToolRecord] = []
147
+
148
+ @property
149
+ def definitions(self) -> tuple[ModelToolDefinition, ...]:
150
+ return tuple(
151
+ ModelToolDefinition(
152
+ name=c.tool_name, description=c.description, parameters=c.parameters
153
+ )
154
+ for c in sorted(self._capabilities.values(), key=lambda c: c.id)
155
+ if (policy := self._policies.get(c.id, ToolPolicy())).enabled
156
+ and (not policy.approval_required or self._authorize is not None)
157
+ )
158
+
159
+ def _stopping(self) -> bool:
160
+ task = asyncio.current_task()
161
+ return self._cancelled() or bool(task is not None and task.cancelling())
162
+
163
+ def _policy(self, call: ModelFunctionCall) -> ToolPolicy:
164
+ capability = self._capabilities.get(call.name)
165
+ return self._policies.get(capability.id, ToolPolicy()) if capability else ToolPolicy()
166
+
167
+ def _record(
168
+ self,
169
+ call: ModelFunctionCall,
170
+ outcome: ToolOutcome,
171
+ *,
172
+ executed: bool,
173
+ started: float,
174
+ code: str | None = None,
175
+ parts: tuple[Any, ...] | None = None,
176
+ is_error: bool = True,
177
+ ) -> ToolRecord:
178
+ if parts is None:
179
+ content = {"status": outcome.value, "code": code, "executed": executed}
180
+ if outcome == ToolOutcome.UNKNOWN:
181
+ content["warning"] = "The external action may have completed; do not retry blindly."
182
+ parts = (ModelTextPart(json.dumps(content, separators=(",", ":"))),)
183
+
184
+ record = ToolRecord(
185
+ call=call,
186
+ output=ModelFunctionOutput(
187
+ id=f"tool_result:{uuid4().hex}",
188
+ call_id=call.call_id,
189
+ name=call.name,
190
+ parts=parts,
191
+ is_error=is_error,
192
+ ),
193
+ outcome=outcome,
194
+ executed=executed,
195
+ duration_ms=(time.perf_counter() - started) * 1000,
196
+ )
197
+ self._records[call.call_id] = record
198
+ self._feedback.signal(
199
+ ExecutionSignalKind.TOOL_FINISHED,
200
+ call_id=call.call_id,
201
+ tool_name=call.name,
202
+ outcome=outcome.value,
203
+ executed=executed,
204
+ reason=code,
205
+ )
206
+ return record
207
+
208
+ async def _invoke(self, call: ModelFunctionCall) -> ToolRecord:
209
+ started = time.perf_counter()
210
+ limits = self._budget.request.limits
211
+ executed = False
212
+ policy = self._policy(call)
213
+ deadline = min(
214
+ self._budget.exploration_deadline,
215
+ asyncio.get_running_loop().time() + limits.tool_timeout,
216
+ )
217
+ try:
218
+ if self._stopping():
219
+ raise asyncio.CancelledError
220
+
221
+ capability = self._capabilities.get(call.name)
222
+ if capability is None:
223
+ return self._record(
224
+ call, ToolOutcome.DENIED, executed=False, started=started, code="not_allowed"
225
+ )
226
+
227
+ if not policy.enabled:
228
+ return self._record(
229
+ call, ToolOutcome.DENIED, executed=False, started=started, code="policy_denied"
230
+ )
231
+
232
+ payload = parse_arguments(call.arguments, max_bytes=limits.max_arguments_bytes)
233
+
234
+ # Validate before authorization and before obtaining the side-effect execution slot.
235
+ capability.parse(payload)
236
+ signature = hashlib.sha256(
237
+ json.dumps([capability.id, payload], sort_keys=True, allow_nan=False).encode()
238
+ ).hexdigest()
239
+ count = self._signatures.get(signature, 0)
240
+ if count >= limits.max_same_call:
241
+ return self._record(
242
+ call, ToolOutcome.SKIPPED, executed=False, started=started, code="repeat_limit"
243
+ )
244
+ self._signatures[signature] = count + 1
245
+ # The deployment-selected registry and Run scope grant access. Read-only is an
246
+ # execution/concurrency fact, not a second permission gate. Explicit policy wins.
247
+ authorizer = self._authorize
248
+ if authorizer is None and policy.approval_required:
249
+ return self._record(
250
+ call,
251
+ ToolOutcome.DENIED,
252
+ executed=False,
253
+ started=started,
254
+ code="approval_unavailable",
255
+ )
256
+ if authorizer is not None:
257
+ remaining = deadline - asyncio.get_running_loop().time()
258
+ try:
259
+ async with asyncio.timeout(min(limits.tool_timeout, max(0, remaining))):
260
+ approved = await authorizer(
261
+ ToolCallContext(self._identity, call, capability.id)
262
+ )
263
+ if type(approved) is not bool:
264
+ raise TypeError("Tool authorizers must return bool")
265
+ except (asyncio.CancelledError, TimeoutError):
266
+ raise
267
+ except Exception:
268
+ return self._record(
269
+ call,
270
+ ToolOutcome.DENIED,
271
+ executed=False,
272
+ started=started,
273
+ code="authorization_failed",
274
+ )
275
+ if not approved:
276
+ return self._record(
277
+ call,
278
+ ToolOutcome.DENIED,
279
+ executed=False,
280
+ started=started,
281
+ code="authorization_denied",
282
+ )
283
+
284
+ async def execute() -> Any:
285
+ nonlocal executed
286
+ if self._stopping():
287
+ raise asyncio.CancelledError
288
+ if asyncio.get_running_loop().time() >= deadline:
289
+ raise TimeoutError("Tool dispatch deadline expired")
290
+ self._feedback.signal(
291
+ ExecutionSignalKind.TOOL_STARTED,
292
+ call_id=call.call_id,
293
+ tool_name=call.name,
294
+ executed=True,
295
+ )
296
+ executed = True
297
+ return await self._registry.invoke(call.name, payload)
298
+
299
+ remaining = deadline - asyncio.get_running_loop().time()
300
+ async with asyncio.timeout(min(limits.tool_timeout, max(0, remaining))):
301
+ if policy.read_only:
302
+ raw = await execute()
303
+ else:
304
+ # Serialize local write dispatch across foreground and retiring executions.
305
+ async with self._unsafe_lock:
306
+ raw = await execute()
307
+ try:
308
+ parts, is_error = result_parts(raw, max_bytes=limits.max_result_bytes)
309
+ except (ValueError, TypeError, RecursionError):
310
+ return self._record(
311
+ call,
312
+ ToolOutcome.UNKNOWN,
313
+ executed=True,
314
+ started=started,
315
+ code="result_not_representable",
316
+ )
317
+ return self._record(
318
+ call,
319
+ ToolOutcome.FAILED if is_error else ToolOutcome.SUCCEEDED,
320
+ executed=True,
321
+ started=started,
322
+ parts=parts,
323
+ is_error=is_error,
324
+ )
325
+ except asyncio.CancelledError:
326
+ uncertain = executed and not policy.read_only
327
+ return self._record(
328
+ call,
329
+ ToolOutcome.UNKNOWN if uncertain else ToolOutcome.CANCELLED,
330
+ executed=executed,
331
+ started=started,
332
+ code="cancelled",
333
+ )
334
+ except TimeoutError:
335
+ return self._record(
336
+ call,
337
+ ToolOutcome.FAILED if policy.read_only or not executed else ToolOutcome.UNKNOWN,
338
+ executed=executed,
339
+ started=started,
340
+ code="timeout",
341
+ )
342
+ except Exception as exc:
343
+ return self._record(
344
+ call,
345
+ ToolOutcome.FAILED if policy.read_only or not executed else ToolOutcome.UNKNOWN,
346
+ executed=executed,
347
+ started=started,
348
+ code="invalid_arguments" if not executed else type(exc).__name__,
349
+ )
350
+
351
+ def settle(
352
+ self, calls: tuple[ModelFunctionCall, ...], *, reason: str
353
+ ) -> tuple[ToolRecord, ...]:
354
+ """Reconcile accepted calls even when execution never entered batch()."""
355
+ known = {record.call.call_id for record in self.records}
356
+ ordered = []
357
+ for call in calls:
358
+ if call.call_id not in self._records:
359
+ self._record(
360
+ call,
361
+ ToolOutcome.SKIPPED,
362
+ executed=False,
363
+ started=time.perf_counter(),
364
+ code=reason,
365
+ )
366
+ record = self._records[call.call_id]
367
+ ordered.append(record)
368
+ if call.call_id not in known:
369
+ self.records.append(record)
370
+ known.add(call.call_id)
371
+ return tuple(ordered)
372
+
373
+ async def batch(self, calls: tuple[ModelFunctionCall, ...]) -> tuple[ToolRecord, ...]:
374
+ identities = [call.call_id for call in calls]
375
+ if len(set(identities)) != len(identities) or any(i in self._records for i in identities):
376
+ raise ValueError("Repeated call identity must never execute twice")
377
+
378
+ tasks: list[asyncio.Task[ToolRecord]] = []
379
+ active: list[asyncio.Task[ToolRecord]] = []
380
+ try:
381
+ for call in calls:
382
+ if self._stopping():
383
+ raise asyncio.CancelledError
384
+
385
+ budget = self._budget
386
+ if budget.tool_calls >= budget.request.limits.max_tool_calls:
387
+ self._record(
388
+ call,
389
+ ToolOutcome.SKIPPED,
390
+ executed=False,
391
+ started=time.perf_counter(),
392
+ code="tool_call_budget",
393
+ )
394
+ continue
395
+
396
+ budget.tool_calls += 1
397
+ policy = self._policy(call)
398
+ parallel = policy.read_only and policy.parallel_safe
399
+ at_capacity = len(active) >= budget.request.limits.max_parallel_tools
400
+ if active and (not parallel or at_capacity):
401
+ await asyncio.shield(asyncio.gather(*active))
402
+ active.clear()
403
+ if self._stopping():
404
+ raise asyncio.CancelledError
405
+
406
+ task = asyncio.create_task(self._invoke(call), name=f"loop_tool:{call.call_id}")
407
+ tasks.append(task)
408
+ active.append(task)
409
+ if not parallel:
410
+ await asyncio.shield(task)
411
+ active.clear()
412
+ if active:
413
+ await asyncio.shield(asyncio.gather(*active))
414
+
415
+ finally:
416
+
417
+ async def finish() -> None:
418
+ for task in tasks:
419
+ if not task.done():
420
+ task.cancel()
421
+ await asyncio.gather(*tasks, return_exceptions=True)
422
+
423
+ await wait_for_cleanup(asyncio.create_task(finish(), name="loop_tool_batch_close"))
424
+
425
+ # The execution owner commits all results, including unstarted/cancelled calls.
426
+ return self.settle(calls, reason="execution_stopped")
@@ -0,0 +1,14 @@
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
+ __version__ = "0.6.8"