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.
- agentship_service-0.0.1/.gitignore +27 -0
- agentship_service-0.0.1/PKG-INFO +49 -0
- agentship_service-0.0.1/README.md +34 -0
- agentship_service-0.0.1/pyproject.toml +36 -0
- agentship_service-0.0.1/src/agentship_service/__init__.py +14 -0
- agentship_service-0.0.1/src/agentship_service/a2a/__init__.py +13 -0
- agentship_service-0.0.1/src/agentship_service/a2a/mapping.py +64 -0
- agentship_service-0.0.1/src/agentship_service/a2a/server.py +96 -0
- agentship_service-0.0.1/src/agentship_service/app.py +91 -0
- agentship_service-0.0.1/src/agentship_service/build_info.py +51 -0
- agentship_service-0.0.1/src/agentship_service/context.py +60 -0
- agentship_service-0.0.1/src/agentship_service/errors.py +186 -0
- agentship_service-0.0.1/src/agentship_service/middleware/__init__.py +18 -0
- agentship_service-0.0.1/src/agentship_service/middleware/auth.py +124 -0
- agentship_service-0.0.1/src/agentship_service/middleware/headers.py +73 -0
- agentship_service-0.0.1/src/agentship_service/middleware/ratelimit.py +149 -0
- agentship_service-0.0.1/src/agentship_service/models/__init__.py +25 -0
- agentship_service-0.0.1/src/agentship_service/models/v1.py +195 -0
- agentship_service-0.0.1/src/agentship_service/registry.py +44 -0
- agentship_service-0.0.1/src/agentship_service/routers/__init__.py +14 -0
- agentship_service-0.0.1/src/agentship_service/routers/_common.py +116 -0
- agentship_service-0.0.1/src/agentship_service/routers/a2a.py +93 -0
- agentship_service-0.0.1/src/agentship_service/routers/agents.py +213 -0
- agentship_service-0.0.1/src/agentship_service/routers/live.py +98 -0
- agentship_service-0.0.1/src/agentship_service/routers/studio.py +53 -0
- agentship_service-0.0.1/src/agentship_service/routers/tasks.py +121 -0
- agentship_service-0.0.1/src/agentship_service/serving.py +101 -0
- agentship_service-0.0.1/src/agentship_service/static/studio.html +1142 -0
- agentship_service-0.0.1/tests/test_a2a_conformance.py +96 -0
- agentship_service-0.0.1/tests/test_a2a_server.py +164 -0
- agentship_service-0.0.1/tests/test_app.py +173 -0
- agentship_service-0.0.1/tests/test_error_model.py +93 -0
- agentship_service-0.0.1/tests/test_ratelimit.py +89 -0
- agentship_service-0.0.1/tests/test_serving_observability.py +40 -0
- agentship_service-0.0.1/tests/test_serving_resilience.py +50 -0
- agentship_service-0.0.1/tests/test_studio.py +190 -0
- agentship_service-0.0.1/tests/test_v1_agents.py +393 -0
- agentship_service-0.0.1/tests/test_v1_live.py +97 -0
- 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()
|