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.
Files changed (66) hide show
  1. agentship/__init__.py +53 -0
  2. agentship/a2a/__init__.py +53 -0
  3. agentship/a2a/bridge.py +44 -0
  4. agentship/a2a/card.py +86 -0
  5. agentship/a2a/client.py +86 -0
  6. agentship/a2a/models.py +271 -0
  7. agentship/a2a/resolver.py +46 -0
  8. agentship/a2a/security.py +95 -0
  9. agentship/a2a/specialist.py +63 -0
  10. agentship/auth/__init__.py +125 -0
  11. agentship/auth/api_key.py +153 -0
  12. agentship/auth/composite.py +44 -0
  13. agentship/auth/forwarded.py +110 -0
  14. agentship/auth/jwt.py +171 -0
  15. agentship/auth/registry.py +132 -0
  16. agentship/conformance/__init__.py +449 -0
  17. agentship/context.py +135 -0
  18. agentship/engines/__init__.py +8 -0
  19. agentship/engines/base.py +309 -0
  20. agentship/engines/echo.py +52 -0
  21. agentship/errors.py +122 -0
  22. agentship/logs.py +172 -0
  23. agentship/middleware.py +41 -0
  24. agentship/observability/SEMCONV.md +134 -0
  25. agentship/observability/__init__.py +58 -0
  26. agentship/observability/attributes.py +52 -0
  27. agentship/observability/capture.py +73 -0
  28. agentship/observability/observer.py +195 -0
  29. agentship/observability/phi.py +36 -0
  30. agentship/observability/recorder.py +191 -0
  31. agentship/observability/redaction.py +45 -0
  32. agentship/observability/registry.py +114 -0
  33. agentship/observability/semconv.py +155 -0
  34. agentship/observability/studio.py +90 -0
  35. agentship/observability/trace_view.py +78 -0
  36. agentship/observability/types.py +57 -0
  37. agentship/primitives/__init__.py +45 -0
  38. agentship/primitives/conflict_resolver.py +119 -0
  39. agentship/primitives/dispatch.py +173 -0
  40. agentship/primitives/idempotency.py +140 -0
  41. agentship/primitives/model_router.py +190 -0
  42. agentship/primitives/retry.py +69 -0
  43. agentship/py.typed +0 -0
  44. agentship/registry.py +107 -0
  45. agentship/runtime.py +443 -0
  46. agentship/skills/__init__.py +17 -0
  47. agentship/skills/loader.py +89 -0
  48. agentship/skills/registry.py +38 -0
  49. agentship/skills/render.py +45 -0
  50. agentship/skills/skill.py +34 -0
  51. agentship/spec.py +460 -0
  52. agentship/tenancy.py +107 -0
  53. agentship/thread_lock.py +163 -0
  54. agentship/tools/__init__.py +15 -0
  55. agentship/tools/builtins/__init__.py +10 -0
  56. agentship/tools/builtins/_firecrawl.py +23 -0
  57. agentship/tools/builtins/calculator.py +104 -0
  58. agentship/tools/builtins/http_request.py +71 -0
  59. agentship/tools/builtins/scrape_url.py +83 -0
  60. agentship/tools/builtins/web_search.py +113 -0
  61. agentship/tools/registry.py +56 -0
  62. agentship/tools/tool.py +62 -0
  63. agentship_core-0.0.1.dist-info/METADATA +31 -0
  64. agentship_core-0.0.1.dist-info/RECORD +66 -0
  65. agentship_core-0.0.1.dist-info/WHEEL +4 -0
  66. 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
+ ]
@@ -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
+ )
@@ -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")
@@ -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)