ygo74-agent-runtime-agents 0.1.0__tar.gz
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.
- ygo74_agent_runtime_agents-0.1.0/PKG-INFO +15 -0
- ygo74_agent_runtime_agents-0.1.0/pyproject.toml +61 -0
- ygo74_agent_runtime_agents-0.1.0/setup.cfg +4 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/agent_http_settings.py +120 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/models.py +55 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/validator.py +12 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/capability_registry.py +102 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/contract_errors.py +22 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/conversation.py +77 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/error_envelope.py +12 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/exchange_models.py +24 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/manifests.py +75 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/stream_events.py +12 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/__init__.py +7 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/agent_access_policy.py +59 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/agent_descriptor.py +317 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/anthropic_model_projection.py +56 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/capability_extensions.py +47 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/capability_validator.py +53 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/descriptor_binding.py +32 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/descriptor_defaults.py +76 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/descriptor_registry.py +97 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/dialect_selector.py +73 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/discovery_configuration.py +252 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/discovery_errors.py +142 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/manifest_descriptor.py +221 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/model_route_resolver.py +28 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/openai_model_projection.py +40 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/pagination.py +103 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/adapters.py +24 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/conversation_payloads.py +157 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/fastapi_endpoints.py +775 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/header_forwarding.py +160 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/handlers/handler_protocol.py +11 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/handlers/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/handlers/response_validator.py +12 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/approval_errors.py +40 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/broker.py +60 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/commands.py +77 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/confirmation.py +315 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/confirmed_operations.py +122 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/gated_operations.py +229 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/ledger.py +54 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/pending_renderer.py +79 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/tickets.py +206 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/unattended.py +44 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mapping/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mapping/request_mapper.py +9 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mapping/response_mapper.py +165 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/binding.py +395 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/dialects.py +76 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/mcp_errors.py +25 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/oauth.py +182 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/sessions/conversation_cache.py +257 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/sessions/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/streaming/openai_stream_mapper.py +10 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/streaming/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/streaming/stream_termination.py +17 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/interfaces.py +16 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/middleware_errors.py +6 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/pipeline.py +12 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/registry.py +21 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/observability/logging_config.py +8 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/observability/otel.py +3 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/observability/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/dispatcher.py +17 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/dispatcher_impl.py +12 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/py.typed +0 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/route_registry.py +21 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/routing_errors.py +6 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/PKG-INFO +15 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/SOURCES.txt +80 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/dependency_links.txt +1 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/requires.txt +12 -0
- ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ygo74-agent-runtime-agents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent hosting for the ygo74 agent runtime: endpoints, discovery, sessions and human approval
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: ygo74-agent-runtime-security==0.1.0
|
|
7
|
+
Requires-Dist: pydantic>=2.7
|
|
8
|
+
Requires-Dist: typing-extensions>=4.12
|
|
9
|
+
Provides-Extra: http
|
|
10
|
+
Requires-Dist: fastapi>=0.115; extra == "http"
|
|
11
|
+
Requires-Dist: uvicorn[standard]>=0.30; extra == "http"
|
|
12
|
+
Provides-Extra: mcp
|
|
13
|
+
Requires-Dist: mcp<2,>=1.24; extra == "mcp"
|
|
14
|
+
Requires-Dist: httpx>=0.27; extra == "mcp"
|
|
15
|
+
Requires-Dist: PyYAML>=6; extra == "mcp"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ygo74-agent-runtime-agents"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Agent hosting for the ygo74 agent runtime: endpoints, discovery, sessions and human approval"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
|
|
11
|
+
# Everything needed to host an agent: the contracts, the capability registry, the
|
|
12
|
+
# OpenAI/Anthropic endpoints, discovery, the conversation cache, the human-approval
|
|
13
|
+
# domain, and the MCP *client* an agent uses to reach tools.
|
|
14
|
+
dependencies = [
|
|
15
|
+
"ygo74-agent-runtime-security==0.1.0",
|
|
16
|
+
"pydantic>=2.7",
|
|
17
|
+
"typing-extensions>=4.12",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
# Serving the endpoints over HTTP. `fastapi` is imported defensively in
|
|
22
|
+
# `domains.endpoints.fastapi_endpoints`, so a host that only needs the contracts or
|
|
23
|
+
# the discovery descriptors installs none of this.
|
|
24
|
+
http = [
|
|
25
|
+
"fastapi>=0.115",
|
|
26
|
+
"uvicorn[standard]>=0.30",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Reaching tools through the Model Context Protocol - the client side. Only
|
|
30
|
+
# `domains.mcp` needs it, and that package imports the SDK defensively for the same
|
|
31
|
+
# reason `http` does.
|
|
32
|
+
#
|
|
33
|
+
# The upper bound is deliberate and was earned: `mcp` 2.0 moved to `httpx2` and
|
|
34
|
+
# removed `streamablehttp_client`, so an unbounded floor silently installed a major
|
|
35
|
+
# version this code cannot drive. Raising it is a migration, not a version bump.
|
|
36
|
+
mcp = [
|
|
37
|
+
"mcp>=1.24,<2",
|
|
38
|
+
"httpx>=0.27",
|
|
39
|
+
"PyYAML>=6",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
include = ["ygo74*"]
|
|
44
|
+
namespaces = true
|
|
45
|
+
|
|
46
|
+
# One marker per package this distribution alone owns. See the security
|
|
47
|
+
# distribution's pyproject for why it cannot sit higher up.
|
|
48
|
+
[tool.setuptools.package-data]
|
|
49
|
+
"ygo74.agent_runtime.domains.configuration" = ["py.typed"]
|
|
50
|
+
"ygo74.agent_runtime.domains.contracts" = ["py.typed"]
|
|
51
|
+
"ygo74.agent_runtime.domains.discovery" = ["py.typed"]
|
|
52
|
+
"ygo74.agent_runtime.domains.endpoints" = ["py.typed"]
|
|
53
|
+
"ygo74.agent_runtime.domains.handlers" = ["py.typed"]
|
|
54
|
+
"ygo74.agent_runtime.domains.humanapproval" = ["py.typed"]
|
|
55
|
+
"ygo74.agent_runtime.domains.mapping" = ["py.typed"]
|
|
56
|
+
"ygo74.agent_runtime.domains.mcp" = ["py.typed"]
|
|
57
|
+
"ygo74.agent_runtime.domains.sessions" = ["py.typed"]
|
|
58
|
+
"ygo74.agent_runtime.domains.streaming" = ["py.typed"]
|
|
59
|
+
"ygo74.agent_runtime.middleware" = ["py.typed"]
|
|
60
|
+
"ygo74.agent_runtime.observability" = ["py.typed"]
|
|
61
|
+
"ygo74.agent_runtime.routing" = ["py.typed"]
|
ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/agent_http_settings.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""How an agent is served over HTTP.
|
|
2
|
+
|
|
3
|
+
Routing and posture only. No credential is read, held or logged here: a
|
|
4
|
+
demonstration key is compared, never stored anywhere it could be printed, and
|
|
5
|
+
tokens are validated against a published key set.
|
|
6
|
+
|
|
7
|
+
Two things are deliberate.
|
|
8
|
+
|
|
9
|
+
The settings are a plain ``pydantic`` model with an explicit
|
|
10
|
+
:meth:`AgentHttpSettings.from_env` rather than a ``pydantic-settings`` class.
|
|
11
|
+
Reading configuration is a host's business, and a library that imposed a settings
|
|
12
|
+
framework on every consumer would be trading their choice for a few saved lines.
|
|
13
|
+
A host already using ``pydantic-settings`` can still feed this model.
|
|
14
|
+
|
|
15
|
+
And the key set is *discovered* rather than derived. Appending a path to the
|
|
16
|
+
issuer only works for one provider; asking the issuer works for all of them. An
|
|
17
|
+
explicit override still wins, because an operator who names a URL has a reason.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from datetime import timedelta
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
26
|
+
from typing_extensions import Self
|
|
27
|
+
from ygo74.agent_runtime.domains.auth.oidc_discovery import OidcDiscovery
|
|
28
|
+
|
|
29
|
+
DEFAULT_MAX_CONVERSATIONS = 200
|
|
30
|
+
DEFAULT_IDLE_MINUTES = 30
|
|
31
|
+
DEFAULT_ROLES_CLAIM_PATH = "realm_access.roles"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentHttpSettings(BaseModel):
|
|
35
|
+
"""The posture one agent is served with.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
api_key: Demonstration mode - one key, one caller. Ignored once an issuer
|
|
39
|
+
is configured: two ways in is one too many.
|
|
40
|
+
oidc_issuer: The identity provider tokens are validated against.
|
|
41
|
+
oidc_audience: The audience those tokens must carry.
|
|
42
|
+
jwks_url_override: A key-set URL named explicitly, which wins over
|
|
43
|
+
discovery.
|
|
44
|
+
roles_claim_path: Where roles sit in a token.
|
|
45
|
+
max_conversations: Upper bound on conversations kept in memory.
|
|
46
|
+
idle_minutes: How long an untouched conversation is kept.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
50
|
+
|
|
51
|
+
api_key: str = ""
|
|
52
|
+
oidc_issuer: str = ""
|
|
53
|
+
oidc_audience: str = ""
|
|
54
|
+
jwks_url_override: str = ""
|
|
55
|
+
roles_claim_path: str = DEFAULT_ROLES_CLAIM_PATH
|
|
56
|
+
max_conversations: int = Field(default=DEFAULT_MAX_CONVERSATIONS, ge=1)
|
|
57
|
+
idle_minutes: int = Field(default=DEFAULT_IDLE_MINUTES, ge=1)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_env(
|
|
61
|
+
cls,
|
|
62
|
+
prefix: str,
|
|
63
|
+
*,
|
|
64
|
+
default_audience: str = "",
|
|
65
|
+
environment: dict[str, str] | None = None,
|
|
66
|
+
) -> Self:
|
|
67
|
+
"""Read the settings of one agent from prefixed environment variables.
|
|
68
|
+
|
|
69
|
+
``prefix`` is what distinguishes two agents served by the same process -
|
|
70
|
+
``MAIL_AGENT_HTTP_``, ``WIKI_AGENT_HTTP_``. The key-set override is also
|
|
71
|
+
accepted as ``<prefix>JWKS_URL``, which is what an operator would guess.
|
|
72
|
+
"""
|
|
73
|
+
source = environment if environment is not None else dict(os.environ)
|
|
74
|
+
|
|
75
|
+
def read(name: str, fallback: str = "") -> str:
|
|
76
|
+
return str(source.get(f"{prefix}{name}", fallback)).strip()
|
|
77
|
+
|
|
78
|
+
def read_int(name: str, fallback: int) -> int:
|
|
79
|
+
raw = read(name)
|
|
80
|
+
return int(raw) if raw else fallback
|
|
81
|
+
|
|
82
|
+
return cls(
|
|
83
|
+
api_key=read("API_KEY"),
|
|
84
|
+
oidc_issuer=read("OIDC_ISSUER"),
|
|
85
|
+
oidc_audience=read("OIDC_AUDIENCE", default_audience),
|
|
86
|
+
jwks_url_override=read("JWKS_URL") or read("JWKS_URL_OVERRIDE"),
|
|
87
|
+
roles_claim_path=read("ROLES_CLAIM_PATH", DEFAULT_ROLES_CLAIM_PATH),
|
|
88
|
+
max_conversations=read_int("MAX_CONVERSATIONS", DEFAULT_MAX_CONVERSATIONS),
|
|
89
|
+
idle_minutes=read_int("IDLE_MINUTES", DEFAULT_IDLE_MINUTES),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def uses_oidc(self) -> bool:
|
|
94
|
+
"""Whether tokens are validated against an identity provider."""
|
|
95
|
+
return bool(self.oidc_issuer)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def identifies_a_caller(self) -> bool:
|
|
99
|
+
"""Whether anything at all would establish who is calling.
|
|
100
|
+
|
|
101
|
+
A service that cannot answer this has no subject to partition state by,
|
|
102
|
+
and would serve one person's view to whoever asked first. Hosts are
|
|
103
|
+
expected to refuse to start rather than run open, which is a decision this
|
|
104
|
+
model reports rather than makes.
|
|
105
|
+
"""
|
|
106
|
+
return self.uses_oidc or bool(self.api_key)
|
|
107
|
+
|
|
108
|
+
def jwks_url(self, discovery: OidcDiscovery | None = None) -> str:
|
|
109
|
+
"""Where the signing keys of the issuer are published.
|
|
110
|
+
|
|
111
|
+
An explicit override wins. Otherwise the issuer is asked, through its
|
|
112
|
+
OpenID configuration document.
|
|
113
|
+
"""
|
|
114
|
+
if self.jwks_url_override:
|
|
115
|
+
return self.jwks_url_override
|
|
116
|
+
return (discovery or OidcDiscovery()).jwks_url(self.oidc_issuer)
|
|
117
|
+
|
|
118
|
+
def idle_lifetime(self) -> timedelta:
|
|
119
|
+
"""How long an untouched conversation is kept."""
|
|
120
|
+
return timedelta(minutes=self.idle_minutes)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ygo74.agent_runtime.domains.discovery.agent_descriptor import AgentDescriptor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class EndpointConfiguration:
|
|
12
|
+
route_key: str
|
|
13
|
+
enable_chat_completions: bool = False
|
|
14
|
+
enable_responses: bool = False
|
|
15
|
+
enable_anthropic_messages: bool = False
|
|
16
|
+
enable_streaming: bool = False
|
|
17
|
+
agent_descriptor: AgentDescriptor | None = None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_dict(cls, source: Mapping[str, Any]) -> EndpointConfiguration:
|
|
21
|
+
"""Bind from a framework-native configuration section.
|
|
22
|
+
|
|
23
|
+
The optional ``agentDescriptor`` section is the declarative form of the
|
|
24
|
+
discovery single source of truth, so a host declares identity and
|
|
25
|
+
capabilities in the same settings block that enables the endpoints.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
raw_descriptor = source.get("agentDescriptor")
|
|
29
|
+
return cls(
|
|
30
|
+
route_key=str(source["routeKey"]),
|
|
31
|
+
enable_chat_completions=bool(source.get("enableChatCompletions", False)),
|
|
32
|
+
enable_responses=bool(source.get("enableResponses", False)),
|
|
33
|
+
enable_anthropic_messages=bool(source.get("enableAnthropicMessages", False)),
|
|
34
|
+
enable_streaming=bool(source.get("enableStreaming", False)),
|
|
35
|
+
agent_descriptor=(
|
|
36
|
+
AgentDescriptor.from_dict(raw_descriptor) if isinstance(raw_descriptor, Mapping) else None
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class RuntimeConfiguration:
|
|
43
|
+
"""Aggregate of every configured route, keyed for descriptor validation."""
|
|
44
|
+
|
|
45
|
+
endpoints: tuple[EndpointConfiguration, ...] = field(default_factory=tuple)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def by_route_key(self) -> dict[str, EndpointConfiguration]:
|
|
49
|
+
return {endpoint.route_key: endpoint for endpoint in self.endpoints}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def declared_descriptors(self) -> tuple[AgentDescriptor, ...]:
|
|
53
|
+
return tuple(
|
|
54
|
+
endpoint.agent_descriptor for endpoint in self.endpoints if endpoint.agent_descriptor is not None
|
|
55
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from ygo74.agent_runtime.domains.configuration.models import EndpointConfiguration
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def validate_configuration(config: EndpointConfiguration) -> None:
|
|
5
|
+
if not config.route_key:
|
|
6
|
+
raise ValueError("route_key is required")
|
|
7
|
+
|
|
8
|
+
if not (config.enable_chat_completions or config.enable_responses or config.enable_anthropic_messages):
|
|
9
|
+
raise ValueError("At least one endpoint surface must be enabled")
|
|
10
|
+
|
|
11
|
+
if config.enable_streaming and not (config.enable_chat_completions or config.enable_responses or config.enable_anthropic_messages):
|
|
12
|
+
raise ValueError("Streaming requires at least one enabled endpoint")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Registry of the capabilities an agent exposes.
|
|
2
|
+
|
|
3
|
+
This is deliberately not an abstraction over an agent. It is the list of
|
|
4
|
+
capabilities an orchestrator needs in order to build its own tools: a name, a
|
|
5
|
+
description, an argument schema, the security posture and the coroutine that runs
|
|
6
|
+
the capability.
|
|
7
|
+
|
|
8
|
+
Microsoft Agent Framework, LangChain and CrewAI can each build their tools from
|
|
9
|
+
this same registry, which is what keeps a set of capabilities from being written
|
|
10
|
+
three times. Building the agent itself is left to each framework, in its own
|
|
11
|
+
documented way.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
from ygo74.agent_runtime.domains.contracts.manifests import SkillManifest
|
|
22
|
+
from ygo74.agent_runtime.domains.security.operations import ToolOperationDescriptor
|
|
23
|
+
from ygo74.agent_runtime.domains.security.user_context import UserContext
|
|
24
|
+
|
|
25
|
+
SkillInvocation = Callable[[BaseModel, UserContext], Awaitable[BaseModel]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResultRenderer(Protocol):
|
|
29
|
+
"""Turns the typed result of a capability into the text a model reads.
|
|
30
|
+
|
|
31
|
+
Each agent renders its own results: what a search should look like in a
|
|
32
|
+
conversation is a domain decision, not a framework one. A framework adapter
|
|
33
|
+
asks for this and nothing more, which is what keeps it usable by the next
|
|
34
|
+
agent without a change.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def render(self, result: BaseModel | Sequence[BaseModel]) -> str:
|
|
38
|
+
"""Render a capability result as text."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class SkillDescriptor:
|
|
44
|
+
"""One capability, ready to be turned into a tool.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
tool_name: Name advertised to the model.
|
|
48
|
+
description: What the capability does and what it does not do. The model
|
|
49
|
+
selects tools from this text, so it is part of the contract.
|
|
50
|
+
input_model: Schema the model must fill in. A contract, hence code.
|
|
51
|
+
operation: Security metadata driving the confirmation policy.
|
|
52
|
+
invoke: Coroutine executing the capability.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
tool_name: str
|
|
56
|
+
description: str
|
|
57
|
+
input_model: type[BaseModel]
|
|
58
|
+
operation: ToolOperationDescriptor
|
|
59
|
+
invoke: SkillInvocation
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
"""Reject a descriptor whose declared name does not match its operation."""
|
|
63
|
+
if self.tool_name != self.operation.tool_name:
|
|
64
|
+
raise ValueError(f"skill {self.tool_name!r} declares the operation of {self.operation.tool_name!r}")
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_manifest(
|
|
68
|
+
cls,
|
|
69
|
+
manifest: SkillManifest,
|
|
70
|
+
input_model: type[BaseModel],
|
|
71
|
+
invoke: SkillInvocation,
|
|
72
|
+
) -> SkillDescriptor:
|
|
73
|
+
"""Bind a delivered manifest to the code that runs it."""
|
|
74
|
+
return cls(
|
|
75
|
+
tool_name=manifest.tool_name,
|
|
76
|
+
description=manifest.description,
|
|
77
|
+
input_model=input_model,
|
|
78
|
+
operation=manifest.operation,
|
|
79
|
+
invoke=invoke,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class SkillRegistry:
|
|
85
|
+
"""The capabilities available to one agent, in the order they are offered."""
|
|
86
|
+
|
|
87
|
+
skills: Sequence[SkillDescriptor]
|
|
88
|
+
|
|
89
|
+
def skill(self, tool_name: str) -> SkillDescriptor:
|
|
90
|
+
"""Return one capability by name."""
|
|
91
|
+
for descriptor in self.skills:
|
|
92
|
+
if descriptor.tool_name == tool_name:
|
|
93
|
+
return descriptor
|
|
94
|
+
raise KeyError(f"no skill named {tool_name!r} is registered")
|
|
95
|
+
|
|
96
|
+
def write_skills(self) -> tuple[SkillDescriptor, ...]:
|
|
97
|
+
"""Capabilities that change external state.
|
|
98
|
+
|
|
99
|
+
Whether such a capability is actually gated depends on the confirmation
|
|
100
|
+
policy evaluated for a given caller, which lives outside the registry.
|
|
101
|
+
"""
|
|
102
|
+
return tuple(descriptor for descriptor in self.skills if descriptor.operation.is_write)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Errors raised when a request does not satisfy an agent contract.
|
|
2
|
+
|
|
3
|
+
Separate from the authentication and authorization errors: nothing here says a
|
|
4
|
+
caller was refused, only that what arrived cannot be answered as written.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentContractError(Exception):
|
|
11
|
+
"""Base class for a request that violates an agent contract."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EmptyRequestError(AgentContractError):
|
|
15
|
+
"""Raised when a request carries nothing for the agent to answer.
|
|
16
|
+
|
|
17
|
+
Reported rather than answered with silence: an agent replying to an empty
|
|
18
|
+
message would look like a model failure instead of a malformed request.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
super().__init__("the request carried no user message")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""What a serving surface needs from an agent, and nothing more.
|
|
2
|
+
|
|
3
|
+
An HTTP surface should not know which agentic framework answers it, and an agent
|
|
4
|
+
should not know which protocol carried the question. Between the two sits one
|
|
5
|
+
port: a turn goes in, a reply comes out.
|
|
6
|
+
|
|
7
|
+
Keeping it this narrow is what lets several frameworks be compared honestly.
|
|
8
|
+
Microsoft Agent Framework, LangChain and CrewAI each provide an implementation;
|
|
9
|
+
everything in front of it - identity, transport, session isolation - is written
|
|
10
|
+
once and measured once, so a benchmark compares the frameworks rather than three
|
|
11
|
+
home-grown web layers.
|
|
12
|
+
|
|
13
|
+
Nothing here imports a framework or a transport.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from ygo74.agent_runtime.domains.auth.agent_principal import AgentPrincipal
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ConversationTurn:
|
|
26
|
+
"""One question, from an identified person, in a named conversation.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
principal: Who is asking. Established by the transport, never by the
|
|
30
|
+
message, and never carrying a credential.
|
|
31
|
+
conversation_id: What this turn continues. Supplied by the caller, so it
|
|
32
|
+
is a routing handle rather than a permission: it selects state only
|
|
33
|
+
once combined with the authenticated subject.
|
|
34
|
+
message: What the person wrote, verbatim.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
principal: AgentPrincipal
|
|
38
|
+
conversation_id: str
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def key(self) -> tuple[str, str]:
|
|
43
|
+
"""The pair that identifies this conversation's state.
|
|
44
|
+
|
|
45
|
+
The subject comes first because it is the part that was authenticated:
|
|
46
|
+
no conversation identifier can reach state belonging to someone else.
|
|
47
|
+
"""
|
|
48
|
+
return (self.principal.subject, self.conversation_id)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class AgentReply:
|
|
53
|
+
"""What the agent answered, and what it left waiting.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
text: The answer, as the person should read it.
|
|
57
|
+
pending_confirmations: Identifiers of operations described but *not*
|
|
58
|
+
performed, awaiting an explicit answer. Empty means nothing is
|
|
59
|
+
waiting - never that everything succeeded.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
text: str
|
|
63
|
+
pending_confirmations: tuple[str, ...] = ()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def awaits_confirmation(self) -> bool:
|
|
67
|
+
"""Whether this turn left something unperformed on purpose."""
|
|
68
|
+
return bool(self.pending_confirmations)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@runtime_checkable
|
|
72
|
+
class ConversationEngine(Protocol):
|
|
73
|
+
"""Answers one turn of a conversation."""
|
|
74
|
+
|
|
75
|
+
async def respond(self, turn: ConversationTurn) -> AgentReply:
|
|
76
|
+
"""Return the agent's answer to a turn."""
|
|
77
|
+
...
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ygo74.agent_runtime.domains.contracts.error_envelope import ErrorEnvelope
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class StandardExchangeRequest:
|
|
9
|
+
request_id: str
|
|
10
|
+
route_key: str
|
|
11
|
+
endpoint_type: str
|
|
12
|
+
input: Any
|
|
13
|
+
stream: bool = False
|
|
14
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
auth_context: dict[str, Any] | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class StandardExchangeResponse:
|
|
20
|
+
request_id: str
|
|
21
|
+
status: str
|
|
22
|
+
output: Any | None = None
|
|
23
|
+
error: ErrorEnvelope | None = None
|
|
24
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Typed description of a skill and of an agent.
|
|
2
|
+
|
|
3
|
+
A manifest is the resolved, validated form of what configuration declares:
|
|
4
|
+
identity, description, security posture and, for a skill driven by a language
|
|
5
|
+
model, its prompt. It carries no file format, no framework type and no callable,
|
|
6
|
+
so the same manifests drive one orchestrator today and another tomorrow.
|
|
7
|
+
|
|
8
|
+
What a manifest deliberately does not carry: the deterministic logic of a skill
|
|
9
|
+
and the schema of its arguments. Both are contracts expressed in code.
|
|
10
|
+
|
|
11
|
+
How a manifest is *loaded* - from YAML, from a database, from a service - is a
|
|
12
|
+
host decision and stays outside this library.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
from ygo74.agent_runtime.domains.security.operations import ToolOperationDescriptor
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SkillManifest(BaseModel):
|
|
22
|
+
"""What configuration declares about one capability.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
tool_name: Name advertised to the model.
|
|
26
|
+
implementation: Key binding the manifest to the code that runs it.
|
|
27
|
+
description: What the capability does and does not do. The model selects
|
|
28
|
+
tools from this text, so it is part of the contract.
|
|
29
|
+
operation: Security metadata driving the confirmation policy.
|
|
30
|
+
mcp_tools: Logical tools the capability is allowed to use.
|
|
31
|
+
prompt: Reasoning instructions. Empty for a deterministic capability.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
35
|
+
|
|
36
|
+
tool_name: str = Field(min_length=1)
|
|
37
|
+
implementation: str = Field(min_length=1)
|
|
38
|
+
description: str = Field(min_length=1)
|
|
39
|
+
operation: ToolOperationDescriptor
|
|
40
|
+
mcp_tools: tuple[str, ...] = ()
|
|
41
|
+
prompt: str = ""
|
|
42
|
+
|
|
43
|
+
@model_validator(mode="after")
|
|
44
|
+
def _check_operation_matches(self) -> SkillManifest:
|
|
45
|
+
"""Reject a manifest declaring the security posture of another tool."""
|
|
46
|
+
if self.operation.tool_name != self.tool_name:
|
|
47
|
+
raise ValueError(f"skill {self.tool_name!r} declares the operation of {self.operation.tool_name!r}")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_reasoning(self) -> bool:
|
|
52
|
+
"""Whether the capability drives a language model."""
|
|
53
|
+
return bool(self.prompt)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AgentManifest(BaseModel):
|
|
57
|
+
"""What configuration declares about one agent.
|
|
58
|
+
|
|
59
|
+
``skills`` lists the capabilities the agent exposes, in the order they are
|
|
60
|
+
offered to the model.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
64
|
+
|
|
65
|
+
name: str = Field(min_length=1)
|
|
66
|
+
description: str = Field(min_length=1)
|
|
67
|
+
instructions: str = Field(min_length=1)
|
|
68
|
+
skills: tuple[SkillManifest, ...] = ()
|
|
69
|
+
|
|
70
|
+
def skill(self, tool_name: str) -> SkillManifest:
|
|
71
|
+
"""Return one declared capability by name."""
|
|
72
|
+
for manifest in self.skills:
|
|
73
|
+
if manifest.tool_name == tool_name:
|
|
74
|
+
return manifest
|
|
75
|
+
raise KeyError(f"agent {self.name!r} declares no skill named {tool_name!r}")
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(slots=True)
|
|
6
|
+
class StandardStreamingExchangeEvent:
|
|
7
|
+
request_id: str
|
|
8
|
+
sequence: int
|
|
9
|
+
event_type: str
|
|
10
|
+
delta: Any | None = None
|
|
11
|
+
final_output: Any | None = None
|
|
12
|
+
error: dict[str, Any] | None = None
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Agent capability discovery domain.
|
|
2
|
+
|
|
3
|
+
Holds the single source of truth for agent identity and capabilities
|
|
4
|
+
(:class:`AgentDescriptor`), the registry that owns uniqueness and ordering, and
|
|
5
|
+
the stateless projections that render descriptors into provider-compatible and
|
|
6
|
+
A2A wire shapes.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Developer-supplied authorization policy shared by discovery and invocation.
|
|
2
|
+
|
|
3
|
+
Authorization decisions still belong to the developer -- the runtime never
|
|
4
|
+
invents a rule on its own -- but a single :class:`AgentAccessPolicy` is now the
|
|
5
|
+
one place that decision lives, instead of being duplicated inside every
|
|
6
|
+
entrypoint. The runtime calls ``is_authorized`` at two points with the same
|
|
7
|
+
descriptor and the same authenticated context:
|
|
8
|
+
|
|
9
|
+
- Before dispatching an invocation (``/v1/responses``, ``/v1/chat/completions``,
|
|
10
|
+
``/v1/messages``): a denial raises :class:`AuthorizationError`, mapped to
|
|
11
|
+
HTTP 403.
|
|
12
|
+
- While serving discovery (``GET /v1/models`` and ``GET /v1/models/{id}``): a
|
|
13
|
+
denied agent is filtered out of listings and its direct retrieval reports
|
|
14
|
+
404, exactly like a hidden agent, so a caller never sees an entry it would
|
|
15
|
+
immediately be denied for.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from ygo74.agent_runtime.domains.auth.auth_context import AuthenticatedUserContext
|
|
24
|
+
from ygo74.agent_runtime.domains.discovery.agent_descriptor import AgentDescriptor
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class AgentAccessPolicy(Protocol):
|
|
29
|
+
"""Contract for the single authorization rule reused across every agent-scoped route."""
|
|
30
|
+
|
|
31
|
+
def is_authorized(
|
|
32
|
+
self,
|
|
33
|
+
descriptor: AgentDescriptor,
|
|
34
|
+
auth_context: AuthenticatedUserContext | None,
|
|
35
|
+
) -> bool:
|
|
36
|
+
"""Return ``True`` when ``auth_context`` may see and invoke ``descriptor``."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class RoleRequiredAccessPolicy:
|
|
42
|
+
"""Ready-made policy denying callers who lack a single required role.
|
|
43
|
+
|
|
44
|
+
Applies uniformly to every descriptor. Leave ``required_role`` empty to
|
|
45
|
+
allow any caller, including anonymous ones.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
required_role: str = ""
|
|
49
|
+
|
|
50
|
+
def is_authorized(
|
|
51
|
+
self,
|
|
52
|
+
descriptor: AgentDescriptor,
|
|
53
|
+
auth_context: AuthenticatedUserContext | None,
|
|
54
|
+
) -> bool:
|
|
55
|
+
if not self.required_role:
|
|
56
|
+
return True
|
|
57
|
+
if auth_context is None:
|
|
58
|
+
return False
|
|
59
|
+
return auth_context.has_role(self.required_role)
|