|
| 1 | +# Extension Points |
| 2 | + |
| 3 | +Each extension point below is versioned independently under SemVer; see |
| 4 | +the [Compatibility Promise](#compatibility-promise) and the per-EP |
| 5 | +`Version` lines. The document itself is not versioned — there is no |
| 6 | +single aggregate "contract version". |
| 7 | + |
| 8 | +This document is the public contract between `evo-ai-processor-community` |
| 9 | +and any external consumer that wants to plug into agent execution |
| 10 | +without forking or patching community source. The authoritative |
| 11 | +architectural decision behind this contract is **ADR13 — Extension |
| 12 | +Points Versioning Strategy**; the rules below are self-contained. |
| 13 | + |
| 14 | +The community release is fully usable on its own. Every extension point |
| 15 | +ships with a working default; a consumer can **replace** the default |
| 16 | +implementation of one or more of them without modifying files in `src/` |
| 17 | +or `migrations/`. |
| 18 | + |
| 19 | +If you are about to change any of the three extension points below, read |
| 20 | +the [Compatibility Promise](#compatibility-promise) first. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Compatibility Promise |
| 25 | + |
| 26 | +Each extension point is versioned independently and treated as a public |
| 27 | +API, with the same backward-compatibility rules as the HTTP endpoints |
| 28 | +exposed by this service: |
| 29 | + |
| 30 | +- **Backward compatibility is forever.** Once shipped at a given major, |
| 31 | + the name, arguments, return shape and observable behavior of an |
| 32 | + extension point do not change silently. |
| 33 | +- **Breaking changes require a major bump** of the affected extension |
| 34 | + point and of the community release that ships it. |
| 35 | +- **Deprecation window is at least one minor release.** The old shape |
| 36 | + keeps working alongside the new one, and the deprecated path emits a |
| 37 | + `DeprecationWarning` via `warnings.warn`. |
| 38 | +- **Additive changes are minor bumps.** New extension point, or new |
| 39 | + optional capability on an existing one. |
| 40 | +- **Bug fixes that preserve the contract are patch bumps.** |
| 41 | + |
| 42 | +Bumping one extension point does not bump the others. |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## Registration API |
| 47 | + |
| 48 | +**Version:** `1.0.0` |
| 49 | + |
| 50 | +The registration mechanism is itself part of the public contract. Every |
| 51 | +override goes through one function: |
| 52 | + |
| 53 | +```python |
| 54 | +def replace(name: str, impl: object) -> None: ... |
| 55 | +``` |
| 56 | + |
| 57 | +**Accepted `name` values (v1.0.0):** `"capability_gate"`, |
| 58 | +`"runtime_context"`, `"usage_reporter"`. Adding a new accepted name is |
| 59 | +a minor bump; removing or renaming an accepted name is a major bump. |
| 60 | + |
| 61 | +**`impl` contract:** any object whose attributes satisfy the |
| 62 | +`typing.Protocol` declared for that extension point. The implementation |
| 63 | +is structurally type-checked at registration time; passing an object |
| 64 | +that does not satisfy the Protocol raises `TypeError` synchronously. |
| 65 | + |
| 66 | +**Idempotency / replacement order:** `replace` is last-write-wins. Each |
| 67 | +call replaces the previously registered implementation atomically. The |
| 68 | +returned value is `None`; callers that need the previous implementation |
| 69 | +should capture it before calling `replace`. |
| 70 | + |
| 71 | +**When it may be called:** any time before the first call site that |
| 72 | +exercises the extension point. The recommended placement is application |
| 73 | +boot (a single `install_extension_points()` function called from the |
| 74 | +FastAPI lifespan, before the first request is served). Calling |
| 75 | +`replace` after the EP has been exercised is allowed and atomic, but |
| 76 | +in-flight calls observe the previous implementation. |
| 77 | + |
| 78 | +**Thread / async safety:** `replace` is safe to call from any thread |
| 79 | +and from inside an async coroutine. Reads of the active implementation |
| 80 | +are lock-free. |
| 81 | + |
| 82 | +**Failure modes:** |
| 83 | +- Unknown `name` → `KeyError`. |
| 84 | +- `impl` does not satisfy the Protocol → `TypeError`. |
| 85 | +- `impl` is `None` → `TypeError` (use a distinct reset helper, not |
| 86 | + `replace`). |
| 87 | + |
| 88 | +A complementary helper, `evo_extension_points.reset(name: str) -> None`, |
| 89 | +restores the community default for a given extension point. Calling |
| 90 | +`reset` with an unknown name raises `KeyError`. |
| 91 | + |
| 92 | +--- |
| 93 | + |
| 94 | +## Extension points |
| 95 | + |
| 96 | +All three are exposed under the `evo_extension_points` package, |
| 97 | +implemented by `src/evo_extension_points/` (shipped in a complementary |
| 98 | +story). Contracts are declared as `typing.Protocol` so that consumers |
| 99 | +get static type checking without inheritance. Each extension point |
| 100 | +exposes its own version as `<extension_point>.VERSION` (e.g. |
| 101 | +`evo_extension_points.capability_gate.VERSION == "1.0.0"`); there is |
| 102 | +no aggregate `EXTENSION_POINTS_VERSION` constant — version each EP |
| 103 | +independently. |
| 104 | + |
| 105 | +### 1. `capability_gate` |
| 106 | + |
| 107 | +**Version:** `1.0.0` |
| 108 | +**Default:** always returns `True`; the community release does not |
| 109 | +filter capabilities. |
| 110 | + |
| 111 | +```python |
| 112 | +from typing import Protocol |
| 113 | + |
| 114 | +class CapabilityGate(Protocol): |
| 115 | + def is_enabled(self, capability: str, *, context: dict | None = None) -> bool: ... |
| 116 | +``` |
| 117 | + |
| 118 | +Default access: |
| 119 | + |
| 120 | +```python |
| 121 | +from evo_extension_points import capability_gate |
| 122 | + |
| 123 | +capability_gate.is_enabled("vision", context={"model": "gpt-4o"}) # => True |
| 124 | +``` |
| 125 | + |
| 126 | +Override: |
| 127 | + |
| 128 | +```python |
| 129 | +import evo_extension_points |
| 130 | + |
| 131 | +class MyCapabilityGate: |
| 132 | + def is_enabled(self, capability: str, *, context: dict | None = None) -> bool: |
| 133 | + return my_consumer.capabilities.enabled(capability, context=context) |
| 134 | + |
| 135 | +evo_extension_points.replace("capability_gate", MyCapabilityGate()) |
| 136 | +``` |
| 137 | + |
| 138 | +**Breaking-change policy:** renaming `is_enabled`, adding a required |
| 139 | +positional argument, or changing the return type from `bool` is a major |
| 140 | +bump. Adding a new key to `context` or a new accepted `capability` |
| 141 | +string is a minor bump. |
| 142 | + |
| 143 | +### 2. `runtime_context` |
| 144 | + |
| 145 | +**Version:** `1.0.0` |
| 146 | +**Default:** `current_context_id` returns `None`; `with_context` yields |
| 147 | +the callable's result without binding any state (single-scope mode). |
| 148 | + |
| 149 | +```python |
| 150 | +from typing import Protocol, Callable, TypeVar |
| 151 | + |
| 152 | +T = TypeVar("T") |
| 153 | + |
| 154 | +class RuntimeContext(Protocol): |
| 155 | + def current_context_id(self, request) -> str | None: ... |
| 156 | + def with_context(self, context_id: str, fn: Callable[[], T]) -> T: ... |
| 157 | +``` |
| 158 | + |
| 159 | +`request` is the framework-native request object (FastAPI / Starlette |
| 160 | +`Request`). The default implementation reads no headers and binds no |
| 161 | +state; consumers wire their own resolution from a neutral header such as |
| 162 | +`X-Operational-Context`. |
| 163 | + |
| 164 | +Override: |
| 165 | + |
| 166 | +```python |
| 167 | +import evo_extension_points |
| 168 | +from my_consumer import current_context |
| 169 | + |
| 170 | +class MyRuntimeContext: |
| 171 | + def current_context_id(self, request) -> str | None: |
| 172 | + return request.headers.get("X-Operational-Context") |
| 173 | + |
| 174 | + def with_context(self, context_id, fn): |
| 175 | + with current_context.bound(context_id): |
| 176 | + return fn() |
| 177 | + |
| 178 | +evo_extension_points.replace("runtime_context", MyRuntimeContext()) |
| 179 | +``` |
| 180 | + |
| 181 | +**Breaking-change policy:** renaming `current_context_id` / |
| 182 | +`with_context`, or changing the return type of `current_context_id` |
| 183 | +from `str | None`, is a major bump. Adding sibling helpers is a minor |
| 184 | +bump. |
| 185 | + |
| 186 | +### 3. `usage_reporter` |
| 187 | + |
| 188 | +**Version:** `1.0.0` |
| 189 | +**Default:** no-op. The community release always persists each |
| 190 | +execution into `evo_agent_processor_execution_metrics` locally and |
| 191 | +then calls `report_execution` once with the same data, regardless of |
| 192 | +which implementation is installed; the default implementation discards |
| 193 | +the call. An external consumer registers a non-default implementation |
| 194 | +to mirror the local table into external observability. |
| 195 | + |
| 196 | +```python |
| 197 | +from dataclasses import dataclass |
| 198 | +from typing import Protocol |
| 199 | + |
| 200 | +@dataclass(frozen=True) |
| 201 | +class ExecutionMetrics: |
| 202 | + execution_id: str |
| 203 | + prompt_tokens: int |
| 204 | + candidate_tokens: int |
| 205 | + total_tokens: int |
| 206 | + cost: float |
| 207 | + |
| 208 | +class UsageReporter(Protocol): |
| 209 | + def report_execution(self, metrics: ExecutionMetrics) -> None: ... |
| 210 | +``` |
| 211 | + |
| 212 | +`execution_id` is the neutral identifier of the agent execution emitted |
| 213 | +by the processor; consumers correlate it back to their own systems. |
| 214 | +`cost` is the monetary value (`float`) already computed by the processor |
| 215 | +in its base currency. |
| 216 | + |
| 217 | +**Call site and threading model:** the processor invokes |
| 218 | +`report_execution` inline at the end of the agent execution |
| 219 | +coroutine, on the FastAPI event loop. The override therefore runs on |
| 220 | +the event loop; a blocking implementation will block other requests |
| 221 | +served by the same worker. Consumers MUST keep the call non-blocking: |
| 222 | +either return immediately and enqueue the work elsewhere |
| 223 | +(`asyncio.create_task`, a background queue, a sidecar), or offload |
| 224 | +synchronous work via `asyncio.to_thread`. The Protocol is declared |
| 225 | +synchronous at v1.0.0; converting it to `async def` is a major bump. |
| 226 | + |
| 227 | +Exceptions raised by `report_execution` are caught and logged at |
| 228 | +`WARNING` by the processor and do not abort the agent execution or |
| 229 | +the parent HTTP response — the local persistence into |
| 230 | +`evo_agent_processor_execution_metrics` is already committed by then. |
| 231 | + |
| 232 | +Override: |
| 233 | + |
| 234 | +```python |
| 235 | +import evo_extension_points |
| 236 | +from evo_extension_points import ExecutionMetrics |
| 237 | + |
| 238 | +class MyUsageReporter: |
| 239 | + def report_execution(self, metrics: ExecutionMetrics) -> None: |
| 240 | + my_consumer.metrics.publish( |
| 241 | + execution_id=metrics.execution_id, |
| 242 | + tokens=metrics.total_tokens, |
| 243 | + cost=metrics.cost, |
| 244 | + ) |
| 245 | + |
| 246 | +evo_extension_points.replace("usage_reporter", MyUsageReporter()) |
| 247 | +``` |
| 248 | + |
| 249 | +**Breaking-change policy:** renaming `report_execution`, removing or |
| 250 | +retyping a field of `ExecutionMetrics`, or changing the call from |
| 251 | +synchronous to asynchronous semantics is a major bump. Adding new |
| 252 | +optional fields to `ExecutionMetrics` (with a sane default) is a minor |
| 253 | +bump. |
| 254 | + |
| 255 | +--- |
| 256 | + |
| 257 | +## How to use as a consumer |
| 258 | + |
| 259 | +A consumer wires its replacements once, from its own bootstrap module, |
| 260 | +and never patches files inside `evo-ai-processor-community`: |
| 261 | + |
| 262 | +```python |
| 263 | +import evo_extension_points |
| 264 | +from evo_extension_points import ExecutionMetrics |
| 265 | + |
| 266 | +class MyCapabilityGate: |
| 267 | + def is_enabled(self, capability: str, *, context: dict | None = None) -> bool: |
| 268 | + return my_consumer.capabilities.enabled(capability, context=context) |
| 269 | + |
| 270 | +class MyRuntimeContext: |
| 271 | + def current_context_id(self, request) -> str | None: |
| 272 | + return request.headers.get("X-Operational-Context") |
| 273 | + |
| 274 | + def with_context(self, context_id, fn): |
| 275 | + with my_consumer.current_context.bound(context_id): |
| 276 | + return fn() |
| 277 | + |
| 278 | +class MyUsageReporter: |
| 279 | + def report_execution(self, metrics: ExecutionMetrics) -> None: |
| 280 | + my_consumer.metrics.publish( |
| 281 | + execution_id=metrics.execution_id, |
| 282 | + tokens=metrics.total_tokens, |
| 283 | + cost=metrics.cost, |
| 284 | + ) |
| 285 | + |
| 286 | +def install_extension_points() -> None: |
| 287 | + evo_extension_points.replace("capability_gate", MyCapabilityGate()) |
| 288 | + evo_extension_points.replace("runtime_context", MyRuntimeContext()) |
| 289 | + evo_extension_points.replace("usage_reporter", MyUsageReporter()) |
| 290 | +``` |
| 291 | + |
| 292 | +A consumer is expected to declare the community version range it |
| 293 | +supports in its own package metadata (`pyproject.toml`). A future CI |
| 294 | +workflow (`extension-points-contract`) will run a neutral consumer |
| 295 | +stub against every community PR and fail the build on a contract |
| 296 | +break; until that workflow lands, contract regressions are caught by |
| 297 | +manual review of changes to this file and the |
| 298 | +`src/evo_extension_points/` implementation. |
| 299 | + |
| 300 | +--- |
| 301 | + |
| 302 | +## Cross-references |
| 303 | + |
| 304 | +- Companion contract on the CRM side: |
| 305 | + [evo-ai-crm-community/EXTENSION_POINTS.md](https://github.com/evolution-foundation/evo-ai-crm-community/blob/main/EXTENSION_POINTS.md). |
| 306 | +- Companion contract on the auth-service side: |
| 307 | + [evo-auth-service-community/EXTENSION_POINTS.md](https://github.com/evolution-foundation/evo-auth-service-community/blob/main/EXTENSION_POINTS.md). |
| 308 | +- The architectural decision that motivates this contract is **ADR13 — |
| 309 | + Extension Points Versioning Strategy**. |
| 310 | + |
| 311 | +--- |
| 312 | + |
| 313 | +## Versioning history |
| 314 | + |
| 315 | +Each line below tracks one independently versioned surface. The |
| 316 | +document itself is unversioned. |
| 317 | + |
| 318 | +- Registration API `1.0.0` — Initial: `replace(name, impl)` + |
| 319 | + `reset(name)`. |
| 320 | +- `capability_gate` `1.0.0` — Initial contract. |
| 321 | +- `runtime_context` `1.0.0` — Initial contract. |
| 322 | +- `usage_reporter` `1.0.0` — Initial contract. |
0 commit comments