Skip to content

Commit d2783cf

Browse files
committed
feat(platform): Sprint 3 — HTTP/RAG connectors, control plane, sticky Redis/PG
Add HttpConnector and RagConnector with settings validation, apply PolicyRegistry constraints at API boundaries, and implement get_pack_version_for_session for Redis and Postgres run history. Document Sprint 3 and update CHANGELOG [Unreleased].
1 parent c4f9305 commit d2783cf

21 files changed

Lines changed: 604 additions & 99 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ SEARCH_PROVIDER=mock
179179
# (POST /run, POST /packs/research_analysis/run, and stream variants).
180180
CONNECTOR_ENABLED=false
181181
CONNECTOR_ID=example_memory
182+
# CONNECTOR_ID=http requires CONNECTOR_HTTP_URL (GET with ?q=...&limit=...)
183+
# CONNECTOR_HTTP_URL=https://your-search-api.example.com/retrieve
184+
# CONNECTOR_ID=rag requires RAG_ENABLED=true and: uv sync --extra rag
182185

183186
# =============================================================================
184187
# RAG — Vector Store Integration

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Second domain pack** `research_only` (`ResearchOnlyPack`) with typed `/packs/research_only/run` routes.
12+
- **Retrieval connectors**`example_memory`, `http` (`CONNECTOR_HTTP_URL`), and `rag` (`RAG_ENABLED`); API injection via `CONNECTOR_ENABLED` into `ResearchAnalysisPack`.
13+
- **Control plane enforcement**`PolicyRegistry`, `control_plane/enforce.py` (per-pack query limits, budget ceiling, stream timeout).
14+
- **Sticky pack versions** on Redis and Postgres run-history backends (`get_pack_version_for_session`).
15+
1016
## [0.5.0] - 2026-05-04
1117

1218
### Added

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ User Query
7979
- **Second domain pack**`research_only` (`ResearchOnlyPack`) registered alongside `research_analysis`; use `POST /packs/research_only/run` for research-only output.
8080
- **Optional retrieval connector**`CONNECTOR_ENABLED` + `CONNECTOR_ID` inject a built-in connector into `ResearchAnalysisPack` on `/run` and `/packs/research_analysis/*` (default id: `example_memory`; query containing `demo` returns canned snippets).
8181

82+
**Platform kernel (Sprint 3)**
83+
84+
- **Connectors**`http` (`CONNECTOR_HTTP_URL`) and `rag` (`RAG_ENABLED=true`, `CONNECTOR_ID=rag`) in addition to `example_memory`.
85+
- **Control plane**`PolicyRegistry` + enforcement at API boundaries (query length, budget, stream timeout per pack).
86+
- **Sticky sessions**`get_pack_version_for_session` implemented for **Redis** and **Postgres** run-history backends (SQLite unchanged).
87+
8288
## Quick Start
8389

8490
**Prerequisites**
@@ -521,7 +527,8 @@ All configuration is loaded from environment variables. Copy `.env.example` to `
521527
| `API_PORT` | `8000` | TCP port the FastAPI server listens on |
522528
| `API_KEY` || Bearer token for API auth. Leave unset to disable auth. |
523529
| `CONNECTOR_ENABLED` | `false` | Inject retrieval connector into `research_analysis` runs |
524-
| `CONNECTOR_ID` | `example_memory` | Built-in connector when enabled (see `core/connectors.py`) |
530+
| `CONNECTOR_ID` | `example_memory` | `example_memory`, `http`, or `rag` (see `core/connectors.py`) |
531+
| `CONNECTOR_HTTP_URL` || Required when `CONNECTOR_ID=http` |
525532

526533
### LLM providers
527534

api/main.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@
7474
RunRequest,
7575
RunResponse,
7676
)
77+
from control_plane.enforce import (
78+
effective_budget_usd,
79+
effective_stream_timeout_seconds,
80+
validate_query_for_pack,
81+
)
7782
from core.config import Settings, get_settings
7883
from core.connectors import resolve_connector
7984
from core.graph import MultiAgentGraph
@@ -347,13 +352,28 @@ def get_shared_memory() -> Any:
347352
return _shared_memory
348353

349354

355+
def _validate_pack_query(pack_id: str, raw_query: str) -> str:
356+
"""Validate query text using pack policy constraints and global sanitizer."""
357+
try:
358+
return validate_query_for_pack(raw_query, pack_id, _input_validator)
359+
except ValueError as exc:
360+
raise HTTPException(
361+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
362+
detail=str(exc),
363+
) from exc
364+
365+
350366
def _pack_runtime_kwargs(pack_cls: type) -> dict[str, Any]:
351-
"""Extra constructor kwargs for packs that support an optional connector."""
352-
if _shared_connector is None:
353-
return {}
354-
if getattr(pack_cls, "pack_id", None) != "research_analysis":
355-
return {}
356-
return {"connector": _shared_connector}
367+
"""Extra constructor kwargs: policy budget and optional connector."""
368+
kwargs: dict[str, Any] = {}
369+
pack_id = getattr(pack_cls, "pack_id", None)
370+
if pack_id:
371+
budget = effective_budget_usd(pack_id, get_settings())
372+
if budget is not None:
373+
kwargs["budget_usd"] = budget
374+
if _shared_connector is not None and pack_id == "research_analysis":
375+
kwargs["connector"] = _shared_connector
376+
return kwargs
357377

358378

359379
def _legacy_pipeline_pack_cls() -> Any:
@@ -515,7 +535,7 @@ async def run_pack( # type: ignore[misc]
515535

516536
raw_query = body.query if hasattr(body, "query") else str(body)
517537
try:
518-
query = _input_validator.validate(raw_query)
538+
query = _validate_pack_query(pack_id, raw_query)
519539
except AgentValidationError as exc:
520540
raise HTTPException(
521541
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -534,6 +554,7 @@ def _execute() -> Any:
534554

535555
# Record run in history with pack_version metadata
536556
if _shared_memory is not None:
557+
session_id_for_history = getattr(body, "session_id", None) or None
537558
_shared_memory.save_run(
538559
run_id=run_id,
539560
query=query,
@@ -543,6 +564,11 @@ def _execute() -> Any:
543564
metadata={
544565
"pack_id": pack_id,
545566
"pack_version": used_version,
567+
**(
568+
{"session_id": session_id_for_history}
569+
if session_id_for_history
570+
else {}
571+
),
546572
},
547573
)
548574

@@ -607,7 +633,7 @@ async def stream_pack( # type: ignore[misc]
607633

608634
raw_query = body.query if hasattr(body, "query") else str(body)
609635
try:
610-
query = _input_validator.validate(raw_query)
636+
query = _validate_pack_query(pack_id, raw_query)
611637
except AgentValidationError as exc:
612638
raise HTTPException(
613639
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -628,13 +654,15 @@ async def _event_generator() -> AsyncGenerator[str, None]:
628654
finally:
629655
pack.close()
630656

657+
stream_timeout = effective_stream_timeout_seconds(pack_id, get_settings())
658+
631659
async def _timed_event_generator() -> AsyncGenerator[str, None]:
632660
try:
633-
async with asyncio.timeout(get_settings().stream_timeout_seconds):
661+
async with asyncio.timeout(stream_timeout):
634662
async for chunk in _event_generator():
635663
yield chunk
636664
except TimeoutError:
637-
yield f"data: {json.dumps({'type': 'error', 'message': f'Stream timed out after {get_settings().stream_timeout_seconds}s'})}\n\n"
665+
yield f"data: {json.dumps({'type': 'error', 'message': f'Stream timed out after {stream_timeout}s'})}\n\n"
638666

639667
return StreamingResponse(
640668
_timed_event_generator(),
@@ -1115,8 +1143,9 @@ async def run_pipeline(
11151143
detail="Server is shutting down.",
11161144
)
11171145

1146+
settings = get_settings()
11181147
try:
1119-
query = _input_validator.validate(body.query)
1148+
query = _validate_pack_query(settings.default_pack_id, body.query)
11201149
except ValueError as exc:
11211150
raise HTTPException(
11221151
status_code=status.HTTP_400_BAD_REQUEST,
@@ -1415,7 +1444,7 @@ async def run_stream(
14151444
)
14161445

14171446
try:
1418-
query = _input_validator.validate(body.query)
1447+
query = _validate_pack_query(settings.default_pack_id, body.query)
14191448
except ValueError as exc:
14201449
raise HTTPException(
14211450
status_code=status.HTTP_400_BAD_REQUEST,
@@ -1436,6 +1465,9 @@ async def run_stream(
14361465

14371466
session_id = body.session_id or str(uuid.uuid4())
14381467
run_id = str(uuid.uuid4())
1468+
stream_timeout = effective_stream_timeout_seconds(
1469+
settings.default_pack_id, settings
1470+
)
14391471

14401472
logger.info(
14411473
"POST /run/stream — pipeline started",
@@ -1448,11 +1480,11 @@ async def run_stream(
14481480

14491481
async def _guarded_stream() -> AsyncGenerator[str, None]:
14501482
try:
1451-
async with asyncio.timeout(settings.stream_timeout_seconds):
1483+
async with asyncio.timeout(stream_timeout):
14521484
async for event in _stream_pipeline(query, session_id, run_id):
14531485
yield event
14541486
except TimeoutError:
1455-
yield f"data: {json.dumps({'type': 'error', 'message': f'Stream timed out after {settings.stream_timeout_seconds}s'})}\n\n"
1487+
yield f"data: {json.dumps({'type': 'error', 'message': f'Stream timed out after {stream_timeout}s'})}\n\n"
14561488

14571489
return StreamingResponse(
14581490
_guarded_stream(),

connectors/http_connector.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""
2+
connectors/http_connector.py — HTTP GET connector for JSON or text retrieval APIs.
3+
4+
Expects ``CONNECTOR_HTTP_URL`` (base URL). Appends ``q`` and ``limit`` query params.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any, ClassVar
10+
from urllib.parse import urlencode, urlparse, urlunparse
11+
12+
import httpx
13+
14+
from connectors.base import BaseConnector, ConnectorRequest, ConnectorResult
15+
16+
17+
class HttpConnector(BaseConnector):
18+
"""Fetches retrieval snippets from a configurable HTTP endpoint."""
19+
20+
connector_id: ClassVar[str] = "http"
21+
name: ClassVar[str] = "HTTP retrieval connector"
22+
description: ClassVar[str] = (
23+
"GET request to CONNECTOR_HTTP_URL with query/limit parameters."
24+
)
25+
26+
def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
27+
if not base_url or not base_url.strip():
28+
raise ValueError("HttpConnector requires a non-empty base_url")
29+
self._base_url = base_url.strip().rstrip("/")
30+
self._timeout = timeout_seconds
31+
32+
async def fetch(self, request: ConnectorRequest) -> ConnectorResult:
33+
params = {
34+
"q": request.query,
35+
"limit": str(request.limit),
36+
**{k: str(v) for k, v in request.filters.items()},
37+
}
38+
url = self._build_url(params)
39+
40+
async with httpx.AsyncClient(timeout=self._timeout) as client:
41+
response = await client.get(url)
42+
response.raise_for_status()
43+
44+
content_type = response.headers.get("content-type", "")
45+
if "application/json" in content_type:
46+
records = _parse_json_payload(response.json())
47+
else:
48+
records = _parse_text_payload(response.text)
49+
50+
return ConnectorResult(
51+
records=tuple(records[: request.limit]),
52+
metadata={"url": url, "status_code": response.status_code},
53+
)
54+
55+
def _build_url(self, params: dict[str, str]) -> str:
56+
parsed = urlparse(self._base_url)
57+
query = urlencode(params)
58+
if parsed.query:
59+
query = f"{parsed.query}&{query}"
60+
return urlunparse(parsed._replace(query=query))
61+
62+
63+
def _parse_json_payload(payload: Any) -> list[dict[str, Any]]:
64+
if isinstance(payload, list):
65+
return [_normalize_record(item) for item in payload if isinstance(item, dict)]
66+
if isinstance(payload, dict):
67+
for key in ("results", "records", "items", "data"):
68+
items = payload.get(key)
69+
if isinstance(items, list):
70+
return [
71+
_normalize_record(item) for item in items if isinstance(item, dict)
72+
]
73+
if "snippet" in payload or "text" in payload:
74+
return [_normalize_record(payload)]
75+
return []
76+
77+
78+
def _parse_text_payload(text: str) -> list[dict[str, Any]]:
79+
lines = [line.strip() for line in text.splitlines() if line.strip()]
80+
return [{"source": "http", "snippet": line} for line in lines]
81+
82+
83+
def _normalize_record(item: dict[str, Any]) -> dict[str, Any]:
84+
snippet = item.get("snippet") or item.get("text") or item.get("content")
85+
if snippet is None and len(item) == 1:
86+
snippet = next(iter(item.values()))
87+
record = dict(item)
88+
if snippet is not None:
89+
record.setdefault("snippet", str(snippet))
90+
record.setdefault("source", str(record.get("source", "http")))
91+
return record

connectors/rag_connector.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""
2+
connectors/rag_connector.py — Vector-store retrieval via core.vectorstore.
3+
4+
Requires ``RAG_ENABLED=true`` and the ``rag`` optional dependencies.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import TYPE_CHECKING, Any, ClassVar
10+
11+
from connectors.base import BaseConnector, ConnectorRequest, ConnectorResult
12+
13+
if TYPE_CHECKING:
14+
from core.config import Settings
15+
16+
17+
class RagConnector(BaseConnector):
18+
"""Runs similarity search against the configured RAG vector store."""
19+
20+
connector_id: ClassVar[str] = "rag"
21+
name: ClassVar[str] = "RAG vector store connector"
22+
description: ClassVar[str] = (
23+
"Similarity search via get_vectorstore() when RAG_ENABLED=true."
24+
)
25+
26+
def __init__(self, settings: Settings) -> None:
27+
self._settings = settings
28+
29+
async def fetch(self, request: ConnectorRequest) -> ConnectorResult:
30+
from core.vectorstore import get_vectorstore
31+
32+
store = get_vectorstore(self._settings)
33+
documents = store.similarity_search(request.query, k=request.limit)
34+
records: list[dict[str, Any]] = []
35+
for index, doc in enumerate(documents):
36+
meta = dict(doc.metadata) if doc.metadata else {}
37+
records.append(
38+
{
39+
"source": str(meta.get("source", f"rag:{index}")),
40+
"snippet": doc.page_content,
41+
"score": meta.get("score"),
42+
}
43+
)
44+
return ConnectorResult(
45+
records=tuple(records),
46+
metadata={"backend": "rag", "count": len(records)},
47+
)

control_plane/README.md

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,30 @@
1-
# Control plane (foundation — Sprint 2)
1+
# Control plane
22

3-
This folder holds **types and documentation only**. There is no evaluator, no tenant model, no feature-flag service, and no integration with FastAPI or `PackRegistry` beyond naming compatibility (`pack_id` aligns with registered packs).
3+
Pack-level policies are registered explicitly in `control_plane/__init__.py` (Approach B, same spirit as `PackRegistry`).
44

5-
## What the control plane will own (directional)
5+
## What is enforced today
66

7-
| Area | Intent (future) |
8-
|------|-----------------|
9-
| **Pack-level policies** | Named bundles keyed by `pack_id` — caps, labels, extension metadata. |
10-
| **Execution constraints** | Advisory limits (`ExecutionConstraints`) that orchestration or packs may honour. |
11-
| **Governance hooks** | Labels and `extensions` dict reserved for audit, cost centres, or routing hints — **not interpreted here**. |
12-
| **Feature flags / policy engine** | Explicitly **out of scope** for this skeleton; strings like `labels` are placeholders only. |
7+
| Mechanism | Where |
8+
|-----------|--------|
9+
| `max_query_chars` | `validate_query_for_pack()` — used by API before pack and legacy runs |
10+
| `budget_usd_ceiling` | `effective_budget_usd()` — passed as `budget_usd` when constructing packs (overridden by `PACK_DEFAULT_BUDGET_USD`) |
11+
| `stream_timeout_seconds` | `effective_stream_timeout_seconds()` — caps SSE timeouts per pack route and legacy `/run/stream` |
1312

14-
## What this is **not**
13+
## Registry
1514

16-
- Not a dynamic policy DSL or OPA integration.
17-
- Not multi-tenant isolation or quota enforcement.
18-
- Not a replacement for `DEFAULT_PACK_ID` or `PackRegistry.register()`.
15+
```python
16+
from control_plane import PolicyRegistry, PackPolicy, ExecutionConstraints
1917

20-
## Compatibility with `PackRegistry`
18+
PolicyRegistry.register(
19+
PackPolicy(
20+
pack_id="my_pack",
21+
constraints=ExecutionConstraints(max_query_chars=1500, budget_usd_ceiling=0.50),
22+
)
23+
)
24+
```
2125

22-
Policies use the same **`pack_id`** strings as `platform.registry.PackRegistry`. Registration remains **explicit and static** in `platform/__init__.py`. This package does **not** register packs or duplicate the registry.
26+
## What is still not here
2327

24-
## Files
25-
26-
| File | Role |
27-
|------|------|
28-
| `policies.py` | `ExecutionConstraints`, `PackPolicy` dataclasses. |
29-
| `__init__.py` | Public exports for imports: `from control_plane import PackPolicy`. |
30-
31-
## Minimalism
32-
33-
Two frozen dataclasses avoid inventing interfaces before call sites exist. Enforcement belongs in API middleware or pack code in a later sprint.
28+
- Dynamic policy DSL / OPA
29+
- Multi-tenant quotas
30+
- Automatic pack registration from policies (policies reference existing `pack_id` values only)

0 commit comments

Comments
 (0)