Skip to content

Commit 7c877f9

Browse files
committed
2 parents 810f3bb + 8ad9011 commit 7c877f9

8 files changed

Lines changed: 592 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Public extension contract of evo-ai-processor-community.
2+
3+
See ``EXTENSION_POINTS.md`` at the repository root for the full contract.
4+
Each extension point is versioned independently via its own ``VERSION``
5+
attribute; there is no aggregate version constant.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from . import capability_gate, runtime_context, usage_reporter
11+
from .registry import KNOWN_KEYS, impl_for, replace, reset
12+
from .usage_reporter import ExecutionMetrics
13+
14+
__all__ = [
15+
"ExecutionMetrics",
16+
"KNOWN_KEYS",
17+
"capability_gate",
18+
"impl_for",
19+
"replace",
20+
"reset",
21+
"runtime_context",
22+
"usage_reporter",
23+
]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""CapabilityGate extension point.
2+
3+
Community default: every capability is enabled. A consumer overrides via
4+
``evo_extension_points.replace("capability_gate", impl)`` where ``impl``
5+
is any object whose attributes satisfy the ``CapabilityGate`` Protocol.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any, Protocol, runtime_checkable
11+
12+
from . import registry
13+
14+
VERSION: str = "1.0.0"
15+
16+
17+
@runtime_checkable
18+
class CapabilityGate(Protocol):
19+
def is_enabled(
20+
self, capability: str, *, context: dict[str, Any] | None = None
21+
) -> bool: ...
22+
23+
24+
class _DefaultCapabilityGate:
25+
def is_enabled(
26+
self, capability: str, *, context: dict[str, Any] | None = None
27+
) -> bool:
28+
return True
29+
30+
31+
_DEFAULT = _DefaultCapabilityGate()
32+
registry._register_protocol("capability_gate", CapabilityGate)
33+
34+
35+
def is_enabled(capability: str, *, context: dict[str, Any] | None = None) -> bool:
36+
impl = registry.impl_for("capability_gate") or _DEFAULT
37+
return impl.is_enabled(capability, context=context)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""In-memory override registry shared by the three extension points.
2+
3+
The Registration API is itself part of the public contract — see
4+
``EXTENSION_POINTS.md`` at the repository root for the full specification.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import threading
10+
from typing import Any, Final
11+
12+
KNOWN_KEYS: Final[frozenset[str]] = frozenset(
13+
{"capability_gate", "runtime_context", "usage_reporter"}
14+
)
15+
16+
_PROTOCOLS: dict[str, type] = {}
17+
_registry: dict[str, Any] = {}
18+
_lock = threading.RLock()
19+
20+
21+
def _register_protocol(name: str, protocol: type) -> None:
22+
if name not in KNOWN_KEYS:
23+
raise KeyError(f"unknown extension point: {name!r}")
24+
_PROTOCOLS[name] = protocol
25+
26+
27+
def replace(name: str, impl: object) -> None:
28+
if name not in KNOWN_KEYS:
29+
raise KeyError(f"unknown extension point: {name!r}")
30+
if impl is None:
31+
raise TypeError(
32+
f"impl for {name!r} must not be None; use reset({name!r}) instead"
33+
)
34+
protocol = _PROTOCOLS.get(name)
35+
if protocol is not None and not isinstance(impl, protocol):
36+
raise TypeError(
37+
f"impl for {name!r} does not satisfy {protocol.__name__}"
38+
)
39+
with _lock:
40+
_registry[name] = impl
41+
42+
43+
def reset(name: str) -> None:
44+
if name not in KNOWN_KEYS:
45+
raise KeyError(f"unknown extension point: {name!r}")
46+
with _lock:
47+
_registry.pop(name, None)
48+
49+
50+
def impl_for(name: str) -> Any | None:
51+
return _registry.get(name)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""RuntimeContext extension point.
2+
3+
Community default: ``current_context_id`` returns ``None``;
4+
``with_context`` yields the callable's result without binding any state
5+
(single-scope mode).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any, Callable, Protocol, TypeVar, runtime_checkable
11+
12+
from . import registry
13+
14+
VERSION: str = "1.0.0"
15+
16+
T = TypeVar("T")
17+
18+
19+
@runtime_checkable
20+
class RuntimeContext(Protocol):
21+
def current_context_id(self, source: Any) -> str | None: ...
22+
23+
def with_context(self, context_id: str, fn: Callable[[], T]) -> T: ...
24+
25+
26+
class _DefaultRuntimeContext:
27+
def current_context_id(self, source: Any) -> str | None:
28+
return None
29+
30+
def with_context(self, context_id: str, fn: Callable[[], T]) -> T:
31+
return fn()
32+
33+
34+
_DEFAULT = _DefaultRuntimeContext()
35+
registry._register_protocol("runtime_context", RuntimeContext)
36+
37+
38+
def current_context_id(source: Any = None) -> str | None:
39+
impl = registry.impl_for("runtime_context") or _DEFAULT
40+
return impl.current_context_id(source)
41+
42+
43+
def with_context(context_id: str, fn: Callable[[], T]) -> T:
44+
impl = registry.impl_for("runtime_context") or _DEFAULT
45+
return impl.with_context(context_id, fn)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""UsageReporter extension point.
2+
3+
Community default: no-op. The processor always persists each execution
4+
into ``evo_agent_processor_execution_metrics`` locally and then calls
5+
``report_execution`` with the same data; the default discards the call.
6+
A consumer registers a non-default implementation to mirror the local
7+
table into external observability.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass
13+
from typing import Protocol, runtime_checkable
14+
15+
from . import registry
16+
17+
VERSION: str = "1.0.0"
18+
19+
20+
@dataclass(frozen=True)
21+
class ExecutionMetrics:
22+
execution_id: str
23+
prompt_tokens: int
24+
candidate_tokens: int
25+
total_tokens: int
26+
cost: float
27+
28+
29+
@runtime_checkable
30+
class UsageReporter(Protocol):
31+
def report_execution(self, metrics: ExecutionMetrics) -> None: ...
32+
33+
34+
class _DefaultUsageReporter:
35+
def report_execution(self, metrics: ExecutionMetrics) -> None:
36+
return None
37+
38+
39+
_DEFAULT = _DefaultUsageReporter()
40+
registry._register_protocol("usage_reporter", UsageReporter)
41+
42+
43+
def report_execution(metrics: ExecutionMetrics) -> None:
44+
impl = registry.impl_for("usage_reporter") or _DEFAULT
45+
impl.report_execution(metrics)

src/services/adk/runners/standard_runner.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@
3737
from typing import Optional, Dict, Any
3838
from src.services.session_service import create_execution_metrics
3939
from src.schemas.schemas import ExecutionMetricsCreate
40+
from src.evo_extension_points import (
41+
ExecutionMetrics,
42+
capability_gate,
43+
impl_for as ep_impl_for,
44+
runtime_context,
45+
usage_reporter,
46+
)
4047
import uuid
4148

4249
logger = setup_logger(__name__)
@@ -69,6 +76,34 @@ async def run_agent(
6976
f"Starting execution of agent {agent_id} for external_id {external_id}"
7077
)
7178

79+
# Extension point: capability gate. Community default returns True
80+
# for every capability, so behavior is unchanged unless a consumer
81+
# mounts a denying gate. Denial short-circuits with a structured
82+
# error code rather than a fake agent reply.
83+
capability = (metadata or {}).get("capability")
84+
if capability and not capability_gate.is_enabled(
85+
capability, context=metadata
86+
):
87+
logger.warning(
88+
f"capability_gate denied capability={capability!r} for"
89+
f" agent={agent_id} external_id={external_id}"
90+
)
91+
return {
92+
"error": "capability_denied",
93+
"capability": capability,
94+
"message_history": [],
95+
}
96+
97+
# Extension point: runtime context resolution. Default returns
98+
# None; consumer overrides return an operational context id that
99+
# is logged here and (in a follow-up) propagated into metrics.
100+
context_id = runtime_context.current_context_id(metadata)
101+
if context_id:
102+
logger.info(
103+
f"runtime_context resolved id={context_id!r}"
104+
f" for agent={agent_id}"
105+
)
106+
72107
# Get and build agent
73108
root_agent, state_params = await self.utils.get_and_build_agent(agent_id)
74109

@@ -471,6 +506,27 @@ async def run_agent(
471506
except Exception as e:
472507
logger.error(f"Error creating execution metrics: {e}")
473508

509+
# Extension point: usage reporter. Always called after the
510+
# local persistence above; default is a no-op. A misbehaving
511+
# consumer cannot break the run — we swallow the exception
512+
# and log with full context.
513+
try:
514+
usage_reporter.report_execution(
515+
ExecutionMetrics(
516+
execution_id=adk_session_id,
517+
prompt_tokens=total_prompt_tokens,
518+
candidate_tokens=total_candidate_tokens,
519+
total_tokens=total_tokens,
520+
cost=0.0,
521+
)
522+
)
523+
except Exception:
524+
logger.exception(
525+
"usage_reporter.report_execution failed for"
526+
f" execution_id={adk_session_id!r}"
527+
f" impl={ep_impl_for('usage_reporter')!r}"
528+
)
529+
474530
except Exception as e:
475531
logger.error(f"Error processing request: {str(e)}")
476532
raise InternalServerError(str(e)) from e

0 commit comments

Comments
 (0)