agentship-core 0.0.1__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.
- agentship/__init__.py +53 -0
- agentship/a2a/__init__.py +53 -0
- agentship/a2a/bridge.py +44 -0
- agentship/a2a/card.py +86 -0
- agentship/a2a/client.py +86 -0
- agentship/a2a/models.py +271 -0
- agentship/a2a/resolver.py +46 -0
- agentship/a2a/security.py +95 -0
- agentship/a2a/specialist.py +63 -0
- agentship/auth/__init__.py +125 -0
- agentship/auth/api_key.py +153 -0
- agentship/auth/composite.py +44 -0
- agentship/auth/forwarded.py +110 -0
- agentship/auth/jwt.py +171 -0
- agentship/auth/registry.py +132 -0
- agentship/conformance/__init__.py +449 -0
- agentship/context.py +135 -0
- agentship/engines/__init__.py +8 -0
- agentship/engines/base.py +309 -0
- agentship/engines/echo.py +52 -0
- agentship/errors.py +122 -0
- agentship/logs.py +172 -0
- agentship/middleware.py +41 -0
- agentship/observability/SEMCONV.md +134 -0
- agentship/observability/__init__.py +58 -0
- agentship/observability/attributes.py +52 -0
- agentship/observability/capture.py +73 -0
- agentship/observability/observer.py +195 -0
- agentship/observability/phi.py +36 -0
- agentship/observability/recorder.py +191 -0
- agentship/observability/redaction.py +45 -0
- agentship/observability/registry.py +114 -0
- agentship/observability/semconv.py +155 -0
- agentship/observability/studio.py +90 -0
- agentship/observability/trace_view.py +78 -0
- agentship/observability/types.py +57 -0
- agentship/primitives/__init__.py +45 -0
- agentship/primitives/conflict_resolver.py +119 -0
- agentship/primitives/dispatch.py +173 -0
- agentship/primitives/idempotency.py +140 -0
- agentship/primitives/model_router.py +190 -0
- agentship/primitives/retry.py +69 -0
- agentship/py.typed +0 -0
- agentship/registry.py +107 -0
- agentship/runtime.py +443 -0
- agentship/skills/__init__.py +17 -0
- agentship/skills/loader.py +89 -0
- agentship/skills/registry.py +38 -0
- agentship/skills/render.py +45 -0
- agentship/skills/skill.py +34 -0
- agentship/spec.py +460 -0
- agentship/tenancy.py +107 -0
- agentship/thread_lock.py +163 -0
- agentship/tools/__init__.py +15 -0
- agentship/tools/builtins/__init__.py +10 -0
- agentship/tools/builtins/_firecrawl.py +23 -0
- agentship/tools/builtins/calculator.py +104 -0
- agentship/tools/builtins/http_request.py +71 -0
- agentship/tools/builtins/scrape_url.py +83 -0
- agentship/tools/builtins/web_search.py +113 -0
- agentship/tools/registry.py +56 -0
- agentship/tools/tool.py +62 -0
- agentship_core-0.0.1.dist-info/METADATA +31 -0
- agentship_core-0.0.1.dist-info/RECORD +66 -0
- agentship_core-0.0.1.dist-info/WHEEL +4 -0
- agentship_core-0.0.1.dist-info/entry_points.txt +11 -0
agentship/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""AgentShip — an open, recipe-first harness for agentic systems.
|
|
2
|
+
|
|
3
|
+
Author an agent (or a team) in YAML or Python and get the plumbing wired behind
|
|
4
|
+
small, stable seams. This module re-exports the kernel's public surface: the spec,
|
|
5
|
+
the identity backbone, the runtime, and the error taxonomy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .context import Caller, RunContext, RunMode, get_run_context
|
|
11
|
+
from .engines.base import Engine, EngineCapabilities, Event, Result, ResumeToken
|
|
12
|
+
from .errors import (
|
|
13
|
+
AgentShipError,
|
|
14
|
+
CapabilityError,
|
|
15
|
+
EngineNotFoundError,
|
|
16
|
+
SpecError,
|
|
17
|
+
)
|
|
18
|
+
from .middleware import Middleware
|
|
19
|
+
from .primitives.model_router import (
|
|
20
|
+
DefaultModelRouter,
|
|
21
|
+
ModelRouter,
|
|
22
|
+
resolve_model_router,
|
|
23
|
+
)
|
|
24
|
+
from .runtime import RunnableAgent, build_agent
|
|
25
|
+
from .spec import AgentSpec, MemberSpec, ModelParams, ObservabilitySpec, load_spec, resolve_code
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AgentSpec",
|
|
29
|
+
"MemberSpec",
|
|
30
|
+
"ModelParams",
|
|
31
|
+
"ObservabilitySpec",
|
|
32
|
+
"load_spec",
|
|
33
|
+
"resolve_code",
|
|
34
|
+
"Caller",
|
|
35
|
+
"RunContext",
|
|
36
|
+
"RunMode",
|
|
37
|
+
"get_run_context",
|
|
38
|
+
"RunnableAgent",
|
|
39
|
+
"build_agent",
|
|
40
|
+
"Middleware",
|
|
41
|
+
"ModelRouter",
|
|
42
|
+
"DefaultModelRouter",
|
|
43
|
+
"resolve_model_router",
|
|
44
|
+
"Engine",
|
|
45
|
+
"EngineCapabilities",
|
|
46
|
+
"Event",
|
|
47
|
+
"ResumeToken",
|
|
48
|
+
"Result",
|
|
49
|
+
"AgentShipError",
|
|
50
|
+
"SpecError",
|
|
51
|
+
"CapabilityError",
|
|
52
|
+
"EngineNotFoundError",
|
|
53
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""A2A (agent-to-agent) interop — the optional networked-specialist layer (Phase 05).
|
|
2
|
+
|
|
3
|
+
This package is **Layer 1**: it is imported only when an agent opts into A2A (a networked
|
|
4
|
+
``specialists[].a2a:`` block or an ``a2a.expose: true`` block in YAML). Layer 0 — in-process
|
|
5
|
+
specialists (P02) and direct MCP (P03) — never touches it, so the whole ``agentship.a2a``
|
|
6
|
+
subtree can be absent and the default runtime stays green (the ``interop.optional`` invariant).
|
|
7
|
+
|
|
8
|
+
It carries the transport-agnostic pieces that live in core: the A2A wire models
|
|
9
|
+
(:mod:`agentship.a2a.models`), Agent Card generation (:mod:`agentship.a2a.card`), and the
|
|
10
|
+
:class:`~agentship.a2a.resolver.SpecialistResolver` that turns one ``AgentRef`` into either an
|
|
11
|
+
in-process or a networked specialist — so a supervisor's ``kit.specialist(name)`` call is
|
|
12
|
+
identical whichever side of the wire the specialist lives on. The FastAPI server adapter that
|
|
13
|
+
*exposes* an agent over A2A lives in ``agentship_service.a2a`` (it needs the web app), not here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .bridge import agent_ref_from_member
|
|
19
|
+
from .card import build_agent_card
|
|
20
|
+
from .client import HttpxTransport, RemoteA2aAgent
|
|
21
|
+
from .models import (
|
|
22
|
+
AgentCapabilities,
|
|
23
|
+
AgentCard,
|
|
24
|
+
AgentRef,
|
|
25
|
+
AgentSkill,
|
|
26
|
+
JsonRpcRequest,
|
|
27
|
+
JsonRpcResponse,
|
|
28
|
+
Message,
|
|
29
|
+
RemoteSpec,
|
|
30
|
+
TextPart,
|
|
31
|
+
)
|
|
32
|
+
from .resolver import SpecialistResolver
|
|
33
|
+
from .specialist import LocalSpecialist, RemoteSpecialist, Specialist
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AgentCapabilities",
|
|
37
|
+
"AgentCard",
|
|
38
|
+
"AgentRef",
|
|
39
|
+
"AgentSkill",
|
|
40
|
+
"HttpxTransport",
|
|
41
|
+
"JsonRpcRequest",
|
|
42
|
+
"JsonRpcResponse",
|
|
43
|
+
"LocalSpecialist",
|
|
44
|
+
"Message",
|
|
45
|
+
"RemoteA2aAgent",
|
|
46
|
+
"RemoteSpec",
|
|
47
|
+
"RemoteSpecialist",
|
|
48
|
+
"Specialist",
|
|
49
|
+
"SpecialistResolver",
|
|
50
|
+
"TextPart",
|
|
51
|
+
"agent_ref_from_member",
|
|
52
|
+
"build_agent_card",
|
|
53
|
+
]
|
agentship/a2a/bridge.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Bridge the authoring layer (``MemberSpec``) to a resolvable ``AgentRef`` (§C3 connective tissue).
|
|
2
|
+
|
|
3
|
+
The authoring-side blocks live in :mod:`agentship.spec` (so a spec parses with the interop layer
|
|
4
|
+
absent); the resolver consumes :class:`~agentship.a2a.models.AgentRef`. This module's
|
|
5
|
+
:func:`agent_ref_from_member` is the one-call translation, so a multi-agent builder treats a remote
|
|
6
|
+
member and an in-process one identically — mapping each to an ``AgentRef`` and letting the resolver
|
|
7
|
+
pick the transport. Kept here (a2a → spec is a one-way import) rather than in ``spec`` so the spec
|
|
8
|
+
module never depends on the optional interop package.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from ..errors import CapabilityError
|
|
16
|
+
from .models import A2AAuthConfig, AgentRef, RemoteSpec
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..spec import MemberSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def agent_ref_from_member(member: MemberSpec) -> AgentRef:
|
|
23
|
+
"""Map one declared team ``member`` onto the ``AgentRef`` the resolver understands.
|
|
24
|
+
|
|
25
|
+
A member with an ``a2a`` block becomes a **remote** ref (its URL/auth/timeout carried across); a
|
|
26
|
+
referenced (``ref``) or inline (``prompt``) member becomes a **local** ref keyed by the member
|
|
27
|
+
name — the registry key its sub-agent registers under. A member declaring no resolvable target
|
|
28
|
+
is a :class:`CapabilityError`, matching the "declare exactly one way" spec rule.
|
|
29
|
+
"""
|
|
30
|
+
if member.a2a is not None:
|
|
31
|
+
auth = A2AAuthConfig(**member.a2a.auth.model_dump())
|
|
32
|
+
remote = RemoteSpec(
|
|
33
|
+
url=member.a2a.url,
|
|
34
|
+
card_path=member.a2a.card_path,
|
|
35
|
+
auth=auth,
|
|
36
|
+
timeout_seconds=member.a2a.timeout_seconds,
|
|
37
|
+
)
|
|
38
|
+
return AgentRef(name=member.name, remote=remote)
|
|
39
|
+
if member.ref is not None or member.prompt is not None:
|
|
40
|
+
return AgentRef(name=member.name, local_ref=member.name)
|
|
41
|
+
raise CapabilityError(
|
|
42
|
+
f"member {member.name!r} declares no resolvable target — cannot resolve it as a "
|
|
43
|
+
f"specialist; give it a ref, a prompt, or an a2a block"
|
|
44
|
+
)
|
agentship/a2a/card.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Generate an A2A Agent Card from an ``AgentSpec`` + the engine's real capabilities (§C4).
|
|
2
|
+
|
|
3
|
+
The author never writes a card by hand. :func:`build_agent_card` reads the declarative spec and
|
|
4
|
+
the engine's :class:`~agentship.engines.base.EngineCapabilities`, so every claim on the card is
|
|
5
|
+
one the agent can actually honour — ``streaming`` is true only when the engine streams, the
|
|
6
|
+
advertised ``securitySchemes`` are exactly the ones the service enforces. That "declare, don't
|
|
7
|
+
fake" honesty is the whole point of generating rather than hand-writing (DESIGN §3 honesty).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ..engines.base import EngineCapabilities
|
|
13
|
+
from ..spec import A2aOAuth2Spec, AgentSpec
|
|
14
|
+
from .models import (
|
|
15
|
+
AgentCapabilities,
|
|
16
|
+
AgentCard,
|
|
17
|
+
ClientCredentialsFlow,
|
|
18
|
+
OAuthFlows,
|
|
19
|
+
SecurityScheme,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
#: Human-readable descriptions for the auth schemes an agent can advertise. The card names the
|
|
23
|
+
#: scheme; the service enforces exactly this set (they come from one config, so they can't drift).
|
|
24
|
+
_SECURITY_SCHEME_DESCRIPTIONS = {
|
|
25
|
+
"oauth2": "OAuth 2.0 bearer token (client-credentials flow).",
|
|
26
|
+
"apiKey": "Static API key in the X-API-Key header.",
|
|
27
|
+
"mtls": "Mutual TLS — a client certificate verified at the ingress.",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _security_scheme(name: str, oauth2: A2aOAuth2Spec | None) -> SecurityScheme:
|
|
32
|
+
"""Build the advertised :class:`SecurityScheme` for one scheme name in its A2A-conformant shape.
|
|
33
|
+
|
|
34
|
+
Each A2A scheme ``type`` needs different fields: ``apiKey`` must declare *where* the key travels
|
|
35
|
+
(``in: header``, ``name: X-API-Key``); ``oauth2`` must carry ``flows`` (we advertise the
|
|
36
|
+
client-credentials flow from ``oauth2`` config, or empty flows when none is given); and the
|
|
37
|
+
``mtls`` scheme's A2A ``type`` is spelled ``mutualTLS``. The dict key stays the author's name
|
|
38
|
+
(``mtls``), only the emitted ``type`` differs.
|
|
39
|
+
"""
|
|
40
|
+
description = _SECURITY_SCHEME_DESCRIPTIONS.get(name)
|
|
41
|
+
if name == "apiKey":
|
|
42
|
+
return SecurityScheme(
|
|
43
|
+
type="apiKey", name="X-API-Key", location="header", description=description
|
|
44
|
+
)
|
|
45
|
+
if name == "oauth2":
|
|
46
|
+
flows = OAuthFlows()
|
|
47
|
+
if oauth2 is not None:
|
|
48
|
+
flows = OAuthFlows(
|
|
49
|
+
client_credentials=ClientCredentialsFlow(
|
|
50
|
+
token_url=oauth2.token_url, scopes=dict(oauth2.scopes)
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return SecurityScheme(type="oauth2", description=description, flows=flows)
|
|
54
|
+
if name == "mtls":
|
|
55
|
+
return SecurityScheme(type="mutualTLS", description=description)
|
|
56
|
+
return SecurityScheme(type=name, description=description)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_agent_card(
|
|
60
|
+
spec: AgentSpec,
|
|
61
|
+
capabilities: EngineCapabilities,
|
|
62
|
+
*,
|
|
63
|
+
base_url: str,
|
|
64
|
+
security: list[str] | None = None,
|
|
65
|
+
oauth2: A2aOAuth2Spec | None = None,
|
|
66
|
+
) -> AgentCard:
|
|
67
|
+
"""Build the A2A :class:`AgentCard` for ``spec`` served under ``base_url``.
|
|
68
|
+
|
|
69
|
+
``base_url`` is the service root (e.g. ``https://host``); the card's ``url`` is the agent's
|
|
70
|
+
A2A endpoint ``{base_url}/a2a/{name}``. ``capabilities`` is the built engine's declared
|
|
71
|
+
capabilities — ``streaming`` on the card mirrors ``capabilities.streaming`` exactly, never the
|
|
72
|
+
spec's *request* to stream. ``security`` is the list of scheme names the agent enforces
|
|
73
|
+
(from its ``a2a.security`` block); each becomes both a ``securitySchemes`` entry and a
|
|
74
|
+
``security`` requirement so the card advertises precisely what the inbound router checks.
|
|
75
|
+
``oauth2`` supplies the token endpoint/scopes advertised on the ``oauth2`` scheme, when set.
|
|
76
|
+
"""
|
|
77
|
+
root = base_url.rstrip("/")
|
|
78
|
+
schemes = {name: _security_scheme(name, oauth2) for name in (security or [])}
|
|
79
|
+
return AgentCard(
|
|
80
|
+
name=spec.name,
|
|
81
|
+
description=spec.prompt,
|
|
82
|
+
url=f"{root}/a2a/{spec.name}",
|
|
83
|
+
capabilities=AgentCapabilities(streaming=capabilities.streaming),
|
|
84
|
+
security_schemes=schemes,
|
|
85
|
+
security=[{name: []} for name in (security or [])],
|
|
86
|
+
)
|
agentship/a2a/client.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The A2A client: send ``message/send`` to a remote agent over a pluggable transport (§C3/C6).
|
|
2
|
+
|
|
3
|
+
:class:`RemoteA2aAgent` speaks the client side of A2A — it builds the JSON-RPC envelope, attaches
|
|
4
|
+
the outbound credential + tenant/trace headers, hands the request to a :class:`A2ATransport`, and
|
|
5
|
+
unwraps the agent's reply. The transport is an injection seam: production uses
|
|
6
|
+
:class:`HttpxTransport` (a thin ``httpx.AsyncClient`` wrapper); tests pass a fake, so the client
|
|
7
|
+
logic is exercised with no network. Any transport-level failure becomes a retryable
|
|
8
|
+
:class:`~agentship.errors.A2AUnavailable` — the framework never silently swaps a remote for a local.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from ..context import RunContext
|
|
17
|
+
from ..errors import A2AUnavailable, CapabilityError
|
|
18
|
+
from .models import JsonRpcRequest, JsonRpcResponse, Message, RemoteSpec
|
|
19
|
+
from .security import build_outbound_auth, propagation_headers
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class A2ATransport(Protocol):
|
|
23
|
+
"""Carry one JSON-RPC call to ``url`` and return the parsed JSON response body."""
|
|
24
|
+
|
|
25
|
+
async def rpc(self, url: str, payload: dict, headers: dict) -> dict: # noqa: D102 - stub
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HttpxTransport:
|
|
30
|
+
"""The production transport: POST the JSON-RPC body with ``httpx`` (imported lazily)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, timeout_seconds: float = 60.0) -> None:
|
|
33
|
+
"""Bind the per-call timeout; ``httpx`` is imported on first use so core stays light."""
|
|
34
|
+
self._timeout = timeout_seconds
|
|
35
|
+
|
|
36
|
+
async def rpc(self, url: str, payload: dict, headers: dict) -> dict:
|
|
37
|
+
"""POST ``payload`` as JSON to ``url`` and return the decoded response body."""
|
|
38
|
+
import httpx
|
|
39
|
+
|
|
40
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
41
|
+
resp = await client.post(url, json=payload, headers=headers)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
return resp.json()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RemoteA2aAgent:
|
|
47
|
+
"""A networked specialist reached over A2A, satisfying the same ``send`` port as a local one."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, remote: RemoteSpec, *, transport: A2ATransport) -> None:
|
|
50
|
+
"""Bind the remote's location/auth and the transport that carries calls to it."""
|
|
51
|
+
self._remote = remote
|
|
52
|
+
self._transport = transport
|
|
53
|
+
self._auth = build_outbound_auth(remote.auth)
|
|
54
|
+
|
|
55
|
+
async def send(self, text: str, ctx: RunContext) -> str:
|
|
56
|
+
"""Send one ``message/send`` turn and return the remote agent's text reply.
|
|
57
|
+
|
|
58
|
+
Builds the JSON-RPC envelope, layers the credential over the tenant/trace headers, and
|
|
59
|
+
unwraps the result Message. A transport failure or a JSON-RPC error becomes
|
|
60
|
+
:class:`A2AUnavailable` (retryable) / :class:`CapabilityError` respectively.
|
|
61
|
+
"""
|
|
62
|
+
request = JsonRpcRequest(
|
|
63
|
+
id=uuid.uuid4().hex,
|
|
64
|
+
method="message/send",
|
|
65
|
+
params={"message": Message.user(text).model_dump(by_alias=True)},
|
|
66
|
+
)
|
|
67
|
+
headers = {**propagation_headers(ctx), **self._auth.headers(ctx)}
|
|
68
|
+
try:
|
|
69
|
+
raw = await self._transport.rpc(
|
|
70
|
+
self._remote.url, request.model_dump(by_alias=True), headers
|
|
71
|
+
)
|
|
72
|
+
except Exception as exc: # noqa: BLE001 — any transport failure is a retryable A2A outage
|
|
73
|
+
raise A2AUnavailable(f"A2A call to {self._remote.url!r} failed: {exc}") from exc
|
|
74
|
+
return _unwrap(raw)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _unwrap(raw: dict[str, Any]) -> str:
|
|
78
|
+
"""Turn a JSON-RPC response body into the agent's reply text, or raise on a protocol error."""
|
|
79
|
+
response = JsonRpcResponse.model_validate(raw)
|
|
80
|
+
if response.error is not None:
|
|
81
|
+
raise CapabilityError(
|
|
82
|
+
f"remote A2A agent returned error {response.error.code}: {response.error.message}"
|
|
83
|
+
)
|
|
84
|
+
result = response.result or {}
|
|
85
|
+
parts = result.get("parts") or []
|
|
86
|
+
return "".join(part.get("text", "") for part in parts if part.get("kind", "text") == "text")
|
agentship/a2a/models.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""A2A wire models and the ``AgentRef`` handle (Phase 05 · C3, C4).
|
|
2
|
+
|
|
3
|
+
Two families live here, both transport-agnostic (no ``httpx``/FastAPI), so they belong in core:
|
|
4
|
+
|
|
5
|
+
* **Authoring models** — :class:`AgentRef` and :class:`RemoteSpec`: how an author declares a
|
|
6
|
+
specialist that may be in-process *or* networked. Exactly one target is required, validated at
|
|
7
|
+
construction so a bad YAML block fails fast at load (§C3).
|
|
8
|
+
* **A2A protocol models** — :class:`AgentCard`, :class:`Message`/:class:`TextPart`, and the
|
|
9
|
+
:class:`JsonRpcRequest`/:class:`JsonRpcResponse` envelope. These mirror the A2A spec's JSON
|
|
10
|
+
shapes (camelCase on the wire via field aliases) so our server and client speak the protocol
|
|
11
|
+
without pulling in the ``a2a-sdk`` dependency; the models are deliberately a faithful subset of
|
|
12
|
+
what we implement (``message/send``, ``message/stream``, the Agent Card).
|
|
13
|
+
|
|
14
|
+
We keep these thin models rather than adopting ``a2a-sdk``'s types directly, by design:
|
|
15
|
+
a2a-sdk 1.x is protobuf-first (``a2a.types`` is compiled protobuf), and its only Pydantic/JSON
|
|
16
|
+
representation is the legacy ``a2a.compat.v0_3`` shim — adopting either would force grpc/protobuf
|
|
17
|
+
lock-in and protobuf-JSON semantics into this clean Pydantic/JSON service. Instead of importing
|
|
18
|
+
their models we *validate against* them: ``agentship-service[a2a]``'s drift guard
|
|
19
|
+
(``tests/test_a2a_conformance.py``) parses every shape we emit with a2a-sdk's own schema, so we
|
|
20
|
+
cannot drift from the spec. That is what "integrate, don't reinvent" means here — conform to the
|
|
21
|
+
standard's schema, without taking a dependency that fits this service poorly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Literal
|
|
27
|
+
from uuid import uuid4
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
30
|
+
|
|
31
|
+
from ..errors import CapabilityError
|
|
32
|
+
|
|
33
|
+
#: The A2A protocol revision our wire models target. Pinned so a spec bump is a deliberate,
|
|
34
|
+
#: reviewable change rather than a silent drift (R1 in the phase risks).
|
|
35
|
+
A2A_PROTOCOL_VERSION = "0.3.0"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------------------------
|
|
39
|
+
# Authoring models — how a specialist reference is declared (in-process or networked).
|
|
40
|
+
# --------------------------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class A2AAuthConfig(BaseModel):
|
|
44
|
+
"""How to authenticate an *outbound* A2A call to a remote agent (§C6).
|
|
45
|
+
|
|
46
|
+
``type`` selects the credential injector the client builds: ``bearer`` reads a static token
|
|
47
|
+
from ``token_env``; ``oauth2`` runs client-credentials against ``token_url``; ``mtls`` uses a
|
|
48
|
+
client cert/key pair. Only the fields the chosen ``type`` needs are set; the rest stay ``None``.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
type: Literal["none", "bearer", "oauth2", "mtls"] = "none"
|
|
52
|
+
#: Environment variable holding the static bearer token (``bearer``).
|
|
53
|
+
token_env: str | None = None
|
|
54
|
+
#: OAuth2 token endpoint + client credentials env vars (``oauth2``).
|
|
55
|
+
token_url: str | None = None
|
|
56
|
+
client_id_env: str | None = None
|
|
57
|
+
client_secret_env: str | None = None
|
|
58
|
+
#: Client certificate/key file paths (``mtls``).
|
|
59
|
+
cert_path: str | None = None
|
|
60
|
+
key_path: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RemoteSpec(BaseModel):
|
|
64
|
+
"""Where a networked specialist lives and how to reach it (§C3).
|
|
65
|
+
|
|
66
|
+
``url`` is the remote agent's A2A base URL; its Agent Card is fetched from ``url + card_path``
|
|
67
|
+
unless a caller passes one in. ``auth`` selects the outbound credential; ``timeout_seconds``
|
|
68
|
+
bounds a synchronous consult.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
url: str
|
|
72
|
+
card_path: str = "/.well-known/agent-card.json"
|
|
73
|
+
auth: A2AAuthConfig = Field(default_factory=A2AAuthConfig)
|
|
74
|
+
timeout_seconds: int = 60
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AgentRef(BaseModel):
|
|
78
|
+
"""A reference to a specialist, resolved either in-process or over A2A (§C3).
|
|
79
|
+
|
|
80
|
+
Exactly one of ``local_ref`` (a locally-registered agent name) or ``remote`` (a
|
|
81
|
+
:class:`RemoteSpec`) must be set — presence of ``remote`` means networked, presence of
|
|
82
|
+
``local_ref`` means in-process. Both or neither is a load-time :class:`CapabilityError` so a
|
|
83
|
+
malformed ``specialists:`` block never reaches build. One reference type, two resolutions: the
|
|
84
|
+
supervisor's authoring code never branches on transport.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
name: str
|
|
88
|
+
local_ref: str | None = None
|
|
89
|
+
remote: RemoteSpec | None = None
|
|
90
|
+
|
|
91
|
+
def model_post_init(self, _context: Any) -> None:
|
|
92
|
+
"""Enforce the exactly-one-target rule at construction (fail-fast at load)."""
|
|
93
|
+
has_local = self.local_ref is not None
|
|
94
|
+
has_remote = self.remote is not None
|
|
95
|
+
if has_local == has_remote:
|
|
96
|
+
raise CapabilityError(
|
|
97
|
+
f"specialist {self.name!r} must set exactly one of local_ref (in-process) or "
|
|
98
|
+
f"a2a/remote (networked) — got "
|
|
99
|
+
f"{'both' if has_local else 'neither'}; fix the specialists block"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def is_remote(self) -> bool:
|
|
103
|
+
"""Whether this specialist is reached over the network (A2A) rather than in-process."""
|
|
104
|
+
return self.remote is not None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------------------------
|
|
108
|
+
# A2A protocol models — the Agent Card and the JSON-RPC message envelope.
|
|
109
|
+
# --------------------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _A2AModel(BaseModel):
|
|
113
|
+
"""Base for wire models: serialise with A2A's camelCase aliases, accept either casing in."""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class AgentCapabilities(_A2AModel):
|
|
119
|
+
"""The ``capabilities`` block of an Agent Card — honest flags, generated from the engine."""
|
|
120
|
+
|
|
121
|
+
streaming: bool = False
|
|
122
|
+
push_notifications: bool = Field(default=False, alias="pushNotifications")
|
|
123
|
+
state_transition_history: bool = Field(default=False, alias="stateTransitionHistory")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AgentSkill(_A2AModel):
|
|
127
|
+
"""One advertised skill on an Agent Card (A2A ``skills[]`` entry)."""
|
|
128
|
+
|
|
129
|
+
id: str
|
|
130
|
+
name: str
|
|
131
|
+
description: str
|
|
132
|
+
tags: list[str] = Field(default_factory=list)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ClientCredentialsFlow(_A2AModel):
|
|
136
|
+
"""The OAuth2 client-credentials flow on the card (A2A ``ClientCredentialsOAuthFlow``).
|
|
137
|
+
|
|
138
|
+
Advertised so a service-to-service caller can obtain a token on its own.
|
|
139
|
+
``token_url`` is where a client exchanges its client id/secret for a bearer token; ``scopes``
|
|
140
|
+
maps each scope name the service accepts to a human description. A2A requires both on the flow.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
token_url: str = Field(alias="tokenUrl")
|
|
144
|
+
scopes: dict[str, str] = Field(default_factory=dict)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class OAuthFlows(_A2AModel):
|
|
148
|
+
"""The set of OAuth2 flows an ``oauth2`` scheme offers (A2A ``OAuthFlows``).
|
|
149
|
+
|
|
150
|
+
We advertise only the client-credentials flow (service-to-service, which is how agents call each
|
|
151
|
+
other over A2A); the other flow slots stay unset. An empty ``OAuthFlows`` is spec-valid and is
|
|
152
|
+
what we emit when the author names ``oauth2`` without declaring a token endpoint.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
client_credentials: ClientCredentialsFlow | None = Field(
|
|
156
|
+
default=None, alias="clientCredentials"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SecurityScheme(_A2AModel):
|
|
161
|
+
"""A single accepted auth scheme on the card (A2A ``securitySchemes`` value).
|
|
162
|
+
|
|
163
|
+
We advertise exactly the schemes the service enforces (they are generated from one config so
|
|
164
|
+
the card and the inbound check can never drift — §C6). Only the fields a given ``type`` needs
|
|
165
|
+
are set; A2A ignores the unused ``None`` fields, and the drift guard proves each variant
|
|
166
|
+
validates against ``a2a-sdk``'s discriminated ``SecurityScheme`` union.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
type: str
|
|
170
|
+
description: str | None = None
|
|
171
|
+
# apiKey schemes must declare WHERE the key travels so a client knows how to authenticate
|
|
172
|
+
# (A2A/OpenAPI APIKeySecurityScheme requires both). ``location`` serialises as ``in``.
|
|
173
|
+
name: str | None = None
|
|
174
|
+
location: str | None = Field(default=None, alias="in")
|
|
175
|
+
# oauth2 schemes carry the flows a client can use to obtain a token; unset for apiKey/mutualTLS.
|
|
176
|
+
flows: OAuthFlows | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class AgentCard(_A2AModel):
|
|
180
|
+
"""The A2A Agent Card served at ``/.well-known/agent-card.json`` (§7, §C4).
|
|
181
|
+
|
|
182
|
+
Generated from an :class:`~agentship.spec.AgentSpec` plus the engine's real
|
|
183
|
+
:class:`~agentship.engines.base.EngineCapabilities` — never hand-written — so what the card
|
|
184
|
+
claims (streaming, skills, security) is exactly what the agent does. Serialise with
|
|
185
|
+
``by_alias=True`` to emit the A2A camelCase field names.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
name: str
|
|
189
|
+
description: str | None = None
|
|
190
|
+
url: str
|
|
191
|
+
version: str = "1.0.0"
|
|
192
|
+
protocol_version: str = Field(default=A2A_PROTOCOL_VERSION, alias="protocolVersion")
|
|
193
|
+
capabilities: AgentCapabilities = Field(default_factory=AgentCapabilities)
|
|
194
|
+
default_input_modes: list[str] = Field(
|
|
195
|
+
default_factory=lambda: ["text/plain"], alias="defaultInputModes"
|
|
196
|
+
)
|
|
197
|
+
default_output_modes: list[str] = Field(
|
|
198
|
+
default_factory=lambda: ["text/plain"], alias="defaultOutputModes"
|
|
199
|
+
)
|
|
200
|
+
skills: list[AgentSkill] = Field(default_factory=list)
|
|
201
|
+
security_schemes: dict[str, SecurityScheme] = Field(
|
|
202
|
+
default_factory=dict, alias="securitySchemes"
|
|
203
|
+
)
|
|
204
|
+
security: list[dict[str, list[str]]] = Field(default_factory=list)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TextPart(_A2AModel):
|
|
208
|
+
"""A text ``Part`` of an A2A ``Message`` (the only part kind we speak today)."""
|
|
209
|
+
|
|
210
|
+
kind: Literal["text"] = "text"
|
|
211
|
+
text: str
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Message(_A2AModel):
|
|
215
|
+
"""An A2A ``Message``: a role plus an ordered list of parts (§C3 mapping)."""
|
|
216
|
+
|
|
217
|
+
role: Literal["user", "agent"] = "user"
|
|
218
|
+
parts: list[TextPart] = Field(default_factory=list)
|
|
219
|
+
# The A2A spec requires every Message to carry a stable id; generate one so our outbound
|
|
220
|
+
# messages are spec-conformant (verified by the schema drift guard in test_a2a_conformance).
|
|
221
|
+
message_id: str = Field(default_factory=lambda: uuid4().hex, alias="messageId")
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def user(cls, text: str) -> Message:
|
|
225
|
+
"""Build a single-text-part ``user`` message (the common inbound/outbound shape)."""
|
|
226
|
+
return cls(role="user", parts=[TextPart(text=text)])
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def agent(cls, text: str) -> Message:
|
|
230
|
+
"""Build a single-text-part ``agent`` message (a specialist's reply)."""
|
|
231
|
+
return cls(role="agent", parts=[TextPart(text=text)])
|
|
232
|
+
|
|
233
|
+
def text(self) -> str:
|
|
234
|
+
"""Concatenate every text part into one string (what an engine's ``run`` consumes)."""
|
|
235
|
+
return "".join(part.text for part in self.parts)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class JsonRpcError(_A2AModel):
|
|
239
|
+
"""The ``error`` member of a JSON-RPC failure response."""
|
|
240
|
+
|
|
241
|
+
code: int
|
|
242
|
+
message: str
|
|
243
|
+
data: Any | None = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class JsonRpcRequest(_A2AModel):
|
|
247
|
+
"""A JSON-RPC 2.0 request envelope (``message/send``, ``message/stream``, ``tasks/*``)."""
|
|
248
|
+
|
|
249
|
+
jsonrpc: Literal["2.0"] = "2.0"
|
|
250
|
+
id: str | int | None = None
|
|
251
|
+
method: str
|
|
252
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class JsonRpcResponse(_A2AModel):
|
|
256
|
+
"""A JSON-RPC 2.0 response envelope — exactly one of ``result`` or ``error`` is set."""
|
|
257
|
+
|
|
258
|
+
jsonrpc: Literal["2.0"] = "2.0"
|
|
259
|
+
id: str | int | None = None
|
|
260
|
+
result: Any | None = None
|
|
261
|
+
error: JsonRpcError | None = None
|
|
262
|
+
|
|
263
|
+
@classmethod
|
|
264
|
+
def ok(cls, id: str | int | None, result: Any) -> JsonRpcResponse:
|
|
265
|
+
"""A success response carrying ``result``."""
|
|
266
|
+
return cls(id=id, result=result)
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def fail(cls, id: str | int | None, code: int, message: str) -> JsonRpcResponse:
|
|
270
|
+
"""A failure response carrying a JSON-RPC ``error``."""
|
|
271
|
+
return cls(id=id, error=JsonRpcError(code=code, message=message))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Resolve one ``AgentRef`` into a specialist — in-process or networked (§C3).
|
|
2
|
+
|
|
3
|
+
:class:`SpecialistResolver` is the single seam where a specialist reference becomes a callable. A
|
|
4
|
+
``local_ref`` is looked up in the registry and wrapped as a :class:`LocalSpecialist`; a ``remote``
|
|
5
|
+
block becomes a :class:`RemoteSpecialist` over an A2A client. Either way the caller gets the same
|
|
6
|
+
``send`` port, so a supervisor graph never branches on transport (the transparency invariant).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..context import RunContext
|
|
12
|
+
from ..errors import CapabilityError
|
|
13
|
+
from .client import HttpxTransport, RemoteA2aAgent
|
|
14
|
+
from .models import AgentRef
|
|
15
|
+
from .specialist import LocalSpecialist, RemoteSpecialist, Specialist
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SpecialistResolver:
|
|
19
|
+
"""Turn an :class:`AgentRef` into a :class:`Specialist`, picking transport from the ref."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *, registry, transport=None) -> None:
|
|
22
|
+
"""Bind the in-process ``registry`` (name → agent) and the A2A ``transport`` for remotes.
|
|
23
|
+
|
|
24
|
+
``registry`` is any ``name → agent`` mapping (a plain dict works). ``transport`` is the A2A
|
|
25
|
+
transport used for networked refs; it defaults to :class:`HttpxTransport` so production
|
|
26
|
+
needs no wiring, while tests inject a fake to exercise the client without a network.
|
|
27
|
+
"""
|
|
28
|
+
self._registry = registry
|
|
29
|
+
self._transport = transport if transport is not None else HttpxTransport()
|
|
30
|
+
|
|
31
|
+
def resolve(self, ref: AgentRef, ctx: RunContext) -> Specialist:
|
|
32
|
+
"""Resolve ``ref`` to a specialist; fail fast on an unknown local ref.
|
|
33
|
+
|
|
34
|
+
A remote ref is bound to the A2A client here but not called until ``send`` — resolution is
|
|
35
|
+
cheap and synchronous; the network work happens on the turn.
|
|
36
|
+
"""
|
|
37
|
+
if ref.is_remote():
|
|
38
|
+
remote_agent = RemoteA2aAgent(ref.remote, transport=self._transport)
|
|
39
|
+
return RemoteSpecialist(ref.name, remote_agent, ctx)
|
|
40
|
+
agent = self._registry.get(ref.local_ref)
|
|
41
|
+
if agent is None:
|
|
42
|
+
raise CapabilityError(
|
|
43
|
+
f"no agent registered as {ref.local_ref!r} — cannot resolve specialist "
|
|
44
|
+
f"{ref.name!r}; register it or fix the ref"
|
|
45
|
+
)
|
|
46
|
+
return LocalSpecialist(ref.name, agent, ctx)
|