agentplane-runtime 0.0.1__tar.gz → 0.0.2__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.
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/.gitignore +1 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/PKG-INFO +3 -3
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/pyproject.toml +6 -3
- agentplane_runtime-0.0.2/src/agentplane_runtime/__main__.py +14 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/api.py +7 -1
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/app.py +12 -1
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/db.py +23 -1
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/definitions.py +76 -25
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/engine.py +32 -4
- agentplane_runtime-0.0.2/src/agentplane_runtime/py.typed +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/resources.py +18 -6
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/serving.py +44 -7
- agentplane_runtime-0.0.2/src/agentplane_runtime/settings.py +48 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/validation.py +3 -1
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/vector.py +10 -3
- agentplane_runtime-0.0.2/tests/test_app_cors.py +49 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_definitions_api.py +84 -1
- agentplane_runtime-0.0.2/tests/test_engine_join.py +112 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_serving.py +33 -0
- agentplane_runtime-0.0.1/src/agentplane_runtime/settings.py +0 -31
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/README.md +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/__init__.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/auth.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/llm.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/registration.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/secrets.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/tracing.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/conftest.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_engine.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_engine_router.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_resources_api.py +0 -0
- {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_secrets.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentplane-runtime
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
4
4
|
Summary: agentplane runtime: definitions, resources, flow execution, A2A + MCP serving
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.12
|
|
7
7
|
Requires-Dist: a2a-sdk[http-server]<2,>=1.1
|
|
8
|
-
Requires-Dist: agentplane-core<0.1.0,>=0.0.
|
|
9
|
-
Requires-Dist: agentplane-sdk<0.1.0,>=0.0.
|
|
8
|
+
Requires-Dist: agentplane-core<0.1.0,>=0.0.2
|
|
9
|
+
Requires-Dist: agentplane-sdk<0.1.0,>=0.0.2
|
|
10
10
|
Requires-Dist: aiosqlite>=0.20
|
|
11
11
|
Requires-Dist: cryptography>=43
|
|
12
12
|
Requires-Dist: fastapi>=0.115
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agentplane-runtime"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.2"
|
|
4
4
|
description = "agentplane runtime: definitions, resources, flow execution, A2A + MCP serving"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.12"
|
|
8
8
|
dependencies = [
|
|
9
|
-
"agentplane-core>=0.0.
|
|
10
|
-
"agentplane-sdk>=0.0.
|
|
9
|
+
"agentplane-core>=0.0.2,<0.1.0",
|
|
10
|
+
"agentplane-sdk>=0.0.2,<0.1.0",
|
|
11
11
|
"a2a-sdk[http-server]>=1.1,<2",
|
|
12
12
|
"fastapi>=0.115",
|
|
13
13
|
"sqlalchemy[asyncio]>=2.0.30",
|
|
@@ -28,6 +28,9 @@ dependencies = [
|
|
|
28
28
|
[project.optional-dependencies]
|
|
29
29
|
postgres = ["asyncpg>=0.29", "pgvector>=0.3"]
|
|
30
30
|
|
|
31
|
+
[project.scripts]
|
|
32
|
+
agentplane-runtime = "agentplane_runtime.__main__:main"
|
|
33
|
+
|
|
31
34
|
[build-system]
|
|
32
35
|
requires = ["hatchling"]
|
|
33
36
|
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Process entrypoint: ``agentplane-runtime`` (console script) / ``python -m agentplane_runtime``.
|
|
2
|
+
|
|
3
|
+
Configuration comes from the environment (prefix ``AGENTPLANE_RUNTIME_``) or a
|
|
4
|
+
``.env`` file in the working directory — no uvicorn incantation needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from agentplane_runtime.app import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__": # pragma: no cover - process entrypoint
|
|
12
|
+
main()
|
|
13
|
+
|
|
14
|
+
__all__ = ["main"]
|
|
@@ -15,6 +15,7 @@ from agentplane_core import (
|
|
|
15
15
|
JsonObject,
|
|
16
16
|
Resource,
|
|
17
17
|
ValidationResult,
|
|
18
|
+
VersionLabel,
|
|
18
19
|
)
|
|
19
20
|
from agentplane_runtime.auth import Principal
|
|
20
21
|
from agentplane_runtime.definitions import (
|
|
@@ -100,14 +101,19 @@ async def deploy_definition(
|
|
|
100
101
|
state: State,
|
|
101
102
|
caller: Caller,
|
|
102
103
|
version: Annotated[int | None, Query(ge=1)] = None,
|
|
104
|
+
version_label: Annotated[VersionLabel | None, Query()] = None,
|
|
103
105
|
ephemeral: Annotated[bool, Query()] = False,
|
|
104
106
|
) -> DeploymentInfo | JSONResponse:
|
|
105
107
|
try:
|
|
106
|
-
return await state.definitions.deploy(
|
|
108
|
+
return await state.definitions.deploy(
|
|
109
|
+
name, version=version, version_label=version_label, ephemeral=ephemeral
|
|
110
|
+
)
|
|
107
111
|
except DefinitionInvalidError as exc:
|
|
108
112
|
return _validation_response(exc.result)
|
|
109
113
|
except DefinitionNotFoundError as exc:
|
|
110
114
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc)) from None
|
|
115
|
+
except DefinitionConflictError as exc:
|
|
116
|
+
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from None
|
|
111
117
|
|
|
112
118
|
|
|
113
119
|
@router.post("/definitions/{name}/undeploy", status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator
|
|
|
9
9
|
from contextlib import asynccontextmanager
|
|
10
10
|
|
|
11
11
|
from fastapi import FastAPI
|
|
12
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
12
13
|
|
|
13
14
|
from agentplane_runtime.api import RuntimeState, health_router, router
|
|
14
15
|
from agentplane_runtime.auth import Authenticator
|
|
@@ -72,6 +73,15 @@ def create_app(settings: RuntimeSettings | None = None) -> FastAPI:
|
|
|
72
73
|
app.include_router(health_router)
|
|
73
74
|
app.mount("/a2a", endpoints.a2a)
|
|
74
75
|
app.mount("/mcp", endpoints.mcp)
|
|
76
|
+
if cfg.cors_origins:
|
|
77
|
+
# Outermost middleware, so it also covers the mounted /a2a and /mcp
|
|
78
|
+
# endpoints — that is the point: a browser talking A2A without a gateway.
|
|
79
|
+
app.add_middleware(
|
|
80
|
+
CORSMiddleware,
|
|
81
|
+
allow_origins=cfg.cors_origins,
|
|
82
|
+
allow_methods=["*"],
|
|
83
|
+
allow_headers=["*"],
|
|
84
|
+
)
|
|
75
85
|
setup_tracing(app, service_name="agentplane-runtime")
|
|
76
86
|
return app
|
|
77
87
|
|
|
@@ -79,7 +89,8 @@ def create_app(settings: RuntimeSettings | None = None) -> FastAPI:
|
|
|
79
89
|
def main() -> None: # pragma: no cover - process entrypoint
|
|
80
90
|
import uvicorn # noqa: PLC0415 - only needed for the entrypoint
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
cfg = RuntimeSettings()
|
|
93
|
+
uvicorn.run(create_app(cfg), host=cfg.host, port=cfg.port)
|
|
83
94
|
|
|
84
95
|
|
|
85
96
|
__all__ = ["create_app", "main"]
|
|
@@ -45,11 +45,17 @@ class DefinitionRow(Base):
|
|
|
45
45
|
|
|
46
46
|
class VersionRow(Base):
|
|
47
47
|
__tablename__ = "definition_versions"
|
|
48
|
-
__table_args__ = (
|
|
48
|
+
__table_args__ = (
|
|
49
|
+
UniqueConstraint("name", "version", name="uq_versions_name_version"),
|
|
50
|
+
UniqueConstraint("name", "version_label", name="uq_versions_name_label"),
|
|
51
|
+
)
|
|
49
52
|
|
|
50
53
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
51
54
|
name: Mapped[str] = mapped_column(String(64), index=True)
|
|
52
55
|
version: Mapped[int] = mapped_column(Integer)
|
|
56
|
+
# Publisher-chosen semantic version for this snapshot; unique per definition.
|
|
57
|
+
# NULL for versions deployed without a label (SQL treats NULLs as distinct).
|
|
58
|
+
version_label: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
53
59
|
definition_json: Mapped[str] = mapped_column(String)
|
|
54
60
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
|
55
61
|
|
|
@@ -114,6 +120,20 @@ async def latest_version(session: AsyncSession, name: str) -> int:
|
|
|
114
120
|
return first or 0
|
|
115
121
|
|
|
116
122
|
|
|
123
|
+
async def version_row(session: AsyncSession, name: str, version: int) -> VersionRow | None:
|
|
124
|
+
result = await session.execute(
|
|
125
|
+
select(VersionRow).where(VersionRow.name == name, VersionRow.version == version)
|
|
126
|
+
)
|
|
127
|
+
return result.scalar_one_or_none()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def version_row_by_label(session: AsyncSession, name: str, label: str) -> VersionRow | None:
|
|
131
|
+
result = await session.execute(
|
|
132
|
+
select(VersionRow).where(VersionRow.name == name, VersionRow.version_label == label)
|
|
133
|
+
)
|
|
134
|
+
return result.scalar_one_or_none()
|
|
135
|
+
|
|
136
|
+
|
|
117
137
|
__all__ = [
|
|
118
138
|
"RESOURCE_ADAPTER",
|
|
119
139
|
"Base",
|
|
@@ -126,4 +146,6 @@ __all__ = [
|
|
|
126
146
|
"latest_version",
|
|
127
147
|
"load_definition",
|
|
128
148
|
"load_resource",
|
|
149
|
+
"version_row",
|
|
150
|
+
"version_row_by_label",
|
|
129
151
|
]
|
|
@@ -20,6 +20,8 @@ from agentplane_runtime.db import (
|
|
|
20
20
|
dump_definition,
|
|
21
21
|
latest_version,
|
|
22
22
|
load_definition,
|
|
23
|
+
version_row,
|
|
24
|
+
version_row_by_label,
|
|
23
25
|
)
|
|
24
26
|
from agentplane_runtime.registration import RegistryRegistrar
|
|
25
27
|
from agentplane_runtime.resources import ResourceService
|
|
@@ -108,31 +110,42 @@ class DefinitionService:
|
|
|
108
110
|
return await self.info(name)
|
|
109
111
|
|
|
110
112
|
async def deploy(
|
|
111
|
-
self,
|
|
113
|
+
self,
|
|
114
|
+
name: str,
|
|
115
|
+
*,
|
|
116
|
+
version: int | None = None,
|
|
117
|
+
version_label: str | None = None,
|
|
118
|
+
ephemeral: bool = False,
|
|
112
119
|
) -> DeploymentInfo:
|
|
120
|
+
"""Freeze the draft as a new version and serve it — or re-serve an existing one.
|
|
121
|
+
|
|
122
|
+
A new deploy may carry a ``version_label`` (semantic version chosen by
|
|
123
|
+
the publisher, unique per definition). Rollback selects an existing
|
|
124
|
+
version by ``version`` (the deploy counter) or by ``version_label``;
|
|
125
|
+
passing both, or a label that is already taken by another version, is a
|
|
126
|
+
conflict.
|
|
127
|
+
"""
|
|
113
128
|
row = await self._row(name)
|
|
114
129
|
if ephemeral:
|
|
115
130
|
return await self._deploy_ephemeral(row)
|
|
131
|
+
if version is not None and version_label is not None:
|
|
132
|
+
raise DefinitionConflictError(
|
|
133
|
+
"pass either 'version' (rollback by counter) or 'version_label', not both"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if version is None and version_label is not None:
|
|
137
|
+
existing = await self._version_by_label(name, version_label)
|
|
138
|
+
if existing is not None:
|
|
139
|
+
version = existing # rollback: the label already identifies a version
|
|
140
|
+
version_label = None
|
|
116
141
|
|
|
117
142
|
if version is None:
|
|
118
|
-
|
|
119
|
-
result = await self.validate(draft)
|
|
120
|
-
if not result.valid:
|
|
121
|
-
raise DefinitionInvalidError(result)
|
|
122
|
-
async with self._db.session() as session, session.begin():
|
|
123
|
-
new_version = await latest_version(session, name) + 1
|
|
124
|
-
session.add(
|
|
125
|
-
VersionRow(
|
|
126
|
-
name=name,
|
|
127
|
-
version=new_version,
|
|
128
|
-
definition_json=dump_definition(draft),
|
|
129
|
-
)
|
|
130
|
-
)
|
|
131
|
-
defn, active_version = draft, new_version
|
|
143
|
+
defn, active_version, active_label = await self._freeze_draft(name, version_label)
|
|
132
144
|
else:
|
|
133
|
-
defn,
|
|
145
|
+
defn, active_label = await self._load_version_row(name, version)
|
|
146
|
+
active_version = version
|
|
134
147
|
|
|
135
|
-
endpoint = await self._endpoints.start(defn, active_version)
|
|
148
|
+
endpoint = await self._endpoints.start(defn, active_version, version_label=active_label)
|
|
136
149
|
registry_id = await self._registrar.register(
|
|
137
150
|
defn, endpoint.public_url, uuid.UUID(row.registry_id) if row.registry_id else None
|
|
138
151
|
)
|
|
@@ -146,10 +159,41 @@ class DefinitionService:
|
|
|
146
159
|
return DeploymentInfo(
|
|
147
160
|
name=name,
|
|
148
161
|
version=active_version,
|
|
162
|
+
version_label=active_label,
|
|
149
163
|
endpoint_url=endpoint.public_url,
|
|
150
164
|
registry_id=registry_id,
|
|
151
165
|
)
|
|
152
166
|
|
|
167
|
+
async def _freeze_draft(
|
|
168
|
+
self, name: str, version_label: str | None
|
|
169
|
+
) -> tuple[FlowDefinition, int, str | None]:
|
|
170
|
+
"""Validate the draft and freeze it as the next immutable version."""
|
|
171
|
+
row = await self._row(name)
|
|
172
|
+
draft = load_definition(row.draft_json)
|
|
173
|
+
result = await self.validate(draft)
|
|
174
|
+
if not result.valid:
|
|
175
|
+
raise DefinitionInvalidError(result)
|
|
176
|
+
if version_label is not None and await self._version_by_label(name, version_label):
|
|
177
|
+
raise DefinitionConflictError(
|
|
178
|
+
f"version label {version_label!r} is already used by {name!r}"
|
|
179
|
+
)
|
|
180
|
+
async with self._db.session() as session, session.begin():
|
|
181
|
+
new_version = await latest_version(session, name) + 1
|
|
182
|
+
session.add(
|
|
183
|
+
VersionRow(
|
|
184
|
+
name=name,
|
|
185
|
+
version=new_version,
|
|
186
|
+
version_label=version_label,
|
|
187
|
+
definition_json=dump_definition(draft),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
return draft, new_version, version_label
|
|
191
|
+
|
|
192
|
+
async def _version_by_label(self, name: str, label: str) -> int | None:
|
|
193
|
+
async with self._db.session() as session:
|
|
194
|
+
found = await version_row_by_label(session, name, label)
|
|
195
|
+
return found.version if found is not None else None
|
|
196
|
+
|
|
153
197
|
async def _deploy_ephemeral(self, row: DefinitionRow) -> DeploymentInfo:
|
|
154
198
|
"""Playground endpoint for the draft: no registry entry, TTL-bound (§6.2)."""
|
|
155
199
|
draft = load_definition(row.draft_json)
|
|
@@ -161,15 +205,16 @@ class DefinitionService:
|
|
|
161
205
|
name=row.name, version=0, endpoint_url=endpoint.public_url, registry_id=None
|
|
162
206
|
)
|
|
163
207
|
|
|
164
|
-
async def
|
|
208
|
+
async def _load_version_row(self, name: str, version: int) -> tuple[FlowDefinition, str | None]:
|
|
165
209
|
async with self._db.session() as session:
|
|
166
|
-
|
|
167
|
-
select(VersionRow).where(VersionRow.name == name, VersionRow.version == version)
|
|
168
|
-
)
|
|
169
|
-
row = result.scalar_one_or_none()
|
|
210
|
+
row = await version_row(session, name, version)
|
|
170
211
|
if row is None:
|
|
171
212
|
raise DefinitionNotFoundError(f"{name} version {version}")
|
|
172
|
-
return load_definition(row.definition_json)
|
|
213
|
+
return load_definition(row.definition_json), row.version_label
|
|
214
|
+
|
|
215
|
+
async def _load_version(self, name: str, version: int) -> FlowDefinition:
|
|
216
|
+
defn, _ = await self._load_version_row(name, version)
|
|
217
|
+
return defn
|
|
173
218
|
|
|
174
219
|
async def undeploy(self, name: str) -> None:
|
|
175
220
|
row = await self._row(name)
|
|
@@ -211,6 +256,11 @@ class DefinitionService:
|
|
|
211
256
|
draft = load_definition(row.draft_json)
|
|
212
257
|
async with self._db.session() as session:
|
|
213
258
|
latest = await latest_version(session, row.name)
|
|
259
|
+
deployed = (
|
|
260
|
+
await version_row(session, row.name, row.deployed_version)
|
|
261
|
+
if row.deployed_version is not None
|
|
262
|
+
else None
|
|
263
|
+
)
|
|
214
264
|
endpoint = self._endpoints.endpoint_for(row.name)
|
|
215
265
|
status = row.status
|
|
216
266
|
if status not in ("draft", "deployed", "undeployed"): # pragma: no cover
|
|
@@ -225,6 +275,7 @@ class DefinitionService:
|
|
|
225
275
|
"status": status,
|
|
226
276
|
"latest_version": latest or None,
|
|
227
277
|
"deployed_version": row.deployed_version,
|
|
278
|
+
"deployed_version_label": deployed.version_label if deployed else None,
|
|
228
279
|
"endpoint_url": endpoint.public_url if endpoint is not None else None,
|
|
229
280
|
"owner": row.owner,
|
|
230
281
|
"created_at": row.created_at,
|
|
@@ -262,8 +313,8 @@ class DefinitionService:
|
|
|
262
313
|
for row in rows:
|
|
263
314
|
if row.deployed_version is None:
|
|
264
315
|
continue
|
|
265
|
-
defn = await self.
|
|
266
|
-
endpoint = await self._endpoints.start(defn, row.deployed_version)
|
|
316
|
+
defn, label = await self._load_version_row(row.name, row.deployed_version)
|
|
317
|
+
endpoint = await self._endpoints.start(defn, row.deployed_version, version_label=label)
|
|
267
318
|
await self._registrar.register(
|
|
268
319
|
defn,
|
|
269
320
|
endpoint.public_url,
|
|
@@ -58,9 +58,10 @@ _stream_var: ContextVar[StreamCallback | None] = ContextVar("agentplane_stream",
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
class FlowState(TypedDict):
|
|
61
|
-
"""LangGraph state: values keyed by 'node_id.port'."""
|
|
61
|
+
"""LangGraph state: values keyed by 'node_id.port', plus the nodes that ran."""
|
|
62
62
|
|
|
63
63
|
values: Annotated[dict[str, PortValue], or_]
|
|
64
|
+
executed: Annotated[set[str], or_]
|
|
64
65
|
|
|
65
66
|
|
|
66
67
|
class _NodeFn(Protocol):
|
|
@@ -171,6 +172,8 @@ class FlowRunner:
|
|
|
171
172
|
|
|
172
173
|
def _executor_for(self, node: Node) -> _NodeFn:
|
|
173
174
|
async def run_node(state: FlowState) -> dict[str, object]:
|
|
175
|
+
if node.id in state["executed"] or not self._ready(node, state["values"]):
|
|
176
|
+
return {}
|
|
174
177
|
with tracer.start_as_current_span(
|
|
175
178
|
"agentplane.node",
|
|
176
179
|
attributes={
|
|
@@ -181,10 +184,26 @@ class FlowRunner:
|
|
|
181
184
|
},
|
|
182
185
|
):
|
|
183
186
|
outputs = await self._run(node, state["values"])
|
|
184
|
-
return {"values": outputs}
|
|
187
|
+
return {"values": outputs, "executed": {node.id}}
|
|
185
188
|
|
|
186
189
|
return run_node
|
|
187
190
|
|
|
191
|
+
def _ready(self, node: Node, values: dict[str, PortValue]) -> bool:
|
|
192
|
+
"""Whether every wired input port of ``node`` carries a value yet.
|
|
193
|
+
|
|
194
|
+
LangGraph (Pregel) triggers a node as soon as *any* predecessor wrote, so
|
|
195
|
+
a node fed from different graph depths would run once per predecessor —
|
|
196
|
+
the first time with inputs still missing, i.e. a wasted LLM call. Waiting
|
|
197
|
+
until all wired ports are filled turns such a fan-in into a join, and it
|
|
198
|
+
gates router branches for free: the port fed by a branch that was not
|
|
199
|
+
chosen never fills, so the node stays dormant. Every predecessor write
|
|
200
|
+
re-triggers the node, so a skipped run is retried once the value arrives.
|
|
201
|
+
"""
|
|
202
|
+
return all(
|
|
203
|
+
any(source_ref in values for source_ref in source_refs)
|
|
204
|
+
for source_refs in self._inputs.get(node.id, {}).values()
|
|
205
|
+
)
|
|
206
|
+
|
|
188
207
|
def _gather_inputs(self, node: Node, values: dict[str, PortValue]) -> dict[str, PortValue]:
|
|
189
208
|
gathered: dict[str, PortValue] = {}
|
|
190
209
|
for port, source_refs in self._inputs.get(node.id, {}).items():
|
|
@@ -296,7 +315,13 @@ class FlowRunner:
|
|
|
296
315
|
if resource.dsn_secret:
|
|
297
316
|
dsn = await self._context.resources.secret_value(config.resource, "dsn_secret")
|
|
298
317
|
reader = await reader_for(resource, api_key=api_key, dsn=dsn)
|
|
299
|
-
documents = await reader.search(
|
|
318
|
+
documents = await reader.search(
|
|
319
|
+
config.collection,
|
|
320
|
+
vector,
|
|
321
|
+
config.top_k,
|
|
322
|
+
config.filter,
|
|
323
|
+
min_score=config.min_score,
|
|
324
|
+
)
|
|
300
325
|
return {f"{node.id}.documents": documents}
|
|
301
326
|
|
|
302
327
|
async def _run_mcp_tool(
|
|
@@ -346,7 +371,10 @@ class FlowRunner:
|
|
|
346
371
|
},
|
|
347
372
|
):
|
|
348
373
|
graph = self._graph
|
|
349
|
-
|
|
374
|
+
empty: set[str] = set()
|
|
375
|
+
result = await graph.ainvoke( # type: ignore[attr-defined]
|
|
376
|
+
{"values": seeded, "executed": empty}
|
|
377
|
+
)
|
|
350
378
|
values: dict[str, PortValue] = result["values"]
|
|
351
379
|
return values.get(f"{end.id}.output")
|
|
352
380
|
|
|
File without changes
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Resource service (SPEC §6.3): CRUD, write-only secrets, E022 dimension check.
|
|
1
|
+
"""Resource service (SPEC §6.3): CRUD, write-only secrets, E022 dimension check.
|
|
2
|
+
|
|
3
|
+
Create/update stay offline: a resource carries no collection, so there is
|
|
4
|
+
nothing to compare a dimension against until a retrieval node names one. The
|
|
5
|
+
E022 check therefore runs in definition validation (see ``validation.py``).
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
@@ -74,9 +79,15 @@ class ResourceService:
|
|
|
74
79
|
return await self._secrets.get(secret_ref(resource_name, field))
|
|
75
80
|
|
|
76
81
|
async def check_collection_dimension(
|
|
77
|
-
self, resource: VectorDBResource, collection: str
|
|
82
|
+
self, resource: VectorDBResource, collection: str, path: str
|
|
78
83
|
) -> ValidationIssue | None:
|
|
79
|
-
"""E022 for a concrete collection (
|
|
84
|
+
"""E022 for a concrete collection (SPEC §3.7).
|
|
85
|
+
|
|
86
|
+
The collection lives on the retrieval *node*, not on the resource, so
|
|
87
|
+
this check only has something to compare during definition validation —
|
|
88
|
+
which is why ``path`` points at the offending node. An unreachable vector
|
|
89
|
+
DB is not an E022: it surfaces at execution time, not as a schema error.
|
|
90
|
+
"""
|
|
80
91
|
api_key, dsn = "", ""
|
|
81
92
|
try:
|
|
82
93
|
if resource.api_key_secret:
|
|
@@ -94,10 +105,11 @@ class ResourceService:
|
|
|
94
105
|
return ValidationIssue(
|
|
95
106
|
code="E022",
|
|
96
107
|
severity="error",
|
|
97
|
-
path=
|
|
108
|
+
path=path,
|
|
98
109
|
message=(
|
|
99
|
-
f"
|
|
100
|
-
f"collection {collection!r}
|
|
110
|
+
f"resource {resource.name!r} embeds with dimension "
|
|
111
|
+
f"{resource.embedding.dimension}, but collection {collection!r} has "
|
|
112
|
+
f"dimension {actual}"
|
|
101
113
|
),
|
|
102
114
|
)
|
|
103
115
|
return None
|
|
@@ -34,7 +34,8 @@ from fastmcp import FastMCP
|
|
|
34
34
|
from fastmcp.tools import Tool, ToolResult
|
|
35
35
|
from pydantic import PrivateAttr
|
|
36
36
|
from starlette.applications import Starlette
|
|
37
|
-
from starlette.
|
|
37
|
+
from starlette.requests import Request
|
|
38
|
+
from starlette.responses import JSONResponse, PlainTextResponse
|
|
38
39
|
from starlette.routing import Route
|
|
39
40
|
|
|
40
41
|
from agentplane_core import (
|
|
@@ -47,6 +48,8 @@ from agentplane_runtime.engine import ExecutionContext, FlowRunner, _as_text
|
|
|
47
48
|
from agentplane_runtime.resources import ResourceService
|
|
48
49
|
from agentplane_runtime.settings import EPHEMERAL_TTL_S, RuntimeSettings
|
|
49
50
|
|
|
51
|
+
AGENT_CARD_PATH = "/.well-known/agent-card.json"
|
|
52
|
+
|
|
50
53
|
# ASGI protocol types — inherently loose (the ASGI spec is dict-based).
|
|
51
54
|
Scope = MutableMapping[str, Any]
|
|
52
55
|
Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
|
|
@@ -54,20 +57,26 @@ Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
|
|
|
54
57
|
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
55
58
|
|
|
56
59
|
|
|
57
|
-
def build_agent_card(defn: FlowDefinition, public_url: str) -> AgentCard:
|
|
58
|
-
"""Derive the A2A card from the definition (SPEC §6.5).
|
|
60
|
+
def build_agent_card(defn: FlowDefinition, public_url: str, version: str = "1") -> AgentCard:
|
|
61
|
+
"""Derive the A2A card from the definition (SPEC §6.5).
|
|
62
|
+
|
|
63
|
+
``version`` is the published version label when the publisher chose one,
|
|
64
|
+
else the deploy counter — the card always carries the version that is
|
|
65
|
+
actually being served.
|
|
66
|
+
"""
|
|
59
67
|
skills = [
|
|
60
68
|
AgentSkill(
|
|
61
69
|
id=defn.name,
|
|
62
70
|
name=defn.display_name or defn.name,
|
|
63
71
|
description=defn.description or defn.display_name or defn.name,
|
|
64
72
|
tags=list(defn.tags) or ["flow"],
|
|
73
|
+
examples=list(defn.expose.examples),
|
|
65
74
|
)
|
|
66
75
|
]
|
|
67
76
|
return AgentCard(
|
|
68
77
|
name=defn.name,
|
|
69
78
|
description=defn.description,
|
|
70
|
-
version=
|
|
79
|
+
version=version,
|
|
71
80
|
supported_interfaces=[
|
|
72
81
|
AgentInterface(url=public_url, protocol_binding="JSONRPC", protocol_version="1.0")
|
|
73
82
|
],
|
|
@@ -151,15 +160,35 @@ def build_a2a_app(
|
|
|
151
160
|
defn: FlowDefinition,
|
|
152
161
|
public_url: str,
|
|
153
162
|
runner_factory: Callable[[], FlowRunner],
|
|
163
|
+
version: str = "1",
|
|
154
164
|
) -> Starlette:
|
|
155
165
|
"""One Starlette app per A2A-exposed flow: card + JSON-RPC routes."""
|
|
156
|
-
card = build_agent_card(defn, public_url)
|
|
166
|
+
card = build_agent_card(defn, public_url, version)
|
|
157
167
|
handler = DefaultRequestHandler(
|
|
158
168
|
agent_executor=FlowAgentExecutor(runner_factory, defn),
|
|
159
169
|
task_store=InMemoryTaskStore(),
|
|
160
170
|
agent_card=card,
|
|
161
171
|
)
|
|
172
|
+
|
|
173
|
+
async def endpoint_info(_: Request) -> JSONResponse:
|
|
174
|
+
"""A browser GET on the endpoint lands here — the JSON-RPC binding is POST-only."""
|
|
175
|
+
return JSONResponse(
|
|
176
|
+
{
|
|
177
|
+
"name": defn.name,
|
|
178
|
+
"description": defn.description,
|
|
179
|
+
"protocol": "A2A",
|
|
180
|
+
"protocol_version": "1.0",
|
|
181
|
+
"agent_card_url": f"{public_url}{AGENT_CARD_PATH}",
|
|
182
|
+
"jsonrpc_url": public_url,
|
|
183
|
+
"hint": (
|
|
184
|
+
"POST JSON-RPC 2.0 requests here with the header 'A2A-Version: 1.0'; "
|
|
185
|
+
"GET the agent card at agent_card_url."
|
|
186
|
+
),
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
162
190
|
routes: list[Route] = [
|
|
191
|
+
Route("/", endpoint_info, methods=["GET"]),
|
|
163
192
|
*create_agent_card_routes(card),
|
|
164
193
|
*create_jsonrpc_routes(handler, "/"),
|
|
165
194
|
]
|
|
@@ -274,6 +303,7 @@ class Endpoint:
|
|
|
274
303
|
kind: str # "a2a" | "mcp"
|
|
275
304
|
version: int
|
|
276
305
|
public_url: str
|
|
306
|
+
version_label: str | None = None
|
|
277
307
|
lifespan: LifespanHost | None = None
|
|
278
308
|
expires_at: float | None = None # ephemeral only
|
|
279
309
|
|
|
@@ -319,7 +349,12 @@ class EndpointManager:
|
|
|
319
349
|
return f"{base}/{defn.expose.kind}/{defn.name}"
|
|
320
350
|
|
|
321
351
|
async def start(
|
|
322
|
-
self,
|
|
352
|
+
self,
|
|
353
|
+
defn: FlowDefinition,
|
|
354
|
+
version: int,
|
|
355
|
+
*,
|
|
356
|
+
version_label: str | None = None,
|
|
357
|
+
ephemeral: bool = False,
|
|
323
358
|
) -> Endpoint:
|
|
324
359
|
"""(Re)start the endpoint for a definition at a given version."""
|
|
325
360
|
key = f"_draft/{defn.name}" if ephemeral else defn.name
|
|
@@ -335,7 +370,7 @@ class EndpointManager:
|
|
|
335
370
|
await lifespan.start()
|
|
336
371
|
self.mcp.mount(key, http_app)
|
|
337
372
|
else:
|
|
338
|
-
app = build_a2a_app(defn, public_url, runner_factory)
|
|
373
|
+
app = build_a2a_app(defn, public_url, runner_factory, version_label or str(version))
|
|
339
374
|
self.a2a.mount(key, app)
|
|
340
375
|
|
|
341
376
|
endpoint = Endpoint(
|
|
@@ -343,6 +378,7 @@ class EndpointManager:
|
|
|
343
378
|
kind=defn.expose.kind if not ephemeral else "a2a",
|
|
344
379
|
version=version,
|
|
345
380
|
public_url=public_url,
|
|
381
|
+
version_label=version_label,
|
|
346
382
|
lifespan=lifespan,
|
|
347
383
|
expires_at=time.monotonic() + EPHEMERAL_TTL_S if ephemeral else None,
|
|
348
384
|
)
|
|
@@ -373,6 +409,7 @@ class EndpointManager:
|
|
|
373
409
|
|
|
374
410
|
|
|
375
411
|
__all__ = [
|
|
412
|
+
"AGENT_CARD_PATH",
|
|
376
413
|
"EndpointManager",
|
|
377
414
|
"FlowAgentExecutor",
|
|
378
415
|
"PathDispatcher",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Runtime configuration (SPEC §7.2), env prefix ``AGENTPLANE_RUNTIME_``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import field_validator
|
|
8
|
+
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
RUNTIME_VERSION = "0.0.2"
|
|
11
|
+
|
|
12
|
+
EPHEMERAL_TTL_S = 30 * 60 # SPEC §6.2: draft endpoints live 30 minutes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RuntimeSettings(BaseSettings):
|
|
16
|
+
model_config = SettingsConfigDict(
|
|
17
|
+
env_prefix="AGENTPLANE_RUNTIME_", extra="ignore", env_file=".env"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
db_url: str = "sqlite+aiosqlite:///runtime.db"
|
|
21
|
+
public_base_url: str = "" # required to serve; validated on app start
|
|
22
|
+
registry_url: str = "" # required to self-register; empty disables registration
|
|
23
|
+
registry_token: str = ""
|
|
24
|
+
secret_key: str = "" # Fernet key; required for resources with secrets
|
|
25
|
+
llm_base_url: str = "" # gateway's OpenAI-compatible endpoint (resource default)
|
|
26
|
+
auth_mode: Literal["none", "oidc"] = "none"
|
|
27
|
+
oidc_issuer: str = ""
|
|
28
|
+
oidc_audience: str = ""
|
|
29
|
+
roles_claim: str = "realm_access.roles"
|
|
30
|
+
admin_role: str = "admin"
|
|
31
|
+
http_timeout_s: float = 60.0
|
|
32
|
+
host: str = "0.0.0.0"
|
|
33
|
+
port: int = 8000
|
|
34
|
+
# Browsers only reach the endpoints directly when the runtime runs without a
|
|
35
|
+
# gateway (local builder playground). In production agentgateway owns CORS,
|
|
36
|
+
# so this stays empty and no middleware is installed. "*" allows any origin.
|
|
37
|
+
cors_origins: Annotated[list[str], NoDecode] = []
|
|
38
|
+
|
|
39
|
+
@field_validator("cors_origins", mode="before")
|
|
40
|
+
@classmethod
|
|
41
|
+
def _split_origins(cls, value: object) -> object:
|
|
42
|
+
"""Accept a comma-separated env value (``a.example,b.example``) or a JSON list."""
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["EPHEMERAL_TTL_S", "RUNTIME_VERSION", "RuntimeSettings"]
|
|
@@ -81,7 +81,9 @@ async def validate_full(
|
|
|
81
81
|
)
|
|
82
82
|
continue
|
|
83
83
|
if isinstance(node, RetrievalNode) and isinstance(resource, VectorDBResource):
|
|
84
|
-
e022 = await resources.check_collection_dimension(
|
|
84
|
+
e022 = await resources.check_collection_dimension(
|
|
85
|
+
resource, node.config.collection, f"nodes/{node.id}/config/collection"
|
|
86
|
+
)
|
|
85
87
|
if e022 is not None:
|
|
86
88
|
issues.append(e022)
|
|
87
89
|
|
|
@@ -50,10 +50,14 @@ class QdrantReader:
|
|
|
50
50
|
vector: list[float],
|
|
51
51
|
top_k: int,
|
|
52
52
|
filter: JsonObject | None = None,
|
|
53
|
+
*,
|
|
54
|
+
min_score: float | None = None,
|
|
53
55
|
) -> list[Document]:
|
|
54
56
|
body: dict[str, object] = {"vector": vector, "limit": top_k, "with_payload": True}
|
|
55
57
|
if filter is not None:
|
|
56
58
|
body["filter"] = filter
|
|
59
|
+
if min_score is not None:
|
|
60
|
+
body["score_threshold"] = min_score
|
|
57
61
|
try:
|
|
58
62
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
59
63
|
response = await client.post(
|
|
@@ -109,6 +113,8 @@ class PgvectorReader:
|
|
|
109
113
|
vector: list[float],
|
|
110
114
|
top_k: int,
|
|
111
115
|
filter: JsonObject | None = None,
|
|
116
|
+
*,
|
|
117
|
+
min_score: float | None = None,
|
|
112
118
|
) -> list[Document]:
|
|
113
119
|
import asyncpg # noqa: PLC0415 - optional [postgres] extra
|
|
114
120
|
|
|
@@ -129,10 +135,11 @@ class PgvectorReader:
|
|
|
129
135
|
await conn.close()
|
|
130
136
|
documents: list[Document] = []
|
|
131
137
|
for row in rows:
|
|
138
|
+
score = float(row["score"])
|
|
139
|
+
if min_score is not None and score < min_score:
|
|
140
|
+
continue
|
|
132
141
|
metadata = row["metadata"] if isinstance(row["metadata"], dict) else {}
|
|
133
|
-
documents.append(
|
|
134
|
-
Document(text=str(row["text"]), score=float(row["score"]), metadata=metadata)
|
|
135
|
-
)
|
|
142
|
+
documents.append(Document(text=str(row["text"]), score=score, metadata=metadata))
|
|
136
143
|
return documents
|
|
137
144
|
|
|
138
145
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Optional CORS middleware (FEEDBACK 1.2).
|
|
2
|
+
|
|
3
|
+
Without a gateway in front of the runtime, a browser cannot talk A2A to
|
|
4
|
+
``/a2a/{name}`` at all — the builder playground chats straight from the page.
|
|
5
|
+
In production agentgateway owns CORS, so the middleware stays off by default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from asgi_lifespan import LifespanManager
|
|
12
|
+
|
|
13
|
+
from agentplane_runtime.app import create_app
|
|
14
|
+
|
|
15
|
+
from .conftest import make_settings
|
|
16
|
+
|
|
17
|
+
ORIGIN = "http://builder.localhost:5173"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _preflight(**settings: object) -> httpx.Response:
|
|
21
|
+
app = create_app(make_settings(**settings))
|
|
22
|
+
async with LifespanManager(app):
|
|
23
|
+
transport = httpx.ASGITransport(app=app)
|
|
24
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://runtime.test") as client:
|
|
25
|
+
return await client.options(
|
|
26
|
+
"/a2a/echo-agent/",
|
|
27
|
+
headers={
|
|
28
|
+
"Origin": ORIGIN,
|
|
29
|
+
"Access-Control-Request-Method": "POST",
|
|
30
|
+
"Access-Control-Request-Headers": "content-type,a2a-version",
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_no_cors_headers_by_default() -> None:
|
|
36
|
+
response = await _preflight()
|
|
37
|
+
assert "access-control-allow-origin" not in response.headers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_configured_origin_is_allowed_on_the_a2a_mount() -> None:
|
|
41
|
+
response = await _preflight(cors_origins=[ORIGIN])
|
|
42
|
+
assert response.status_code == 200
|
|
43
|
+
assert response.headers["access-control-allow-origin"] == ORIGIN
|
|
44
|
+
assert "POST" in response.headers["access-control-allow-methods"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_origins_come_from_a_comma_separated_env_value() -> None:
|
|
48
|
+
settings = make_settings(cors_origins="http://a.test, http://b.test")
|
|
49
|
+
assert settings.cors_origins == ["http://a.test", "http://b.test"]
|
|
@@ -12,7 +12,13 @@ from asgi_lifespan import LifespanManager
|
|
|
12
12
|
|
|
13
13
|
from agentplane_runtime.app import create_app
|
|
14
14
|
|
|
15
|
-
from .conftest import
|
|
15
|
+
from .conftest import (
|
|
16
|
+
QDRANT_BASE,
|
|
17
|
+
REGISTRY_BASE,
|
|
18
|
+
create_default_resources,
|
|
19
|
+
load_example,
|
|
20
|
+
make_settings,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
async def test_validate_endpoint_reports_stateful_codes(client: httpx.AsyncClient) -> None:
|
|
@@ -210,3 +216,80 @@ async def test_deploy_registers_with_registry(client: httpx.AsyncClient) -> None
|
|
|
210
216
|
await local_client.post("/api/v1/definitions/echo-agent/undeploy")
|
|
211
217
|
).status_code == 204
|
|
212
218
|
assert delete_route.called
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def test_deploy_with_version_label(client: httpx.AsyncClient) -> None:
|
|
222
|
+
"""Publishers may label a deploy with a semantic version (FEEDBACK 2.3)."""
|
|
223
|
+
await create_default_resources(client)
|
|
224
|
+
defn = load_example("echo-agent.yaml")
|
|
225
|
+
await client.post("/api/v1/definitions", json=defn.canonical_dict())
|
|
226
|
+
|
|
227
|
+
first = await client.post(
|
|
228
|
+
"/api/v1/definitions/echo-agent/deploy", params={"version_label": "1.0.0"}
|
|
229
|
+
)
|
|
230
|
+
assert first.status_code == 200, first.text
|
|
231
|
+
assert first.json()["version"] == 1
|
|
232
|
+
assert first.json()["version_label"] == "1.0.0"
|
|
233
|
+
|
|
234
|
+
info = (await client.get("/api/v1/definitions/echo-agent")).json()
|
|
235
|
+
assert info["deployed_version_label"] == "1.0.0"
|
|
236
|
+
|
|
237
|
+
# the label is served on the agent card
|
|
238
|
+
card = (await client.get("/a2a/echo-agent/.well-known/agent-card.json")).json()
|
|
239
|
+
assert card["version"] == "1.0.0"
|
|
240
|
+
|
|
241
|
+
# a second deploy takes the next counter and its own label
|
|
242
|
+
updated = defn.model_copy(update={"description": "v2"})
|
|
243
|
+
await client.put("/api/v1/definitions/echo-agent", json=updated.canonical_dict())
|
|
244
|
+
second = await client.post(
|
|
245
|
+
"/api/v1/definitions/echo-agent/deploy", params={"version_label": "1.1.0"}
|
|
246
|
+
)
|
|
247
|
+
assert (second.json()["version"], second.json()["version_label"]) == (2, "1.1.0")
|
|
248
|
+
|
|
249
|
+
# a known label rolls back to that version instead of freezing a new one
|
|
250
|
+
rollback = await client.post(
|
|
251
|
+
"/api/v1/definitions/echo-agent/deploy", params={"version_label": "1.0.0"}
|
|
252
|
+
)
|
|
253
|
+
assert (rollback.json()["version"], rollback.json()["version_label"]) == (1, "1.0.0")
|
|
254
|
+
info = (await client.get("/api/v1/definitions/echo-agent")).json()
|
|
255
|
+
assert (info["deployed_version"], info["latest_version"]) == (1, 2)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def test_deploy_rejects_malformed_and_conflicting_version_labels(
|
|
259
|
+
client: httpx.AsyncClient,
|
|
260
|
+
) -> None:
|
|
261
|
+
await create_default_resources(client)
|
|
262
|
+
defn = load_example("echo-agent.yaml")
|
|
263
|
+
await client.post("/api/v1/definitions", json=defn.canonical_dict())
|
|
264
|
+
|
|
265
|
+
not_semver = await client.post(
|
|
266
|
+
"/api/v1/definitions/echo-agent/deploy", params={"version_label": "v1"}
|
|
267
|
+
)
|
|
268
|
+
assert not_semver.status_code == 422
|
|
269
|
+
|
|
270
|
+
await client.post("/api/v1/definitions/echo-agent/deploy", params={"version_label": "1.0.0"})
|
|
271
|
+
both = await client.post(
|
|
272
|
+
"/api/v1/definitions/echo-agent/deploy",
|
|
273
|
+
params={"version": 1, "version_label": "2.0.0"},
|
|
274
|
+
)
|
|
275
|
+
assert both.status_code == 409
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@respx.mock
|
|
279
|
+
async def test_validate_reports_e022_dimension_mismatch(client: httpx.AsyncClient) -> None:
|
|
280
|
+
"""The collection lives on the retrieval node, so E022 points at that node (FEEDBACK 1.3)."""
|
|
281
|
+
respx.get(f"{QDRANT_BASE}/collections/support_docs").mock(
|
|
282
|
+
return_value=httpx.Response(
|
|
283
|
+
200, json={"result": {"config": {"params": {"vectors": {"size": 768}}}}}
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
respx.route(host="runtime.test").pass_through()
|
|
287
|
+
await create_default_resources(client) # resource declares dimension 3
|
|
288
|
+
defn = load_example("support-rag.yaml").canonical_dict()
|
|
289
|
+
|
|
290
|
+
body = (await client.post("/api/v1/definitions/validate", json=defn)).json()
|
|
291
|
+
|
|
292
|
+
e022 = [i for i in body["issues"] if i["code"] == "E022"]
|
|
293
|
+
assert body["valid"] is False
|
|
294
|
+
assert e022[0]["path"] == "nodes/retrieve_1/config/collection"
|
|
295
|
+
assert "768" in e022[0]["message"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Fan-in joins: every node runs at most once, and router branches gate execution.
|
|
2
|
+
|
|
3
|
+
LangGraph triggers a node as soon as *any* predecessor wrote, so a node fed
|
|
4
|
+
from different graph depths (``start -> call`` AND ``start -> retrieve ->
|
|
5
|
+
call``) used to run twice — the first time with its documents port still empty,
|
|
6
|
+
i.e. a wasted LLM call. In a routed flow it was worse: an LLM node that also
|
|
7
|
+
takes an unconditional edge from ``start`` ran even when the router had chosen
|
|
8
|
+
the other branch, and its output won the race on ``end.input``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import respx
|
|
15
|
+
from cryptography.fernet import Fernet
|
|
16
|
+
|
|
17
|
+
from agentplane_core import FlowDefinition, ModelProviderResource, VectorDBResource
|
|
18
|
+
from agentplane_runtime.db import Database
|
|
19
|
+
from agentplane_runtime.engine import ExecutionContext, FlowRunner
|
|
20
|
+
from agentplane_runtime.resources import ResourceService
|
|
21
|
+
from agentplane_runtime.secrets import FernetSecretsProvider
|
|
22
|
+
|
|
23
|
+
from .conftest import LLM_BASE, QDRANT_BASE, load_example, make_settings, vector_db_body
|
|
24
|
+
|
|
25
|
+
SEARCH_URL = f"{QDRANT_BASE}/collections/support_docs/points/search"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _runner(defn: FlowDefinition) -> FlowRunner:
|
|
29
|
+
db = Database("sqlite+aiosqlite://")
|
|
30
|
+
await db.create_all()
|
|
31
|
+
resources = ResourceService(db, FernetSecretsProvider(db, Fernet.generate_key().decode()))
|
|
32
|
+
await resources.create(
|
|
33
|
+
ModelProviderResource(name="default-llm", base_url=LLM_BASE, default_model="gpt-5-mini"),
|
|
34
|
+
"anonymous",
|
|
35
|
+
)
|
|
36
|
+
await resources.create(VectorDBResource.model_validate(vector_db_body()), "anonymous")
|
|
37
|
+
return FlowRunner(
|
|
38
|
+
defn,
|
|
39
|
+
ExecutionContext(resources=resources, settings=make_settings(), flow_name=defn.name),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _mock_embeddings() -> None:
|
|
44
|
+
respx.post(f"{LLM_BASE}/embeddings").mock(
|
|
45
|
+
return_value=httpx.Response(200, json={"data": [{"embedding": [0.1, 0.2, 0.3]}]})
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _mock_search(*documents: dict[str, object]) -> respx.Route:
|
|
50
|
+
return respx.post(SEARCH_URL).mock(return_value=httpx.Response(200, json={"result": documents}))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _mock_llm(answer: str) -> respx.Route:
|
|
54
|
+
return respx.post(f"{LLM_BASE}/chat/completions").mock(
|
|
55
|
+
return_value=httpx.Response(200, json={"choices": [{"message": {"content": answer}}]})
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@respx.mock
|
|
60
|
+
async def test_fan_in_node_runs_once() -> None:
|
|
61
|
+
"""support-rag feeds `call_1` from `start_1` AND `retrieve_1` — one LLM call, not two."""
|
|
62
|
+
_mock_embeddings()
|
|
63
|
+
_mock_search({"score": 0.9, "payload": {"text": "Reset the router.", "source": "kb/1"}})
|
|
64
|
+
llm = _mock_llm("Please reset the router.")
|
|
65
|
+
|
|
66
|
+
runner = await _runner(load_example("support-rag.yaml"))
|
|
67
|
+
result = await runner.execute({"query": "wifi broken"})
|
|
68
|
+
|
|
69
|
+
assert result == "Please reset the router."
|
|
70
|
+
assert llm.call_count == 1
|
|
71
|
+
# the single call saw the retrieved documents — it did not fire before retrieval
|
|
72
|
+
assert "Reset the router." in llm.calls.last.request.content.decode()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@respx.mock
|
|
76
|
+
async def test_router_gates_the_llm_branch() -> None:
|
|
77
|
+
"""Nothing retrieved -> the LLM node must not run at all, fallback text is returned."""
|
|
78
|
+
_mock_embeddings()
|
|
79
|
+
_mock_search() # empty result set
|
|
80
|
+
llm = _mock_llm("should never be produced")
|
|
81
|
+
|
|
82
|
+
runner = await _runner(load_example("rag-with-fallback.yaml"))
|
|
83
|
+
result = await runner.execute({"query": "unknown topic"})
|
|
84
|
+
|
|
85
|
+
assert result == "I have no information about that in the knowledge base."
|
|
86
|
+
assert llm.call_count == 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@respx.mock
|
|
90
|
+
async def test_router_found_branch_runs_the_llm_once() -> None:
|
|
91
|
+
_mock_embeddings()
|
|
92
|
+
_mock_search({"score": 0.8, "payload": {"text": "Reset the router.", "source": "kb/1"}})
|
|
93
|
+
llm = _mock_llm("Reset it.")
|
|
94
|
+
|
|
95
|
+
runner = await _runner(load_example("rag-with-fallback.yaml"))
|
|
96
|
+
result = await runner.execute({"query": "wifi broken"})
|
|
97
|
+
|
|
98
|
+
assert result == "Reset it."
|
|
99
|
+
assert llm.call_count == 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@respx.mock
|
|
103
|
+
async def test_min_score_is_sent_to_the_vector_db() -> None:
|
|
104
|
+
"""Without a threshold a filled collection always returns hits (FEEDBACK 2.1)."""
|
|
105
|
+
_mock_embeddings()
|
|
106
|
+
search = _mock_search()
|
|
107
|
+
_mock_llm("unused")
|
|
108
|
+
|
|
109
|
+
runner = await _runner(load_example("rag-with-fallback.yaml"))
|
|
110
|
+
await runner.execute({"query": "unknown topic"})
|
|
111
|
+
|
|
112
|
+
assert search.calls.last.request.read().decode().count('"score_threshold":0.5') == 1
|
|
@@ -183,3 +183,36 @@ def test_agent_card_fields_derive_from_definition() -> None:
|
|
|
183
183
|
assert card.name == "support-rag"
|
|
184
184
|
assert card.skills[0].tags == ["support", "rag"]
|
|
185
185
|
assert card.supported_interfaces[0].protocol_version == "1.0"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_agent_card_carries_expose_examples() -> None:
|
|
189
|
+
"""expose.examples land on the skill so calling agents can route (FEEDBACK 2.2)."""
|
|
190
|
+
defn = load_example("echo-agent.yaml")
|
|
191
|
+
card = build_agent_card(defn, "https://api.example/a2a/echo-agent")
|
|
192
|
+
assert list(card.skills[0].examples) == [
|
|
193
|
+
"What is the capital of France?",
|
|
194
|
+
"Summarise this paragraph in one sentence.",
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_agent_card_version_defaults_to_the_deploy_counter() -> None:
|
|
199
|
+
defn = load_example("echo-agent.yaml")
|
|
200
|
+
assert build_agent_card(defn, "https://api.example/a2a/echo-agent").version == "1"
|
|
201
|
+
labelled = build_agent_card(defn, "https://api.example/a2a/echo-agent", "2.1.0")
|
|
202
|
+
assert labelled.version == "2.1.0"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def test_browser_get_on_the_endpoint_explains_itself(manager: EndpointManager) -> None:
|
|
206
|
+
"""The JSON-RPC binding is POST-only; a plain GET used to be a bare 405 (FEEDBACK 2.4)."""
|
|
207
|
+
defn = load_example("echo-agent.yaml")
|
|
208
|
+
await manager.start(defn, 1)
|
|
209
|
+
transport = httpx.ASGITransport(app=manager.a2a)
|
|
210
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://rt.test") as client:
|
|
211
|
+
response = await client.get("/echo-agent/")
|
|
212
|
+
assert response.status_code == 200
|
|
213
|
+
body = response.json()
|
|
214
|
+
assert body["protocol"] == "A2A"
|
|
215
|
+
assert body["agent_card_url"] == (
|
|
216
|
+
"https://api.example/a2a/echo-agent/.well-known/agent-card.json"
|
|
217
|
+
)
|
|
218
|
+
assert "A2A-Version: 1.0" in body["hint"]
|
|
@@ -1,31 +0,0 @@
|
|
|
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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/registration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|