agentplane-runtime 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.
@@ -0,0 +1,383 @@
1
+ """Endpoint serving (SPEC §6.5): A2A agents and MCP servers behind the gateway.
2
+
3
+ The runtime mounts one ASGI sub-app per deployed flow under
4
+ ``/a2a/{name}`` (a2a-sdk server, A2A v1.0 wire format) or ``/mcp/{name}``
5
+ (FastMCP, streamable HTTP). Ephemeral draft endpoints live under
6
+ ``/a2a/_draft/{name}`` with a TTL and never touch the registry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import time
14
+ from collections.abc import Awaitable, Callable, MutableMapping
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from a2a.server.agent_execution import AgentExecutor, RequestContext
19
+ from a2a.server.events.event_queue import EventQueue
20
+ from a2a.server.request_handlers import DefaultRequestHandler
21
+ from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
22
+ from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
23
+ from a2a.types import (
24
+ AgentCapabilities,
25
+ AgentCard,
26
+ AgentInterface,
27
+ AgentSkill,
28
+ Part,
29
+ Task,
30
+ TaskState,
31
+ TaskStatus,
32
+ )
33
+ from fastmcp import FastMCP
34
+ from fastmcp.tools import Tool, ToolResult
35
+ from pydantic import PrivateAttr
36
+ from starlette.applications import Starlette
37
+ from starlette.responses import PlainTextResponse
38
+ from starlette.routing import Route
39
+
40
+ from agentplane_core import (
41
+ FlowDefinition,
42
+ JsonObject,
43
+ StartNode,
44
+ single_required_string_input,
45
+ )
46
+ from agentplane_runtime.engine import ExecutionContext, FlowRunner, _as_text
47
+ from agentplane_runtime.resources import ResourceService
48
+ from agentplane_runtime.settings import EPHEMERAL_TTL_S, RuntimeSettings
49
+
50
+ # ASGI protocol types — inherently loose (the ASGI spec is dict-based).
51
+ Scope = MutableMapping[str, Any]
52
+ Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
53
+ Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
54
+ ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
55
+
56
+
57
+ def build_agent_card(defn: FlowDefinition, public_url: str) -> AgentCard:
58
+ """Derive the A2A card from the definition (SPEC §6.5)."""
59
+ skills = [
60
+ AgentSkill(
61
+ id=defn.name,
62
+ name=defn.display_name or defn.name,
63
+ description=defn.description or defn.display_name or defn.name,
64
+ tags=list(defn.tags) or ["flow"],
65
+ )
66
+ ]
67
+ return AgentCard(
68
+ name=defn.name,
69
+ description=defn.description,
70
+ version="1",
71
+ supported_interfaces=[
72
+ AgentInterface(url=public_url, protocol_binding="JSONRPC", protocol_version="1.0")
73
+ ],
74
+ capabilities=AgentCapabilities(streaming=True),
75
+ default_input_modes=["text/plain", "application/json"],
76
+ default_output_modes=["text/plain"],
77
+ skills=skills,
78
+ )
79
+
80
+
81
+ def bind_message_to_inputs(defn: FlowDefinition, text: str) -> JsonObject:
82
+ """A2A input binding (SPEC §6.4).
83
+
84
+ Message text binds to the single required string property when there is
85
+ exactly one; otherwise the text must be a JSON object matching the schema.
86
+ """
87
+ single = single_required_string_input(defn)
88
+ if single is not None:
89
+ return {single: text}
90
+ try:
91
+ parsed = json.loads(text)
92
+ except ValueError as exc:
93
+ raise ValueError(
94
+ "flow input schema has multiple properties; send a JSON object message"
95
+ ) from exc
96
+ if not isinstance(parsed, dict):
97
+ raise ValueError("message must be a JSON object matching the flow input schema")
98
+ return parsed
99
+
100
+
101
+ class FlowAgentExecutor(AgentExecutor):
102
+ """Runs the flow for each A2A request; streams tokens for stream:true nodes."""
103
+
104
+ def __init__(self, runner_factory: Callable[[], FlowRunner], defn: FlowDefinition) -> None:
105
+ self._runner_factory = runner_factory
106
+ self._defn = defn
107
+
108
+ async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
109
+ task_id = context.task_id or ""
110
+ context_id = context.context_id or ""
111
+ if context.current_task is None:
112
+ # Async workflow: the Task object must be enqueued before updates.
113
+ await event_queue.enqueue_event(
114
+ Task(
115
+ id=task_id,
116
+ context_id=context_id,
117
+ status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED),
118
+ )
119
+ )
120
+ updater = TaskUpdater(event_queue, task_id, context_id)
121
+ await updater.start_work()
122
+
123
+ async def stream_chunk(delta: str) -> None:
124
+ await updater.update_status(
125
+ TaskState.TASK_STATE_WORKING,
126
+ message=updater.new_agent_message([Part(text=delta)]),
127
+ )
128
+
129
+ runner = self._runner_factory()
130
+ try:
131
+ inputs = bind_message_to_inputs(self._defn, context.get_user_input())
132
+ result = await runner.execute(inputs, stream=stream_chunk)
133
+ except ValueError as exc:
134
+ await updater.failed(updater.new_agent_message([Part(text=str(exc))]))
135
+ return
136
+ except Exception as exc:
137
+ await updater.failed(
138
+ updater.new_agent_message([Part(text=f"flow execution failed: {exc}")])
139
+ )
140
+ return
141
+ text = _as_text(result) if result is not None else ""
142
+ await updater.add_artifact([Part(text=text)], name="output")
143
+ await updater.complete()
144
+
145
+ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
146
+ updater = TaskUpdater(event_queue, context.task_id or "", context.context_id or "")
147
+ await updater.cancel()
148
+
149
+
150
+ def build_a2a_app(
151
+ defn: FlowDefinition,
152
+ public_url: str,
153
+ runner_factory: Callable[[], FlowRunner],
154
+ ) -> Starlette:
155
+ """One Starlette app per A2A-exposed flow: card + JSON-RPC routes."""
156
+ card = build_agent_card(defn, public_url)
157
+ handler = DefaultRequestHandler(
158
+ agent_executor=FlowAgentExecutor(runner_factory, defn),
159
+ task_store=InMemoryTaskStore(),
160
+ agent_card=card,
161
+ )
162
+ routes: list[Route] = [
163
+ *create_agent_card_routes(card),
164
+ *create_jsonrpc_routes(handler, "/"),
165
+ ]
166
+ return Starlette(routes=routes)
167
+
168
+
169
+ class FlowTool(Tool):
170
+ """FastMCP tool whose parameters come from ``start.input_schema``."""
171
+
172
+ _runner_factory: Callable[[], FlowRunner] = PrivateAttr()
173
+
174
+ def bind(self, runner_factory: Callable[[], FlowRunner]) -> FlowTool:
175
+ self._runner_factory = runner_factory
176
+ return self
177
+
178
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
179
+ runner = self._runner_factory()
180
+ result = await runner.execute(dict(arguments))
181
+ return ToolResult(content=_as_text(result) if result is not None else "")
182
+
183
+
184
+ def build_mcp_server(defn: FlowDefinition, runner_factory: Callable[[], FlowRunner]) -> FastMCP:
185
+ """One FastMCP server per MCP-exposed flow, one tool per flow (SPEC §6.5)."""
186
+ start = next(n for n in defn.nodes if isinstance(n, StartNode))
187
+ tool_name = defn.expose.tool_name or defn.name.replace("-", "_")
188
+ server: FastMCP = FastMCP(name=defn.name, instructions=defn.description)
189
+ tool = FlowTool(
190
+ name=tool_name,
191
+ description=defn.expose.tool_description or defn.description,
192
+ parameters=start.config.input_schema,
193
+ ).bind(runner_factory)
194
+ server.add_tool(tool)
195
+ return server
196
+
197
+
198
+ class PathDispatcher:
199
+ """Dispatches ``/{name}/...`` to per-flow ASGI apps; supports live add/remove."""
200
+
201
+ def __init__(self) -> None:
202
+ self._apps: dict[str, ASGIApp] = {}
203
+
204
+ def mount(self, name: str, app: ASGIApp) -> None:
205
+ self._apps[name] = app
206
+
207
+ def unmount(self, name: str) -> None:
208
+ self._apps.pop(name, None)
209
+
210
+ def mounted(self) -> list[str]:
211
+ return sorted(self._apps)
212
+
213
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
214
+ if scope["type"] == "lifespan":
215
+ return
216
+ # Starlette convention: `path` is the full request path and `root_path`
217
+ # the already-consumed prefix; route matching happens on the difference.
218
+ path: str = scope.get("path", "")
219
+ root_path: str = scope.get("root_path", "")
220
+ route_path = path[len(root_path) :] if path.startswith(root_path) else path
221
+ segments = [s for s in route_path.split("/") if s]
222
+ name = ""
223
+ consumed = 0
224
+ if len(segments) > 1 and segments[0] == "_draft":
225
+ name = f"_draft/{segments[1]}"
226
+ consumed = 2
227
+ elif segments:
228
+ name = segments[0]
229
+ consumed = 1
230
+ app = self._apps.get(name)
231
+ if app is None:
232
+ response = PlainTextResponse("no such endpoint", status_code=404)
233
+ await response(scope, receive, send)
234
+ return
235
+ child_scope = dict(scope)
236
+ child_scope["root_path"] = root_path + "/" + "/".join(segments[:consumed])
237
+ await app(child_scope, receive, send)
238
+
239
+
240
+ class LifespanHost:
241
+ """Runs an ASGI app's lifespan in a dedicated task.
242
+
243
+ anyio cancel scopes must be entered and exited in the same task, so the
244
+ FastMCP http_app lifespan cannot live in an exit stack that is closed
245
+ from elsewhere.
246
+ """
247
+
248
+ def __init__(self, app: Starlette) -> None:
249
+ self._app = app
250
+ self._started: asyncio.Event = asyncio.Event()
251
+ self._stop: asyncio.Event = asyncio.Event()
252
+ self._task: asyncio.Task[None] | None = None
253
+
254
+ async def _run(self) -> None:
255
+ async with self._app.router.lifespan_context(self._app):
256
+ self._started.set()
257
+ await self._stop.wait()
258
+
259
+ async def start(self) -> None:
260
+ self._task = asyncio.create_task(self._run(), name="endpoint-lifespan")
261
+ await self._started.wait()
262
+
263
+ async def stop(self) -> None:
264
+ if self._task is None:
265
+ return
266
+ self._stop.set()
267
+ await self._task
268
+ self._task = None
269
+
270
+
271
+ @dataclass
272
+ class Endpoint:
273
+ name: str
274
+ kind: str # "a2a" | "mcp"
275
+ version: int
276
+ public_url: str
277
+ lifespan: LifespanHost | None = None
278
+ expires_at: float | None = None # ephemeral only
279
+
280
+
281
+ class EndpointManager:
282
+ """Owns the /a2a and /mcp dispatchers and the running endpoints."""
283
+
284
+ def __init__(self, resources: ResourceService, settings: RuntimeSettings) -> None:
285
+ self._resources = resources
286
+ self._settings = settings
287
+ self.a2a = PathDispatcher()
288
+ self.mcp = PathDispatcher()
289
+ self._endpoints: dict[str, Endpoint] = {}
290
+
291
+ def endpoint_for(self, name: str) -> Endpoint | None:
292
+ return self._endpoints.get(name)
293
+
294
+ def _runner_factory(self, defn: FlowDefinition, version: int) -> Callable[[], FlowRunner]:
295
+ compiled: list[FlowRunner] = []
296
+
297
+ def factory() -> FlowRunner:
298
+ # compile once, reuse the graph; context is shared per endpoint
299
+ if not compiled:
300
+ compiled.append(
301
+ FlowRunner(
302
+ defn,
303
+ ExecutionContext(
304
+ resources=self._resources,
305
+ settings=self._settings,
306
+ flow_name=defn.name,
307
+ flow_version=version,
308
+ ),
309
+ )
310
+ )
311
+ return compiled[0]
312
+
313
+ return factory
314
+
315
+ def public_url(self, defn: FlowDefinition, *, ephemeral: bool = False) -> str:
316
+ base = self._settings.public_base_url.rstrip("/")
317
+ if ephemeral:
318
+ return f"{base}/a2a/_draft/{defn.name}"
319
+ return f"{base}/{defn.expose.kind}/{defn.name}"
320
+
321
+ async def start(
322
+ self, defn: FlowDefinition, version: int, *, ephemeral: bool = False
323
+ ) -> Endpoint:
324
+ """(Re)start the endpoint for a definition at a given version."""
325
+ key = f"_draft/{defn.name}" if ephemeral else defn.name
326
+ await self.stop(key)
327
+ public_url = self.public_url(defn, ephemeral=ephemeral)
328
+ runner_factory = self._runner_factory(defn, version)
329
+ lifespan: LifespanHost | None = None
330
+
331
+ if defn.expose.kind == "mcp" and not ephemeral:
332
+ server = build_mcp_server(defn, runner_factory)
333
+ http_app = server.http_app(path="/", stateless_http=True)
334
+ lifespan = LifespanHost(http_app)
335
+ await lifespan.start()
336
+ self.mcp.mount(key, http_app)
337
+ else:
338
+ app = build_a2a_app(defn, public_url, runner_factory)
339
+ self.a2a.mount(key, app)
340
+
341
+ endpoint = Endpoint(
342
+ name=key,
343
+ kind=defn.expose.kind if not ephemeral else "a2a",
344
+ version=version,
345
+ public_url=public_url,
346
+ lifespan=lifespan,
347
+ expires_at=time.monotonic() + EPHEMERAL_TTL_S if ephemeral else None,
348
+ )
349
+ self._endpoints[key] = endpoint
350
+ return endpoint
351
+
352
+ async def stop(self, name: str) -> None:
353
+ endpoint = self._endpoints.pop(name, None)
354
+ if endpoint is None:
355
+ return
356
+ if endpoint.kind == "mcp":
357
+ self.mcp.unmount(name)
358
+ else:
359
+ self.a2a.unmount(name)
360
+ if endpoint.lifespan is not None:
361
+ await endpoint.lifespan.stop()
362
+
363
+ async def stop_all(self) -> None:
364
+ for name in list(self._endpoints):
365
+ await self.stop(name)
366
+
367
+ async def reap_expired(self) -> None:
368
+ """Drop ephemeral endpoints past their TTL."""
369
+ now = time.monotonic()
370
+ for name, endpoint in list(self._endpoints.items()):
371
+ if endpoint.expires_at is not None and endpoint.expires_at < now:
372
+ await self.stop(name)
373
+
374
+
375
+ __all__ = [
376
+ "EndpointManager",
377
+ "FlowAgentExecutor",
378
+ "PathDispatcher",
379
+ "bind_message_to_inputs",
380
+ "build_a2a_app",
381
+ "build_agent_card",
382
+ "build_mcp_server",
383
+ ]
@@ -0,0 +1,31 @@
1
+ """Runtime configuration (SPEC §7.2), env prefix ``AGENTPLANE_RUNTIME_``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ RUNTIME_VERSION = "0.0.1"
10
+
11
+ EPHEMERAL_TTL_S = 30 * 60 # SPEC §6.2: draft endpoints live 30 minutes
12
+
13
+
14
+ class RuntimeSettings(BaseSettings):
15
+ model_config = SettingsConfigDict(env_prefix="AGENTPLANE_RUNTIME_", extra="ignore")
16
+
17
+ db_url: str = "sqlite+aiosqlite:///runtime.db"
18
+ public_base_url: str = "" # required to serve; validated on app start
19
+ registry_url: str = "" # required to self-register; empty disables registration
20
+ registry_token: str = ""
21
+ secret_key: str = "" # Fernet key; required for resources with secrets
22
+ llm_base_url: str = "" # gateway's OpenAI-compatible endpoint (resource default)
23
+ auth_mode: Literal["none", "oidc"] = "none"
24
+ oidc_issuer: str = ""
25
+ oidc_audience: str = ""
26
+ roles_claim: str = "realm_access.roles"
27
+ admin_role: str = "admin"
28
+ http_timeout_s: float = 60.0
29
+
30
+
31
+ __all__ = ["EPHEMERAL_TTL_S", "RUNTIME_VERSION", "RuntimeSettings"]
@@ -0,0 +1,52 @@
1
+ """OpenTelemetry wiring — OTLP endpoint is configuration, never a vendor SDK.
2
+
3
+ Uses the standard ``OTEL_EXPORTER_OTLP_ENDPOINT`` variable; without it the
4
+ no-op tracer provider stays active and nothing is exported.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Awaitable, Callable
11
+
12
+ from fastapi import FastAPI, Request, Response
13
+ from opentelemetry import trace
14
+ from opentelemetry.sdk.resources import Resource
15
+ from opentelemetry.sdk.trace import TracerProvider
16
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
17
+ from opentelemetry.trace import Status, StatusCode
18
+
19
+
20
+ def setup_tracing(app: FastAPI, *, service_name: str) -> None:
21
+ """Install an OTLP exporter (when configured) and a request span middleware."""
22
+ endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "")
23
+ if endpoint:
24
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # noqa: PLC0415
25
+ OTLPSpanExporter,
26
+ )
27
+
28
+ provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
29
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
30
+ trace.set_tracer_provider(provider)
31
+
32
+ tracer = trace.get_tracer(service_name)
33
+
34
+ @app.middleware("http")
35
+ async def _trace_requests(
36
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
37
+ ) -> Response:
38
+ with tracer.start_as_current_span(
39
+ f"{request.method} {request.url.path}",
40
+ attributes={
41
+ "http.request.method": request.method,
42
+ "url.path": request.url.path,
43
+ },
44
+ ) as span:
45
+ response = await call_next(request)
46
+ span.set_attribute("http.response.status_code", response.status_code)
47
+ if response.status_code >= 500: # noqa: PLR2004
48
+ span.set_status(Status(StatusCode.ERROR))
49
+ return response
50
+
51
+
52
+ __all__ = ["setup_tracing"]
@@ -0,0 +1,91 @@
1
+ """Stateful validation (SPEC §3.7): core checks + E020/E021/E022/W001.
2
+
3
+ The runtime's answer (``POST /definitions/validate``) is authoritative; it
4
+ runs ``agentplane_core.validation.validate_structure`` first — literally the
5
+ same code the builder uses locally — and adds the checks that need runtime
6
+ state (resources, dimensions, deprecations).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+
13
+ from agentplane_core import (
14
+ DEPRECATED_NODE_VERSIONS,
15
+ FlowDefinition,
16
+ LlmCallNode,
17
+ McpToolNode,
18
+ RetrievalNode,
19
+ ValidationIssue,
20
+ ValidationResult,
21
+ VectorDBResource,
22
+ validate_structure,
23
+ )
24
+ from agentplane_core.resources import VECTOR_DB_KINDS
25
+ from agentplane_runtime.resources import ResourceNotFoundError, ResourceService
26
+
27
+ _EXPECTED_KINDS = {
28
+ "llm_call": frozenset({"model_provider"}),
29
+ "retrieval": VECTOR_DB_KINDS,
30
+ "mcp_tool": frozenset({"mcp_server"}),
31
+ }
32
+
33
+
34
+ def _issue(code: str, severity: str, path: str, message: str) -> ValidationIssue:
35
+ return ValidationIssue.model_validate(
36
+ {"code": code, "severity": severity, "path": path, "message": message}
37
+ )
38
+
39
+
40
+ async def validate_full(
41
+ defn: FlowDefinition | Mapping[str, object], resources: ResourceService
42
+ ) -> ValidationResult:
43
+ """Structural checks (core) + resource/dimension/deprecation checks."""
44
+ issues = list(validate_structure(defn))
45
+ if any(i.severity == "error" for i in issues):
46
+ return ValidationResult.from_issues(issues)
47
+
48
+ parsed = defn if isinstance(defn, FlowDefinition) else FlowDefinition.model_validate(dict(defn))
49
+
50
+ for node in parsed.nodes:
51
+ if (node.type, node.version) in DEPRECATED_NODE_VERSIONS:
52
+ issues.append(
53
+ _issue(
54
+ "W001",
55
+ "warning",
56
+ f"nodes/{node.id}/version",
57
+ f"node version {node.version} of {node.type!r} is deprecated",
58
+ )
59
+ )
60
+ if not isinstance(node, LlmCallNode | RetrievalNode | McpToolNode):
61
+ continue
62
+ resource_name = node.config.resource
63
+ if resource_name is None: # mcp_tool with direct url
64
+ continue
65
+ path = f"nodes/{node.id}/config/resource"
66
+ try:
67
+ resource = await resources.get_raw(resource_name)
68
+ except ResourceNotFoundError:
69
+ issues.append(_issue("E020", "error", path, f"unknown resource {resource_name!r}"))
70
+ continue
71
+ expected = _EXPECTED_KINDS[node.type]
72
+ if resource.kind not in expected:
73
+ issues.append(
74
+ _issue(
75
+ "E021",
76
+ "error",
77
+ path,
78
+ f"resource {resource_name!r} has kind {resource.kind!r}; "
79
+ f"expected one of {sorted(expected)}",
80
+ )
81
+ )
82
+ continue
83
+ if isinstance(node, RetrievalNode) and isinstance(resource, VectorDBResource):
84
+ e022 = await resources.check_collection_dimension(resource, node.config.collection)
85
+ if e022 is not None:
86
+ issues.append(e022)
87
+
88
+ return ValidationResult.from_issues(issues)
89
+
90
+
91
+ __all__ = ["validate_full"]