agentship-core 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentship_core-0.0.1/.gitignore +27 -0
- agentship_core-0.0.1/PKG-INFO +31 -0
- agentship_core-0.0.1/README.md +16 -0
- agentship_core-0.0.1/pyproject.toml +50 -0
- agentship_core-0.0.1/src/agentship/__init__.py +53 -0
- agentship_core-0.0.1/src/agentship/a2a/__init__.py +53 -0
- agentship_core-0.0.1/src/agentship/a2a/bridge.py +44 -0
- agentship_core-0.0.1/src/agentship/a2a/card.py +86 -0
- agentship_core-0.0.1/src/agentship/a2a/client.py +86 -0
- agentship_core-0.0.1/src/agentship/a2a/models.py +271 -0
- agentship_core-0.0.1/src/agentship/a2a/resolver.py +46 -0
- agentship_core-0.0.1/src/agentship/a2a/security.py +95 -0
- agentship_core-0.0.1/src/agentship/a2a/specialist.py +63 -0
- agentship_core-0.0.1/src/agentship/auth/__init__.py +125 -0
- agentship_core-0.0.1/src/agentship/auth/api_key.py +153 -0
- agentship_core-0.0.1/src/agentship/auth/composite.py +44 -0
- agentship_core-0.0.1/src/agentship/auth/forwarded.py +110 -0
- agentship_core-0.0.1/src/agentship/auth/jwt.py +171 -0
- agentship_core-0.0.1/src/agentship/auth/registry.py +132 -0
- agentship_core-0.0.1/src/agentship/conformance/__init__.py +449 -0
- agentship_core-0.0.1/src/agentship/context.py +135 -0
- agentship_core-0.0.1/src/agentship/engines/__init__.py +8 -0
- agentship_core-0.0.1/src/agentship/engines/base.py +309 -0
- agentship_core-0.0.1/src/agentship/engines/echo.py +52 -0
- agentship_core-0.0.1/src/agentship/errors.py +122 -0
- agentship_core-0.0.1/src/agentship/logs.py +172 -0
- agentship_core-0.0.1/src/agentship/middleware.py +41 -0
- agentship_core-0.0.1/src/agentship/observability/SEMCONV.md +134 -0
- agentship_core-0.0.1/src/agentship/observability/__init__.py +58 -0
- agentship_core-0.0.1/src/agentship/observability/attributes.py +52 -0
- agentship_core-0.0.1/src/agentship/observability/capture.py +73 -0
- agentship_core-0.0.1/src/agentship/observability/observer.py +195 -0
- agentship_core-0.0.1/src/agentship/observability/phi.py +36 -0
- agentship_core-0.0.1/src/agentship/observability/recorder.py +191 -0
- agentship_core-0.0.1/src/agentship/observability/redaction.py +45 -0
- agentship_core-0.0.1/src/agentship/observability/registry.py +114 -0
- agentship_core-0.0.1/src/agentship/observability/semconv.py +155 -0
- agentship_core-0.0.1/src/agentship/observability/studio.py +90 -0
- agentship_core-0.0.1/src/agentship/observability/trace_view.py +78 -0
- agentship_core-0.0.1/src/agentship/observability/types.py +57 -0
- agentship_core-0.0.1/src/agentship/primitives/__init__.py +45 -0
- agentship_core-0.0.1/src/agentship/primitives/conflict_resolver.py +119 -0
- agentship_core-0.0.1/src/agentship/primitives/dispatch.py +173 -0
- agentship_core-0.0.1/src/agentship/primitives/idempotency.py +140 -0
- agentship_core-0.0.1/src/agentship/primitives/model_router.py +190 -0
- agentship_core-0.0.1/src/agentship/primitives/retry.py +69 -0
- agentship_core-0.0.1/src/agentship/py.typed +0 -0
- agentship_core-0.0.1/src/agentship/registry.py +107 -0
- agentship_core-0.0.1/src/agentship/runtime.py +443 -0
- agentship_core-0.0.1/src/agentship/skills/__init__.py +17 -0
- agentship_core-0.0.1/src/agentship/skills/loader.py +89 -0
- agentship_core-0.0.1/src/agentship/skills/registry.py +38 -0
- agentship_core-0.0.1/src/agentship/skills/render.py +45 -0
- agentship_core-0.0.1/src/agentship/skills/skill.py +34 -0
- agentship_core-0.0.1/src/agentship/spec.py +460 -0
- agentship_core-0.0.1/src/agentship/tenancy.py +107 -0
- agentship_core-0.0.1/src/agentship/thread_lock.py +163 -0
- agentship_core-0.0.1/src/agentship/tools/__init__.py +15 -0
- agentship_core-0.0.1/src/agentship/tools/builtins/__init__.py +10 -0
- agentship_core-0.0.1/src/agentship/tools/builtins/_firecrawl.py +23 -0
- agentship_core-0.0.1/src/agentship/tools/builtins/calculator.py +104 -0
- agentship_core-0.0.1/src/agentship/tools/builtins/http_request.py +71 -0
- agentship_core-0.0.1/src/agentship/tools/builtins/scrape_url.py +83 -0
- agentship_core-0.0.1/src/agentship/tools/builtins/web_search.py +113 -0
- agentship_core-0.0.1/src/agentship/tools/registry.py +56 -0
- agentship_core-0.0.1/src/agentship/tools/tool.py +62 -0
- agentship_core-0.0.1/tests/test_a2a_bridge.py +53 -0
- agentship_core-0.0.1/tests/test_a2a_card.py +48 -0
- agentship_core-0.0.1/tests/test_a2a_models.py +78 -0
- agentship_core-0.0.1/tests/test_a2a_resolver.py +104 -0
- agentship_core-0.0.1/tests/test_auth.py +161 -0
- agentship_core-0.0.1/tests/test_auth_api_key.py +118 -0
- agentship_core-0.0.1/tests/test_auth_composite.py +92 -0
- agentship_core-0.0.1/tests/test_auth_forwarded.py +150 -0
- agentship_core-0.0.1/tests/test_auth_jwt.py +190 -0
- agentship_core-0.0.1/tests/test_auth_registry.py +87 -0
- agentship_core-0.0.1/tests/test_capture.py +83 -0
- agentship_core-0.0.1/tests/test_conflict_resolver.py +186 -0
- agentship_core-0.0.1/tests/test_context.py +130 -0
- agentship_core-0.0.1/tests/test_dispatch.py +174 -0
- agentship_core-0.0.1/tests/test_dispatch_empty.py +40 -0
- agentship_core-0.0.1/tests/test_echo.py +27 -0
- agentship_core-0.0.1/tests/test_engine.py +214 -0
- agentship_core-0.0.1/tests/test_errors.py +34 -0
- agentship_core-0.0.1/tests/test_idempotency.py +191 -0
- agentship_core-0.0.1/tests/test_logs.py +81 -0
- agentship_core-0.0.1/tests/test_lookup_model_router.py +154 -0
- agentship_core-0.0.1/tests/test_model_router.py +141 -0
- agentship_core-0.0.1/tests/test_observability_contract.py +245 -0
- agentship_core-0.0.1/tests/test_observability_default.py +82 -0
- agentship_core-0.0.1/tests/test_observer_registry.py +127 -0
- agentship_core-0.0.1/tests/test_packaging_isolation.py +63 -0
- agentship_core-0.0.1/tests/test_phi_gate.py +45 -0
- agentship_core-0.0.1/tests/test_reasoning.py +241 -0
- agentship_core-0.0.1/tests/test_registry.py +72 -0
- agentship_core-0.0.1/tests/test_resume.py +148 -0
- agentship_core-0.0.1/tests/test_retry.py +75 -0
- agentship_core-0.0.1/tests/test_runtime.py +156 -0
- agentship_core-0.0.1/tests/test_runtime_caller.py +83 -0
- agentship_core-0.0.1/tests/test_runtime_observability.py +100 -0
- agentship_core-0.0.1/tests/test_skills.py +125 -0
- agentship_core-0.0.1/tests/test_span_content_capture.py +64 -0
- agentship_core-0.0.1/tests/test_span_names_and_threads.py +86 -0
- agentship_core-0.0.1/tests/test_spec.py +462 -0
- agentship_core-0.0.1/tests/test_spec_a2a.py +52 -0
- agentship_core-0.0.1/tests/test_studio_manifest.py +74 -0
- agentship_core-0.0.1/tests/test_subagent_span_nesting.py +67 -0
- agentship_core-0.0.1/tests/test_tenancy.py +96 -0
- agentship_core-0.0.1/tests/test_thread_lock.py +167 -0
- agentship_core-0.0.1/tests/test_tools.py +175 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.coverage
|
|
14
|
+
htmlcov/
|
|
15
|
+
|
|
16
|
+
# Env / secrets — never commit
|
|
17
|
+
.env
|
|
18
|
+
.env.*
|
|
19
|
+
!.env.example
|
|
20
|
+
|
|
21
|
+
# Editor / OS
|
|
22
|
+
.DS_Store
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
|
|
26
|
+
# Docs build
|
|
27
|
+
site/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentship-core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: AgentShip kernel — spec, context, runtime, registry, errors, middleware, and the echo engine. The thin, vendor-free harness core.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: pydantic>=2
|
|
8
|
+
Requires-Dist: pyyaml>=6
|
|
9
|
+
Provides-Extra: jwt
|
|
10
|
+
Requires-Dist: httpx>=0.27; extra == 'jwt'
|
|
11
|
+
Requires-Dist: pyjwt[crypto]>=2.9; extra == 'jwt'
|
|
12
|
+
Provides-Extra: postgres
|
|
13
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# agentship-core
|
|
17
|
+
|
|
18
|
+
The AgentShip kernel: spec, context, runtime, registry, errors, middleware, and the zero-dependency `echo` engine. Vendor-free — depends only on `pydantic` and `pyyaml`. Import as `agentship`.
|
|
19
|
+
|
|
20
|
+
## Vendor-free by contract — and guarded against drift
|
|
21
|
+
|
|
22
|
+
The kernel cannot import OpenTelemetry, `a2a-sdk`, or any other vendor library (design §4.6): engine
|
|
23
|
+
and eval hooks must work with only `agentship-core` installed. That forces us to keep a few small
|
|
24
|
+
implementations of our own — the semantic-convention key constants, the in-memory
|
|
25
|
+
`RecordingObserver`, the A2A wire models, and the JWT/JWKS seam. We do not just trust those to match
|
|
26
|
+
the standards they claim to speak; each is pinned to its upstream by a **conformance guard** — a test
|
|
27
|
+
in the relevant package that validates our output against the real library. So the code stays thin and
|
|
28
|
+
vendor-free, and it still can't silently drift.
|
|
29
|
+
|
|
30
|
+
See [`docs/decisions/0001-integrate-not-invent.md`](../../docs/decisions/0001-integrate-not-invent.md)
|
|
31
|
+
for the delegations and the keep-thin-and-guard decisions.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# agentship-core
|
|
2
|
+
|
|
3
|
+
The AgentShip kernel: spec, context, runtime, registry, errors, middleware, and the zero-dependency `echo` engine. Vendor-free — depends only on `pydantic` and `pyyaml`. Import as `agentship`.
|
|
4
|
+
|
|
5
|
+
## Vendor-free by contract — and guarded against drift
|
|
6
|
+
|
|
7
|
+
The kernel cannot import OpenTelemetry, `a2a-sdk`, or any other vendor library (design §4.6): engine
|
|
8
|
+
and eval hooks must work with only `agentship-core` installed. That forces us to keep a few small
|
|
9
|
+
implementations of our own — the semantic-convention key constants, the in-memory
|
|
10
|
+
`RecordingObserver`, the A2A wire models, and the JWT/JWKS seam. We do not just trust those to match
|
|
11
|
+
the standards they claim to speak; each is pinned to its upstream by a **conformance guard** — a test
|
|
12
|
+
in the relevant package that validates our output against the real library. So the code stays thin and
|
|
13
|
+
vendor-free, and it still can't silently drift.
|
|
14
|
+
|
|
15
|
+
See [`docs/decisions/0001-integrate-not-invent.md`](../../docs/decisions/0001-integrate-not-invent.md)
|
|
16
|
+
for the delegations and the keep-thin-and-guard decisions.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentship-core"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "AgentShip kernel — spec, context, runtime, registry, errors, middleware, and the echo engine. The thin, vendor-free harness core."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pydantic>=2",
|
|
14
|
+
"pyyaml>=6",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Postgres substrate (the single-owner ThreadLock and, later, the durable task
|
|
18
|
+
# store). Optional so a bare install stays vendor-light and runs on the in-memory
|
|
19
|
+
# ThreadLock fallback; `pip install agentship-core[postgres]` opts in. psycopg3 is
|
|
20
|
+
# chosen to match LangGraph's AsyncPostgresSaver — one driver for the whole stack.
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
postgres = ["psycopg[binary]>=3.1"]
|
|
23
|
+
|
|
24
|
+
# The optional in-app JWT auth path (JwtAuthProvider). Not the default — behind a
|
|
25
|
+
# gateway the gateway validates tokens — so PyJWT (with crypto for RS256/ES256 and
|
|
26
|
+
# JWKS) is an opt-in extra rather than a base dependency. httpx backs the JWKS fetch.
|
|
27
|
+
jwt = ["pyjwt[crypto]>=2.9", "httpx>=0.27"]
|
|
28
|
+
|
|
29
|
+
# The zero-dependency echo engine registers here; adapter packages (e.g.
|
|
30
|
+
# agentship-langgraph) add their own engines to the same group. Core discovers
|
|
31
|
+
# them via importlib.metadata — no core edit needed to add an engine.
|
|
32
|
+
[project.entry-points."agentship.engines"]
|
|
33
|
+
echo = "agentship.engines.echo:EchoEngine"
|
|
34
|
+
|
|
35
|
+
# The built-in deterministic model router registers here; a project ships its own
|
|
36
|
+
# ModelRouter under the same group to swap routing policy without a core edit.
|
|
37
|
+
[project.entry-points."agentship.model_routers"]
|
|
38
|
+
default = "agentship.primitives.model_router:DefaultModelRouter"
|
|
39
|
+
|
|
40
|
+
# The built-in auth-provider factories register here; a project or vendor ships a
|
|
41
|
+
# custom provider under the same group (selected by name in service config) with no
|
|
42
|
+
# core edit. Each value is a factory: (config mapping) -> AuthProvider.
|
|
43
|
+
[project.entry-points."agentship.auth_providers"]
|
|
44
|
+
forwarded = "agentship.auth.registry:forwarded_from_config"
|
|
45
|
+
api_key = "agentship.auth.registry:api_key_from_config"
|
|
46
|
+
jwt = "agentship.auth.registry:jwt_from_config"
|
|
47
|
+
composite = "agentship.auth.registry:composite_from_config"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/agentship"]
|
|
@@ -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
|
+
)
|
|
@@ -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")
|