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.
- alpha_avatar_plugins_loop-0.6.8.dist-info/METADATA +75 -0
- alpha_avatar_plugins_loop-0.6.8.dist-info/RECORD +13 -0
- alpha_avatar_plugins_loop-0.6.8.dist-info/WHEEL +4 -0
- alpha_avatar_plugins_loop-0.6.8.dist-info/entry_points.txt +2 -0
- alpha_avatar_plugins_loop-0.6.8.dist-info/licenses/LICENSE +201 -0
- alphaavatar/plugins/loop/__init__.py +38 -0
- alphaavatar/plugins/loop/config.py +79 -0
- alphaavatar/plugins/loop/execution.py +594 -0
- alphaavatar/plugins/loop/feedback.py +273 -0
- alphaavatar/plugins/loop/loop.py +157 -0
- alphaavatar/plugins/loop/policy.py +91 -0
- alphaavatar/plugins/loop/tools.py +426 -0
- alphaavatar/plugins/loop/version.py +14 -0
|
@@ -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"
|