Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions EXTENSION_POINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,10 @@ string is a minor bump.

### 2. `runtime_context`

**Version:** `1.0.0`
**Version:** `1.1.0`
**Default:** `current_context_id` returns `None`; `with_context` yields
the callable's result without binding any state (single-scope mode).
the callable's result without binding any state (single-scope mode);
`bind_context` returns a no-op async context manager.

```python
from typing import Protocol, Callable, TypeVar
Expand All @@ -154,6 +155,10 @@ T = TypeVar("T")
class RuntimeContext(Protocol):
def current_context_id(self, request) -> str | None: ...
def with_context(self, context_id: str, fn: Callable[[], T]) -> T: ...
# Optional (added in 1.1.0). If present, callers MAY use it via
# the public `bind_context(...)` shim to scope per-request binding
# across awaits.
# def bind_context(self, context_id: str) -> AsyncContextManager[None]: ...
```

`request` is the framework-native request object (FastAPI / Starlette
Expand Down Expand Up @@ -183,6 +188,14 @@ evo_extension_points.replace("runtime_context", MyRuntimeContext())
from `str | None`, is a major bump. Adding sibling helpers is a minor
bump.

**Why `bind_context` is async-only.** Per-request context binding has to
survive every `await` between the request handler and the next operation
that may observe the bound state. `with_context(fn)` is synchronous by
contract — if `fn` returns a coroutine, the consumer would reset its
binding before the caller awaits it. The dedicated async context manager
keeps the binding alive across awaits and guarantees deterministic reset
on exit (including on exception).

### 3. `usage_reporter`

**Version:** `1.0.0`
Expand Down Expand Up @@ -319,4 +332,5 @@ document itself is unversioned.
`reset(name)`.
- `capability_gate` `1.0.0` — Initial contract.
- `runtime_context` `1.0.0` — Initial contract.
- `runtime_context` `1.1.0` — Adds optional `bind_context(context_id) -> AsyncContextManager[None]` sibling for scoping per-request binding across awaits.
- `usage_reporter` `1.0.0` — Initial contract.
1 change: 1 addition & 0 deletions src/api/a2a_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,7 @@ async def handle_message_send(
files=files if files else None,
metadata=metadata,
user_id=user_id, # Pass contact_id as user_id
request=request,
)

final_response = result.get("final_response", "No response")
Expand Down
1 change: 1 addition & 0 deletions src/api/chat_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ async def chat(
db,
session_id=session_id,
files=payload.files,
request=request,
)

return success_response(
Expand Down
29 changes: 26 additions & 3 deletions src/evo_extension_points/runtime_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@

Community default: ``current_context_id`` returns ``None``;
``with_context`` yields the callable's result without binding any state
(single-scope mode).
(single-scope mode); ``bind_context`` returns a no-op async context
manager so consumers can wire per-request context binding without the
community having to know what binding means.
"""

from __future__ import annotations

from typing import Any, Callable, Protocol, TypeVar, runtime_checkable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any, AsyncIterator, Callable, Protocol, TypeVar, runtime_checkable

from . import registry

VERSION: str = "1.0.0"
VERSION: str = "1.1.0"

T = TypeVar("T")

Expand All @@ -35,6 +38,11 @@ def with_context(self, context_id: str, fn: Callable[[], T]) -> T:
registry._register_protocol("runtime_context", RuntimeContext)


@asynccontextmanager
async def _null_bind(_context_id: str) -> AsyncIterator[None]:
yield


def current_context_id(source: Any = None) -> str | None:
impl = registry.impl_for("runtime_context") or _DEFAULT
return impl.current_context_id(source)
Expand All @@ -43,3 +51,18 @@ def current_context_id(source: Any = None) -> str | None:
def with_context(context_id: str, fn: Callable[[], T]) -> T:
impl = registry.impl_for("runtime_context") or _DEFAULT
return impl.with_context(context_id, fn)


def bind_context(context_id: str) -> AbstractAsyncContextManager[None]:
"""Return an async context manager that binds ``context_id`` for the
enclosed scope.

Optional EP method (added in 1.1.0). The community default is a no-op
async CM; consumers may expose a ``bind_context(context_id)``
method on their impl to participate. Callers must use ``async with``.
"""
impl = registry.impl_for("runtime_context") or _DEFAULT
bind = getattr(impl, "bind_context", None)
if bind is None:
return _null_bind(context_id)
return bind(context_id)
4 changes: 3 additions & 1 deletion src/services/adk/agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from src.services.adk.runners.streaming_runner import StreamingRunner
from src.services.adk.runners.live_runner import LiveRunner
from sqlalchemy.orm import Session
from typing import Optional, AsyncGenerator, Dict, Any
from typing import Any, AsyncGenerator, Dict, Optional


async def run_agent(
Expand All @@ -50,6 +50,7 @@ async def run_agent(
files: Optional[list] = None,
metadata: Optional[Dict[str, Any]] = None,
user_id: Optional[str] = None,
request: Any = None,
) -> Dict[str, Any]:
"""Execute a non-streaming agent request."""
runner = StandardRunner(db)
Expand All @@ -65,6 +66,7 @@ async def run_agent(
files=files,
metadata=metadata,
user_id=user_id,
request=request,
)


Expand Down
Loading