agent-nebula-secureagent 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.
- agent_nebula_secureagent/__init__.py +46 -0
- agent_nebula_secureagent/a2a/README.md +42 -0
- agent_nebula_secureagent/a2a/__init__.py +5 -0
- agent_nebula_secureagent/a2a/client.py +58 -0
- agent_nebula_secureagent/a2a/server.py +177 -0
- agent_nebula_secureagent/a2ui.py +50 -0
- agent_nebula_secureagent/discovery.py +156 -0
- agent_nebula_secureagent/exceptions.py +17 -0
- agent_nebula_secureagent/execution_trace.py +82 -0
- agent_nebula_secureagent/llm.py +13 -0
- agent_nebula_secureagent/logging.py +114 -0
- agent_nebula_secureagent/protocol.py +23 -0
- agent_nebula_secureagent/secure_agent.py +196 -0
- agent_nebula_secureagent/secure_agent_legacy.py +560 -0
- agent_nebula_secureagent-5.0.0.dist-info/METADATA +113 -0
- agent_nebula_secureagent-5.0.0.dist-info/RECORD +18 -0
- agent_nebula_secureagent-5.0.0.dist-info/WHEEL +4 -0
- agent_nebula_secureagent-5.0.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Public API for Agent Nebula SecureAgent."""
|
|
2
|
+
from agent_nebula_platform.skills import SkillInvocation, SkillResult
|
|
3
|
+
|
|
4
|
+
from .a2ui import create_memory_a2ui_session_service
|
|
5
|
+
from .discovery import DiscoveredAgent, DiscoveredSkill
|
|
6
|
+
from .execution_trace import execution_trace
|
|
7
|
+
from .llm import LlmCostEstimator, LlmUsageObserver, LlmUsageRecord
|
|
8
|
+
from .logging import (
|
|
9
|
+
SecureAgentLoggingConfiguration,
|
|
10
|
+
configure_console_logger,
|
|
11
|
+
create_utc_formatter,
|
|
12
|
+
create_uvicorn_log_config,
|
|
13
|
+
load_logging_configuration,
|
|
14
|
+
)
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
SecureAgentConfigurationError,
|
|
17
|
+
SecureAgentError,
|
|
18
|
+
SecureAgentProtocolError,
|
|
19
|
+
SecureAgentSkillRegistrationError,
|
|
20
|
+
)
|
|
21
|
+
from .protocol import ProtocolRole, ProtocolType
|
|
22
|
+
from .secure_agent import SecureAgent
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"create_memory_a2ui_session_service",
|
|
26
|
+
"DiscoveredAgent",
|
|
27
|
+
"DiscoveredSkill",
|
|
28
|
+
"execution_trace",
|
|
29
|
+
"SecureAgentLoggingConfiguration",
|
|
30
|
+
"configure_console_logger",
|
|
31
|
+
"create_utc_formatter",
|
|
32
|
+
"create_uvicorn_log_config",
|
|
33
|
+
"load_logging_configuration",
|
|
34
|
+
"LlmCostEstimator",
|
|
35
|
+
"LlmUsageObserver",
|
|
36
|
+
"LlmUsageRecord",
|
|
37
|
+
"ProtocolRole",
|
|
38
|
+
"ProtocolType",
|
|
39
|
+
"SecureAgent",
|
|
40
|
+
"SecureAgentConfigurationError",
|
|
41
|
+
"SecureAgentError",
|
|
42
|
+
"SecureAgentProtocolError",
|
|
43
|
+
"SecureAgentSkillRegistrationError",
|
|
44
|
+
"SkillInvocation",
|
|
45
|
+
"SkillResult",
|
|
46
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Agent Nebula SecureAgent — A2A Frozen Requirements
|
|
2
|
+
|
|
3
|
+
This directory is the application-facing A2A facade. All code here MUST follow these requirements.
|
|
4
|
+
|
|
5
|
+
## Frozen ownership
|
|
6
|
+
|
|
7
|
+
1. `a2a-sdk` owns standard A2A models, execution, client operations, tasks, artifacts, streaming, cancellation, push notifications, and protocol bindings.
|
|
8
|
+
2. `agent_nebula_platform.a2a` is the only Agent Nebula Platform A2A dependency allowed here.
|
|
9
|
+
3. Code in this directory MUST NOT import `agent_nebula_platform.protocols.*` or `agent_nebula_secureagent.protocols.*`.
|
|
10
|
+
4. SecureAgent adds only configuration-based construction, native `AgentExecutor` registration, lifecycle convenience, and delegation to Platform A2A integration.
|
|
11
|
+
5. Public A2A boundaries use native `a2a-sdk` types. Do not create SecureAgent Message, Task, Artifact, RequestContext, EventQueue, or Client wrappers.
|
|
12
|
+
6. Agent Card is authoritative for skills, interfaces, extensions, and security.
|
|
13
|
+
7. Nebula-specific extension semantics come only from `agent_nebula_platform.a2a.extensions`.
|
|
14
|
+
8. A2UI does not belong in this directory or in the SecureAgent A2A facade.
|
|
15
|
+
9. No LLM-dependent protocol behavior is allowed.
|
|
16
|
+
10. Configuration, credentials, secrets, and mutable runtime state remain external.
|
|
17
|
+
|
|
18
|
+
## Frozen structure
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
a2a/
|
|
22
|
+
├── README.md
|
|
23
|
+
├── __init__.py
|
|
24
|
+
├── client.py
|
|
25
|
+
└── server.py
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Do not add adapters, services, factories, interfaces, models, or compatibility layers without changing this contract first.
|
|
29
|
+
|
|
30
|
+
## Coding standards
|
|
31
|
+
|
|
32
|
+
- Keep the facade thin and direct.
|
|
33
|
+
- Prefer native SDK types in signatures and return values.
|
|
34
|
+
- Do not duplicate Platform configuration, security, or extension logic.
|
|
35
|
+
- Do not import legacy A2A implementation paths.
|
|
36
|
+
- Do not add A2UI helpers.
|
|
37
|
+
- Do not convert native SDK objects to dictionaries merely to hide the SDK.
|
|
38
|
+
- Use explicit type hints and explicit imports.
|
|
39
|
+
- Keep exports minimal.
|
|
40
|
+
- Comments explain Agent Nebula-specific behavior, not Python syntax.
|
|
41
|
+
- Fail explicitly on invalid or ambiguous configuration.
|
|
42
|
+
- Tests validate the public boundary.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""SecureAgent construction of native A2A clients."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from a2a.client import Client
|
|
7
|
+
from a2a.types import AgentCard
|
|
8
|
+
from agent_nebula_platform.a2a.client import A2AClient
|
|
9
|
+
from agent_nebula_platform.a2a.config import A2AClientConfigurationLoader
|
|
10
|
+
from agent_nebula_platform.a2a.security import (
|
|
11
|
+
A2AClientSecurityInterceptor,
|
|
12
|
+
A2AClientSecurityResolver,
|
|
13
|
+
)
|
|
14
|
+
from agent_nebula_platform.a2a.security.client import (
|
|
15
|
+
A2AApiKeyCredentialProvider,
|
|
16
|
+
A2ADiscoveredOAuth2CredentialProvider,
|
|
17
|
+
A2AHttpCredentialProvider,
|
|
18
|
+
A2AMutualTlsCredentialProvider,
|
|
19
|
+
A2AOidcCredentialProvider,
|
|
20
|
+
load_static_credentials,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SecureAgentA2AClient:
|
|
25
|
+
"""Apply external configuration and expose native ``a2a-sdk`` clients."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, configuration_file: Path) -> None:
|
|
28
|
+
configuration = A2AClientConfigurationLoader().load(configuration_file)
|
|
29
|
+
credentials = load_static_credentials(configuration)
|
|
30
|
+
providers = [
|
|
31
|
+
A2AApiKeyCredentialProvider(credentials),
|
|
32
|
+
A2AHttpCredentialProvider(credentials),
|
|
33
|
+
A2AOidcCredentialProvider(credentials),
|
|
34
|
+
A2AMutualTlsCredentialProvider(
|
|
35
|
+
identity_configured=configuration.tls.certificate_file is not None
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
if configuration.oauth is not None:
|
|
39
|
+
providers.append(
|
|
40
|
+
A2ADiscoveredOAuth2CredentialProvider(
|
|
41
|
+
configuration=configuration.oauth,
|
|
42
|
+
tls=configuration.tls,
|
|
43
|
+
timeout_seconds=configuration.timeout_seconds,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
self._security = A2AClientSecurityInterceptor(
|
|
47
|
+
A2AClientSecurityResolver(tuple(providers))
|
|
48
|
+
)
|
|
49
|
+
self._client = A2AClient(configuration, security=self._security)
|
|
50
|
+
|
|
51
|
+
async def resolve_agent_card(self, location: str) -> AgentCard:
|
|
52
|
+
return await self._client.resolve_agent_card(location)
|
|
53
|
+
|
|
54
|
+
async def connect(self, location: str) -> Client:
|
|
55
|
+
return await self._client.connect(location)
|
|
56
|
+
|
|
57
|
+
async def close(self) -> None:
|
|
58
|
+
await self._security.close()
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""SecureAgent composition of a native A2A server."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
|
12
|
+
from a2a.server.events import EventQueue
|
|
13
|
+
from a2a.server.request_handlers import DefaultRequestHandler
|
|
14
|
+
from a2a.server.tasks import InMemoryTaskStore
|
|
15
|
+
from a2a.server.tasks.base_push_notification_sender import BasePushNotificationSender
|
|
16
|
+
from a2a.server.tasks.inmemory_push_notification_config_store import (
|
|
17
|
+
InMemoryPushNotificationConfigStore,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from agent_nebula_platform.a2a.runtime import A2APlatformRuntimeComposer
|
|
21
|
+
from agent_nebula_platform.a2a.config import (
|
|
22
|
+
A2AListenerConfigurationLoader,
|
|
23
|
+
A2AServerConfigurationService,
|
|
24
|
+
)
|
|
25
|
+
from agent_nebula_platform.a2a.extensions import (
|
|
26
|
+
ensure_a2ui_extension,
|
|
27
|
+
ensure_schema_contract_extension,
|
|
28
|
+
ensure_skill_selection_extension,
|
|
29
|
+
selected_skill_id,
|
|
30
|
+
validate_executable_skills,
|
|
31
|
+
)
|
|
32
|
+
from agent_nebula_platform.a2a.security import (
|
|
33
|
+
A2AAuthorizedRequestHandler,
|
|
34
|
+
A2AServerSecurityFactory,
|
|
35
|
+
)
|
|
36
|
+
from agent_nebula_platform.a2a.security.server import A2AConfiguredSecurityLoader
|
|
37
|
+
from agent_nebula_platform.a2ui.configuration import A2UIConfiguration
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class A2AExecutableSkill:
|
|
43
|
+
"""Bind one advertised skill identifier to its native SDK executor."""
|
|
44
|
+
|
|
45
|
+
skill_id: str
|
|
46
|
+
executor: AgentExecutor
|
|
47
|
+
schema_contract_id: str | None = None
|
|
48
|
+
input_schema: Mapping[str, Any] | None = None
|
|
49
|
+
output_schema: Mapping[str, Any] | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _SkillDispatcher(AgentExecutor):
|
|
53
|
+
"""Dispatch only the Nebula skill-selection extension; execution stays native."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, skills: tuple[A2AExecutableSkill, ...]) -> None:
|
|
56
|
+
self._skills = {skill.skill_id: skill.executor for skill in skills}
|
|
57
|
+
self._task_skills: dict[str, str] = {}
|
|
58
|
+
|
|
59
|
+
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
60
|
+
skill_id = selected_skill_id(
|
|
61
|
+
metadata=context.metadata,
|
|
62
|
+
requested_extensions=context.requested_extensions,
|
|
63
|
+
)
|
|
64
|
+
if skill_id is None:
|
|
65
|
+
if len(self._skills) != 1:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"Multi-skill A2A agent requires the Nebula skill-selection extension"
|
|
68
|
+
)
|
|
69
|
+
skill_id = next(iter(self._skills))
|
|
70
|
+
executor = self._skills.get(skill_id)
|
|
71
|
+
if executor is None:
|
|
72
|
+
raise ValueError(f"Selected A2A skill is not registered: {skill_id}")
|
|
73
|
+
|
|
74
|
+
# Authentication and skill authorization are complete before a2a-sdk
|
|
75
|
+
# invokes this dispatcher. This class only routes the selected skill to
|
|
76
|
+
# its application AgentExecutor.
|
|
77
|
+
|
|
78
|
+
if context.task_id is not None:
|
|
79
|
+
self._task_skills[context.task_id] = skill_id
|
|
80
|
+
await executor.execute(context, event_queue)
|
|
81
|
+
|
|
82
|
+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
83
|
+
skill_id = self._task_skills.get(context.task_id or "")
|
|
84
|
+
if skill_id is None and len(self._skills) == 1:
|
|
85
|
+
skill_id = next(iter(self._skills))
|
|
86
|
+
if skill_id is None:
|
|
87
|
+
raise ValueError("Cannot route cancellation without the selected skill")
|
|
88
|
+
try:
|
|
89
|
+
await self._skills[skill_id].cancel(context, event_queue)
|
|
90
|
+
finally:
|
|
91
|
+
if context.task_id is not None:
|
|
92
|
+
self._task_skills.pop(context.task_id, None)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SecureAgentA2AServer:
|
|
96
|
+
"""Compose Platform A2A configuration/security with native SDK execution."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
configuration_file: Path,
|
|
102
|
+
skills: tuple[A2AExecutableSkill, ...],
|
|
103
|
+
application_config: Path,
|
|
104
|
+
nebula_config: Path | None,
|
|
105
|
+
a2ui_config: Path | None,
|
|
106
|
+
) -> None:
|
|
107
|
+
server = A2AServerConfigurationService().load(configuration_file)
|
|
108
|
+
listener = A2AListenerConfigurationLoader().load(configuration_file)
|
|
109
|
+
validate_executable_skills(server.agent_card, tuple(skill.skill_id for skill in skills))
|
|
110
|
+
ensure_skill_selection_extension(server.agent_card, required=False)
|
|
111
|
+
ensure_schema_contract_extension(server.agent_card, skills)
|
|
112
|
+
if a2ui_config is not None:
|
|
113
|
+
a2ui = A2UIConfiguration.load(a2ui_config)
|
|
114
|
+
if a2ui.enabled:
|
|
115
|
+
ensure_a2ui_extension(
|
|
116
|
+
server.agent_card,
|
|
117
|
+
supported_catalog_ids=a2ui.supported_catalog_ids,
|
|
118
|
+
accepts_inline_catalogs=a2ui.accepts_inline_catalogs,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
authenticators = A2AConfiguredSecurityLoader().load(
|
|
122
|
+
configuration_file,
|
|
123
|
+
mtls_transport_enforced=listener.tls.require_client_certificate,
|
|
124
|
+
)
|
|
125
|
+
self._security = A2AServerSecurityFactory().create(
|
|
126
|
+
agent_card=server.agent_card,
|
|
127
|
+
trust_bundle_file=listener.trust_bundle_file,
|
|
128
|
+
authenticators=authenticators,
|
|
129
|
+
)
|
|
130
|
+
self._push_http_client: httpx.AsyncClient | None = None
|
|
131
|
+
push_config_store = None
|
|
132
|
+
push_sender = None
|
|
133
|
+
if server.agent_card.capabilities.push_notifications:
|
|
134
|
+
push_config_store = InMemoryPushNotificationConfigStore()
|
|
135
|
+
self._push_http_client = httpx.AsyncClient()
|
|
136
|
+
push_sender = BasePushNotificationSender(
|
|
137
|
+
self._push_http_client,
|
|
138
|
+
push_config_store,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
native_handler = DefaultRequestHandler(
|
|
142
|
+
agent_executor=_SkillDispatcher(skills),
|
|
143
|
+
task_store=InMemoryTaskStore(),
|
|
144
|
+
agent_card=server.agent_card,
|
|
145
|
+
push_config_store=push_config_store,
|
|
146
|
+
push_sender=push_sender,
|
|
147
|
+
extended_agent_card=server.extended_agent_card,
|
|
148
|
+
)
|
|
149
|
+
handler = A2AAuthorizedRequestHandler(
|
|
150
|
+
inner=native_handler,
|
|
151
|
+
agent_card=server.agent_card,
|
|
152
|
+
security=self._security.service,
|
|
153
|
+
)
|
|
154
|
+
# Keep native a2a-sdk request handling, but host it inside the existing
|
|
155
|
+
# Platform Runtime. The Runtime is required for restricted configuration
|
|
156
|
+
# paths and for onboarding, health, metrics, telemetry, state and lifecycle.
|
|
157
|
+
self._runtime = A2APlatformRuntimeComposer().compose(
|
|
158
|
+
application_config=application_config,
|
|
159
|
+
server_config=configuration_file,
|
|
160
|
+
nebula_config=nebula_config,
|
|
161
|
+
server_configuration=server,
|
|
162
|
+
listener=listener,
|
|
163
|
+
request_handler=handler,
|
|
164
|
+
security=self._security,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def application(self):
|
|
169
|
+
return self._runtime.app
|
|
170
|
+
|
|
171
|
+
def run(self) -> None:
|
|
172
|
+
self._runtime.run()
|
|
173
|
+
|
|
174
|
+
async def close(self) -> None:
|
|
175
|
+
await self._runtime.close()
|
|
176
|
+
if self._push_http_client is not None:
|
|
177
|
+
await self._push_http_client.aclose()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Public SecureAgent A2UI client/runtime facade.
|
|
2
|
+
|
|
3
|
+
Applications import A2UI runtime types from SecureAgent rather than depending on
|
|
4
|
+
Agent Nebula Platform directly. Platform remains the implementation owner; this
|
|
5
|
+
module freezes the application-facing names needed by generic A2UI clients such
|
|
6
|
+
as Agent Explorer and orchestrators.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from agent_nebula_platform.a2ui import (
|
|
12
|
+
A2UIActionDispatcher,
|
|
13
|
+
A2UIActionRecord,
|
|
14
|
+
A2UIComponentPolicy,
|
|
15
|
+
A2UIMessage,
|
|
16
|
+
A2UIProtocolPolicy,
|
|
17
|
+
A2UISessionService,
|
|
18
|
+
A2UISessionSnapshot,
|
|
19
|
+
A2UISurface,
|
|
20
|
+
A2UISurfaceFrame,
|
|
21
|
+
A2UISurfaceMode,
|
|
22
|
+
A2UITaskReference,
|
|
23
|
+
MemoryA2UISessionStore,
|
|
24
|
+
)
|
|
25
|
+
from agent_nebula_platform.platform.events import NullEventSink
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_memory_a2ui_session_service() -> A2UISessionService:
|
|
29
|
+
"""Create an in-memory A2UI session service through the SecureAgent API."""
|
|
30
|
+
|
|
31
|
+
return A2UISessionService(
|
|
32
|
+
store=MemoryA2UISessionStore(),
|
|
33
|
+
events=NullEventSink(),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"A2UIActionDispatcher",
|
|
39
|
+
"A2UIActionRecord",
|
|
40
|
+
"A2UIComponentPolicy",
|
|
41
|
+
"A2UIMessage",
|
|
42
|
+
"A2UIProtocolPolicy",
|
|
43
|
+
"A2UISessionService",
|
|
44
|
+
"A2UISessionSnapshot",
|
|
45
|
+
"A2UISurface",
|
|
46
|
+
"A2UISurfaceFrame",
|
|
47
|
+
"A2UISurfaceMode",
|
|
48
|
+
"A2UITaskReference",
|
|
49
|
+
"create_memory_a2ui_session_service",
|
|
50
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Stable developer-facing Agent Nebula discovery models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class DiscoveredSkill:
|
|
12
|
+
"""One skill advertised by a provider returned from Agent Nebula discovery."""
|
|
13
|
+
|
|
14
|
+
skill_id: str
|
|
15
|
+
name: str | None = None
|
|
16
|
+
description: str | None = None
|
|
17
|
+
required_scopes: tuple[str, ...] = ()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class DiscoveredAgent:
|
|
22
|
+
"""Protocol-neutral discovery result; remote protocol facts remain non-authoritative."""
|
|
23
|
+
|
|
24
|
+
agent_id: str
|
|
25
|
+
display_name: str
|
|
26
|
+
external_agent_id: str | None = None
|
|
27
|
+
provider_id: str | None = None
|
|
28
|
+
provider_name: str | None = None
|
|
29
|
+
category: str | None = None
|
|
30
|
+
tags: tuple[str, ...] = ()
|
|
31
|
+
trust_status: str | None = None
|
|
32
|
+
lifecycle_status: str | None = None
|
|
33
|
+
agent_card_url: str | None = None
|
|
34
|
+
a2a_endpoint: str | None = None
|
|
35
|
+
protocols: tuple[str, ...] = ()
|
|
36
|
+
skills: tuple[DiscoveredSkill, ...] = ()
|
|
37
|
+
raw_document: Mapping[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def invokable(self) -> bool:
|
|
41
|
+
"""Return whether discovery supplied a location from which to resolve the Agent Card."""
|
|
42
|
+
return bool(self.agent_card_url or self.a2a_endpoint)
|
|
43
|
+
|
|
44
|
+
def require_agent_card_location(self) -> str:
|
|
45
|
+
"""Return discovery bootstrap data without treating it as invocation authority.
|
|
46
|
+
|
|
47
|
+
An explicit Agent Card URL is preferred. ``a2a_endpoint`` remains only as
|
|
48
|
+
a compatibility fallback for registries that publish a base location but
|
|
49
|
+
not an Agent Card URL. Platform resolves the authoritative Agent Card
|
|
50
|
+
before selecting the actual A2A interface or security mechanism.
|
|
51
|
+
"""
|
|
52
|
+
location = self.agent_card_url or self.a2a_endpoint
|
|
53
|
+
if not location:
|
|
54
|
+
raise ValueError("Discovered agent does not expose an Agent Card location")
|
|
55
|
+
return location
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DiscoveryNormalizer:
|
|
59
|
+
"""Normalize Registry response envelopes without coupling to one API revision."""
|
|
60
|
+
|
|
61
|
+
def normalize(self, payload: Mapping[str, Any] | Sequence[Any]) -> tuple[DiscoveredAgent, ...]:
|
|
62
|
+
return tuple(
|
|
63
|
+
agent
|
|
64
|
+
for record in self._records(payload)
|
|
65
|
+
if (agent := self._agent(record)) is not None
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _records(payload: Mapping[str, Any] | Sequence[Any]) -> tuple[Mapping[str, Any], ...]:
|
|
70
|
+
if isinstance(payload, Sequence) and not isinstance(payload, (str, bytes, bytearray)):
|
|
71
|
+
return tuple(item for item in payload if isinstance(item, Mapping))
|
|
72
|
+
if not isinstance(payload, Mapping):
|
|
73
|
+
return ()
|
|
74
|
+
for key in ("items", "agents", "results"):
|
|
75
|
+
values = payload.get(key)
|
|
76
|
+
if isinstance(values, Sequence) and not isinstance(values, (str, bytes, bytearray)):
|
|
77
|
+
return tuple(item for item in values if isinstance(item, Mapping))
|
|
78
|
+
return (payload,) if any(key in payload for key in ("agent_id", "id", "external_agent_id")) else ()
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def _agent(cls, record: Mapping[str, Any]) -> DiscoveredAgent | None:
|
|
82
|
+
document = cls._document(record)
|
|
83
|
+
endpoints = cls._first_mapping(record.get("endpoints"), document.get("endpoints"))
|
|
84
|
+
provider = cls._mapping(record.get("provider"))
|
|
85
|
+
agent_id = cls._first_string(record.get("agent_id"), record.get("id"), record.get("external_agent_id"), document.get("id"))
|
|
86
|
+
if agent_id is None:
|
|
87
|
+
return None
|
|
88
|
+
a2a_endpoint = cls._first_string(record.get("a2a_endpoint"), endpoints.get("primary"), endpoints.get("a2a"), document.get("url"))
|
|
89
|
+
categories = cls._strings(record.get("categories"))
|
|
90
|
+
return DiscoveredAgent(
|
|
91
|
+
agent_id=agent_id,
|
|
92
|
+
display_name=cls._first_string(record.get("display_name"), record.get("name"), document.get("name")) or agent_id,
|
|
93
|
+
external_agent_id=cls._first_string(record.get("external_agent_id"), document.get("id")),
|
|
94
|
+
provider_id=cls._first_string(record.get("provider_id"), provider.get("provider_id"), provider.get("id")),
|
|
95
|
+
provider_name=cls._first_string(record.get("provider_name"), provider.get("display_name"), provider.get("name")),
|
|
96
|
+
category=cls._first_string(record.get("category"), document.get("category")) or (categories[0] if categories else None),
|
|
97
|
+
tags=cls._strings(record.get("tags") or document.get("tags")),
|
|
98
|
+
trust_status=cls._first_string(record.get("trust_status"), record.get("trust")),
|
|
99
|
+
lifecycle_status=cls._first_string(record.get("lifecycle_status"), record.get("status")),
|
|
100
|
+
agent_card_url=cls._first_string(record.get("agent_card_url"), record.get("agent_card_uri"), endpoints.get("agent_card"), document.get("agent_card_url")),
|
|
101
|
+
a2a_endpoint=a2a_endpoint,
|
|
102
|
+
protocols=cls._strings(record.get("protocols") or document.get("protocols")),
|
|
103
|
+
skills=cls._skills(record, document),
|
|
104
|
+
raw_document=dict(record),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def _skills(cls, record: Mapping[str, Any], document: Mapping[str, Any]) -> tuple[DiscoveredSkill, ...]:
|
|
109
|
+
values = document.get("skills") or record.get("skills")
|
|
110
|
+
if not isinstance(values, Sequence) or isinstance(values, (str, bytes, bytearray)):
|
|
111
|
+
return ()
|
|
112
|
+
result: list[DiscoveredSkill] = []
|
|
113
|
+
for value in values:
|
|
114
|
+
if not isinstance(value, Mapping):
|
|
115
|
+
continue
|
|
116
|
+
skill_id = cls._first_string(value.get("skill_id"), value.get("id"))
|
|
117
|
+
if skill_id:
|
|
118
|
+
result.append(DiscoveredSkill(
|
|
119
|
+
skill_id=skill_id,
|
|
120
|
+
name=cls._first_string(value.get("name")),
|
|
121
|
+
description=cls._first_string(value.get("description")),
|
|
122
|
+
required_scopes=cls._strings(value.get("required_scopes")),
|
|
123
|
+
))
|
|
124
|
+
return tuple(result)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def _document(cls, record: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
128
|
+
return cls._first_mapping(
|
|
129
|
+
record.get("discovery_document"), record.get("agent_card_document"),
|
|
130
|
+
record.get("agent_card"), record.get("document"), record.get("card")
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _mapping(value: Any) -> Mapping[str, Any]:
|
|
135
|
+
return value if isinstance(value, Mapping) else {}
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _first_mapping(cls, *values: Any) -> Mapping[str, Any]:
|
|
139
|
+
for value in values:
|
|
140
|
+
mapped = cls._mapping(value)
|
|
141
|
+
if mapped:
|
|
142
|
+
return mapped
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _first_string(*values: Any) -> str | None:
|
|
147
|
+
for value in values:
|
|
148
|
+
if isinstance(value, str) and value.strip():
|
|
149
|
+
return value.strip()
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _strings(value: Any) -> tuple[str, ...]:
|
|
154
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
|
|
155
|
+
return ()
|
|
156
|
+
return tuple(dict.fromkeys(item.strip() for item in value if isinstance(item, str) and item.strip()))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Stable exceptions exposed by the SecureAgent developer API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SecureAgentError(Exception):
|
|
5
|
+
"""Base exception for failures reported through the SecureAgent facade."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SecureAgentConfigurationError(SecureAgentError):
|
|
9
|
+
"""Raised when facade or protocol configuration is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SecureAgentSkillRegistrationError(SecureAgentError):
|
|
13
|
+
"""Raised when a business skill cannot be registered safely."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SecureAgentProtocolError(SecureAgentError):
|
|
17
|
+
"""Raised when a requested protocol capability is unavailable or invalid."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Public SecureAgent facade for correlated Platform execution tracing.
|
|
2
|
+
|
|
3
|
+
SecureAgent deliberately exposes only the lifecycle boundary needed by an
|
|
4
|
+
application host. Agent Nebula Platform remains responsible for creating the
|
|
5
|
+
low-level, transport/security-aware events generated by discovery, OAuth,
|
|
6
|
+
DPoP, Agent Card resolution, and A2A operations. The application remains
|
|
7
|
+
responsible for choosing the execution identifier and deciding how the
|
|
8
|
+
already-sanitized events are persisted or rendered.
|
|
9
|
+
|
|
10
|
+
Keeping this context manager in SecureAgent prevents application code from
|
|
11
|
+
importing Platform implementation primitives directly while preserving one
|
|
12
|
+
correlation context across all nested Platform operations.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from agent_nebula_platform.platform.execution_trace import (
|
|
22
|
+
activate_execution_trace,
|
|
23
|
+
reset_execution_trace,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
ExecutionTraceRecorder = Callable[[Mapping[str, Any]], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def execution_trace(
|
|
31
|
+
execution_id: str,
|
|
32
|
+
*,
|
|
33
|
+
recorder: ExecutionTraceRecorder,
|
|
34
|
+
) -> Iterator[None]:
|
|
35
|
+
"""Correlate nested SecureAgent/Platform operations with one host execution.
|
|
36
|
+
|
|
37
|
+
This function provides the public execution-trace boundary for applications
|
|
38
|
+
built on SecureAgent. While the context is active, Platform operations can:
|
|
39
|
+
|
|
40
|
+
* attach the same execution identifier to supported outbound requests;
|
|
41
|
+
* emit semantic events for discovery, Agent Card, OAuth/DPoP, and A2A work;
|
|
42
|
+
* redact sensitive security material before an event reaches ``recorder``;
|
|
43
|
+
* forward those sanitized events to the host without coupling Platform to
|
|
44
|
+
the host's persistence, workflow, or user-interface implementation.
|
|
45
|
+
|
|
46
|
+
SecureAgent does not create or persist the execution identifier and does
|
|
47
|
+
not perform a second interpretation of Platform events. The caller owns the
|
|
48
|
+
trace lifecycle and supplies the recorder used by its observability model.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
execution_id: Host-owned correlation identifier shared by all nested
|
|
52
|
+
Platform operations executed inside this context. Leading/trailing
|
|
53
|
+
whitespace is ignored and a blank identifier is rejected.
|
|
54
|
+
recorder: Host callback that receives sanitized Platform event
|
|
55
|
+
mappings. Each event is copied before forwarding so host code cannot
|
|
56
|
+
mutate the Platform-owned event object.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: If ``execution_id`` is empty or contains only whitespace.
|
|
60
|
+
|
|
61
|
+
Notes:
|
|
62
|
+
The previous Platform context is restored in ``finally``. This makes
|
|
63
|
+
nesting safe and prevents a failed workflow from leaking its execution
|
|
64
|
+
correlation into later requests handled by the same process/context.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
normalized_execution_id = execution_id.strip()
|
|
68
|
+
if not normalized_execution_id:
|
|
69
|
+
raise ValueError("execution_id must not be blank")
|
|
70
|
+
|
|
71
|
+
def forward(event: dict[str, Any]) -> None:
|
|
72
|
+
# Platform has already applied its security redaction boundary. Copying
|
|
73
|
+
# here protects the Platform-owned event object from host-side mutation.
|
|
74
|
+
recorder(dict(event))
|
|
75
|
+
|
|
76
|
+
token = activate_execution_trace(normalized_execution_id, recorder=forward)
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
finally:
|
|
80
|
+
# Always restore the prior ContextVar token, including exception paths.
|
|
81
|
+
# This is required for nested traces and long-lived async server workers.
|
|
82
|
+
reset_execution_trace(token)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Public LLM observability facade for SecureAgent applications.
|
|
2
|
+
|
|
3
|
+
Applications can record provider-reported LLM usage without importing Agent
|
|
4
|
+
Nebula Platform implementation modules directly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from agent_nebula_platform.platform.llm import (
|
|
8
|
+
LlmCostEstimator,
|
|
9
|
+
LlmUsageObserver,
|
|
10
|
+
LlmUsageRecord,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = ["LlmCostEstimator", "LlmUsageObserver", "LlmUsageRecord"]
|