agent-nebula-platform 5.0.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 (163) hide show
  1. agent_nebula_platform/__init__.py +77 -0
  2. agent_nebula_platform/a2a/README.md +60 -0
  3. agent_nebula_platform/a2a/__init__.py +8 -0
  4. agent_nebula_platform/a2a/client.py +105 -0
  5. agent_nebula_platform/a2a/config/__init__.py +4 -0
  6. agent_nebula_platform/a2a/config/client.py +292 -0
  7. agent_nebula_platform/a2a/config/server.py +303 -0
  8. agent_nebula_platform/a2a/extensions/__init__.py +6 -0
  9. agent_nebula_platform/a2a/extensions/a2ui.py +91 -0
  10. agent_nebula_platform/a2a/extensions/health.py +49 -0
  11. agent_nebula_platform/a2a/extensions/schema_contract.py +79 -0
  12. agent_nebula_platform/a2a/extensions/skill_selection.py +100 -0
  13. agent_nebula_platform/a2a/runtime.py +248 -0
  14. agent_nebula_platform/a2a/security/__init__.py +5 -0
  15. agent_nebula_platform/a2a/security/client.py +426 -0
  16. agent_nebula_platform/a2a/security/request_handler.py +118 -0
  17. agent_nebula_platform/a2a/security/server.py +726 -0
  18. agent_nebula_platform/a2a/server.py +239 -0
  19. agent_nebula_platform/a2ui/__init__.py +83 -0
  20. agent_nebula_platform/a2ui/actions/__init__.py +8 -0
  21. agent_nebula_platform/a2ui/actions/service.py +40 -0
  22. agent_nebula_platform/a2ui/composition.py +164 -0
  23. agent_nebula_platform/a2ui/configuration.py +110 -0
  24. agent_nebula_platform/a2ui/data_model/__init__.py +7 -0
  25. agent_nebula_platform/a2ui/data_model/service.py +24 -0
  26. agent_nebula_platform/a2ui/events/__init__.py +7 -0
  27. agent_nebula_platform/a2ui/events/models.py +19 -0
  28. agent_nebula_platform/a2ui/models.py +162 -0
  29. agent_nebula_platform/a2ui/persistence/__init__.py +13 -0
  30. agent_nebula_platform/a2ui/plugin.py +74 -0
  31. agent_nebula_platform/a2ui/service.py +68 -0
  32. agent_nebula_platform/a2ui/session/__init__.py +25 -0
  33. agent_nebula_platform/a2ui/session/models.py +96 -0
  34. agent_nebula_platform/a2ui/session/plugin.py +49 -0
  35. agent_nebula_platform/a2ui/session/service.py +249 -0
  36. agent_nebula_platform/a2ui/session/store.py +167 -0
  37. agent_nebula_platform/a2ui/surfaces/__init__.py +9 -0
  38. agent_nebula_platform/a2ui/surfaces/models.py +13 -0
  39. agent_nebula_platform/a2ui/surfaces/service.py +55 -0
  40. agent_nebula_platform/a2ui/validation.py +325 -0
  41. agent_nebula_platform/integrations/__init__.py +1 -0
  42. agent_nebula_platform/integrations/nebula/__init__.py +26 -0
  43. agent_nebula_platform/integrations/nebula/configuration.py +125 -0
  44. agent_nebula_platform/integrations/nebula/connector.py +146 -0
  45. agent_nebula_platform/integrations/nebula/discovery_service.py +45 -0
  46. agent_nebula_platform/integrations/nebula/onboarding/__init__.py +16 -0
  47. agent_nebula_platform/integrations/nebula/onboarding/models.py +27 -0
  48. agent_nebula_platform/integrations/nebula/onboarding/payloads.py +154 -0
  49. agent_nebula_platform/integrations/nebula/onboarding/plugin.py +104 -0
  50. agent_nebula_platform/integrations/nebula/registration.py +30 -0
  51. agent_nebula_platform/integrations/nebula/transport.py +69 -0
  52. agent_nebula_platform/observability/__init__.py +5 -0
  53. agent_nebula_platform/observability/logging.py +45 -0
  54. agent_nebula_platform/platform/__init__.py +1 -0
  55. agent_nebula_platform/platform/capabilities/__init__.py +17 -0
  56. agent_nebula_platform/platform/capabilities/catalog.py +185 -0
  57. agent_nebula_platform/platform/configuration/__init__.py +26 -0
  58. agent_nebula_platform/platform/configuration/application.py +91 -0
  59. agent_nebula_platform/platform/configuration/home.py +50 -0
  60. agent_nebula_platform/platform/configuration/ini.py +66 -0
  61. agent_nebula_platform/platform/configuration/layered.py +58 -0
  62. agent_nebula_platform/platform/configuration/paths.py +74 -0
  63. agent_nebula_platform/platform/configuration/ports.py +56 -0
  64. agent_nebula_platform/platform/configuration/sources.py +139 -0
  65. agent_nebula_platform/platform/context.py +21 -0
  66. agent_nebula_platform/platform/discovery.py +141 -0
  67. agent_nebula_platform/platform/errors.py +36 -0
  68. agent_nebula_platform/platform/events.py +53 -0
  69. agent_nebula_platform/platform/execution_trace.py +138 -0
  70. agent_nebula_platform/platform/health/__init__.py +15 -0
  71. agent_nebula_platform/platform/health/models.py +42 -0
  72. agent_nebula_platform/platform/health/publication.py +96 -0
  73. agent_nebula_platform/platform/health/service.py +69 -0
  74. agent_nebula_platform/platform/lifecycle/__init__.py +1 -0
  75. agent_nebula_platform/platform/lifecycle/agent_server_plugin.py +101 -0
  76. agent_nebula_platform/platform/lifecycle/fastapi.py +50 -0
  77. agent_nebula_platform/platform/lifecycle/plugin.py +63 -0
  78. agent_nebula_platform/platform/llm/__init__.py +5 -0
  79. agent_nebula_platform/platform/llm/observability.py +192 -0
  80. agent_nebula_platform/platform/metrics/__init__.py +13 -0
  81. agent_nebula_platform/platform/metrics/exporters.py +23 -0
  82. agent_nebula_platform/platform/metrics/models.py +26 -0
  83. agent_nebula_platform/platform/metrics/plugin.py +45 -0
  84. agent_nebula_platform/platform/metrics/registry.py +91 -0
  85. agent_nebula_platform/platform/plugins/__init__.py +14 -0
  86. agent_nebula_platform/platform/plugins/base.py +82 -0
  87. agent_nebula_platform/platform/plugins/registry.py +79 -0
  88. agent_nebula_platform/platform/presets/__init__.py +45 -0
  89. agent_nebula_platform/platform/presets/application.py +691 -0
  90. agent_nebula_platform/platform/presets/common.py +68 -0
  91. agent_nebula_platform/platform/presets/composition.py +256 -0
  92. agent_nebula_platform/platform/presets/embedded/__init__.py +7 -0
  93. agent_nebula_platform/platform/presets/embedded/composition.py +32 -0
  94. agent_nebula_platform/platform/presets/logging.py +28 -0
  95. agent_nebula_platform/platform/presets/models.py +174 -0
  96. agent_nebula_platform/platform/presets/standalone/__init__.py +25 -0
  97. agent_nebula_platform/platform/presets/standalone/application.py +50 -0
  98. agent_nebula_platform/platform/presets/standalone/composition.py +91 -0
  99. agent_nebula_platform/platform/presets/standalone/settings.py +280 -0
  100. agent_nebula_platform/platform/retry/__init__.py +11 -0
  101. agent_nebula_platform/platform/retry/executor.py +39 -0
  102. agent_nebula_platform/platform/retry/policy.py +66 -0
  103. agent_nebula_platform/platform/runtime.py +219 -0
  104. agent_nebula_platform/platform/scheduler/__init__.py +11 -0
  105. agent_nebula_platform/platform/scheduler/models.py +48 -0
  106. agent_nebula_platform/platform/scheduler/plugin.py +58 -0
  107. agent_nebula_platform/platform/scheduler/service.py +108 -0
  108. agent_nebula_platform/platform/services.py +43 -0
  109. agent_nebula_platform/platform/state.py +91 -0
  110. agent_nebula_platform/platform/telemetry/__init__.py +14 -0
  111. agent_nebula_platform/platform/telemetry/events.py +33 -0
  112. agent_nebula_platform/platform/telemetry/plugin.py +47 -0
  113. agent_nebula_platform/platform/telemetry/service.py +26 -0
  114. agent_nebula_platform/platform/telemetry/sinks.py +53 -0
  115. agent_nebula_platform/platform/tls.py +41 -0
  116. agent_nebula_platform/platform/transport/__init__.py +1 -0
  117. agent_nebula_platform/platform/transport/fastapi/__init__.py +12 -0
  118. agent_nebula_platform/platform/transport/fastapi/app.py +211 -0
  119. agent_nebula_platform/platform/transport/fastapi/lifespan.py +33 -0
  120. agent_nebula_platform/platform/transport/fastapi/server.py +225 -0
  121. agent_nebula_platform/platform/transport/http/__init__.py +7 -0
  122. agent_nebula_platform/platform/transport/http/request_target.py +66 -0
  123. agent_nebula_platform/platform/transport/http_client/__init__.py +8 -0
  124. agent_nebula_platform/platform/transport/http_client/factory.py +50 -0
  125. agent_nebula_platform/platform/transport/rest/__init__.py +7 -0
  126. agent_nebula_platform/platform/transport/rest/adapter.py +37 -0
  127. agent_nebula_platform/security/__init__.py +1 -0
  128. agent_nebula_platform/security/oauth/__init__.py +1 -0
  129. agent_nebula_platform/security/oauth/client/__init__.py +29 -0
  130. agent_nebula_platform/security/oauth/client/cache.py +35 -0
  131. agent_nebula_platform/security/oauth/client/httpx_transport.py +95 -0
  132. agent_nebula_platform/security/oauth/client/identity.py +187 -0
  133. agent_nebula_platform/security/oauth/client/models.py +36 -0
  134. agent_nebula_platform/security/oauth/client/plugin.py +37 -0
  135. agent_nebula_platform/security/oauth/client/ports.py +60 -0
  136. agent_nebula_platform/security/oauth/client/protected_http.py +164 -0
  137. agent_nebula_platform/security/oauth/client/service.py +324 -0
  138. agent_nebula_platform/security/oauth/metadata/__init__.py +24 -0
  139. agent_nebula_platform/security/oauth/metadata/authorization_server.py +121 -0
  140. agent_nebula_platform/security/oauth/metadata/builders.py +148 -0
  141. agent_nebula_platform/security/oauth/metadata/factory.py +36 -0
  142. agent_nebula_platform/security/oauth/metadata/metadata.py +52 -0
  143. agent_nebula_platform/security/oauth/protected_resource/__init__.py +19 -0
  144. agent_nebula_platform/security/oauth/protected_resource/factory.py +136 -0
  145. agent_nebula_platform/security/oauth/protected_resource/models.py +30 -0
  146. agent_nebula_platform/security/oauth/protected_resource/plugin.py +41 -0
  147. agent_nebula_platform/security/oauth/protected_resource/policy.py +26 -0
  148. agent_nebula_platform/security/oauth/protected_resource/ports.py +19 -0
  149. agent_nebula_platform/security/oauth/protected_resource/routes.py +94 -0
  150. agent_nebula_platform/security/oauth/protected_resource/service.py +79 -0
  151. agent_nebula_platform/security/oauth/registration/README.md +18 -0
  152. agent_nebula_platform/security/oauth/registration/__init__.py +7 -0
  153. agent_nebula_platform/security/oauth/registration/plugin.py +219 -0
  154. agent_nebula_platform/security/oauth/registration/transport.py +65 -0
  155. agent_nebula_platform/skills/__init__.py +14 -0
  156. agent_nebula_platform/skills/models.py +50 -0
  157. agent_nebula_platform/skills/plugin.py +89 -0
  158. agent_nebula_platform/skills/registry.py +53 -0
  159. agent_nebula_platform/skills/service.py +122 -0
  160. agent_nebula_platform-5.0.0.dist-info/METADATA +14 -0
  161. agent_nebula_platform-5.0.0.dist-info/RECORD +163 -0
  162. agent_nebula_platform-5.0.0.dist-info/WHEEL +4 -0
  163. agent_nebula_platform-5.0.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,77 @@
1
+ """Public API for the pluggable Agent Runtime integration layer."""
2
+
3
+ from .a2ui.session import (
4
+ A2UIActionRecord,
5
+ A2UISessionService,
6
+ A2UISessionSnapshot,
7
+ A2UISurfaceFrame,
8
+ A2UISurfaceMode,
9
+ A2UITaskReference,
10
+ )
11
+ from .platform.capabilities import (
12
+ RuntimeCapability,
13
+ capability_catalog,
14
+ require_available,
15
+ )
16
+ from .platform.context import RuntimeContext
17
+ from .platform.events import (
18
+ EventSink,
19
+ NullEventSink,
20
+ PythonLoggingEventSink,
21
+ RuntimeEvent,
22
+ )
23
+ from .platform.execution_trace import (
24
+ EXECUTION_ID_HEADER,
25
+ SECURITY_TRACE_HEADER,
26
+ activate_execution_trace,
27
+ current_execution_trace,
28
+ reset_execution_trace,
29
+ )
30
+ from .platform.health import HealthStatus, RuntimeHealthService
31
+ from .platform.llm import LlmCostEstimator, LlmUsageObserver, LlmUsageRecord
32
+ from .platform.metrics import MetricsRegistry, MetricsSnapshot
33
+ from .platform.runtime import AgentRuntime
34
+ from .platform.scheduler import JobTriggerType, RuntimeScheduler, ScheduledJob
35
+ from .platform.services import RuntimeServiceRegistry
36
+ from .platform.state import JsonFileStateStore, MemoryStateStore, RuntimeStateStore
37
+ from .platform.telemetry import TelemetryEvent, TelemetryService, TelemetrySeverity
38
+
39
+ __all__ = [
40
+ "EXECUTION_ID_HEADER",
41
+ "SECURITY_TRACE_HEADER",
42
+ "A2UIActionRecord",
43
+ "A2UISessionService",
44
+ "A2UISessionSnapshot",
45
+ "A2UISurfaceFrame",
46
+ "A2UISurfaceMode",
47
+ "A2UITaskReference",
48
+ "AgentRuntime",
49
+ "EventSink",
50
+ "HealthStatus",
51
+ "JobTriggerType",
52
+ "JsonFileStateStore",
53
+ "MemoryStateStore",
54
+ "LlmCostEstimator",
55
+ "LlmUsageObserver",
56
+ "LlmUsageRecord",
57
+ "MetricsRegistry",
58
+ "MetricsSnapshot",
59
+ "NullEventSink",
60
+ "PythonLoggingEventSink",
61
+ "RuntimeCapability",
62
+ "RuntimeContext",
63
+ "RuntimeEvent",
64
+ "RuntimeHealthService",
65
+ "RuntimeScheduler",
66
+ "RuntimeServiceRegistry",
67
+ "RuntimeStateStore",
68
+ "ScheduledJob",
69
+ "TelemetryEvent",
70
+ "TelemetryService",
71
+ "TelemetrySeverity",
72
+ "activate_execution_trace",
73
+ "capability_catalog",
74
+ "current_execution_trace",
75
+ "require_available",
76
+ "reset_execution_trace",
77
+ ]
@@ -0,0 +1,60 @@
1
+ # Agent Nebula Platform — A2A Frozen Requirements
2
+
3
+ This directory is the clean Agent Nebula integration boundary on top of the official `a2a-sdk`. **All code changes under this directory MUST follow this document.**
4
+
5
+ ## Frozen ownership
6
+
7
+ 1. `a2a-sdk` is authoritative for standard A2A protocol functionality.
8
+ 2. This package contains only Agent Nebula functionality required on top of `a2a-sdk`: configuration integration, security integration, and Nebula-specific extension semantics.
9
+ 3. Do not duplicate or wrap standard `a2a-sdk` models or operations, including AgentCard, AgentSkill, Message, Task, Artifact, AgentExecutor, RequestContext, EventQueue, Client operations, streaming, cancellation, push notifications, or protocol bindings.
10
+ 4. Public boundaries use and return native `a2a-sdk` types. Do not convert native models to dictionaries merely to hide the SDK.
11
+ 5. Agent Card is authoritative for interfaces, skills, capabilities, extensions, and security declarations.
12
+ 6. A2A extensions use the standard A2A extension mechanism. Nebula code implements only Nebula-specific extension semantics.
13
+ 7. A2UI protocol functionality does not belong in Platform. No A2UI surface, component, action, session, result-mapping, or rendering implementation may be added here.
14
+ 8. No LLM-dependent protocol behavior is allowed.
15
+ 9. Configuration, secrets, credentials, and mutable runtime state remain external to source code.
16
+ 10. Legacy compatibility code stays outside this directory and must not be imported by this directory.
17
+
18
+ ## Frozen structure
19
+
20
+ ```text
21
+ a2a/
22
+ ├── README.md
23
+ ├── __init__.py
24
+ ├── client.py
25
+ ├── server.py
26
+ ├── config/
27
+ │ ├── __init__.py
28
+ │ ├── client.py
29
+ │ └── server.py
30
+ ├── security/
31
+ │ ├── __init__.py
32
+ │ ├── client.py
33
+ │ └── server.py
34
+ └── extensions/
35
+ ├── __init__.py
36
+ ├── health.py
37
+ ├── schema_contract.py
38
+ └── skill_selection.py
39
+ ```
40
+
41
+ Do not add `models`, `services`, `adapters`, `interfaces`, `factories`, `plugins`, `execution`, or similar layering without first changing this frozen contract.
42
+
43
+ ## Coding standards
44
+
45
+ - Keep modules small in responsibility and code paths direct.
46
+ - Prefer composition of official SDK objects over Agent Nebula wrappers.
47
+ - Use explicit type hints and native SDK types at public boundaries.
48
+ - Do not use wildcard imports. Keep package exports minimal and explicit.
49
+ - Do not duplicate constants or protocol definitions.
50
+ - Do not add speculative abstractions for hypothetical future implementations.
51
+ - Do not add compatibility aliases or dead migration code to this tree.
52
+ - Comments explain Agent Nebula-specific behavior or standards boundaries, not Python syntax.
53
+ - Configuration errors must fail explicitly; do not silently invent defaults for protocol/security facts advertised by an Agent Card.
54
+ - Security must integrate through official A2A SDK extension points such as client interceptors and server call-context builders.
55
+ - New functionality requires tests at the public boundary.
56
+ - A patch is not complete until formatting/static checks, imports, and targeted tests pass. Runtime success must not be claimed unless runtime execution occurred.
57
+
58
+ ## Change test
59
+
60
+ Before adding code here, ask: **Would this functionality exist without Agent Nebula configuration, security, or a Nebula-specific extension?** If yes, it belongs to `a2a-sdk` or another standard library, not here.
@@ -0,0 +1,8 @@
1
+ """Agent Nebula integration layer for the official A2A SDK.
2
+
3
+ Keep this package initializer side-effect free. Configuration, security and
4
+ runtime modules import one another through explicit submodules; eagerly
5
+ importing the server here creates cycles while Platform presets are loading.
6
+ """
7
+
8
+ __all__: list[str] = []
@@ -0,0 +1,105 @@
1
+ """Native a2a-sdk client construction with Agent Nebula configuration and security."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from urllib.parse import urlsplit, urlunsplit
7
+ import httpx
8
+ from a2a.client import A2ACardResolver, Client, ClientConfig, ClientFactory
9
+ from a2a.types import AgentCard
10
+ from a2a.utils.constants import TransportProtocol
11
+ from .config.client import A2AClientConfiguration
12
+ from .security.client import A2AClientSecurityInterceptor
13
+
14
+ class A2AClient:
15
+ """Resolve Agent Cards and return native ``a2a-sdk`` clients."""
16
+ def __init__(self, configuration: A2AClientConfiguration, *, security: A2AClientSecurityInterceptor | None = None) -> None:
17
+ self.configuration = configuration
18
+ self.security = security
19
+
20
+ async def resolve_agent_card(self, location: str) -> AgentCard:
21
+ base_url, card_path = self._resolution_target(location)
22
+ async with self._http_client() as http_client:
23
+ return await A2ACardResolver(http_client, base_url).get_agent_card(relative_card_path=card_path)
24
+
25
+ async def connect(self, location: str) -> Client:
26
+ return self.create(await self.resolve_agent_card(location))
27
+
28
+ def create(self, agent_card: AgentCard) -> Client:
29
+ interceptors = [self.security] if self.security is not None else []
30
+ config = ClientConfig(
31
+ streaming=self.configuration.streaming,
32
+ polling=self.configuration.polling,
33
+ httpx_client=self._http_client(),
34
+ grpc_channel_factory=self._grpc_channel_factory(),
35
+ supported_protocol_bindings=list(self.configuration.supported_protocol_bindings),
36
+ use_client_preference=self.configuration.use_client_preference,
37
+ accepted_output_modes=list(self.configuration.accepted_output_modes),
38
+ )
39
+ return ClientFactory(config).create(agent_card, interceptors)
40
+
41
+ def _resolution_target(self, location: str) -> tuple[str, str]:
42
+ path = self.configuration.agent_card_path or "/.well-known/agent-card.json"
43
+ parsed = urlsplit(location)
44
+ if parsed.path.endswith(path):
45
+ base_path = parsed.path[:-len(path)].rstrip("/")
46
+ return urlunsplit((parsed.scheme, parsed.netloc, base_path, "", "")), path
47
+ return location, path
48
+
49
+ def _http_client(self) -> httpx.AsyncClient:
50
+ tls = self.configuration.tls
51
+ verify: bool | str = True
52
+ cert = None
53
+ if tls.trust_bundle is not None:
54
+ verify = str(tls.trust_bundle)
55
+ if tls.certificate_file is not None and tls.private_key_file is not None:
56
+ cert = (str(tls.certificate_file), str(tls.private_key_file))
57
+ return httpx.AsyncClient(verify=verify, cert=cert, timeout=self.configuration.timeout_seconds)
58
+
59
+ def _grpc_channel_factory(self) -> Callable[[str], object] | None:
60
+ """Create the gRPC channel factory required by the official A2A SDK."""
61
+ if TransportProtocol.GRPC not in self.configuration.supported_protocol_bindings:
62
+ return None
63
+ try:
64
+ import grpc
65
+ except ImportError as exc:
66
+ raise RuntimeError(
67
+ 'A2A gRPC client support requires the Platform "a2a-grpc" extra'
68
+ ) from exc
69
+
70
+ tls = self.configuration.tls
71
+ if not tls.enabled:
72
+ def create_insecure_channel(target: str) -> object:
73
+ authority = target.removeprefix("grpc://").removeprefix("grpcs://")
74
+ return grpc.aio.insecure_channel(authority)
75
+
76
+ return create_insecure_channel
77
+
78
+ root_certificates = (
79
+ self._read_bytes(tls.trust_bundle) if tls.trust_bundle is not None else None
80
+ )
81
+ private_key = (
82
+ self._read_bytes(tls.private_key_file)
83
+ if tls.private_key_file is not None
84
+ else None
85
+ )
86
+ certificate_chain = (
87
+ self._read_bytes(tls.certificate_file)
88
+ if tls.certificate_file is not None
89
+ else None
90
+ )
91
+ credentials = grpc.ssl_channel_credentials(
92
+ root_certificates=root_certificates,
93
+ private_key=private_key,
94
+ certificate_chain=certificate_chain,
95
+ )
96
+
97
+ def create_secure_channel(target: str) -> object:
98
+ authority = target.removeprefix("grpcs://").removeprefix("grpc://")
99
+ return grpc.aio.secure_channel(authority, credentials)
100
+
101
+ return create_secure_channel
102
+
103
+ @staticmethod
104
+ def _read_bytes(path: Path) -> bytes:
105
+ return path.read_bytes()
@@ -0,0 +1,4 @@
1
+ """A2A configuration integration."""
2
+ from .client import A2AClientConfiguration, A2AClientConfigurationLoader
3
+ from .server import A2AListenerConfiguration, A2AListenerConfigurationLoader, A2AServerConfiguration, A2AServerConfigurationService, A2AServerTlsConfiguration
4
+ __all__ = ["A2AClientConfiguration", "A2AClientConfigurationLoader", "A2AListenerConfiguration", "A2AListenerConfigurationLoader", "A2AServerConfiguration", "A2AServerConfigurationService", "A2AServerTlsConfiguration"]
@@ -0,0 +1,292 @@
1
+ """Typed loading and validation for the dedicated A2A client configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from a2a.utils.constants import TransportProtocol
11
+
12
+ from agent_nebula_platform.platform.errors import ConfigurationError
13
+
14
+ _SUPPORTED_BINDINGS = {
15
+ TransportProtocol.JSONRPC,
16
+ TransportProtocol.HTTP_JSON,
17
+ TransportProtocol.GRPC,
18
+ }
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class A2AClientTlsConfiguration:
23
+ """TLS mode and material used by outbound A2A transports and Agent Card discovery."""
24
+
25
+ enabled: bool = False
26
+ trust_bundle: Path | None = None
27
+ certificate_file: Path | None = None
28
+ private_key_file: Path | None = None
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class A2AClientCredentialConfiguration:
33
+ """Local secret material keyed by arbitrary remote Agent Card scheme name."""
34
+
35
+ scheme_name: str
36
+ secret_file: Path
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class A2AClientOAuthConfiguration:
41
+ """Locally provisioned OAuth client identity used for discovered OAuth2 schemes."""
42
+
43
+ client_id_file: Path
44
+ authentication_private_key_file: Path
45
+ dpop_private_key_file: Path
46
+ key_id: str = "a2a-oauth-client"
47
+ algorithm: str = "ES256"
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class A2AClientConfiguration:
52
+ """Platform-owned settings mapped to the official A2A ``ClientConfig``."""
53
+
54
+ streaming: bool
55
+ polling: bool
56
+ supported_protocol_bindings: tuple[str, ...]
57
+ use_client_preference: bool
58
+ accepted_output_modes: tuple[str, ...]
59
+ timeout_seconds: float
60
+ agent_card_path: str | None
61
+ tls: A2AClientTlsConfiguration
62
+ credentials: tuple[A2AClientCredentialConfiguration, ...] = ()
63
+ oauth: A2AClientOAuthConfiguration | None = None
64
+
65
+
66
+ class A2AClientConfigurationLoader:
67
+ """Load ``a2aclient.conf`` without interpreting remote Agent Card security."""
68
+
69
+ def load(self, path: Path) -> A2AClientConfiguration:
70
+ """Parse and validate one dedicated A2A client TOML configuration file."""
71
+
72
+ configuration_path = path.expanduser().resolve()
73
+ if not configuration_path.is_file():
74
+ raise ConfigurationError(
75
+ "A2A client configuration file does not exist",
76
+ details={"path": str(configuration_path)},
77
+ )
78
+ try:
79
+ with configuration_path.open("rb") as stream:
80
+ document = tomllib.load(stream)
81
+ except (OSError, tomllib.TOMLDecodeError) as exc:
82
+ raise ConfigurationError(
83
+ "A2A client configuration file is invalid",
84
+ details={"path": str(configuration_path)},
85
+ ) from exc
86
+
87
+ client = self._require_table(document, "client")
88
+ tls = self._require_table(client, "tls", parent="client")
89
+ bindings = tuple(self._string_list(client, "supported_protocol_bindings"))
90
+ if not bindings:
91
+ raise ConfigurationError(
92
+ "A2A client must configure at least one supported protocol binding"
93
+ )
94
+ unknown = sorted(set(bindings) - _SUPPORTED_BINDINGS)
95
+ if unknown:
96
+ raise ConfigurationError(
97
+ "A2A client configuration contains unsupported protocol bindings",
98
+ details={"bindings": unknown},
99
+ )
100
+ if len(set(bindings)) != len(bindings):
101
+ raise ConfigurationError("A2A client protocol bindings must be unique")
102
+
103
+ timeout = self._number(client, "timeout_seconds", default=30.0)
104
+ if timeout <= 0:
105
+ raise ConfigurationError(
106
+ "A2A client timeout_seconds must be greater than zero"
107
+ )
108
+
109
+ tls_enabled = self._boolean(tls, "enabled", default=False)
110
+ trust_bundle = self._optional_path(
111
+ tls, "trust_bundle", configuration_path.parent
112
+ )
113
+ certificate_file = self._optional_path(
114
+ tls, "certificate_file", configuration_path.parent
115
+ )
116
+ private_key_file = self._optional_path(
117
+ tls, "private_key_file", configuration_path.parent
118
+ )
119
+ if (certificate_file is None) != (private_key_file is None):
120
+ raise ConfigurationError(
121
+ "A2A client TLS certificate_file and private_key_file must be configured together"
122
+ )
123
+ # A trust bundle controls certificate verification for outbound HTTPS
124
+ # (Agent Card, OAuth metadata/token endpoints, and HTTP A2A bindings)
125
+ # even when explicit client TLS/mTLS mode is disabled. Requiring
126
+ # ``enabled = true`` for a CA bundle made ordinary HTTPS callers either
127
+ # reject valid private PKI configuration or fall back to disabled
128
+ # verification. Client certificate material remains explicit mTLS
129
+ # configuration and therefore still requires TLS mode to be enabled.
130
+ if not tls_enabled and (certificate_file is not None or private_key_file is not None):
131
+ raise ConfigurationError(
132
+ "A2A client certificate material requires [client.tls].enabled = true"
133
+ )
134
+
135
+ credentials_table = client.get("credentials", {})
136
+ if not isinstance(credentials_table, dict):
137
+ raise ConfigurationError("A2A client [client.credentials] must be a table")
138
+ credentials: list[A2AClientCredentialConfiguration] = []
139
+ for scheme_name, entry in credentials_table.items():
140
+ if (
141
+ not isinstance(scheme_name, str)
142
+ or not scheme_name.strip()
143
+ or not isinstance(entry, dict)
144
+ ):
145
+ raise ConfigurationError(
146
+ "A2A client credential entries must be named tables"
147
+ )
148
+ secret_file = self._required_path(
149
+ entry, "secret_file", configuration_path.parent
150
+ )
151
+ credentials.append(
152
+ A2AClientCredentialConfiguration(
153
+ scheme_name=scheme_name.strip(), secret_file=secret_file
154
+ )
155
+ )
156
+
157
+ oauth_table = client.get("oauth")
158
+ oauth = None
159
+ if oauth_table is not None:
160
+ if not isinstance(oauth_table, dict):
161
+ raise ConfigurationError("A2A client [client.oauth] must be a table")
162
+ oauth = A2AClientOAuthConfiguration(
163
+ client_id_file=self._destination_path(
164
+ oauth_table, "client_id_file", configuration_path.parent
165
+ ),
166
+ authentication_private_key_file=self._required_path(
167
+ oauth_table,
168
+ "authentication_private_key_file",
169
+ configuration_path.parent,
170
+ ),
171
+ dpop_private_key_file=self._required_path(
172
+ oauth_table, "dpop_private_key_file", configuration_path.parent
173
+ ),
174
+ key_id=str(oauth_table.get("key_id", "a2a-oauth-client")).strip(),
175
+ algorithm=str(oauth_table.get("algorithm", "ES256")).strip(),
176
+ )
177
+ if not oauth.key_id or not oauth.algorithm:
178
+ raise ConfigurationError(
179
+ "A2A client OAuth key_id and algorithm must be non-empty"
180
+ )
181
+
182
+ agent_card_path = client.get("agent_card_path")
183
+ if agent_card_path is not None:
184
+ if not isinstance(agent_card_path, str) or not agent_card_path.strip():
185
+ raise ConfigurationError(
186
+ "A2A client agent_card_path must be a non-empty string"
187
+ )
188
+ agent_card_path = agent_card_path.strip()
189
+
190
+ return A2AClientConfiguration(
191
+ streaming=self._boolean(client, "streaming", default=True),
192
+ polling=self._boolean(client, "polling", default=False),
193
+ supported_protocol_bindings=bindings,
194
+ use_client_preference=self._boolean(
195
+ client, "use_client_preference", default=False
196
+ ),
197
+ accepted_output_modes=tuple(
198
+ self._string_list(client, "accepted_output_modes")
199
+ ),
200
+ timeout_seconds=float(timeout),
201
+ agent_card_path=agent_card_path,
202
+ tls=A2AClientTlsConfiguration(
203
+ enabled=tls_enabled,
204
+ trust_bundle=trust_bundle,
205
+ certificate_file=certificate_file,
206
+ private_key_file=private_key_file,
207
+ ),
208
+ credentials=tuple(credentials),
209
+ oauth=oauth,
210
+ )
211
+
212
+ @staticmethod
213
+ def _require_table(
214
+ document: dict[str, Any], name: str, *, parent: str | None = None
215
+ ) -> dict[str, Any]:
216
+ value = document.get(name)
217
+ if not isinstance(value, dict):
218
+ location = f"[{parent}.{name}]" if parent else f"[{name}]"
219
+ raise ConfigurationError(f"A2A client configuration requires {location}")
220
+ return value
221
+
222
+ @staticmethod
223
+ def _boolean(document: dict[str, Any], name: str, *, default: bool) -> bool:
224
+ value = document.get(name, default)
225
+ if not isinstance(value, bool):
226
+ raise ConfigurationError(f"A2A client {name} must be a boolean")
227
+ return value
228
+
229
+ @staticmethod
230
+ def _number(document: dict[str, Any], name: str, *, default: float) -> float:
231
+ value = document.get(name, default)
232
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
233
+ raise ConfigurationError(f"A2A client {name} must be numeric")
234
+ return float(value)
235
+
236
+ @staticmethod
237
+ def _string_list(document: dict[str, Any], name: str) -> list[str]:
238
+ value = document.get(name, [])
239
+ if not isinstance(value, list) or any(
240
+ not isinstance(item, str) or not item.strip() for item in value
241
+ ):
242
+ raise ConfigurationError(
243
+ f"A2A client {name} must be a list of non-empty strings"
244
+ )
245
+ return [item.strip() for item in value]
246
+
247
+ @staticmethod
248
+ def _destination_path(document: dict[str, Any], name: str, base: Path) -> Path:
249
+ """Resolve an OAuth output path without requiring the file to exist yet."""
250
+ value = document.get(name)
251
+ if not isinstance(value, str) or not value.strip():
252
+ raise ConfigurationError(
253
+ f"A2A client OAuth {name} must be a non-empty path"
254
+ )
255
+ path = Path(value).expanduser()
256
+ if not path.is_absolute():
257
+ path = base / path
258
+ path = path.resolve()
259
+ if not path.parent.is_dir():
260
+ raise ConfigurationError(
261
+ f"A2A client OAuth {name} parent directory does not exist",
262
+ details={"path": str(path), "parent": str(path.parent)},
263
+ )
264
+ return path
265
+
266
+ @staticmethod
267
+ def _required_path(document: dict[str, Any], name: str, base: Path) -> Path:
268
+ value = document.get(name)
269
+ if not isinstance(value, str) or not value.strip():
270
+ raise ConfigurationError(f"A2A client TLS {name} is required")
271
+ return A2AClientConfigurationLoader._resolve_existing_path(value, name, base)
272
+
273
+ @staticmethod
274
+ def _optional_path(document: dict[str, Any], name: str, base: Path) -> Path | None:
275
+ value = document.get(name)
276
+ if value is None:
277
+ return None
278
+ if not isinstance(value, str) or not value.strip():
279
+ raise ConfigurationError(f"A2A client TLS {name} must be a non-empty path")
280
+ return A2AClientConfigurationLoader._resolve_existing_path(value, name, base)
281
+
282
+ @staticmethod
283
+ def _resolve_existing_path(value: str, name: str, base: Path) -> Path:
284
+ path = Path(value).expanduser()
285
+ if not path.is_absolute():
286
+ path = base / path
287
+ path = path.resolve()
288
+ if not path.is_file():
289
+ raise ConfigurationError(
290
+ f"A2A client TLS {name} does not exist", details={"path": str(path)}
291
+ )
292
+ return path