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.
Files changed (82) hide show
  1. ygo74_agent_runtime_agents-0.1.0/PKG-INFO +15 -0
  2. ygo74_agent_runtime_agents-0.1.0/pyproject.toml +61 -0
  3. ygo74_agent_runtime_agents-0.1.0/setup.cfg +4 -0
  4. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/agent_http_settings.py +120 -0
  5. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/models.py +55 -0
  6. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/py.typed +0 -0
  7. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/configuration/validator.py +12 -0
  8. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/capability_registry.py +102 -0
  9. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/contract_errors.py +22 -0
  10. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/conversation.py +77 -0
  11. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/error_envelope.py +12 -0
  12. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/exchange_models.py +24 -0
  13. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/manifests.py +75 -0
  14. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/py.typed +0 -0
  15. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/contracts/stream_events.py +12 -0
  16. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/__init__.py +7 -0
  17. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/agent_access_policy.py +59 -0
  18. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/agent_descriptor.py +317 -0
  19. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/anthropic_model_projection.py +56 -0
  20. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/capability_extensions.py +47 -0
  21. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/capability_validator.py +53 -0
  22. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/descriptor_binding.py +32 -0
  23. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/descriptor_defaults.py +76 -0
  24. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/descriptor_registry.py +97 -0
  25. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/dialect_selector.py +73 -0
  26. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/discovery_configuration.py +252 -0
  27. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/discovery_errors.py +142 -0
  28. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/manifest_descriptor.py +221 -0
  29. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/model_route_resolver.py +28 -0
  30. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/openai_model_projection.py +40 -0
  31. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/pagination.py +103 -0
  32. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/discovery/py.typed +0 -0
  33. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/adapters.py +24 -0
  34. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/conversation_payloads.py +157 -0
  35. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/fastapi_endpoints.py +775 -0
  36. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/header_forwarding.py +160 -0
  37. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/endpoints/py.typed +0 -0
  38. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/handlers/handler_protocol.py +11 -0
  39. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/handlers/py.typed +0 -0
  40. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/handlers/response_validator.py +12 -0
  41. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/approval_errors.py +40 -0
  42. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/broker.py +60 -0
  43. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/commands.py +77 -0
  44. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/confirmation.py +315 -0
  45. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/confirmed_operations.py +122 -0
  46. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/gated_operations.py +229 -0
  47. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/ledger.py +54 -0
  48. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/pending_renderer.py +79 -0
  49. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/py.typed +0 -0
  50. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/tickets.py +206 -0
  51. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/humanapproval/unattended.py +44 -0
  52. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mapping/py.typed +0 -0
  53. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mapping/request_mapper.py +9 -0
  54. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mapping/response_mapper.py +165 -0
  55. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/binding.py +395 -0
  56. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/dialects.py +76 -0
  57. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/mcp_errors.py +25 -0
  58. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/oauth.py +182 -0
  59. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/mcp/py.typed +0 -0
  60. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/sessions/conversation_cache.py +257 -0
  61. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/sessions/py.typed +0 -0
  62. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/streaming/openai_stream_mapper.py +10 -0
  63. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/streaming/py.typed +0 -0
  64. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/domains/streaming/stream_termination.py +17 -0
  65. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/interfaces.py +16 -0
  66. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/middleware_errors.py +6 -0
  67. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/pipeline.py +12 -0
  68. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/py.typed +0 -0
  69. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/middleware/registry.py +21 -0
  70. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/observability/logging_config.py +8 -0
  71. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/observability/otel.py +3 -0
  72. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/observability/py.typed +0 -0
  73. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/dispatcher.py +17 -0
  74. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/dispatcher_impl.py +12 -0
  75. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/py.typed +0 -0
  76. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/route_registry.py +21 -0
  77. ygo74_agent_runtime_agents-0.1.0/ygo74/agent_runtime/routing/routing_errors.py +6 -0
  78. ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/PKG-INFO +15 -0
  79. ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/SOURCES.txt +80 -0
  80. ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/dependency_links.txt +1 -0
  81. ygo74_agent_runtime_agents-0.1.0/ygo74_agent_runtime_agents.egg-info/requires.txt +12 -0
  82. 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )
@@ -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,12 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class ErrorEnvelope:
7
+ code: str
8
+ category: str
9
+ message: str
10
+ details: Any | None = None
11
+ request_id: str | None = None
12
+ retryable: bool | None = None
@@ -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}")
@@ -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)