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.
Files changed (32) hide show
  1. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/.gitignore +1 -0
  2. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/PKG-INFO +3 -3
  3. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/pyproject.toml +6 -3
  4. agentplane_runtime-0.0.2/src/agentplane_runtime/__main__.py +14 -0
  5. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/api.py +7 -1
  6. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/app.py +12 -1
  7. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/db.py +23 -1
  8. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/definitions.py +76 -25
  9. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/engine.py +32 -4
  10. agentplane_runtime-0.0.2/src/agentplane_runtime/py.typed +0 -0
  11. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/resources.py +18 -6
  12. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/serving.py +44 -7
  13. agentplane_runtime-0.0.2/src/agentplane_runtime/settings.py +48 -0
  14. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/validation.py +3 -1
  15. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/vector.py +10 -3
  16. agentplane_runtime-0.0.2/tests/test_app_cors.py +49 -0
  17. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_definitions_api.py +84 -1
  18. agentplane_runtime-0.0.2/tests/test_engine_join.py +112 -0
  19. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_serving.py +33 -0
  20. agentplane_runtime-0.0.1/src/agentplane_runtime/settings.py +0 -31
  21. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/README.md +0 -0
  22. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/__init__.py +0 -0
  23. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/auth.py +0 -0
  24. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/llm.py +0 -0
  25. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/registration.py +0 -0
  26. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/secrets.py +0 -0
  27. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/src/agentplane_runtime/tracing.py +0 -0
  28. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/conftest.py +0 -0
  29. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_engine.py +0 -0
  30. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_engine_router.py +0 -0
  31. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_resources_api.py +0 -0
  32. {agentplane_runtime-0.0.1 → agentplane_runtime-0.0.2}/tests/test_secrets.py +0 -0
@@ -1,6 +1,7 @@
1
1
  # internal working documents — not part of the public repo
2
2
  CLAUDE.md
3
3
  SPEC.md
4
+ FEEDBACK.md
4
5
  .claude/
5
6
 
6
7
  # python
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentplane-runtime
3
- Version: 0.0.1
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.1
9
- Requires-Dist: agentplane-sdk<0.1.0,>=0.0.1
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.1"
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.1,<0.1.0",
10
- "agentplane-sdk>=0.0.1,<0.1.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(name, version=version, ephemeral=ephemeral)
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
- uvicorn.run(create_app(), host="0.0.0.0", port=8000)
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__ = (UniqueConstraint("name", "version", name="uq_versions_name_version"),)
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, name: str, *, version: int | None = None, ephemeral: bool = False
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
- draft = load_definition(row.draft_json)
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, active_version = await self._load_version(name, version), version
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 _load_version(self, name: str, version: int) -> FlowDefinition:
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
- result = await session.execute(
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._load_version(row.name, row.deployed_version)
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(config.collection, vector, config.top_k, config.filter)
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
- result = await graph.ainvoke({"values": seeded}) # type: ignore[attr-defined]
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
 
@@ -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 (used by definition validation and create)."""
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=f"resources/{resource.name}/embedding/dimension",
108
+ path=path,
98
109
  message=(
99
- f"embedding dimension {resource.embedding.dimension} does not match "
100
- f"collection {collection!r} dimension {actual}"
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.responses import PlainTextResponse
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="1",
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, defn: FlowDefinition, version: int, *, ephemeral: bool = False
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(resource, node.config.collection)
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 REGISTRY_BASE, create_default_resources, load_example, make_settings
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"]