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.
- agentplane_runtime/__init__.py +8 -0
- agentplane_runtime/api.py +232 -0
- agentplane_runtime/app.py +85 -0
- agentplane_runtime/auth.py +137 -0
- agentplane_runtime/db.py +129 -0
- agentplane_runtime/definitions.py +280 -0
- agentplane_runtime/engine.py +407 -0
- agentplane_runtime/llm.py +121 -0
- agentplane_runtime/registration.py +145 -0
- agentplane_runtime/resources.py +237 -0
- agentplane_runtime/secrets.py +45 -0
- agentplane_runtime/serving.py +383 -0
- agentplane_runtime/settings.py +31 -0
- agentplane_runtime/tracing.py +52 -0
- agentplane_runtime/validation.py +91 -0
- agentplane_runtime/vector.py +149 -0
- agentplane_runtime-0.0.1.dist-info/METADATA +48 -0
- agentplane_runtime-0.0.1.dist-info/RECORD +19 -0
- agentplane_runtime-0.0.1.dist-info/WHEEL +4 -0
|
@@ -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"]
|