agentframe-py 0.1.0__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.
- agentframe/__init__.py +170 -0
- agentframe/authoring/__init__.py +1 -0
- agentframe/authoring/agent.py +75 -0
- agentframe/authoring/context.py +123 -0
- agentframe/authoring/decorators.py +202 -0
- agentframe/authoring/definitions.py +372 -0
- agentframe/authoring/services.py +176 -0
- agentframe/cli.py +293 -0
- agentframe/errors.py +194 -0
- agentframe/hosting/__init__.py +1 -0
- agentframe/hosting/a2a_executor.py +569 -0
- agentframe/hosting/admission.py +69 -0
- agentframe/hosting/app.py +617 -0
- agentframe/hosting/card.py +138 -0
- agentframe/hosting/dedup.py +193 -0
- agentframe/hosting/draining_server.py +47 -0
- agentframe/hosting/host.py +143 -0
- agentframe/hosting/lifecycle.py +81 -0
- agentframe/hosting/model_router.py +407 -0
- agentframe/hosting/request_size.py +52 -0
- agentframe/hosting/routing.py +349 -0
- agentframe/models/__init__.py +1 -0
- agentframe/models/base.py +264 -0
- agentframe/models/foundry.py +570 -0
- agentframe/models/ollama.py +320 -0
- agentframe/models/strict_schema.py +211 -0
- agentframe/py.typed +0 -0
- agentframe/runtime/__init__.py +1 -0
- agentframe/runtime/generation.py +725 -0
- agentframe/runtime/limits.py +124 -0
- agentframe/runtime/options.py +50 -0
- agentframe/runtime/runtime.py +449 -0
- agentframe/runtime/tools.py +216 -0
- agentframe/runtime/usage.py +40 -0
- agentframe/security/__init__.py +1 -0
- agentframe/security/authentication.py +265 -0
- agentframe/security/authorization.py +66 -0
- agentframe/security/entra.py +260 -0
- agentframe/security/execution_policy.py +241 -0
- agentframe/security/principal.py +16 -0
- agentframe/testing.py +135 -0
- agentframe_py-0.1.0.dist-info/METADATA +570 -0
- agentframe_py-0.1.0.dist-info/RECORD +46 -0
- agentframe_py-0.1.0.dist-info/WHEEL +4 -0
- agentframe_py-0.1.0.dist-info/entry_points.txt +2 -0
- agentframe_py-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Validated agent, skill and tool definitions built from decorated code."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, get_args, get_origin, get_type_hints
|
|
9
|
+
|
|
10
|
+
from pydantic import (
|
|
11
|
+
ConfigDict,
|
|
12
|
+
PydanticSchemaGenerationError,
|
|
13
|
+
TypeAdapter,
|
|
14
|
+
create_model,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from agentframe.authoring.context import RunContext
|
|
18
|
+
from agentframe.authoring.services import Inject
|
|
19
|
+
from agentframe.errors import DefinitionError
|
|
20
|
+
from agentframe.models.base import ModelSettings
|
|
21
|
+
from agentframe.runtime.limits import RunLimits
|
|
22
|
+
|
|
23
|
+
# Callers can never supply these as business arguments.
|
|
24
|
+
_RESERVED_ARGUMENT_NAMES = frozenset({"ctx", "principal"})
|
|
25
|
+
|
|
26
|
+
# Model providers accept tool names of this shape (OpenAI's function-name rule).
|
|
27
|
+
_TOOL_NAME_PATTERN = re.compile(r"[a-zA-Z0-9_-]{1,64}")
|
|
28
|
+
|
|
29
|
+
# Where ``@tool`` stores a function's ``ToolDefinition``.
|
|
30
|
+
TOOL_ATTRIBUTE = "_tool_definition"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SkillMetadata:
|
|
35
|
+
"""Metadata recorded by ``@skill`` before the class is complete."""
|
|
36
|
+
|
|
37
|
+
id: str
|
|
38
|
+
name: str | None
|
|
39
|
+
description: str | None
|
|
40
|
+
tags: tuple[str, ...]
|
|
41
|
+
requires_permissions: frozenset[str]
|
|
42
|
+
limits: RunLimits | None
|
|
43
|
+
model_settings: ModelSettings | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SkillDefinition:
|
|
48
|
+
"""A validated skill: advertised metadata plus its call contract."""
|
|
49
|
+
|
|
50
|
+
id: str
|
|
51
|
+
name: str
|
|
52
|
+
description: str
|
|
53
|
+
tags: tuple[str, ...]
|
|
54
|
+
requires_permissions: frozenset[str]
|
|
55
|
+
limits: RunLimits | None
|
|
56
|
+
model_settings: ModelSettings | None
|
|
57
|
+
method_name: str
|
|
58
|
+
argument_names: tuple[str, ...]
|
|
59
|
+
injected: dict[str, object]
|
|
60
|
+
arguments_adapter: TypeAdapter[Any]
|
|
61
|
+
return_adapter: TypeAdapter[Any]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ToolDefinition:
|
|
66
|
+
"""A validated model-callable tool: its description, flags and call contract.
|
|
67
|
+
|
|
68
|
+
Built by ``@tool`` when a function is decorated. Building one grants
|
|
69
|
+
nothing: a tool is only offered to a model once registered on an agent
|
|
70
|
+
and selected by a ``ctx.generate`` call.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
description: str
|
|
75
|
+
side_effects: bool
|
|
76
|
+
idempotent: bool
|
|
77
|
+
requires_permissions: frozenset[str]
|
|
78
|
+
function: Callable[..., Any]
|
|
79
|
+
takes_context: bool
|
|
80
|
+
argument_names: tuple[str, ...]
|
|
81
|
+
injected: dict[str, object]
|
|
82
|
+
arguments_adapter: TypeAdapter[Any]
|
|
83
|
+
return_adapter: TypeAdapter[Any]
|
|
84
|
+
|
|
85
|
+
def json_schema(self) -> dict[str, Any]:
|
|
86
|
+
"""Return the JSON Schema of the business arguments a model supplies.
|
|
87
|
+
|
|
88
|
+
``ctx`` and ``Inject[T]`` parameters never appear in it.
|
|
89
|
+
"""
|
|
90
|
+
return self.arguments_adapter.json_schema()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class AgentDefinition:
|
|
95
|
+
"""A validated agent: identity plus its skills keyed by skill id."""
|
|
96
|
+
|
|
97
|
+
name: str
|
|
98
|
+
description: str
|
|
99
|
+
version: str
|
|
100
|
+
skills: dict[str, SkillDefinition]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_skill_definition(
|
|
104
|
+
agent_name: str, method: Callable[..., Any], metadata: SkillMetadata
|
|
105
|
+
) -> SkillDefinition:
|
|
106
|
+
"""Inspect a decorated skill method and build its definition.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
DefinitionError: If the method breaks a skill rule.
|
|
110
|
+
"""
|
|
111
|
+
method_name = method.__name__
|
|
112
|
+
where = f"Agent '{agent_name}': skill '{method_name}'"
|
|
113
|
+
|
|
114
|
+
if not inspect.iscoroutinefunction(method):
|
|
115
|
+
raise DefinitionError(f"{where} is not async")
|
|
116
|
+
# Drop ``self``: skills are always instance methods.
|
|
117
|
+
parameters = _named_parameters(
|
|
118
|
+
where, list(inspect.signature(method).parameters.values())[1:]
|
|
119
|
+
)
|
|
120
|
+
hints = _type_hints(where, method)
|
|
121
|
+
_check_annotations(where, parameters, hints)
|
|
122
|
+
if "ctx" not in {parameter.name for parameter in parameters}:
|
|
123
|
+
raise DefinitionError(f"{where} needs a 'ctx: RunContext' parameter")
|
|
124
|
+
_check_tags(where, metadata.tags)
|
|
125
|
+
arguments, injected = _split_parameters(where, parameters, hints)
|
|
126
|
+
|
|
127
|
+
description = metadata.description or _docstring(method)
|
|
128
|
+
if not description:
|
|
129
|
+
raise DefinitionError(f"{where} needs a description or docstring")
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
arguments_adapter = _arguments_adapter(method_name, arguments, hints)
|
|
133
|
+
return_adapter = TypeAdapter(hints["return"])
|
|
134
|
+
except PydanticSchemaGenerationError:
|
|
135
|
+
raise DefinitionError(f"{where} uses an unsupported type annotation") from None
|
|
136
|
+
|
|
137
|
+
return SkillDefinition(
|
|
138
|
+
id=metadata.id,
|
|
139
|
+
name=metadata.name or method_name.replace("_", " ").title(),
|
|
140
|
+
description=description,
|
|
141
|
+
tags=metadata.tags,
|
|
142
|
+
requires_permissions=metadata.requires_permissions,
|
|
143
|
+
limits=metadata.limits,
|
|
144
|
+
model_settings=metadata.model_settings,
|
|
145
|
+
method_name=method_name,
|
|
146
|
+
argument_names=tuple(parameter.name for parameter in arguments),
|
|
147
|
+
injected=injected,
|
|
148
|
+
arguments_adapter=arguments_adapter,
|
|
149
|
+
return_adapter=return_adapter,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_tool_definition(
|
|
154
|
+
target: object,
|
|
155
|
+
*,
|
|
156
|
+
description: str | None,
|
|
157
|
+
side_effects: bool,
|
|
158
|
+
idempotent: bool,
|
|
159
|
+
requires_permissions: frozenset[str],
|
|
160
|
+
) -> ToolDefinition:
|
|
161
|
+
"""Inspect a module-level or static function and build its tool definition.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
target: The decorated function, or a ``staticmethod`` wrapping one.
|
|
165
|
+
description: Explicit description; defaults to the docstring.
|
|
166
|
+
side_effects: Whether calling the tool has consequential effects.
|
|
167
|
+
idempotent: Whether repeating a call has no further effect.
|
|
168
|
+
requires_permissions: Permissions the caller must all hold.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
DefinitionError: If the function breaks a tool rule.
|
|
172
|
+
"""
|
|
173
|
+
function = _tool_function(target)
|
|
174
|
+
name = function.__name__
|
|
175
|
+
where = f"Tool '{name}'"
|
|
176
|
+
if not _TOOL_NAME_PATTERN.fullmatch(name):
|
|
177
|
+
raise DefinitionError(
|
|
178
|
+
f"{where}: name must be 1-64 ASCII letters, digits, '_' or '-'"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
parameters = _named_parameters(
|
|
182
|
+
where, list(inspect.signature(function).parameters.values())
|
|
183
|
+
)
|
|
184
|
+
hints = _type_hints(where, function)
|
|
185
|
+
_check_annotations(where, parameters, hints)
|
|
186
|
+
arguments, injected = _split_parameters(where, parameters, hints)
|
|
187
|
+
|
|
188
|
+
tool_description = description or _docstring(function)
|
|
189
|
+
if not tool_description:
|
|
190
|
+
raise DefinitionError(f"{where} needs a description or docstring")
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
arguments_adapter = _arguments_adapter(name, arguments, hints)
|
|
194
|
+
return_adapter = TypeAdapter(hints["return"])
|
|
195
|
+
except PydanticSchemaGenerationError:
|
|
196
|
+
raise DefinitionError(f"{where} uses an unsupported type annotation") from None
|
|
197
|
+
|
|
198
|
+
return ToolDefinition(
|
|
199
|
+
name=name,
|
|
200
|
+
description=tool_description,
|
|
201
|
+
side_effects=side_effects,
|
|
202
|
+
idempotent=idempotent,
|
|
203
|
+
requires_permissions=requires_permissions,
|
|
204
|
+
function=function,
|
|
205
|
+
takes_context="ctx" in {parameter.name for parameter in parameters},
|
|
206
|
+
argument_names=tuple(parameter.name for parameter in arguments),
|
|
207
|
+
injected=injected,
|
|
208
|
+
arguments_adapter=arguments_adapter,
|
|
209
|
+
return_adapter=return_adapter,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _tool_function(target: object) -> Callable[..., Any]:
|
|
214
|
+
"""Return the plain function behind ``target``; no lambdas or methods."""
|
|
215
|
+
is_static = isinstance(target, staticmethod)
|
|
216
|
+
# Repeats the isinstance check because mypy cannot narrow through is_static.
|
|
217
|
+
function = target.__func__ if isinstance(target, staticmethod) else target
|
|
218
|
+
if not inspect.isfunction(function):
|
|
219
|
+
raise DefinitionError(
|
|
220
|
+
f"@tool needs a module-level or static function, got {function!r}"
|
|
221
|
+
)
|
|
222
|
+
if function.__name__ == "<lambda>":
|
|
223
|
+
raise DefinitionError("@tool cannot decorate a lambda; use a named function")
|
|
224
|
+
if inspect.isgeneratorfunction(function) or inspect.isasyncgenfunction(function):
|
|
225
|
+
raise DefinitionError(
|
|
226
|
+
f"Tool '{function.__name__}': generators cannot be tools; return a value"
|
|
227
|
+
)
|
|
228
|
+
# A function written directly in a class body has a qualname "Class.name";
|
|
229
|
+
# one nested in a function has "outer.<locals>.name".
|
|
230
|
+
enclosing = function.__qualname__.split(".")[:-1]
|
|
231
|
+
if not is_static and enclosing and enclosing[-1] != "<locals>":
|
|
232
|
+
raise DefinitionError(
|
|
233
|
+
f"Tool '{function.__name__}': methods cannot be tools; use a "
|
|
234
|
+
"module-level function, or put @tool above @staticmethod"
|
|
235
|
+
)
|
|
236
|
+
return function
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def tool_definition(function: Callable[..., Any]) -> ToolDefinition:
|
|
240
|
+
"""Return the definition ``@tool`` attached to ``function``.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
DefinitionError: If ``function`` is not decorated with ``@tool``.
|
|
244
|
+
"""
|
|
245
|
+
definition = getattr(function, TOOL_ATTRIBUTE, None)
|
|
246
|
+
if not isinstance(definition, ToolDefinition):
|
|
247
|
+
raise DefinitionError(f"{function!r} is not decorated with @tool")
|
|
248
|
+
return definition
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def tools_by_name(
|
|
252
|
+
tools: Iterable[Callable[..., Any]],
|
|
253
|
+
) -> Mapping[str, ToolDefinition]:
|
|
254
|
+
"""Build an agent's read-only tool registry from ``@tool`` functions.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
DefinitionError: A function is not decorated with ``@tool``, or two
|
|
258
|
+
tools share a name.
|
|
259
|
+
"""
|
|
260
|
+
registered: dict[str, ToolDefinition] = {}
|
|
261
|
+
for function in tools:
|
|
262
|
+
definition = tool_definition(function)
|
|
263
|
+
if definition.name in registered:
|
|
264
|
+
raise DefinitionError(
|
|
265
|
+
f"Tool name '{definition.name}' is registered more than once"
|
|
266
|
+
)
|
|
267
|
+
registered[definition.name] = definition
|
|
268
|
+
return MappingProxyType(registered)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _check_tags(where: str, tags: tuple[str, ...]) -> None:
|
|
272
|
+
"""Require at least one non-blank tag.
|
|
273
|
+
|
|
274
|
+
A2A 1.0 marks ``AgentSkill.tags`` REQUIRED, and required arrays must
|
|
275
|
+
contain at least one element (spec section 5.7).
|
|
276
|
+
"""
|
|
277
|
+
if not tags:
|
|
278
|
+
raise DefinitionError(
|
|
279
|
+
f"{where} needs at least one tag; A2A 1.0 requires skill tags"
|
|
280
|
+
)
|
|
281
|
+
if any(not tag.strip() for tag in tags):
|
|
282
|
+
raise DefinitionError(f"{where} has a blank tag")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _named_parameters(
|
|
286
|
+
where: str, parameters: list[inspect.Parameter]
|
|
287
|
+
) -> list[inspect.Parameter]:
|
|
288
|
+
"""Return ``parameters``, rejecting ``*args``, ``**kwargs`` and positional-only."""
|
|
289
|
+
for parameter in parameters:
|
|
290
|
+
if parameter.kind not in (
|
|
291
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
292
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
293
|
+
):
|
|
294
|
+
raise DefinitionError(
|
|
295
|
+
f"{where}: parameter '{parameter.name}' must be a named parameter"
|
|
296
|
+
)
|
|
297
|
+
return parameters
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _split_parameters(
|
|
301
|
+
where: str, parameters: list[inspect.Parameter], hints: dict[str, Any]
|
|
302
|
+
) -> tuple[list[inspect.Parameter], dict[str, object]]:
|
|
303
|
+
"""Separate business arguments from ``Inject[T]`` services.
|
|
304
|
+
|
|
305
|
+
``ctx`` belongs to neither group: it is supplied by the runtime.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The business parameters, and the injected service types by parameter name.
|
|
309
|
+
"""
|
|
310
|
+
arguments: list[inspect.Parameter] = []
|
|
311
|
+
injected: dict[str, object] = {}
|
|
312
|
+
for parameter in parameters:
|
|
313
|
+
annotation = hints[parameter.name]
|
|
314
|
+
if parameter.name == "ctx":
|
|
315
|
+
if annotation is not RunContext:
|
|
316
|
+
raise DefinitionError(f"{where}: 'ctx' must be annotated RunContext")
|
|
317
|
+
elif get_origin(annotation) is Inject:
|
|
318
|
+
injected[parameter.name] = get_args(annotation)[0]
|
|
319
|
+
elif parameter.name in _RESERVED_ARGUMENT_NAMES:
|
|
320
|
+
raise DefinitionError(f"{where}: '{parameter.name}' is a reserved name")
|
|
321
|
+
else:
|
|
322
|
+
arguments.append(parameter)
|
|
323
|
+
return arguments, injected
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _type_hints(where: str, method: Callable[..., Any]) -> dict[str, Any]:
|
|
327
|
+
try:
|
|
328
|
+
return get_type_hints(method, include_extras=True)
|
|
329
|
+
except (NameError, TypeError) as error:
|
|
330
|
+
raise DefinitionError(
|
|
331
|
+
f"{where}: cannot resolve annotations ({error})"
|
|
332
|
+
) from None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _check_annotations(
|
|
336
|
+
where: str, parameters: list[inspect.Parameter], hints: dict[str, Any]
|
|
337
|
+
) -> None:
|
|
338
|
+
for parameter in parameters:
|
|
339
|
+
if parameter.name not in hints:
|
|
340
|
+
raise DefinitionError(f"{where}: parameter '{parameter.name}' needs a type")
|
|
341
|
+
if "return" not in hints:
|
|
342
|
+
raise DefinitionError(f"{where} needs a return annotation")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _arguments_adapter(
|
|
346
|
+
method_name: str, arguments: list[inspect.Parameter], hints: dict[str, Any]
|
|
347
|
+
) -> TypeAdapter[Any]:
|
|
348
|
+
"""Build a validator for the business arguments; unknown names are rejected."""
|
|
349
|
+
fields: dict[str, Any] = {}
|
|
350
|
+
for parameter in arguments:
|
|
351
|
+
default = (
|
|
352
|
+
... if parameter.default is inspect.Parameter.empty else parameter.default
|
|
353
|
+
)
|
|
354
|
+
fields[parameter.name] = (hints[parameter.name], default)
|
|
355
|
+
model = create_model(
|
|
356
|
+
f"{method_name}_arguments",
|
|
357
|
+
__config__=ConfigDict(extra="forbid"),
|
|
358
|
+
**fields,
|
|
359
|
+
)
|
|
360
|
+
return TypeAdapter(model)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _docstring(target: object) -> str:
|
|
364
|
+
# Read __doc__ directly: inspect.getdoc would inherit a parent's docstring.
|
|
365
|
+
doc = getattr(target, "__doc__", None)
|
|
366
|
+
return inspect.cleandoc(doc) if doc else ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def class_docstring(cls: type) -> str:
|
|
370
|
+
"""Return the docstring written on ``cls`` itself, not inherited."""
|
|
371
|
+
doc = cls.__dict__.get("__doc__")
|
|
372
|
+
return inspect.cleandoc(doc) if doc else ""
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Service registration and per-run resolution for ``Inject[T]`` parameters."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping
|
|
6
|
+
from contextlib import AsyncExitStack
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Annotated, cast
|
|
9
|
+
|
|
10
|
+
from agentframe.errors import DefinitionError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _InjectMarker:
|
|
14
|
+
"""Marks a skill parameter as an injected service."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Type checkers treat ``Inject[T]`` as ``T``. At runtime AgentFrame detects the
|
|
18
|
+
# parameter by checking ``typing.get_origin(annotation) is Inject``.
|
|
19
|
+
type Inject[T] = Annotated[T, _InjectMarker()]
|
|
20
|
+
|
|
21
|
+
type ScopedFactory[T] = (
|
|
22
|
+
Callable[[], T] | Callable[[], Awaitable[T]] | Callable[[], AsyncIterator[T]]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Services:
|
|
27
|
+
"""Registry of services that skills receive through ``Inject[T]``.
|
|
28
|
+
|
|
29
|
+
Pass an instance to ``Runtime(services=...)``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._singletons: dict[object, object] = {}
|
|
34
|
+
self._scoped: dict[object, Callable[[], object]] = {}
|
|
35
|
+
|
|
36
|
+
def add[T](self, service_type: type[T], instance: T) -> None:
|
|
37
|
+
"""Register an application-owned service shared by all runs.
|
|
38
|
+
|
|
39
|
+
AgentFrame never closes this instance; the application owns its lifecycle.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
service_type: Type that skills request with ``Inject[service_type]``.
|
|
43
|
+
instance: The service instance to inject.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
DefinitionError: If ``service_type`` is already registered.
|
|
47
|
+
"""
|
|
48
|
+
self._check_not_registered(service_type)
|
|
49
|
+
self._singletons[service_type] = instance
|
|
50
|
+
|
|
51
|
+
def add_scoped[T](self, service_type: type[T], factory: ScopedFactory[T]) -> None:
|
|
52
|
+
"""Register a service created freshly for each run.
|
|
53
|
+
|
|
54
|
+
The factory may be a sync function, an async function, or an async
|
|
55
|
+
generator function. For a generator, the code after its single ``yield``
|
|
56
|
+
is cleanup and runs when the run ends, whether it succeeded or failed.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
service_type: Type that skills request with ``Inject[service_type]``.
|
|
60
|
+
factory: Zero-argument callable that creates the service.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
DefinitionError: If ``service_type`` is already registered.
|
|
64
|
+
"""
|
|
65
|
+
self._check_not_registered(service_type)
|
|
66
|
+
self._scoped[service_type] = factory
|
|
67
|
+
|
|
68
|
+
def is_registered(self, service_type: object) -> bool:
|
|
69
|
+
"""Return whether ``service_type`` has been registered."""
|
|
70
|
+
return service_type in self._singletons or service_type in self._scoped
|
|
71
|
+
|
|
72
|
+
def scope(self, stack: AsyncExitStack) -> "ServiceScope":
|
|
73
|
+
"""Return the services of one run, cleaned up when ``stack`` closes.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
stack: Exit stack that lives exactly as long as the run.
|
|
77
|
+
"""
|
|
78
|
+
return ServiceScope(
|
|
79
|
+
MappingProxyType(self._singletons), MappingProxyType(self._scoped), stack
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _check_not_registered(self, service_type: object) -> None:
|
|
83
|
+
if self.is_registered(service_type):
|
|
84
|
+
raise DefinitionError(
|
|
85
|
+
f"Service {_type_name(service_type)} is already registered"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ServiceScope:
|
|
90
|
+
"""The services of one run, shared by its skill and the tools it calls.
|
|
91
|
+
|
|
92
|
+
Created by ``Services.scope``. Scoped services are created at most once
|
|
93
|
+
per type for the run, even when concurrent ``ctx.generate`` calls resolve
|
|
94
|
+
them at the same time. Their cleanup is registered on the run's exit
|
|
95
|
+
stack; singletons are never closed.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
singletons: Registered shared instances by type.
|
|
99
|
+
scoped: Registered scoped factories by type.
|
|
100
|
+
stack: Exit stack that lives exactly as long as the run.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
singletons: Mapping[object, object],
|
|
106
|
+
scoped: Mapping[object, Callable[[], object]],
|
|
107
|
+
stack: AsyncExitStack,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._singletons = singletons
|
|
110
|
+
self._scoped = scoped
|
|
111
|
+
self._stack = stack
|
|
112
|
+
self._created: dict[object, object] = {}
|
|
113
|
+
self._creating = asyncio.Lock()
|
|
114
|
+
|
|
115
|
+
async def resolve(self, needed: Mapping[str, object]) -> dict[str, object]:
|
|
116
|
+
"""Return service instances keyed by parameter name.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
needed: Parameter name to requested service type.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
DefinitionError: If a requested service type is not registered, or a
|
|
123
|
+
generator factory does not yield exactly once.
|
|
124
|
+
"""
|
|
125
|
+
resolved: dict[str, object] = {}
|
|
126
|
+
for parameter_name, service_type in needed.items():
|
|
127
|
+
if service_type in self._singletons:
|
|
128
|
+
resolved[parameter_name] = self._singletons[service_type]
|
|
129
|
+
continue
|
|
130
|
+
if service_type not in self._scoped:
|
|
131
|
+
raise DefinitionError(
|
|
132
|
+
f"Service {_type_name(service_type)} is not registered"
|
|
133
|
+
)
|
|
134
|
+
resolved[parameter_name] = await self._scoped_instance(service_type)
|
|
135
|
+
return resolved
|
|
136
|
+
|
|
137
|
+
async def _scoped_instance(self, service_type: object) -> object:
|
|
138
|
+
# A factory may await, so without the lock two concurrent resolves
|
|
139
|
+
# could both see the type missing and create it twice.
|
|
140
|
+
async with self._creating:
|
|
141
|
+
if service_type not in self._created:
|
|
142
|
+
factory = self._scoped[service_type]
|
|
143
|
+
self._created[service_type] = await _create_scoped(factory, self._stack)
|
|
144
|
+
return self._created[service_type]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _create_scoped(
|
|
148
|
+
factory: Callable[[], object], stack: AsyncExitStack
|
|
149
|
+
) -> object:
|
|
150
|
+
if inspect.isasyncgenfunction(factory):
|
|
151
|
+
generator = factory()
|
|
152
|
+
instance = await anext(generator)
|
|
153
|
+
stack.push_async_callback(_finish_generator, generator)
|
|
154
|
+
return instance
|
|
155
|
+
if inspect.iscoroutinefunction(factory):
|
|
156
|
+
return await cast(Callable[[], Awaitable[object]], factory)()
|
|
157
|
+
return factory()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _finish_generator(generator: AsyncGenerator[object]) -> None:
|
|
161
|
+
# Resume the generator instead of throwing the run's exception into it (as
|
|
162
|
+
# contextlib.asynccontextmanager would), so the code after ``yield`` always
|
|
163
|
+
# runs, like a pytest yield fixture.
|
|
164
|
+
try:
|
|
165
|
+
await anext(generator)
|
|
166
|
+
except StopAsyncIteration:
|
|
167
|
+
return
|
|
168
|
+
await generator.aclose()
|
|
169
|
+
raise DefinitionError("A scoped service factory must yield exactly once")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _type_name(service_type: object) -> str:
|
|
173
|
+
qualname = getattr(service_type, "__qualname__", None)
|
|
174
|
+
# Keys without a __qualname__, such as generic aliases, have a useful repr.
|
|
175
|
+
# noinspection PyStringConversionWithoutDunderMethod
|
|
176
|
+
return qualname if isinstance(qualname, str) else repr(service_type)
|