agentship-service 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.
Files changed (39) hide show
  1. agentship_service-0.0.1/.gitignore +27 -0
  2. agentship_service-0.0.1/PKG-INFO +49 -0
  3. agentship_service-0.0.1/README.md +34 -0
  4. agentship_service-0.0.1/pyproject.toml +36 -0
  5. agentship_service-0.0.1/src/agentship_service/__init__.py +14 -0
  6. agentship_service-0.0.1/src/agentship_service/a2a/__init__.py +13 -0
  7. agentship_service-0.0.1/src/agentship_service/a2a/mapping.py +64 -0
  8. agentship_service-0.0.1/src/agentship_service/a2a/server.py +96 -0
  9. agentship_service-0.0.1/src/agentship_service/app.py +91 -0
  10. agentship_service-0.0.1/src/agentship_service/build_info.py +51 -0
  11. agentship_service-0.0.1/src/agentship_service/context.py +60 -0
  12. agentship_service-0.0.1/src/agentship_service/errors.py +186 -0
  13. agentship_service-0.0.1/src/agentship_service/middleware/__init__.py +18 -0
  14. agentship_service-0.0.1/src/agentship_service/middleware/auth.py +124 -0
  15. agentship_service-0.0.1/src/agentship_service/middleware/headers.py +73 -0
  16. agentship_service-0.0.1/src/agentship_service/middleware/ratelimit.py +149 -0
  17. agentship_service-0.0.1/src/agentship_service/models/__init__.py +25 -0
  18. agentship_service-0.0.1/src/agentship_service/models/v1.py +195 -0
  19. agentship_service-0.0.1/src/agentship_service/registry.py +44 -0
  20. agentship_service-0.0.1/src/agentship_service/routers/__init__.py +14 -0
  21. agentship_service-0.0.1/src/agentship_service/routers/_common.py +116 -0
  22. agentship_service-0.0.1/src/agentship_service/routers/a2a.py +93 -0
  23. agentship_service-0.0.1/src/agentship_service/routers/agents.py +213 -0
  24. agentship_service-0.0.1/src/agentship_service/routers/live.py +98 -0
  25. agentship_service-0.0.1/src/agentship_service/routers/studio.py +53 -0
  26. agentship_service-0.0.1/src/agentship_service/routers/tasks.py +121 -0
  27. agentship_service-0.0.1/src/agentship_service/serving.py +101 -0
  28. agentship_service-0.0.1/src/agentship_service/static/studio.html +1142 -0
  29. agentship_service-0.0.1/tests/test_a2a_conformance.py +96 -0
  30. agentship_service-0.0.1/tests/test_a2a_server.py +164 -0
  31. agentship_service-0.0.1/tests/test_app.py +173 -0
  32. agentship_service-0.0.1/tests/test_error_model.py +93 -0
  33. agentship_service-0.0.1/tests/test_ratelimit.py +89 -0
  34. agentship_service-0.0.1/tests/test_serving_observability.py +40 -0
  35. agentship_service-0.0.1/tests/test_serving_resilience.py +50 -0
  36. agentship_service-0.0.1/tests/test_studio.py +190 -0
  37. agentship_service-0.0.1/tests/test_v1_agents.py +393 -0
  38. agentship_service-0.0.1/tests/test_v1_live.py +97 -0
  39. agentship_service-0.0.1/tests/test_v1_tasks.py +129 -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,49 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentship-service
3
+ Version: 0.0.1
4
+ Summary: AgentShip runtime service — the REST/SSE/WS surface over an agent, with pluggable auth adapters and tenant isolation. The irreducible in-app core a gateway cannot replace.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: agentship-core==0.0.1
8
+ Requires-Dist: fastapi>=0.115
9
+ Requires-Dist: sse-starlette>=2.1
10
+ Provides-Extra: a2a
11
+ Requires-Dist: a2a-sdk<2,>=1.1; extra == 'a2a'
12
+ Provides-Extra: serve
13
+ Requires-Dist: uvicorn[standard]>=0.30; extra == 'serve'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # agentship-service
17
+
18
+ The AgentShip **runtime service** — the REST / SSE / WebSocket surface that turns a built
19
+ agent into an HTTP API, with pluggable authentication adapters and, above all, **tenant
20
+ isolation**.
21
+
22
+ This is the part of the stack a gateway *cannot* replace. A gateway (agentgateway) routes
23
+ requests and can authenticate them at the edge; but only the app can guarantee that every
24
+ stored read and write is scoped to the authenticated tenant. That guarantee lives here.
25
+
26
+ ```python
27
+ from agentship.auth import ApiKeyAuthProvider, EnvApiKeyStore
28
+ from agentship_service import create_app
29
+
30
+ app = create_app(auth=ApiKeyAuthProvider(EnvApiKeyStore()))
31
+ ```
32
+
33
+ ## Integrations (we wire, we don't reinvent)
34
+
35
+ - **Streaming** — `/v1 …:stream` and A2A `message/stream` frame Server-Sent Events with
36
+ [`sse-starlette`](https://github.com/sysid/sse-starlette)'s `EventSourceResponse`, which owns
37
+ the wire encoding, keepalive comments, and client-disconnect cancellation. We only shape each
38
+ `StreamEvent` into its event/data fields.
39
+ - **Auth** — `agentship.auth` ships pluggable providers; the optional OIDC path
40
+ (`JwtAuthProvider`) delegates JWKS fetching, key-id resolution, and rotation to PyJWT's
41
+ `PyJWKClient`. Tenant isolation on every read/write is the part that stays here.
42
+ - **A2A** — we speak the protocol with our own thin Pydantic wire models rather than pull in the
43
+ protobuf-first `a2a-sdk`. A drift guard under the `agentship-service[a2a]` extra
44
+ (`tests/test_a2a_conformance.py`) validates every AgentCard / Message / status frame against
45
+ `a2a-sdk`'s own schema, so we cannot drift from the spec. See
46
+ [`docs/decisions/0001-integrate-not-invent.md`](../../docs/decisions/0001-integrate-not-invent.md).
47
+
48
+ See `agentship serve` for the one-command server, and the phase-04 spec for the full
49
+ endpoint contract.
@@ -0,0 +1,34 @@
1
+ # agentship-service
2
+
3
+ The AgentShip **runtime service** — the REST / SSE / WebSocket surface that turns a built
4
+ agent into an HTTP API, with pluggable authentication adapters and, above all, **tenant
5
+ isolation**.
6
+
7
+ This is the part of the stack a gateway *cannot* replace. A gateway (agentgateway) routes
8
+ requests and can authenticate them at the edge; but only the app can guarantee that every
9
+ stored read and write is scoped to the authenticated tenant. That guarantee lives here.
10
+
11
+ ```python
12
+ from agentship.auth import ApiKeyAuthProvider, EnvApiKeyStore
13
+ from agentship_service import create_app
14
+
15
+ app = create_app(auth=ApiKeyAuthProvider(EnvApiKeyStore()))
16
+ ```
17
+
18
+ ## Integrations (we wire, we don't reinvent)
19
+
20
+ - **Streaming** — `/v1 …:stream` and A2A `message/stream` frame Server-Sent Events with
21
+ [`sse-starlette`](https://github.com/sysid/sse-starlette)'s `EventSourceResponse`, which owns
22
+ the wire encoding, keepalive comments, and client-disconnect cancellation. We only shape each
23
+ `StreamEvent` into its event/data fields.
24
+ - **Auth** — `agentship.auth` ships pluggable providers; the optional OIDC path
25
+ (`JwtAuthProvider`) delegates JWKS fetching, key-id resolution, and rotation to PyJWT's
26
+ `PyJWKClient`. Tenant isolation on every read/write is the part that stays here.
27
+ - **A2A** — we speak the protocol with our own thin Pydantic wire models rather than pull in the
28
+ protobuf-first `a2a-sdk`. A drift guard under the `agentship-service[a2a]` extra
29
+ (`tests/test_a2a_conformance.py`) validates every AgentCard / Message / status frame against
30
+ `a2a-sdk`'s own schema, so we cannot drift from the spec. See
31
+ [`docs/decisions/0001-integrate-not-invent.md`](../../docs/decisions/0001-integrate-not-invent.md).
32
+
33
+ See `agentship serve` for the one-command server, and the phase-04 spec for the full
34
+ endpoint contract.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentship-service"
7
+ version = "0.0.1"
8
+ description = "AgentShip runtime service — the REST/SSE/WS surface over an agent, with pluggable auth adapters and tenant isolation. The irreducible in-app core a gateway cannot replace."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "Apache-2.0"
12
+ dependencies = [
13
+ "agentship-core==0.0.1",
14
+ "fastapi>=0.115",
15
+ # SSE framing for :stream and A2A message/stream — owns the event:/data: wire encoding,
16
+ # keepalive comments, and client-disconnect cancellation so we do not hand-frame it.
17
+ "sse-starlette>=2.1",
18
+ ]
19
+
20
+ # `agentship serve` launches the app under uvicorn. Kept as an extra so importing the
21
+ # app (e.g. behind gunicorn, or in tests) does not require the server, while the
22
+ # documented `pip install "agentship-service[serve]"` gives the one-command server.
23
+ [project.optional-dependencies]
24
+ serve = ["uvicorn[standard]>=0.30"]
25
+ # a2a-sdk is NOT a runtime dep — we speak A2A with our own thin vendor-free wire models
26
+ # (a2a-sdk 1.x is protobuf-first; only its legacy compat.v0_3 layer is Pydantic/JSON). This
27
+ # extra installs it for the schema drift guard (tests/test_a2a_conformance.py), which validates
28
+ # every AgentCard/Message/status frame we emit against a2a-sdk's own schema so we cannot drift
29
+ # from the A2A spec. Heavy (grpc/protobuf), hence opt-in and test-only.
30
+ a2a = ["a2a-sdk>=1.1,<2"]
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/agentship_service"]
34
+ # The Studio page is data, not code: name it explicitly so it cannot be dropped from a
35
+ # wheel by a future exclude rule. GET /studio reads this file at request time.
36
+ artifacts = ["src/agentship_service/static/studio.html"]
@@ -0,0 +1,14 @@
1
+ """AgentShip runtime service: the REST / SSE / WebSocket surface over an agent.
2
+
3
+ This package is the irreducible in-app core a gateway cannot replace: it authenticates
4
+ each request into a :class:`~agentship.context.Caller`, binds a tenant scope so every
5
+ query is filtered to the caller's tenant, and renders every failure as RFC-9457
6
+ problem+json. :func:`create_app` assembles the FastAPI app and its middleware stack.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .app import create_app
12
+ from .registry import AgentRegistry
13
+
14
+ __all__ = ["AgentRegistry", "create_app"]
@@ -0,0 +1,13 @@
1
+ """The A2A *server* adapter — exposes a built agent over the A2A protocol (Phase 05 · C4).
2
+
3
+ This lives in the service package (not core) because it needs the web app: it maps A2A JSON-RPC
4
+ methods (``message/send``, ``message/stream``, ``tasks/*``) onto the ``RunnableAgent`` run/stream
5
+ port and mounts alongside the ``/v1`` routes, so it inherits P04's auth, TLS, and rate-limit.
6
+ The transport-agnostic pieces (wire models, Agent Card generation) live in ``agentship.a2a``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .server import handle_rpc, stream_rpc
12
+
13
+ __all__ = ["handle_rpc", "stream_rpc"]
@@ -0,0 +1,64 @@
1
+ """Translate between A2A wire shapes and AgentShip's run inputs/outputs (§C3 mapping).
2
+
3
+ One small module so the request handler and the stream handler map identically:
4
+
5
+ * inbound — an A2A ``message/send`` param block → the plain text an engine's ``run``/``stream``
6
+ consumes (:func:`message_text`);
7
+ * outbound — an engine :class:`~agentship.engines.base.Result` → an A2A agent ``Message``
8
+ (:func:`result_message`), and a streamed engine event → an A2A ``TaskStatusUpdate`` frame
9
+ (:func:`status_update`).
10
+
11
+ Kept deliberately thin: A2A models the world as Messages/Tasks; we model it as run/stream, and the
12
+ only shapes we translate are the ones we actually serve.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from agentship.a2a.models import Message
20
+
21
+
22
+ def message_text(params: dict[str, Any]) -> str:
23
+ """Extract the user text from a ``message/send`` param block.
24
+
25
+ A2A carries the turn as ``params.message.parts[]``; we speak text parts, so this joins their
26
+ ``text`` into the single string the engine runs on. A missing/empty message yields ``""`` — an
27
+ empty turn, not a crash.
28
+ """
29
+ raw = params.get("message") or {}
30
+ parts = raw.get("parts") or []
31
+ return "".join(part.get("text", "") for part in parts if part.get("kind", "text") == "text")
32
+
33
+
34
+ def result_message(output: Any) -> dict[str, Any]:
35
+ """Shape an engine result's ``output`` into an A2A agent ``Message`` dict.
36
+
37
+ ``message/send`` returns a Message when the turn completes synchronously; the output is
38
+ rendered as one agent text part (a non-string output is stringified so the wire stays valid).
39
+ """
40
+ text = output if isinstance(output, str) else str(output)
41
+ return Message.agent(text).model_dump(by_alias=True)
42
+
43
+
44
+ def status_update(
45
+ task_id: str, context_id: str, *, state: str, text: str = "", final: bool = False
46
+ ) -> dict[str, Any]:
47
+ """Build an A2A ``TaskStatusUpdate`` result for one streamed step.
48
+
49
+ ``task_id`` identifies this task and ``context_id`` the conversation it belongs to — the A2A
50
+ spec requires both on every status update. ``state`` is the A2A task state (``working`` while
51
+ tokens flow, ``completed``/``failed`` at the end); ``text`` carries the incremental agent text
52
+ for this step; ``final`` marks the last frame so a client knows the stream is done. This is the
53
+ streamed analogue of :func:`result_message`.
54
+ """
55
+ update: dict[str, Any] = {
56
+ "kind": "status-update",
57
+ "taskId": task_id,
58
+ "contextId": context_id,
59
+ "status": {"state": state},
60
+ "final": final,
61
+ }
62
+ if text:
63
+ update["status"]["message"] = Message.agent(text).model_dump(by_alias=True)
64
+ return update
@@ -0,0 +1,96 @@
1
+ """Dispatch A2A JSON-RPC methods onto a built agent (§C4 adapter).
2
+
3
+ The A2A method table maps onto the same ``BaseAgent`` run/stream port the ``/v1`` routes use, so
4
+ an agent exposed over A2A behaves identically to one invoked directly — one runtime, two protocols:
5
+
6
+ * ``message/send`` → :meth:`RunnableAgent.run` → an A2A agent ``Message`` result.
7
+ * ``message/stream`` → :meth:`RunnableAgent.stream` → SSE frames of A2A ``TaskStatusUpdate``
8
+ results, reusing the ``/v1`` SSE event normalisation so both surfaces stream the same events.
9
+
10
+ Long-running ``tasks/*`` methods are recognised and answered with a not-implemented JSON-RPC error
11
+ for now (they land with the P11 task bridge, C5); an unknown method returns the standard JSON-RPC
12
+ "method not found" (-32601) rather than crashing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import uuid
19
+ from collections.abc import AsyncIterator
20
+
21
+ from agentship.a2a.models import JsonRpcRequest, JsonRpcResponse
22
+ from agentship.context import Caller
23
+ from agentship.runtime import RunnableAgent
24
+
25
+ from ..routers._common import frame_data, frame_type
26
+ from .mapping import message_text, result_message, status_update
27
+
28
+ #: JSON-RPC "method not found" per the spec, returned for any method we do not serve.
29
+ _METHOD_NOT_FOUND = -32601
30
+
31
+ #: Task lifecycle methods A2A defines but we do not serve yet (they arrive with the P11 bridge).
32
+ _DEFERRED_TASK_METHODS = frozenset(
33
+ {"tasks/get", "tasks/cancel", "tasks/resubscribe", "tasks/pushNotificationConfig/set"}
34
+ )
35
+
36
+
37
+ async def handle_rpc(agent: RunnableAgent, caller: Caller, req: JsonRpcRequest) -> JsonRpcResponse:
38
+ """Handle a non-streaming A2A method, returning a JSON-RPC response envelope.
39
+
40
+ Only ``message/send`` runs the agent today; ``tasks/*`` are recognised-but-deferred and any
41
+ other method is a "method not found" error. A recognised-but-deferred or unknown method is a
42
+ JSON-RPC *error*, never an HTTP error — the transport call succeeded, the method did not.
43
+ """
44
+ if req.method == "message/send":
45
+ text = message_text(req.params)
46
+ result = await agent.run(text, caller=caller, session_id=uuid.uuid4().hex)
47
+ return JsonRpcResponse.ok(req.id, result_message(result.output))
48
+ if req.method in _DEFERRED_TASK_METHODS:
49
+ return JsonRpcResponse.fail(
50
+ req.id, _METHOD_NOT_FOUND, f"method {req.method!r} is not implemented yet (P11 tasks)"
51
+ )
52
+ return JsonRpcResponse.fail(req.id, _METHOD_NOT_FOUND, f"unknown method {req.method!r}")
53
+
54
+
55
+ async def stream_rpc(
56
+ agent: RunnableAgent, caller: Caller, req: JsonRpcRequest
57
+ ) -> AsyncIterator[dict]:
58
+ """Stream an A2A ``message/stream`` turn as SSE JSON-RPC frames of task status updates.
59
+
60
+ Each engine event with text becomes a ``working`` status update carrying that text; the stream
61
+ always ends with a terminal frame — ``completed`` on success, ``failed`` if the engine raised
62
+ mid-stream (the HTTP status was already 200 once streaming began, so a failure is delivered as
63
+ a final frame, mirroring the ``/v1`` stream contract).
64
+ """
65
+ task_id = uuid.uuid4().hex
66
+ # The context id groups every update of this turn under one A2A conversation; one per stream.
67
+ context_id = uuid.uuid4().hex
68
+ text = message_text(req.params)
69
+ try:
70
+ async for event in agent.stream(text, caller=caller, session_id=task_id):
71
+ chunk = _event_text(event)
72
+ if chunk:
73
+ yield _sse(req.id, status_update(task_id, context_id, state="working", text=chunk))
74
+ yield _sse(req.id, status_update(task_id, context_id, state="completed", final=True))
75
+ except Exception as exc: # noqa: BLE001 — a mid-stream failure becomes a terminal failed frame
76
+ yield _sse(
77
+ req.id, status_update(task_id, context_id, state="failed", text=str(exc), final=True)
78
+ )
79
+
80
+
81
+ def _event_text(event) -> str:
82
+ """Pull the incremental agent text out of one engine event (empty for non-text events)."""
83
+ data = frame_data(event)
84
+ if frame_type(event.type) in ("token", "content"):
85
+ return data.get("content") or data.get("token") or ""
86
+ return ""
87
+
88
+
89
+ def _sse(req_id, result: dict) -> dict:
90
+ """Shape one JSON-RPC response as ``sse-starlette`` ``ServerSentEvent`` fields.
91
+
92
+ A2A streams JSON-RPC responses over SSE with only a ``data:`` payload; sse-starlette
93
+ frames it (and adds keepalive comments / disconnect handling) via EventSourceResponse.
94
+ """
95
+ body = JsonRpcResponse.ok(req_id, result).model_dump(by_alias=True)
96
+ return {"data": json.dumps(body)}
@@ -0,0 +1,91 @@
1
+ """``create_app`` — assemble the runtime-service FastAPI app with its middleware stack.
2
+
3
+ The middleware are mounted in one documented order (outermost → innermost):
4
+
5
+ CORS → SecurityHeaders (+ trace id) → Auth (+ TenantScope) → router
6
+
7
+ so a request is CORS-checked, stamped, rate-limited (when enabled, P04 C5), and
8
+ authenticated *before* any route runs, and a CORS preflight (``OPTIONS``) short-circuits at
9
+ the outermost layer without ever reaching authentication. Error handlers render every
10
+ failure as RFC-9457 problem+json. The agent/discovery/task routers are attached here as
11
+ they land (P04 C1); this module owns the wiring, not the routes.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+
18
+ from agentship.auth import AuthProvider
19
+ from fastapi import FastAPI
20
+ from starlette.middleware.cors import CORSMiddleware
21
+
22
+ from .build_info import build_info
23
+ from .errors import install_error_handlers
24
+ from .middleware import AuthMiddleware, RateLimitMiddleware, SecurityHeadersMiddleware
25
+ from .registry import AgentRegistry
26
+ from .routers import a2a_router, agents_router, live_router, studio_router, tasks_router
27
+ from .routers.tasks import TaskStore
28
+
29
+
30
+ def create_app(
31
+ *,
32
+ auth: AuthProvider,
33
+ agents: AgentRegistry | None = None,
34
+ cors_origins: Sequence[str] = (),
35
+ hsts: bool = False,
36
+ rate_limit: bool = False,
37
+ requests_per_second: float = 10.0,
38
+ rate_limit_burst: int = 20,
39
+ title: str = "AgentShip",
40
+ ) -> FastAPI:
41
+ """Build the runtime-service app authenticated by ``auth`` serving ``agents``.
42
+
43
+ ``agents`` is the catalog the v1 routes invoke and discover (an empty registry when
44
+ omitted). ``cors_origins`` is an explicit allow-list (never ``*`` with credentials);
45
+ ``hsts`` turns on Strict-Transport-Security for a TLS deployment. ``rate_limit`` enables
46
+ the optional in-process token-bucket limiter (off by default — a gateway-free safety
47
+ net only; real rate-limiting is agentgateway's job). The returned app already has a
48
+ public ``GET /healthz`` liveness probe, the public ``GET /studio`` debug UI, the v1
49
+ agent + task routers, and the problem+json error handlers installed.
50
+ """
51
+ app = FastAPI(title=title, docs_url="/docs", redoc_url="/redoc")
52
+ app.state.agents = agents if agents is not None else AgentRegistry()
53
+ app.state.tasks = TaskStore()
54
+ install_error_handlers(app)
55
+
56
+ @app.get("/healthz", include_in_schema=False)
57
+ async def healthz() -> dict[str, object]:
58
+ """Unauthenticated liveness probe, carrying which build is answering.
59
+
60
+ The build stamp and package versions are here so a caller can tell *what code*
61
+ is running without shelling into the container — the question "is this the
62
+ latest?" should be answerable from outside.
63
+ """
64
+ return {"status": "ok", **build_info()}
65
+
66
+ app.include_router(agents_router)
67
+ app.include_router(live_router)
68
+ app.include_router(studio_router)
69
+ app.include_router(tasks_router)
70
+ app.include_router(a2a_router)
71
+
72
+ # Mount inner → outer. add_middleware makes each call the new outermost layer, so the
73
+ # last call (CORS) runs first on a request and the first call (Auth) runs last. The
74
+ # resulting request-path order is CORS → SecurityHeaders → RateLimit → Auth → router.
75
+ app.add_middleware(AuthMiddleware, auth=auth)
76
+ app.add_middleware(
77
+ RateLimitMiddleware,
78
+ enabled=rate_limit,
79
+ requests_per_second=requests_per_second,
80
+ burst=rate_limit_burst,
81
+ )
82
+ app.add_middleware(SecurityHeadersMiddleware, hsts=hsts)
83
+ if cors_origins:
84
+ app.add_middleware(
85
+ CORSMiddleware,
86
+ allow_origins=list(cors_origins),
87
+ allow_credentials=True,
88
+ allow_methods=["GET", "POST", "OPTIONS"],
89
+ allow_headers=["authorization", "content-type", "x-api-key"],
90
+ )
91
+ return app
@@ -0,0 +1,51 @@
1
+ """Report which AgentShip build is running.
2
+
3
+ A deployment that cannot tell you what code it is running is a deployment you cannot
4
+ trust a bug report against. The package version alone is not enough here — every package
5
+ is pinned at ``0.0.1`` and does not move between builds — so the image also stamps a build
6
+ id at build time (``AGENTSHIP_BUILD``, typically a git sha or a timestamp).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
14
+ #: The distributions that make up a running service, in the order a reader cares about.
15
+ PACKAGES = (
16
+ "agentship-core",
17
+ "agentship-langgraph",
18
+ "agentship-service",
19
+ "agentship-cli",
20
+ "agentship-observability",
21
+ )
22
+
23
+
24
+ def installed_versions() -> dict[str, str]:
25
+ """Return ``{distribution: version}`` for every AgentShip package that is installed.
26
+
27
+ A package that is not installed is simply absent, so the result also shows which
28
+ extras a deployment actually has — an image without ``agentship-observability`` is
29
+ visibly different from one that has it.
30
+ """
31
+ found: dict[str, str] = {}
32
+ for name in PACKAGES:
33
+ try:
34
+ found[name] = version(name)
35
+ except PackageNotFoundError:
36
+ continue
37
+ return found
38
+
39
+
40
+ def build_id() -> str:
41
+ """The image's build stamp from ``AGENTSHIP_BUILD``, or ``"dev"`` when unset.
42
+
43
+ Set it at image build time (a git sha, or a timestamp) so two images built from
44
+ different source are distinguishable even though the package versions match.
45
+ """
46
+ return os.environ.get("AGENTSHIP_BUILD") or "dev"
47
+
48
+
49
+ def build_info() -> dict[str, object]:
50
+ """The full build description: the build stamp plus every installed package version."""
51
+ return {"build": build_id(), "packages": installed_versions()}
@@ -0,0 +1,60 @@
1
+ """Per-request service state: the authenticated caller and the trace id.
2
+
3
+ The auth middleware binds the :class:`~agentship.context.Caller` for a request here; route
4
+ handlers and the :func:`~agentship_service.middleware.auth.require_scope` dependency read it
5
+ back. Both live in contextvars so concurrent requests never see each other's identity, and
6
+ so a handler need not thread the caller through every signature.
7
+
8
+ The trace id is minted (or adopted from an inbound ``x-request-id``) per request and echoed
9
+ into responses and error bodies, giving one id to correlate a request across logs and — once
10
+ P07 lands — the span tree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from contextvars import ContextVar, Token
16
+
17
+ from agentship.context import Caller
18
+ from agentship.errors import AgentShipError
19
+
20
+ _current_caller: ContextVar[Caller | None] = ContextVar("service_current_caller", default=None)
21
+ _current_trace_id: ContextVar[str | None] = ContextVar("service_current_trace_id", default=None)
22
+
23
+
24
+ def bind_caller(caller: Caller) -> Token[Caller | None]:
25
+ """Bind ``caller`` for the current request; returns a token to :func:`reset_caller`."""
26
+ return _current_caller.set(caller)
27
+
28
+
29
+ def reset_caller(token: Token[Caller | None]) -> None:
30
+ """Restore the caller binding captured by :func:`bind_caller`."""
31
+ _current_caller.reset(token)
32
+
33
+
34
+ def current_caller() -> Caller:
35
+ """Return the authenticated caller for this request, or raise if none is bound.
36
+
37
+ A missing caller means a handler ran outside the auth middleware — a bug to surface,
38
+ never an anonymous fallback.
39
+ """
40
+ caller = _current_caller.get()
41
+ if caller is None:
42
+ raise AgentShipError(
43
+ "no authenticated caller is bound — the request bypassed the auth middleware"
44
+ )
45
+ return caller
46
+
47
+
48
+ def bind_trace_id(trace_id: str) -> Token[str | None]:
49
+ """Bind the trace id for the current request; returns a token to :func:`reset_trace_id`."""
50
+ return _current_trace_id.set(trace_id)
51
+
52
+
53
+ def reset_trace_id(token: Token[str | None]) -> None:
54
+ """Restore the trace-id binding captured by :func:`bind_trace_id`."""
55
+ _current_trace_id.reset(token)
56
+
57
+
58
+ def current_trace_id() -> str | None:
59
+ """Return the trace id bound for this request, or ``None`` outside a request."""
60
+ return _current_trace_id.get()