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.
Files changed (46) hide show
  1. agentframe/__init__.py +170 -0
  2. agentframe/authoring/__init__.py +1 -0
  3. agentframe/authoring/agent.py +75 -0
  4. agentframe/authoring/context.py +123 -0
  5. agentframe/authoring/decorators.py +202 -0
  6. agentframe/authoring/definitions.py +372 -0
  7. agentframe/authoring/services.py +176 -0
  8. agentframe/cli.py +293 -0
  9. agentframe/errors.py +194 -0
  10. agentframe/hosting/__init__.py +1 -0
  11. agentframe/hosting/a2a_executor.py +569 -0
  12. agentframe/hosting/admission.py +69 -0
  13. agentframe/hosting/app.py +617 -0
  14. agentframe/hosting/card.py +138 -0
  15. agentframe/hosting/dedup.py +193 -0
  16. agentframe/hosting/draining_server.py +47 -0
  17. agentframe/hosting/host.py +143 -0
  18. agentframe/hosting/lifecycle.py +81 -0
  19. agentframe/hosting/model_router.py +407 -0
  20. agentframe/hosting/request_size.py +52 -0
  21. agentframe/hosting/routing.py +349 -0
  22. agentframe/models/__init__.py +1 -0
  23. agentframe/models/base.py +264 -0
  24. agentframe/models/foundry.py +570 -0
  25. agentframe/models/ollama.py +320 -0
  26. agentframe/models/strict_schema.py +211 -0
  27. agentframe/py.typed +0 -0
  28. agentframe/runtime/__init__.py +1 -0
  29. agentframe/runtime/generation.py +725 -0
  30. agentframe/runtime/limits.py +124 -0
  31. agentframe/runtime/options.py +50 -0
  32. agentframe/runtime/runtime.py +449 -0
  33. agentframe/runtime/tools.py +216 -0
  34. agentframe/runtime/usage.py +40 -0
  35. agentframe/security/__init__.py +1 -0
  36. agentframe/security/authentication.py +265 -0
  37. agentframe/security/authorization.py +66 -0
  38. agentframe/security/entra.py +260 -0
  39. agentframe/security/execution_policy.py +241 -0
  40. agentframe/security/principal.py +16 -0
  41. agentframe/testing.py +135 -0
  42. agentframe_py-0.1.0.dist-info/METADATA +570 -0
  43. agentframe_py-0.1.0.dist-info/RECORD +46 -0
  44. agentframe_py-0.1.0.dist-info/WHEEL +4 -0
  45. agentframe_py-0.1.0.dist-info/entry_points.txt +2 -0
  46. 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)