From ff1c24f2fd0330b5258f3f36e89506cf822dfa70 Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Mon, 22 Jun 2026 19:33:53 +0100 Subject: [PATCH 1/2] Replace container singleton with request-scoped runtime DI; simplify error logging Remove the process-global `SingletonMeta`/`Container`/`container` service locator in favor of explicit, request-scoped dependency injection, and reduce execution-error logging to a single concise, id-bearing line. Runtime DI (no component-interface change): - Add grafi/runtime: `ExecutionServices` (frozen, non-serializable bundle of event_store/tracer/error_reporter with in-process default_factory defaults), `GrafiRuntime` (composition root + invoke entry point), and the request-scoped `_current_services` ContextVar with `current_services()` / `bind_services()`. - `GrafiRuntime.invoke()` binds the services for the invocation; components resolve them via `current_services()`. No `invoke()` signature gains a `services` parameter -- tool/provider/node/command overrides are untouched. Relies on asyncio context propagation (grafi uses no thread offloads). - Switch the four container read sites (record_base, llm_command, event_driven_workflow, WorkflowRun) to `current_services()`. - Remove `container`, `SingletonMeta`, `register_event_store`/`register_tracer`, and `AssistantBaseBuilder.event_store`. Callers construct a `GrafiRuntime` (or `GrafiRuntime()` for in-process defaults) and invoke through it. Error diagnostics: - One concise log line per failure, emitted once at the layer closest to the failure, carrying error_id + conversation/invoke/assistant_request ids so the full structured record (cause chain, traceback, component fields) can be pulled from the event store. No traceback dumped to the log. - Add `error_id` correlation preserved across re-wrapped exceptions; stable wrapper messages (no embedded `str(cause)`); no double-wrapped NodeExecutionError; preserve the primary failure when failed-event persistence fails; EventStorePostgres raises EventPersistenceError with operation context consistently. - `ErrorReporter` is a single concrete loguru-backed class (subclass to customize); a misbehaving reporter can never mask the execution failure. Tests/examples: - conftest binds a default `ExecutionServices` per test (sync autouse fixture); migrate workflow/decorator tests off `container`; add tests/runtime suite (DI isolation, ContextVar propagation, serialization safety, error reporting). - Migrate tests_integration examples to run inside a bound runtime scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- grafi/assistants/assistant_base.py | 17 +- grafi/common/containers/container.py | 85 ------- grafi/common/decorators/record_base.py | 203 ++++++++++++----- .../event_stores/event_store_postgres.py | 65 ++++-- grafi/runtime/__init__.py | 22 ++ grafi/runtime/execution_services.py | 122 ++++++++++ grafi/runtime/runtime.py | 55 +++++ grafi/tools/llms/impl/claude_tool.py | 2 +- grafi/tools/llms/impl/gemini_tool.py | 2 +- grafi/tools/llms/impl/ollama_tool.py | 5 +- grafi/tools/llms/impl/openai_compatible.py | 4 +- grafi/tools/llms/llm_command.py | 7 +- grafi/workflows/impl/async_output_queue.py | 7 +- grafi/workflows/impl/event_driven_workflow.py | 3 +- grafi/workflows/impl/parallel_engine.py | 27 +-- grafi/workflows/impl/sequential_engine.py | 5 +- grafi/workflows/impl/workflow_run.py | 13 +- tests/common/containers/test_container.py | 212 ------------------ .../decorators/test_record_decorators.py | 43 +--- tests/conftest.py | 25 +++ tests/runtime/test_error_reporting.py | 167 ++++++++++++++ tests/runtime/test_execution_services.py | 71 ++++++ tests/runtime/test_runtime.py | 73 ++++++ tests/workflow/test_event_driven_workflow.py | 45 ++-- tests/workflow/test_fanout_quiescence.py | 37 ++- tests/workflow/test_invocation_isolation.py | 26 +-- tests/workflow/test_recovery.py | 42 ++-- tests/workflow/test_workflow_run.py | 15 +- .../agents/react_agent_async_example.py | 7 +- .../agents/react_agent_example.py | 7 +- ...act_agent_mcp_tools_async_example_local.py | 5 +- ...dding_retrieval_assistant_async_example.py | 9 +- ...e_embedding_retrieval_assistant_example.py | 9 +- ...mple_llm_assistant_postgres_integration.py | 21 +- ...assistant_deserialize_assistant_example.py | 9 +- .../simple_function_llm_assistant_example.py | 9 +- ...assistant_deserialize_assistant_example.py | 9 +- ...urrent_function_call_invocation_example.py | 9 +- ..._functions_call_assistant_async_example.py | 9 +- .../multi_functions_call_assistant_example.py | 9 +- ...e_function_call_assistant_async_example.py | 9 +- ..._claude_function_call_assistant_example.py | 9 +- ...eepseek_function_call_assistant_example.py | 9 +- ...tion_call_assistant_agent_async_example.py | 9 +- ...e_function_call_assistant_agent_example.py | 9 +- ...e_function_call_assistant_async_example.py | 9 +- ...call_assistant_complex_function_example.py | 9 +- ...assistant_deserialize_assistant_example.py | 9 +- ...call_assistant_duckduckgo_example_local.py | 9 +- .../simple_function_call_assistant_example.py | 9 +- ...on_call_assistant_google_search_example.py | 9 +- ..._call_assistant_multi_functions_example.py | 9 +- ...ll_assistant_no_func_call_async_example.py | 9 +- ...ion_call_assistant_no_func_call_example.py | 9 +- ...nction_call_assistant_synthetic_example.py | 9 +- ..._function_call_assistant_tavily_example.py | 9 +- ..._gemini_function_call_assistant_example.py | 9 +- ...a_function_call_assistant_async_example.py | 9 +- ..._ollama_function_call_assistant_example.py | 9 +- ...nrouter_function_call_assistant_example.py | 9 +- .../human_tool_approval_assistant_example.py | 12 +- .../hith_assistant/kyc_assistant_example.py | 5 +- .../simple_hitl_assistant_async_example.py | 9 +- .../simple_hitl_assistant_example.py | 9 +- .../mimo_llm_assistant_example.py | 12 +- ...e_llm_prompt_template_assistant_example.py | 12 +- ...mcp_deserialize_assistant_example_local.py | 9 +- .../mcp_assistant/mcp_function_tool_local.py | 5 +- ...on_call_assistant_fastmcp_example_local.py | 9 +- ...nction_call_assistant_mcp_example_local.py | 9 +- .../simple_llm_assistant_image_rec_example.py | 9 +- .../simple_rag_assistant_async_example.py | 9 +- .../simple_rag_assistant_example.py | 9 +- .../react_assistant_async_example.py | 9 +- ...t_assistant_async_stop_workflow_example.py | 9 +- ...assistant_deserialize_assistant_example.py | 5 +- .../react_assistant_example.py | 9 +- .../react_assistant_recovery_example.py | 9 +- .../react_assistant_stop_workflow_example.py | 9 +- .../claude_tool_example.py | 24 +- .../concurrent_invocation_example.py | 12 +- ...oncurrent_sequential_invocation_example.py | 9 +- .../deepseek_tool_example.py | 24 +- .../gemini_tool_example.py | 24 +- .../openai_tool_example.py | 30 ++- .../openrouter_tool_example.py | 24 +- .../simple_llm_assistant_async_example.py | 9 +- .../simple_llm_assistant_example.py | 12 +- ...imple_multi_llm_assistant_example_local.py | 9 +- .../simple_ollama_assistant_async_example.py | 9 +- .../simple_ollama_assistant_example.py | 9 +- .../simple_stream_assistant_example.py | 9 +- ..._stream_function_call_assistant_example.py | 9 +- ...call_assistant_no_function_call_example.py | 9 +- 94 files changed, 1290 insertions(+), 792 deletions(-) delete mode 100644 grafi/common/containers/container.py create mode 100644 grafi/runtime/__init__.py create mode 100644 grafi/runtime/execution_services.py create mode 100644 grafi/runtime/runtime.py delete mode 100644 tests/common/containers/test_container.py create mode 100644 tests/runtime/test_error_reporting.py create mode 100644 tests/runtime/test_execution_services.py create mode 100644 tests/runtime/test_runtime.py diff --git a/grafi/assistants/assistant_base.py b/grafi/assistants/assistant_base.py index 1596d1d2..c9e3864a 100644 --- a/grafi/assistants/assistant_base.py +++ b/grafi/assistants/assistant_base.py @@ -9,8 +9,6 @@ from pydantic import ConfigDict from pydantic import Field -from grafi.common.containers.container import container -from grafi.common.event_stores.event_store import EventStore from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) @@ -55,6 +53,9 @@ async def invoke( self, input_data: PublishToTopicEvent, is_sequential: bool = False ) -> AsyncGenerator[ConsumeFromTopicEvent, None]: """Invoke the assistant's workflow with the provided input data asynchronously.""" + # ``yield`` makes this an async generator (matching the concrete + # subclasses and Workflow.invoke), so callers can ``async for`` over it. + yield None # type: ignore raise NotImplementedError("Subclasses must implement 'invoke'.") def to_dict(self) -> dict[str, Any]: @@ -136,15 +137,3 @@ def type(self, type_name: str) -> Self: """ self.kwargs["type"] = type_name return self - - def event_store(self, event_store: EventStore) -> Self: - """Register an event store for persistence. - - Args: - event_store: The event store implementation to use. - - Returns: - Self for method chaining. - """ - container.register_event_store(event_store) - return self diff --git a/grafi/common/containers/container.py b/grafi/common/containers/container.py deleted file mode 100644 index 2a4ba180..00000000 --- a/grafi/common/containers/container.py +++ /dev/null @@ -1,85 +0,0 @@ -# container.py -import os -import threading -from typing import Any -from typing import Optional - -from loguru import logger -from opentelemetry.trace import Tracer - -from grafi.common.event_stores.event_store import EventStore -from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory -from grafi.common.instrumentations.tracing import TracingOptions -from grafi.common.instrumentations.tracing import setup_tracing - - -class SingletonMeta(type): - _instances: dict[type, object] = {} - _lock: threading.Lock = threading.Lock() - - def __call__(cls: "SingletonMeta", *args: Any, **kwargs: Any) -> Any: - # Ensure thread-safe singleton creation - with cls._lock: - if cls not in cls._instances: - cls._instances[cls] = super().__call__(*args, **kwargs) - return cls._instances[cls] - - -class Container(metaclass=SingletonMeta): - def __init__(self) -> None: - # Per-instance attributes: - self._event_store: Optional[EventStore] = None - self._tracer: Optional[Tracer] = None - # Lock for thread-safe lazy initialization of properties - self._init_lock: threading.Lock = threading.Lock() - - def register_event_store(self, event_store: EventStore) -> None: - """Override the default EventStore implementation.""" - with self._init_lock: - if isinstance(event_store, EventStoreInMemory): - logger.warning( - "Using EventStoreInMemory. This is ONLY suitable for local testing but not for production." - ) - self._event_store = event_store - - def register_tracer(self, tracer: Tracer) -> None: - """Override the default Tracer implementation.""" - with self._init_lock: - self._tracer = tracer - - @property - def event_store(self) -> EventStore: - # Fast path: already initialized - if self._event_store is not None: - return self._event_store - # Slow path: initialize with lock (double-checked locking) - with self._init_lock: - if self._event_store is None: - logger.warning( - "Using EventStoreInMemory. This is ONLY suitable for local testing but not for production." - ) - self._event_store = EventStoreInMemory() - return self._event_store - - @property - def tracer(self) -> Tracer: - # Fast path: already initialized - if self._tracer is not None: - return self._tracer - # Slow path: initialize with lock (double-checked locking) - with self._init_lock: - if self._tracer is None: - # Use environment variables with sensible defaults - endpoint = os.getenv("OTEL_COLLECTOR_ENDPOINT", "localhost") - port = int(os.getenv("OTEL_COLLECTOR_PORT", "4317")) - project = os.getenv("GRAFI_PROJECT_NAME", "grafi-trace") - self._tracer = setup_tracing( - tracing_options=TracingOptions.AUTO, - collector_endpoint=endpoint, - collector_port=port, - project_name=project, - ) - return self._tracer - - -container: Container = Container() diff --git a/grafi/common/decorators/record_base.py b/grafi/common/decorators/record_base.py index cb6cb32d..caa5ec76 100644 --- a/grafi/common/decorators/record_base.py +++ b/grafi/common/decorators/record_base.py @@ -3,6 +3,7 @@ import functools import json import os +import uuid from dataclasses import dataclass from typing import Any from typing import AsyncGenerator @@ -14,26 +15,26 @@ from typing import TypeVar from typing import Union -from loguru import logger from opentelemetry.trace import Status from opentelemetry.trace import StatusCode from pydantic import BaseModel from pydantic import ConfigDict from pydantic_core import to_jsonable_python -from grafi.common.containers.container import container from grafi.common.env import env_bool from grafi.common.events.component_base import ComponentEvent from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent +from grafi.common.exceptions import EventPersistenceError from grafi.common.exceptions.serialization import error_message from grafi.common.exceptions.serialization import error_to_dict from grafi.common.exceptions.serialization import iter_cause_chain from grafi.common.models.default_id import default_id from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime.execution_services import current_services T = TypeVar("T") @@ -41,6 +42,11 @@ # logged, so the same root failure is not dumped at every decorator layer. _TRACEBACK_LOGGED_ATTR = "_grafi_traceback_logged" +# Attribute carrying the correlation id shared by every layer of one failure, so +# the wrapped/re-raised exception keeps a stable ``error_id`` across the +# tool -> node -> workflow -> assistant decorators. +_ERROR_ID_ATTR = "_grafi_error_id" + class EventContext(BaseModel): id: str = default_id @@ -123,14 +129,58 @@ def _include_traceback() -> bool: return env_bool("GRAFI_ERROR_INCLUDE_TRACEBACK", default=True) -def _build_error_details(exc: Exception, metadata: EventContext) -> Dict[str, Any]: - """Build the structured ``error_details`` payload for a failed component.""" +def _error_id_for(exc: Exception) -> str: + """Return the correlation id for ``exc``, creating one if the chain has none. + + Scans the cause chain so a re-wrapped outer exception inherits the id minted + at the layer closest to the root failure; the id is stashed on the exception + so the next decorator layer reuses it. + """ + for link in iter_cause_chain(exc, max_depth=1000): + existing = getattr(link, _ERROR_ID_ATTR, None) + if existing: + return str(existing) + error_id = uuid.uuid4().hex[:12] + try: + setattr(exc, _ERROR_ID_ATTR, error_id) + except Exception: # pragma: no cover - builtins generally allow attributes + pass + return error_id + + +def _root_cause(exc: Exception) -> BaseException: + """Return the innermost exception in the cause chain (the root failure).""" + root: BaseException = exc + for link in iter_cause_chain(exc, max_depth=1000): + root = link + return root + + +def _build_error_details( + exc: Exception, metadata: EventContext, invoke_context: InvokeContext +) -> Dict[str, Any]: + """Build the structured ``error_details`` payload for a failed component. + + One correlated record: the failing component (the "where"), the exception's + own type/module/cause chain (the "why"), the request identifiers, the root + cause, and a stable ``error_id`` shared across the wrapper chain. + """ details = error_to_dict(exc, include_traceback=_include_traceback()) - # Identify which component failed (the "where"), alongside the exception's - # own type/module/cause chain (the "why"). + details["error_id"] = _error_id_for(exc) + # Identify which component failed (the "where"). details["component_id"] = metadata.id details["component_name"] = metadata.name details["component_type"] = metadata.type + # Correlate to the request. + details["conversation_id"] = getattr(invoke_context, "conversation_id", None) + details["invoke_id"] = getattr(invoke_context, "invoke_id", None) + details["assistant_request_id"] = getattr( + invoke_context, "assistant_request_id", None + ) + # The original failure at the bottom of the chain (the "why, ultimately"). + root = _root_cause(exc) + details["root_error_type"] = type(root).__name__ + details["root_error_message"] = error_message(root) return details @@ -169,56 +219,77 @@ def _traceback_already_logged(exc: Exception) -> bool: ) -def _log_component_exception( +def _safe_report(message: str, *, level: str = "error") -> None: + """Emit a log line best-effort. + + A misbehaving reporter (or an unbound runtime) must never replace the + execution failure it is describing, so any exception from resolving the + services or calling ``report`` is swallowed here. + """ + try: + current_services().error_reporter.report(message, level=level) + except Exception: # pragma: no cover - reporters must not raise + pass + + +def _report_component_exception( exc: Exception, metadata: EventContext, - invoke_context: InvokeContext, error_details: Dict[str, Any], ) -> None: - """Log a failed component, with a full traceback only once. - - The same root failure propagates through the tool -> node -> workflow -> - assistant decorators (and is re-wrapped along the way). Logging the full - traceback at every layer would print the same stack four-plus times, so the - full traceback is emitted once at the layer closest to the failure (the first - decorator to catch it); outer layers log a one-line summary instead. - - The traceback is taken from ``error_details`` -- a plain, stdlib-formatted - string that does NOT include local-variable values (unlike Loguru's - ``opt(exception=...)`` with ``diagnose=True``). This keeps secrets/PII out of - logs and honors GRAFI_ERROR_INCLUDE_TRACEBACK (the key is absent when the - switch disables traceback capture), without forcing global Loguru config. + """Log one concise, id-bearing line for a failure -- once. + + The same failure propagates through the tool -> node -> workflow -> assistant + decorators (re-wrapped along the way). Only the layer closest to the failure + (the first decorator to catch it) logs; outer layers add nothing. The line + carries the conversation/invoke/assistant_request/error ids so the full + structured record (cause chain, traceback, component fields) can be pulled + from the event store -- the log itself stays minimal and never dumps a + traceback. """ - bound = logger.bind( - component_id=metadata.id, - component_name=metadata.name, - component_type=metadata.type, - conversation_id=getattr(invoke_context, "conversation_id", None), - invoke_id=getattr(invoke_context, "invoke_id", None), - assistant_request_id=getattr(invoke_context, "assistant_request_id", None), - ) - component_label = metadata.type or "component" - summary = ( - f"{component_label} '{metadata.name}' failed: " - f"{type(exc).__name__}: {error_message(exc)}" - ) - already_logged = _traceback_already_logged(exc) # Mark this exception (including re-wrapped outer ones) so the next decorator # layer finds the flag within one link of the chain regardless of nesting - # depth, keeping the full traceback to a single emission. + # depth, keeping emission to a single line. try: setattr(exc, _TRACEBACK_LOGGED_ATTR, True) except Exception: # pragma: no cover - builtins generally allow attributes pass + if already_logged: + return - traceback_str = error_details.get("traceback") - # Pass values as arguments so any braces in them are not re-interpreted as - # Loguru format placeholders. - if already_logged or not traceback_str: - bound.error("{}", summary) - else: - bound.error("{}\n{}", summary, traceback_str) + component_label = metadata.type or "component" + _safe_report( + f"{component_label} '{metadata.name}' failed: " + f"{type(exc).__name__}: {error_message(exc)} " + f"[error_id={error_details.get('error_id')} " + f"conversation_id={error_details.get('conversation_id')} " + f"invoke_id={error_details.get('invoke_id')} " + f"assistant_request_id={error_details.get('assistant_request_id')}]" + ) + + +async def _record_lifecycle_event( + event: ComponentEvent, *, operation: str, invoke_context: InvokeContext +) -> None: + """Persist a lifecycle event, translating a store failure into a contextual + :class:`EventPersistenceError`. + + Used for invoke/respond events, where no execution error is active yet -- a + failure to persist them is itself the primary failure and must surface with + operation context rather than a raw backend exception. + """ + try: + await current_services().event_store.record_event(event) + except Exception as persist_error: + err = EventPersistenceError( + message=f"Failed to persist {operation} event", + invoke_context=invoke_context, + cause=persist_error, + ) + # Captured by serialization's _DOMAIN_FIELDS for a precise diagnostic. + err.operation = operation # type: ignore[attr-defined] + raise err from persist_error def create_async_decorator(config: ComponentConfig) -> Callable: @@ -268,14 +339,16 @@ async def wrapper( input_data=input_data, invoke_context=invoke_context, ) - await container.event_store.record_event(invoke_event) + await _record_lifecycle_event( + invoke_event, operation="invoke", invoke_context=invoke_context + ) # Execute with tracing output_data = None error_details: Optional[Dict[str, Any]] = None try: - with container.tracer.start_as_current_span( + with current_services().tracer.start_as_current_span( f"{metadata.name}.{config.span_name_suffix}" ) as span: try: @@ -308,7 +381,9 @@ async def wrapper( # still attached to the exception, and enrich the span # WHILE it is still recording. Attributes set after the # span context manager exits are dropped by the backend. - error_details = _build_error_details(e, metadata) + error_details = _build_error_details( + e, metadata, invoke_context + ) _record_span_error(span, e, error_details) raise @@ -317,11 +392,12 @@ async def wrapper( # Rebuild defensively only if the failure happened before the # inner try (e.g. span creation itself). if error_details is None: - error_details = _build_error_details(e, metadata) + error_details = _build_error_details(e, metadata, invoke_context) - # Log a full traceback once at the layer closest to the failure; - # outer layers log a concise summary (see _log_component_exception). - _log_component_exception(e, metadata, invoke_context, error_details) + # Emit one authoritative record (full traceback once, at the layer + # closest to the failure; outer layers emit a debug propagation + # record). See _report_component_exception. + _report_component_exception(e, metadata, error_details) # Record failed event with both the human-readable string (kept # for backward compatibility) and the structured details. @@ -334,7 +410,28 @@ async def wrapper( error=str(e), error_details=error_details, ) - await container.event_store.record_event(failed_event) + # A failure to persist the failed event must NOT replace the + # primary execution error. Report it as a secondary diagnostic + # (same error_id), annotate the primary, and re-raise the primary. + try: + await current_services().event_store.record_event(failed_event) + except Exception as persist_error: + error_id = error_details.get("error_id") + arid = error_details.get("assistant_request_id") + _safe_report( + "Secondary failure: failed-event NOT persisted " + f"(error_id={error_id} assistant_request_id={arid}); " + "the failure will not be queryable from the event store: " + f"{type(persist_error).__name__}: {persist_error}", + level="warning", + ) + try: + e.add_note( # Python 3.11+ + f"[grafi] failed to persist failed-event " + f"(error_id={error_id}): {persist_error!r}" + ) + except Exception: # pragma: no cover - add_note always present + pass raise else: # Record respond event @@ -346,7 +443,9 @@ async def wrapper( invoke_context=invoke_context, output_data=output_data, ) - await container.event_store.record_event(respond_event) + await _record_lifecycle_event( + respond_event, operation="respond", invoke_context=invoke_context + ) return wrapper diff --git a/grafi/common/event_stores/event_store_postgres.py b/grafi/common/event_stores/event_store_postgres.py index 14ecfced..e8fb3a77 100644 --- a/grafi/common/event_stores/event_store_postgres.py +++ b/grafi/common/event_stores/event_store_postgres.py @@ -6,8 +6,6 @@ from typing import Sequence from typing import cast -from loguru import logger - from grafi.common.event_stores.event_store import EventStore from grafi.common.events.event import Event from grafi.common.exceptions import EventPersistenceError @@ -102,6 +100,21 @@ def __init__( self.async_engine, class_=AsyncSession, expire_on_commit=False ) + @staticmethod + def _persistence_error( + message: str, operation: str, cause: Exception + ) -> EventPersistenceError: + """Build an :class:`EventPersistenceError` with stable message and the + operation (including any relevant identifier) as structured context. + + Centralizes translation so every store method surfaces a consistent + domain error instead of a raw backend exception, and so the original + cause is always preserved (the lifecycle decorator emits the record).""" + err = EventPersistenceError(message=message, cause=cause) + # Captured by serialization's _DOMAIN_FIELDS for a precise diagnostic. + err.operation = operation # type: ignore[attr-defined] + return err + async def clear_events(self) -> None: """Clear all events from the database.""" async with self.AsyncSession() as session: @@ -110,8 +123,9 @@ async def clear_events(self) -> None: await session.commit() except Exception as e: await session.rollback() - logger.error(f"Failed to clear events: {e}") - raise + raise self._persistence_error( + "Failed to clear events", "clear_events", e + ) from e def _row_to_event(self, row: "EventModel") -> Optional[Event]: """Decode a single ``EventModel`` row into an :class:`Event`. @@ -139,8 +153,9 @@ async def get_events(self) -> List[Event]: return events except Exception as e: - logger.error(f"Failed to get events: {e}") - raise + raise self._persistence_error( + "Failed to read events", "get_events", e + ) from e @staticmethod def _event_values(event: Event) -> dict: @@ -175,10 +190,8 @@ async def record_event(self, event: Event) -> None: await session.commit() except Exception as e: await session.rollback() - logger.error(f"Failed to record event: {e}") - raise EventPersistenceError( - message=f"Failed to record event: {e}", - cause=e, + raise self._persistence_error( + "Failed to record event", "record_event", e ) from e async def record_events(self, events: Sequence[Event]) -> None: @@ -201,10 +214,8 @@ async def record_events(self, events: Sequence[Event]) -> None: await session.commit() except Exception as e: await session.rollback() - logger.error(f"Failed to record events: {e}") - raise EventPersistenceError( - message=f"Failed to record events: {e}", - cause=e, + raise self._persistence_error( + "Failed to record events", "record_events", e ) from e async def get_event(self, event_id: str) -> Optional[Event]: @@ -221,8 +232,9 @@ async def get_event(self, event_id: str) -> Optional[Event]: return self._row_to_event(row) except Exception as e: - logger.error(f"Failed to get event {event_id}: {e}") - raise + raise self._persistence_error( + "Failed to read event", f"get_event:{event_id}", e + ) from e async def get_agent_events(self, assistant_request_id: str) -> List[Event]: """Get all events for a given assistant_request_id asynchronously.""" @@ -246,8 +258,11 @@ async def get_agent_events(self, assistant_request_id: str) -> List[Event]: return events except Exception as e: - logger.error(f"Failed to get agent events {assistant_request_id}: {e}") - raise + raise self._persistence_error( + "Failed to read agent events", + f"get_agent_events:{assistant_request_id}", + e, + ) from e async def get_conversation_events(self, conversation_id: str) -> List[Event]: """Get all events for a given conversation ID asynchronously.""" @@ -271,10 +286,11 @@ async def get_conversation_events(self, conversation_id: str) -> List[Event]: return events except Exception as e: - logger.error( - f"Failed to get conversation events {conversation_id}: {e}" - ) - raise + raise self._persistence_error( + "Failed to read conversation events", + f"get_conversation_events:{conversation_id}", + e, + ) from e async def get_topic_events(self, name: str, offsets: List[int]) -> List[Event]: """Get all events for a given topic name and specific offsets asynchronously.""" @@ -322,8 +338,9 @@ async def get_topic_events(self, name: str, offsets: List[int]) -> List[Event]: return events except Exception as e: - logger.error(f"Failed to get topic events for {name}: {e}") - raise + raise self._persistence_error( + "Failed to read topic events", f"get_topic_events:{name}", e + ) from e async def initialize(self) -> None: """Initialize the database tables asynchronously.""" diff --git a/grafi/runtime/__init__.py b/grafi/runtime/__init__.py new file mode 100644 index 00000000..44e82361 --- /dev/null +++ b/grafi/runtime/__init__.py @@ -0,0 +1,22 @@ +"""Grafi execution runtime: explicit, request-scoped dependency injection. + +Application startup constructs an :class:`ExecutionServices` and a +:class:`GrafiRuntime`, then invokes assistants through ``runtime.invoke(...)``. +The runtime binds the services to a request-scoped ``ContextVar``; components +resolve them via :func:`current_services` without any change to ``invoke`` +signatures. +""" + +from grafi.runtime.execution_services import ErrorReporter +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services +from grafi.runtime.execution_services import current_services +from grafi.runtime.runtime import GrafiRuntime + +__all__ = [ + "ExecutionServices", + "ErrorReporter", + "GrafiRuntime", + "current_services", + "bind_services", +] diff --git a/grafi/runtime/execution_services.py b/grafi/runtime/execution_services.py new file mode 100644 index 00000000..f10544dd --- /dev/null +++ b/grafi/runtime/execution_services.py @@ -0,0 +1,122 @@ +"""Runtime-only dependency container and its request-scoped binding. + +``ExecutionServices`` holds the three live runtime dependencies an invocation +needs -- the event store, the tracer, and the error reporter. It is owned by +:class:`~grafi.runtime.runtime.GrafiRuntime`, bound to a request-scoped +``ContextVar`` for the duration of each ``invoke``, and read through +:func:`current_services`. + +It is deliberately NOT a Pydantic model and exposes no serialization: it must +never be persisted into an event, a manifest, or an ``InvokeContext``. Request +metadata (serializable) and runtime services (never serialized) are kept +strictly separate. +""" + +from __future__ import annotations + +import contextlib +from contextvars import ContextVar +from contextvars import Token +from dataclasses import dataclass +from dataclasses import field +from typing import Iterator +from typing import Optional + +from loguru import logger +from opentelemetry.trace import NoOpTracer +from opentelemetry.trace import Tracer + +from grafi.common.event_stores.event_store import EventStore +from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory + + +class ErrorReporter: + """Emits one concise, id-bearing execution-error line through Loguru. + + The lifecycle decorator builds a one-line ``message`` carrying the failing + component, the error, and the conversation/invoke/assistant_request/error + ids. The full structured record -- cause chain, traceback, component fields + -- is persisted to the event store, so the log only needs to point at it via + those ids. + + This is the default reporter: it logs at the requested level and configures + no sinks (a library must not own the application's logging). Subclass and + override :meth:`report` to route execution errors elsewhere. ``report`` must + not raise. + """ + + def report(self, message: str, *, level: str = "error") -> None: + """Emit one record. ``level`` is ``"error"`` for a failure or + ``"warning"`` for a secondary diagnostic (e.g. a persistence failure).""" + # Resolve the level method defensively; unknown levels fall back to error. + emit = getattr(logger, level, None) + if not callable(emit): + emit = logger.error + # Pass the message as an argument so any braces in it are not + # re-interpreted as Loguru format placeholders. + emit("{}", message) + + +@dataclass(frozen=True, slots=True) +class ExecutionServices: + """Immutable bundle of the live runtime dependencies for one runtime. + + Each field has an in-process default, so ``ExecutionServices()`` is a ready + dev/test bundle and any field can be overridden with a normal keyword -- + ``ExecutionServices(event_store=EventStorePostgres(...))``. There is no + separate factory; the dataclass *is* the default. + + The fields are excluded from ``repr`` so a stray ``repr(services)`` (or an + object that embeds one) cannot leak a database URL, client, or other + infrastructure detail into a log or persisted record. + + Note: the default ``event_store`` is in-memory (lost on exit) and the default + ``tracer`` is a no-op (spans discarded) -- fine for local/test, but + production should pass a durable store and a real tracer explicitly. + """ + + event_store: EventStore = field(default_factory=EventStoreInMemory, repr=False) + tracer: Tracer = field(default_factory=NoOpTracer, repr=False) + error_reporter: ErrorReporter = field(default_factory=ErrorReporter, repr=False) + + +# Request-scoped binding. Holds the services for the currently executing +# invocation. ``None`` outside any ``GrafiRuntime.invoke`` / ``bind_services`` +# scope -- there is intentionally no process-global default. +_current_services: ContextVar[Optional[ExecutionServices]] = ContextVar( + "grafi_current_services", default=None +) + + +def current_services() -> ExecutionServices: + """Return the ``ExecutionServices`` bound for the current invocation. + + Raises ``RuntimeError`` when called outside a bound scope, rather than + silently constructing a process-global default. Drive invocations through + :meth:`GrafiRuntime.invoke` (or wrap direct component calls in + :func:`bind_services`). + """ + services = _current_services.get() + if services is None: + raise RuntimeError( + "No ExecutionServices bound for the current invocation. Invoke through " + "GrafiRuntime.invoke(...), or wrap direct component calls in " + "grafi.runtime.bind_services(...)." + ) + return services + + +@contextlib.contextmanager +def bind_services(services: ExecutionServices) -> Iterator[ExecutionServices]: + """Bind ``services`` to the request scope for the duration of the block. + + Uses ``ContextVar`` set/reset (capture-and-restore), so nested bindings + restore the previous value on exit. ``asyncio`` tasks created inside the + block inherit the binding because ``create_task``/``gather`` snapshot the + current context. + """ + token: Token[Optional[ExecutionServices]] = _current_services.set(services) + try: + yield services + finally: + _current_services.reset(token) diff --git a/grafi/runtime/runtime.py b/grafi/runtime/runtime.py new file mode 100644 index 00000000..a13772ba --- /dev/null +++ b/grafi/runtime/runtime.py @@ -0,0 +1,55 @@ +"""The execution runtime: composition root and invocation entry point. + +``GrafiRuntime`` owns an :class:`ExecutionServices` and is the public way to run +an assistant. ``invoke`` binds those services to the request scope and then +drives the assistant's *unchanged* call chain; components resolve infrastructure +through :func:`current_services`, so no ``invoke`` signature carries a +``services`` parameter. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import AsyncGenerator +from typing import Optional + +from grafi.common.events.topic_events.consume_from_topic_event import ( + ConsumeFromTopicEvent, +) +from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services + +if TYPE_CHECKING: + from grafi.assistants.assistant_base import AssistantBase + + +class GrafiRuntime: + """Holds runtime dependencies and runs assistants under a bound scope. + + ``GrafiRuntime()`` uses the in-process :class:`ExecutionServices` defaults + (dev/test); production passes its own bundle, e.g. + ``GrafiRuntime(ExecutionServices(event_store=EventStorePostgres(...)))``. + """ + + def __init__(self, services: Optional[ExecutionServices] = None) -> None: + self._services = services if services is not None else ExecutionServices() + + @property + def services(self) -> ExecutionServices: + return self._services + + async def invoke( + self, + assistant: "AssistantBase", + input_data: PublishToTopicEvent, + is_sequential: bool = False, + ) -> AsyncGenerator[ConsumeFromTopicEvent, None]: + """Bind this runtime's services and stream the assistant's output. + + The binding is active for the whole iteration and reset on exit; child + ``asyncio`` tasks spawned during execution inherit it. + """ + with bind_services(self._services): + async for event in assistant.invoke(input_data, is_sequential): + yield event diff --git a/grafi/tools/llms/impl/claude_tool.py b/grafi/tools/llms/impl/claude_tool.py index 9f7b58fb..40d8378e 100644 --- a/grafi/tools/llms/impl/claude_tool.py +++ b/grafi/tools/llms/impl/claude_tool.py @@ -237,7 +237,7 @@ async def invoke( raise LLMToolException( tool_name=self.name, model=self.model, - message=f"Anthropic async call failed: {exc}", + message="Anthropic async call failed", invoke_context=invoke_context, cause=exc, ) from exc diff --git a/grafi/tools/llms/impl/gemini_tool.py b/grafi/tools/llms/impl/gemini_tool.py index 65cd70e1..aa8b3ecc 100644 --- a/grafi/tools/llms/impl/gemini_tool.py +++ b/grafi/tools/llms/impl/gemini_tool.py @@ -198,7 +198,7 @@ async def invoke( raise LLMToolException( tool_name=self.name, model=self.model, - message=f"Gemini async call failed: {exc}", + message="Gemini async call failed", invoke_context=invoke_context, cause=exc, ) from exc diff --git a/grafi/tools/llms/impl/ollama_tool.py b/grafi/tools/llms/impl/ollama_tool.py index fa8422bb..2772ead0 100644 --- a/grafi/tools/llms/impl/ollama_tool.py +++ b/grafi/tools/llms/impl/ollama_tool.py @@ -136,11 +136,12 @@ async def invoke( # tool error (matches the other LLM tools' cancellation contract). raise except Exception as e: - logger.error("Ollama API error: %s", e) + # The lifecycle decorator emits the authoritative record; raise a + # domain error with a stable message and the original cause attached. raise LLMToolException( tool_name=self.name, model=self.model, - message=f"Ollama async call failed: {e}", + message="Ollama async call failed", invoke_context=invoke_context, cause=e, ) from e diff --git a/grafi/tools/llms/impl/openai_compatible.py b/grafi/tools/llms/impl/openai_compatible.py index 693387ac..05846b68 100644 --- a/grafi/tools/llms/impl/openai_compatible.py +++ b/grafi/tools/llms/impl/openai_compatible.py @@ -157,7 +157,7 @@ async def invoke( raise LLMToolException( tool_name=tool_name, model=self.model, - message=f"{self._provider_label} API call failed: {exc}", + message=f"{self._provider_label} API call failed", invoke_context=invoke_context, cause=exc, ) from exc @@ -165,7 +165,7 @@ async def invoke( raise LLMToolException( tool_name=tool_name, model=self.model, - message=f"Unexpected error during {self._provider_label} call: {exc}", + message=f"Unexpected error during {self._provider_label} call", invoke_context=invoke_context, cause=exc, ) from exc diff --git a/grafi/tools/llms/llm_command.py b/grafi/tools/llms/llm_command.py index 0d614732..de5198fb 100644 --- a/grafi/tools/llms/llm_command.py +++ b/grafi/tools/llms/llm_command.py @@ -2,7 +2,6 @@ from loguru import logger -from grafi.common.containers.container import container from grafi.common.events.component_events import AssistantRespondEvent from grafi.common.events.event_graph import EventGraph from grafi.common.events.topic_events.consume_from_topic_event import ( @@ -12,6 +11,7 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.common.models.message import Messages +from grafi.runtime.execution_services import current_services from grafi.tools.command import Command @@ -25,7 +25,8 @@ async def get_tool_input( # Get conversation history messages from the event store - conversation_events = await container.event_store.get_conversation_events( + event_store = current_services().event_store + conversation_events = await event_store.get_conversation_events( invoke_context.conversation_id ) @@ -47,7 +48,7 @@ async def get_tool_input( all_messages.extend(output_event.data) # Retrieve agent events related to the current assistant request - agent_events = await container.event_store.get_agent_events( + agent_events = await event_store.get_agent_events( invoke_context.assistant_request_id ) topic_events = { diff --git a/grafi/workflows/impl/async_output_queue.py b/grafi/workflows/impl/async_output_queue.py index c8a9eff2..682e129c 100644 --- a/grafi/workflows/impl/async_output_queue.py +++ b/grafi/workflows/impl/async_output_queue.py @@ -95,9 +95,10 @@ async def _output_listener(self, topic: TopicBase) -> None: except asyncio.CancelledError: break except Exception as e: - logger.error(f"Output listener error for {topic.name}: {e}") - # Record the error and force termination so __anext__ wakes and - # re-raises it to the consumer instead of it being swallowed. + # Transport the error to the consumer via __anext__ (which + # re-raises it) and force termination so it is not swallowed. The + # lifecycle decorator emits the authoritative record when the + # re-raised error reaches the workflow layer, so do not log here. if self._listener_error is None: self._listener_error = e await self.tracker.force_stop() diff --git a/grafi/workflows/impl/event_driven_workflow.py b/grafi/workflows/impl/event_driven_workflow.py index a3627c36..459aa86b 100644 --- a/grafi/workflows/impl/event_driven_workflow.py +++ b/grafi/workflows/impl/event_driven_workflow.py @@ -6,7 +6,6 @@ from openinference.semconv.trace import OpenInferenceSpanKindValues from pydantic import PrivateAttr -from grafi.common.containers.container import container from grafi.common.decorators.record_decorators import record_workflow_invoke from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, @@ -182,7 +181,7 @@ async def invoke( instance carries no per-invocation state and concurrent invocations are isolated. """ - run = WorkflowRun(self, container.event_store) + run = WorkflowRun(self) self._active_runs[id(run)] = run try: async for event in run.run(input_data, is_sequential): diff --git a/grafi/workflows/impl/parallel_engine.py b/grafi/workflows/impl/parallel_engine.py index bf2b640b..d1989f9c 100644 --- a/grafi/workflows/impl/parallel_engine.py +++ b/grafi/workflows/impl/parallel_engine.py @@ -13,8 +13,6 @@ from typing import Dict from typing import List -from loguru import logger - from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) @@ -74,16 +72,18 @@ async def invoke_parallel( if i < len(run.nodes) else f"node_{i}" ) - logger.error( - f"Node {node_name} failed during execution: {task_error}" - ) for t in node_processing_task: if not t.done(): t.cancel() run.stop() + # The lifecycle decorator already reported this failure; + # do not log again, and do not re-wrap an existing + # NodeExecutionError for the same node. + if isinstance(task_error, NodeExecutionError): + raise task_error raise NodeExecutionError( node_name=node_name, - message=f"Node {node_name} execution failed during workflow: {task_error}", + message="Node execution failed", invoke_context=invoke_context, cause=task_error, ) from task_error @@ -125,13 +125,14 @@ async def invoke_parallel( if isinstance(result, Exception) and not isinstance( result, asyncio.CancelledError ): + if isinstance(result, NodeExecutionError): + raise result node_name = ( list(run.nodes.keys())[i] if i < len(run.nodes) else f"node_{i}" ) - logger.error(f"Node {node_name} failed with exception: {result}") raise NodeExecutionError( node_name=node_name, - message=f"Node {node_name} execution failed: {result}", + message="Node execution failed", invoke_context=invoke_context, cause=result, ) from result @@ -223,11 +224,12 @@ def _cancel_all_active_tasks() -> None: get_async_output_events(node_output_events) ) except Exception as node_error: - logger.error(f"Error processing node {node.name}: {node_error}") await run.tracker.force_stop() + if isinstance(node_error, NodeExecutionError): + raise raise NodeExecutionError( node_name=node.name, - message=f"Async node execution failed: {node_error}", + message="Node execution failed", invoke_context=invoke_context, cause=node_error, ) from node_error @@ -235,18 +237,17 @@ def _cancel_all_active_tasks() -> None: await run.tracker.leave(node.name) buffer.clear() except asyncio.CancelledError: - logger.info(f"Node {node.name} was cancelled") + # Cancellation is normal shutdown, not a failure -- do not log/report it. _cancel_all_active_tasks() raise except NodeExecutionError: _cancel_all_active_tasks() raise except Exception as e: - logger.error(f"Fatal error in node {node.name} execution: {e}") _cancel_all_active_tasks() raise NodeExecutionError( node_name=node.name, - message=f"Fatal error in node execution: {e}", + message="Node execution failed", invoke_context=invoke_context, cause=e, ) from e diff --git a/grafi/workflows/impl/sequential_engine.py b/grafi/workflows/impl/sequential_engine.py index 930e58b9..6bbbfaa0 100644 --- a/grafi/workflows/impl/sequential_engine.py +++ b/grafi/workflows/impl/sequential_engine.py @@ -64,10 +64,13 @@ async def invoke_sequential( events.extend(published_events) await run.event_store.record_events(events) + except NodeExecutionError: + # Already a node failure for this node; do not re-wrap. + raise except Exception as e: raise NodeExecutionError( node_name=node.name, - message=f"Node execution failed: {e}", + message="Node execution failed", invoke_context=invoke_context, cause=e, ) from e diff --git a/grafi/workflows/impl/workflow_run.py b/grafi/workflows/impl/workflow_run.py index e7c70a03..0b79c53e 100644 --- a/grafi/workflows/impl/workflow_run.py +++ b/grafi/workflows/impl/workflow_run.py @@ -36,6 +36,7 @@ from grafi.common.exceptions import NodeExecutionError from grafi.common.exceptions import WorkflowError from grafi.nodes.node_base import NodeBase +from grafi.runtime.execution_services import current_services from grafi.topics.topic_base import TopicBase from grafi.topics.topic_types import TopicType from grafi.workflows.impl.async_node_tracker import AsyncNodeTracker @@ -47,13 +48,19 @@ class WorkflowRun: """Mutable runtime state for a single workflow invocation.""" - def __init__(self, definition: Any, event_store: Any) -> None: + def __init__(self, definition: Any, event_store: Any = None) -> None: # Read-only references into the shared definition. self.name: str = definition.name self.type: str = definition.type self.nodes: Dict[str, NodeBase] = definition.nodes self.topic_nodes: Dict[str, List[str]] = definition._topic_nodes - self.event_store = event_store + # The event store is resolved from the runtime services bound for this + # invocation. ``event_store`` may be passed explicitly (tests / advanced + # callers); production code constructs ``WorkflowRun(self)`` inside a + # ``GrafiRuntime.invoke`` scope and lets it resolve from there. + self.event_store = ( + event_store if event_store is not None else current_services().event_store + ) # Per-run topic instances: same config, fresh queue. self.topics: Dict[str, TopicBase] = { @@ -204,7 +211,7 @@ async def run( raise except Exception as e: raise WorkflowError( - message=f"Workflow {self.name} async execution failed: {e}", + message=f"Workflow {self.name} execution failed", invoke_context=invoke_context, cause=e, ) from e diff --git a/tests/common/containers/test_container.py b/tests/common/containers/test_container.py deleted file mode 100644 index 3b48b3f1..00000000 --- a/tests/common/containers/test_container.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Tests for the Container (EventStore and Tracer container).""" - -import threading -from unittest.mock import Mock -from unittest.mock import patch - -import pytest - -from grafi.common.containers.container import Container -from grafi.common.containers.container import SingletonMeta -from grafi.common.event_stores.event_store import EventStore -from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory - - -@pytest.fixture(autouse=True) -def reset_container(): - """Reset the container singleton before each test.""" - # Clear the singleton instances - SingletonMeta._instances.clear() - yield - # Clean up after test - SingletonMeta._instances.clear() - - -class TestContainerSingleton: - """Test Container singleton behavior.""" - - def test_singleton_behavior(self): - """Test that Container is a singleton.""" - container1 = Container() - container2 = Container() - assert container1 is container2 - - def test_thread_safe_singleton(self): - """Test that singleton creation is thread-safe.""" - instances = [] - - def create_container(): - instances.append(Container()) - - # Create multiple containers in parallel threads - threads = [] - for _ in range(10): - thread = threading.Thread(target=create_container) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # All instances should be the same - first_instance = instances[0] - assert all(instance is first_instance for instance in instances) - - -class TestEventStoreManagement: - """Test event store registration and access.""" - - def test_default_event_store(self): - """Test that container provides default event store.""" - container = Container() - - # Should return default EventStoreInMemory - event_store = container.event_store - assert isinstance(event_store, EventStoreInMemory) - - def test_register_custom_event_store(self): - """Test registering a custom event store.""" - container = Container() - mock_store = Mock(spec=EventStore) - - container.register_event_store(mock_store) - - assert container.event_store is mock_store - - def test_event_store_caching(self): - """Test that event store is cached after first access.""" - container = Container() - - # Access event store twice - store1 = container.event_store - store2 = container.event_store - - # Should be the same instance - assert store1 is store2 - - @patch("grafi.common.containers.container.logger") - def test_in_memory_store_warning(self, mock_logger): - """Test warning when using in-memory event store.""" - container = Container() - - # Access default event store - container.event_store - - # Should log warning about in-memory store - mock_logger.warning.assert_called_with( - "Using EventStoreInMemory. This is ONLY suitable for local testing but not for production." - ) - - @patch("grafi.common.containers.container.logger") - def test_register_in_memory_store_warning(self, mock_logger): - """Test warning when registering in-memory event store.""" - container = Container() - in_memory_store = EventStoreInMemory() - - container.register_event_store(in_memory_store) - - # Should log warning about in-memory store - mock_logger.warning.assert_called_with( - "Using EventStoreInMemory. This is ONLY suitable for local testing but not for production." - ) - - -class TestTracerManagement: - """Test tracer registration and access.""" - - @patch("grafi.common.containers.container.setup_tracing") - def test_default_tracer(self, mock_setup_tracing): - """Test that container provides default tracer.""" - from grafi.common.instrumentations.tracing import TracingOptions - - mock_tracer = Mock() - mock_setup_tracing.return_value = mock_tracer - - container = Container() - tracer = container.tracer - - # Should call setup_tracing with default options - mock_setup_tracing.assert_called_once_with( - tracing_options=TracingOptions.AUTO, - collector_endpoint="localhost", - collector_port=4317, - project_name="grafi-trace", - ) - assert tracer is mock_tracer - - def test_register_custom_tracer(self): - """Test registering a custom tracer.""" - container = Container() - mock_tracer = Mock() - - container.register_tracer(mock_tracer) - - assert container.tracer is mock_tracer - - def test_tracer_caching(self): - """Test that tracer is cached after first access.""" - container = Container() - mock_tracer = Mock() - container.register_tracer(mock_tracer) - - # Access tracer twice - tracer1 = container.tracer - tracer2 = container.tracer - - # Should be the same instance - assert tracer1 is tracer2 - - -class TestContainerIntegration: - """Integration tests for container functionality.""" - - def test_container_initialization(self): - """Test container proper initialization.""" - container = Container() - - # Should have proper initial state - assert container._event_store is None - assert container._tracer is None - - def test_multiple_containers_share_state(self): - """Test that multiple container instances share state.""" - container1 = Container() - container2 = Container() - - # Register event store in one container - mock_store = Mock(spec=EventStore) - container1.register_event_store(mock_store) - - # Should be accessible from other container (singleton) - assert container2.event_store is mock_store - - def test_container_properties_independent_lifecycle(self): - """Test that event store and tracer can be set independently.""" - container = Container() - - # Set only event store - mock_store = Mock(spec=EventStore) - container.register_event_store(mock_store) - - # Event store should be set, tracer should use default - assert container.event_store is mock_store - # Tracer access will create default tracer - with patch("grafi.common.containers.container.setup_tracing") as mock_setup: - mock_tracer = Mock() - mock_setup.return_value = mock_tracer - assert container.tracer is mock_tracer - - def test_global_container_instance(self): - """Test the global container instance.""" - from grafi.common.containers.container import container as global_container - - # Should be a Container instance - assert isinstance(global_container, Container) - - # Note: The global container is created at module import time, - # so it might not be the same instance as a new Container() - # due to our test fixture that clears singleton instances. - # But they should both be Container instances - new_container = Container() - assert isinstance(new_container, Container) diff --git a/tests/common/decorators/test_record_decorators.py b/tests/common/decorators/test_record_decorators.py index 0fca8950..c2465ffd 100644 --- a/tests/common/decorators/test_record_decorators.py +++ b/tests/common/decorators/test_record_decorators.py @@ -2,12 +2,10 @@ import json from types import SimpleNamespace -from unittest.mock import MagicMock import pytest from pydantic_core import to_jsonable_python -from grafi.common.containers.container import container from grafi.common.decorators.record_base import _TRACEBACK_LOGGED_ATTR from grafi.common.decorators.record_base import EventContext from grafi.common.decorators.record_base import _traceback_already_logged @@ -18,26 +16,9 @@ from grafi.common.exceptions.workflow_exceptions import NodeExecutionError from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message - - -@pytest.fixture -def isolated_container(): - """Register an isolated in-memory store and a no-op tracer, then restore. - - Using a mock tracer avoids the live ``setup_tracing`` path (a socket probe to - localhost:4317 and possible global instrumentation side effects). The previous - container state is restored so the global singleton does not leak across tests. - """ - prev_store = container._event_store - prev_tracer = container._tracer - store = EventStoreInMemory() - container.register_event_store(store) - container.register_tracer(MagicMock()) - try: - yield store - finally: - container._event_store = prev_store - container._tracer = prev_tracer +from grafi.runtime.execution_services import ErrorReporter +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services class TestEventContext: @@ -145,9 +126,7 @@ class TestFailedEventErrorDetails: """The decorator records structured error details and re-raises.""" @pytest.mark.asyncio - async def test_failed_event_carries_error_details(self, isolated_container): - event_store = isolated_container - + async def test_failed_event_carries_error_details(self, event_store): tool = _ExplodingTool() invoke_context = InvokeContext( conversation_id="conversation_id", @@ -239,12 +218,13 @@ class TestSpanErrorEnrichment: @pytest.mark.asyncio async def test_error_attributes_recorded_before_span_ends(self): - prev_store = container._event_store - prev_tracer = container._tracer tracer = _RecordingTracer() - container.register_event_store(EventStoreInMemory()) - container.register_tracer(tracer) - try: + services = ExecutionServices( + event_store=EventStoreInMemory(), + tracer=tracer, + error_reporter=ErrorReporter(), + ) + with bind_services(services): tool = _ExplodingTool() invoke_context = InvokeContext( conversation_id="c", @@ -256,9 +236,6 @@ async def test_error_attributes_recorded_before_span_ends(self): invoke_context, [Message(role="user", content="hi")] ): pass - finally: - container._event_store = prev_store - container._tracer = prev_tracer span = tracer.span # The span was ended by the context manager exit. diff --git a/tests/conftest.py b/tests/conftest.py index ab1d93f5..219f939e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,31 @@ import pytest +from grafi.common.event_stores.event_store import EventStore from grafi.common.models.invoke_context import InvokeContext +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services + + +@pytest.fixture(autouse=True) +def bound_services() -> ExecutionServices: + """Bind a fresh default ExecutionServices for every test. + + Replaces the old process-global container default: ``current_services()`` is + available throughout each test (and in the asyncio tasks it spawns, because + this sync fixture binds the ContextVar before the event loop runs the test + coroutine). ``ExecutionServices()`` supplies the in-process defaults; tests + that need a specific store or tracer bind their own scope with + ``grafi.runtime.bind_services(ExecutionServices(...))``, which shadows this. + """ + services = ExecutionServices() + with bind_services(services): + yield services + + +@pytest.fixture +def event_store(bound_services: ExecutionServices) -> EventStore: + """The EventStore bound for the current test.""" + return bound_services.event_store @pytest.fixture diff --git a/tests/runtime/test_error_reporting.py b/tests/runtime/test_error_reporting.py new file mode 100644 index 00000000..fcab5cce --- /dev/null +++ b/tests/runtime/test_error_reporting.py @@ -0,0 +1,167 @@ +"""Tests for correlated error reporting through the decorator + ErrorReporter.""" + +from types import SimpleNamespace +from typing import Any +from typing import List + +import pytest +from opentelemetry.trace import NoOpTracer + +from grafi.common.decorators.record_base import _build_error_details +from grafi.common.decorators.record_base import _error_id_for +from grafi.common.decorators.record_decorators import record_tool_invoke +from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory +from grafi.common.exceptions.tool_exceptions import FunctionCallException +from grafi.common.exceptions.workflow_exceptions import NodeExecutionError +from grafi.common.models.invoke_context import InvokeContext +from grafi.common.models.message import Message +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services + + +class _RecordingReporter: + def __init__(self) -> None: + self.calls: List[dict] = [] + + def report(self, message, *, level="error"): + self.calls.append({"message": message, "level": level}) + + +class _RaisingReporter: + """A misbehaving reporter that raises from report().""" + + def report(self, *args, **kwargs): + raise RuntimeError("reporter is broken") + + +class _ExplodingTool: + tool_id = "exploding-tool-id" + name = "ExplodingTool" + type = "FunctionCallTool" + oi_span_type = SimpleNamespace(value="TOOL") + + @record_tool_invoke + async def invoke(self, invoke_context, input_data): + raise FunctionCallException( + tool_name="ExplodingTool", + function_name="detonate", + message="function call blew up", + invoke_context=invoke_context, + cause=ValueError("root boom"), + ) + yield # pragma: no cover - makes this an async generator + + +def _ctx() -> InvokeContext: + return InvokeContext(conversation_id="c", invoke_id="i", assistant_request_id="r") + + +class TestErrorIdCorrelation: + def test_error_id_is_inherited_across_rewrapping(self): + inner = ValueError("root boom") + first = _error_id_for(inner) + # A re-wrapped outer exception carries the same correlation id. + outer = NodeExecutionError(node_name="n", message="node failed", cause=inner) + assert _error_id_for(outer) == first + + def test_error_id_is_stable_for_one_exception(self): + exc = ValueError("x") + assert _error_id_for(exc) == _error_id_for(exc) + + def test_build_error_details_has_correlation_fields(self): + exc = FunctionCallException( + tool_name="T", + function_name="f", + message="boom", + invoke_context=_ctx(), + cause=ValueError("root boom"), + ) + metadata = SimpleNamespace(id="cid", name="C", type="Tool") + details = _build_error_details(exc, metadata, _ctx()) # type: ignore[arg-type] + assert details["error_id"] + assert details["conversation_id"] == "c" + assert details["invoke_id"] == "i" + assert details["assistant_request_id"] == "r" + assert details["root_error_type"] == "ValueError" + assert details["root_error_message"] == "root boom" + assert details["component_name"] == "C" + + +class TestSingleAuthoritativeEmission: + @pytest.mark.asyncio + async def test_one_concise_error_line_with_ids(self): + reporter = _RecordingReporter() + services = ExecutionServices( + event_store=EventStoreInMemory(), + tracer=NoOpTracer(), + error_reporter=reporter, + ) + with bind_services(services): + with pytest.raises(FunctionCallException): + async for _ in _ExplodingTool().invoke( + _ctx(), [Message(role="user", content="hi")] + ): + pass + + # Exactly one concise ERROR line. It names the failure and carries the + # lookup ids -- but dumps no traceback (that lives in the event store). + errors = [c for c in reporter.calls if c["level"] == "error"] + assert len(errors) == 1 + msg = errors[0]["message"] + assert "ExplodingTool" in msg + assert "FunctionCallException" in msg + assert "error_id=" in msg + assert "conversation_id=c" in msg + assert "assistant_request_id=r" in msg + assert "Traceback" not in msg # the traceback is not logged + + +class _FailOnFailedEventStore(EventStoreInMemory): + """Store that fails to persist *failed* events (but records others).""" + + async def record_event(self, event: Any) -> None: + if type(event).__name__.endswith("FailedEvent"): + raise RuntimeError("store down") + await super().record_event(event) + + +class TestPreservePrimaryFailure: + @pytest.mark.asyncio + async def test_failed_event_persistence_failure_does_not_mask_primary(self): + reporter = _RecordingReporter() + services = ExecutionServices( + event_store=_FailOnFailedEventStore(), + tracer=NoOpTracer(), + error_reporter=reporter, + ) + with bind_services(services): + # The PRIMARY execution error wins, not the persistence RuntimeError. + with pytest.raises(FunctionCallException): + async for _ in _ExplodingTool().invoke( + _ctx(), [Message(role="user", content="hi")] + ): + pass + + # The secondary persistence failure is reported as a warning, carrying + # the error_id and request id so it can still be correlated. + warnings = [c for c in reporter.calls if c["level"] == "warning"] + assert len(warnings) == 1 + msg = warnings[0]["message"] + assert "failed-event NOT persisted" in msg + assert "error_id=" in msg + assert "assistant_request_id=r" in msg + + @pytest.mark.asyncio + async def test_raising_reporter_does_not_mask_primary(self): + services = ExecutionServices( + event_store=EventStoreInMemory(), + tracer=NoOpTracer(), + error_reporter=_RaisingReporter(), + ) + with bind_services(services): + # A reporter that raises must not replace the execution failure. + with pytest.raises(FunctionCallException): + async for _ in _ExplodingTool().invoke( + _ctx(), [Message(role="user", content="hi")] + ): + pass diff --git a/tests/runtime/test_execution_services.py b/tests/runtime/test_execution_services.py new file mode 100644 index 00000000..054c8c05 --- /dev/null +++ b/tests/runtime/test_execution_services.py @@ -0,0 +1,71 @@ +"""Tests for ExecutionServices, current_services(), and bind_services().""" + +import contextvars +import dataclasses + +import pytest +from opentelemetry.trace import NoOpTracer + +from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory +from grafi.runtime.execution_services import ErrorReporter +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services +from grafi.runtime.execution_services import current_services + + +def _services() -> ExecutionServices: + return ExecutionServices( + event_store=EventStoreInMemory(), + tracer=NoOpTracer(), + error_reporter=ErrorReporter(), + ) + + +class TestExecutionServicesShape: + def test_is_frozen_dataclass_not_pydantic(self): + services = _services() + assert dataclasses.is_dataclass(services) + # Not a Pydantic model: no serialization surface. + assert not hasattr(services, "model_dump") + assert not hasattr(services, "to_dict") + + def test_is_immutable(self): + services = _services() + with pytest.raises(dataclasses.FrozenInstanceError): + services.event_store = EventStoreInMemory() # type: ignore[misc] + + def test_repr_hides_dependencies(self): + services = _services() + text = repr(services) + # repr must not leak the store/tracer/reporter (e.g. a db URL). + assert "EventStoreInMemory" not in text + assert "event_store" not in text + assert text == "ExecutionServices()" + + +class TestBinding: + def test_current_services_raises_outside_scope(self): + # A fresh context has the ContextVar at its default (None). + ctx = contextvars.Context() + + def _call() -> None: + with pytest.raises(RuntimeError): + current_services() + + ctx.run(_call) + + def test_bind_makes_services_current(self): + services = _services() + with bind_services(services) as bound: + assert bound is services + assert current_services() is services + + def test_bind_nests_and_restores(self): + outer = _services() + inner = _services() + with bind_services(outer): + assert current_services() is outer + with bind_services(inner): + assert current_services() is inner + # Inner scope restored the previous (outer) binding. + assert current_services() is outer diff --git a/tests/runtime/test_runtime.py b/tests/runtime/test_runtime.py new file mode 100644 index 00000000..c6934e07 --- /dev/null +++ b/tests/runtime/test_runtime.py @@ -0,0 +1,73 @@ +"""Tests for GrafiRuntime: request-scoped binding and propagation/isolation.""" + +import asyncio + +import pytest + +from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory +from grafi.runtime.execution_services import current_services +from grafi.runtime.runtime import GrafiRuntime + + +async def _read_services(): + """Read the bound services (used to probe a child task's context).""" + return current_services() + + +class _ProbeAssistant: + """Assistant-shaped probe whose invoke reports the services it observes. + + Yields ``(direct, in_task)`` -- the services seen directly and the services + seen from inside an ``asyncio`` task it spawns -- so a test can assert the + runtime bound them for the whole invocation, child tasks included. + """ + + async def invoke(self, input_data, is_sequential=False): + direct = current_services() + in_task = await asyncio.create_task(_read_services()) + yield (direct, in_task) + + +class TestDefaultRuntime: + def test_default_runtime_uses_in_memory_store(self): + runtime = GrafiRuntime() + assert isinstance(runtime.services.event_store, EventStoreInMemory) + + def test_each_default_runtime_is_independent(self): + assert GrafiRuntime() is not GrafiRuntime() + # ... with distinct stores (no shared process-global state). + assert ( + GrafiRuntime().services.event_store + is not GrafiRuntime().services.event_store + ) + + +class TestRuntimeInvokeBinding: + @pytest.mark.asyncio + async def test_invoke_binds_services_for_the_invocation_and_child_tasks(self): + runtime = GrafiRuntime() + + results = [item async for item in runtime.invoke(_ProbeAssistant(), None)] + + assert len(results) == 1 + direct, in_task = results[0] + # The invocation and the task it spawned both see this runtime's services. + assert direct is runtime.services + assert in_task is runtime.services + + @pytest.mark.asyncio + async def test_concurrent_invocations_are_isolated(self): + rt_a = GrafiRuntime() + rt_b = GrafiRuntime() + + async def _drain(rt): + return [item async for item in rt.invoke(_ProbeAssistant(), None)][0] + + (a_direct, a_task), (b_direct, b_task) = await asyncio.gather( + _drain(rt_a), _drain(rt_b) + ) + + # Each concurrent invocation saw its own runtime's services -- no cross-talk. + assert a_direct is rt_a.services and a_task is rt_a.services + assert b_direct is rt_b.services and b_task is rt_b.services + assert rt_a.services is not rt_b.services diff --git a/tests/workflow/test_event_driven_workflow.py b/tests/workflow/test_event_driven_workflow.py index 48313948..89bb1fb7 100644 --- a/tests/workflow/test_event_driven_workflow.py +++ b/tests/workflow/test_event_driven_workflow.py @@ -1,7 +1,4 @@ import asyncio -from unittest.mock import AsyncMock -from unittest.mock import Mock -from unittest.mock import patch import pytest from openinference.semconv.trace import OpenInferenceSpanKindValues @@ -255,33 +252,21 @@ async def test_invoke_basic_flow(self, async_workflow): ) input_messages = [Message(role="user", content="test input")] - # Mock the container to avoid real event store - with patch( - "grafi.workflows.impl.event_driven_workflow.container" - ) as mock_container: - mock_event_store = Mock() - mock_container.event_store = mock_event_store - mock_event_store.get_agent_events = AsyncMock(return_value=[]) - mock_event_store.record_events = AsyncMock() - mock_event_store.record_event = AsyncMock() - - # Create a timeout to avoid hanging - try: - # Run async invoke with timeout - results = [] - async with asyncio.timeout(0.5): - async for msg in async_workflow.invoke( - PublishToTopicEvent( - invoke_context=invoke_context, data=input_messages - ) - ): - results.append(msg) - except asyncio.TimeoutError: - # Expected - the workflow will wait for output - pass - - # The workflow should have been initialized - mock_event_store.get_agent_events.assert_called_with("test") + # The autouse fixture binds an in-memory store, so no real event store + # is needed here. + try: + # Run async invoke with timeout to avoid hanging + results = [] + async with asyncio.timeout(0.5): + async for msg in async_workflow.invoke( + PublishToTopicEvent( + invoke_context=invoke_context, data=input_messages + ) + ): + results.append(msg) + except asyncio.TimeoutError: + # Expected - the workflow will wait for output + pass @pytest.mark.asyncio async def test_invoke_with_async_output_queue(self, async_workflow): diff --git a/tests/workflow/test_fanout_quiescence.py b/tests/workflow/test_fanout_quiescence.py index adb47244..388087c1 100644 --- a/tests/workflow/test_fanout_quiescence.py +++ b/tests/workflow/test_fanout_quiescence.py @@ -6,9 +6,6 @@ the first subscriber committed -- silently dropping the rest of the work. """ -from unittest.mock import Mock -from unittest.mock import patch - import pytest from openinference.semconv.trace import OpenInferenceSpanKindValues @@ -97,17 +94,13 @@ async def test_parallel_fanout_yields_every_subscriber_output(): workflow = _build_fanout_workflow() request_id = "fanout-parallel" - fake_container = Mock() - fake_container.event_store = EventStoreInMemory() - - with patch("grafi.workflows.impl.event_driven_workflow.container", fake_container): - contents = [ - msg.content - async for event in workflow.invoke( - _input_event(request_id), is_sequential=False - ) - for msg in event.data - ] + contents = [ + msg.content + async for event in workflow.invoke( + _input_event(request_id), is_sequential=False + ) + for msg in event.data + ] assert "resp-a" in contents assert "resp-b" in contents @@ -150,16 +143,12 @@ async def test_parallel_does_not_hang_on_unsatisfied_and_subscription(): ) workflow = EventDrivenWorkflow.builder().node(firing).node(stuck).build() - fake_container = Mock() - fake_container.event_store = EventStoreInMemory() - - with patch("grafi.workflows.impl.event_driven_workflow.container", fake_container): - contents = [] - async with asyncio.timeout(10): # fails loudly if it hangs - async for event in workflow.invoke( - _input_event("and-sub"), is_sequential=False - ): - contents.extend(msg.content for msg in event.data) + contents = [] + async with asyncio.timeout(10): # fails loudly if it hangs + async for event in workflow.invoke( + _input_event("and-sub"), is_sequential=False + ): + contents.extend(msg.content for msg in event.data) # The firing node's output is produced; the stuck AND-node never fires. assert "resp-ok" in contents diff --git a/tests/workflow/test_invocation_isolation.py b/tests/workflow/test_invocation_isolation.py index 3c07b536..d59a6f3b 100644 --- a/tests/workflow/test_invocation_isolation.py +++ b/tests/workflow/test_invocation_isolation.py @@ -11,12 +11,10 @@ """ import asyncio -from unittest.mock import Mock import pytest from openinference.semconv.trace import OpenInferenceSpanKindValues -from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message @@ -81,17 +79,13 @@ async def _drain( @pytest.mark.asyncio @pytest.mark.parametrize("sequential", [False, True]) -async def test_concurrent_invokes_on_one_instance_are_isolated(sequential, monkeypatch): - """Two concurrent invokes on one workflow instance complete independently.""" - workflow = _build_workflow() +async def test_concurrent_invokes_on_one_instance_are_isolated(sequential): + """Two concurrent invokes on one workflow instance complete independently. - # A shared store is fine: events are partitioned by assistant_request_id. - store = EventStoreInMemory() - fake_container = Mock() - fake_container.event_store = store - monkeypatch.setattr( - "grafi.workflows.impl.event_driven_workflow.container", fake_container - ) + They share the one store bound for the test (events are partitioned by + assistant_request_id); isolation is in the per-run state, not the store. + """ + workflow = _build_workflow() results_a, results_b = await asyncio.wait_for( asyncio.gather( @@ -107,19 +101,13 @@ async def test_concurrent_invokes_on_one_instance_are_isolated(sequential, monke @pytest.mark.asyncio -async def test_stop_does_not_leak_across_concurrent_invokes(monkeypatch): +async def test_stop_does_not_leak_across_concurrent_invokes(): """Stopping is scoped: one run finishing/stopping must not halt another. Here both runs simply complete; the assertion is that running them concurrently does not strand either one (a shared stop flag / tracker would). """ workflow = _build_workflow() - store = EventStoreInMemory() - fake_container = Mock() - fake_container.event_store = store - monkeypatch.setattr( - "grafi.workflows.impl.event_driven_workflow.container", fake_container - ) batches = await asyncio.wait_for( asyncio.gather(*[_drain(workflow, _input(f"r{i}"), False) for i in range(4)]), diff --git a/tests/workflow/test_recovery.py b/tests/workflow/test_recovery.py index ef02d9d5..e4baa7a7 100644 --- a/tests/workflow/test_recovery.py +++ b/tests/workflow/test_recovery.py @@ -5,9 +5,6 @@ topic state and resumes instead of starting fresh. """ -from unittest.mock import Mock -from unittest.mock import patch - import pytest from openinference.semconv.trace import OpenInferenceSpanKindValues @@ -84,7 +81,7 @@ async def test_count_pending_consumable_reflects_restored_work(): @pytest.mark.asyncio -async def test_parallel_recovery_resumes_and_yields_output(): +async def test_parallel_recovery_resumes_and_yields_output(event_store): """A resumed parallel run must drain restored work, not quiesce immediately. Regression test: before seeding the tracker on recovery, the parallel path @@ -93,18 +90,14 @@ async def test_parallel_recovery_resumes_and_yields_output(): workflow, _input_topic, _node_name = _build_workflow() request_id = "recovery-parallel" - store = EventStoreInMemory() - await store.record_event(_pending_input_event(request_id)) - - fake_container = Mock() - fake_container.event_store = store + # Pre-seed the bound store so the workflow recovers instead of starting fresh. + await event_store.record_event(_pending_input_event(request_id)) - with patch("grafi.workflows.impl.event_driven_workflow.container", fake_container): - outputs = [] - async for event in workflow.invoke( - _pending_input_event(request_id), is_sequential=False - ): - outputs.append(event) + outputs = [] + async for event in workflow.invoke( + _pending_input_event(request_id), is_sequential=False + ): + outputs.append(event) assert outputs, "resumed parallel workflow yielded no output (immediate quiescence)" assert any( @@ -113,23 +106,18 @@ async def test_parallel_recovery_resumes_and_yields_output(): @pytest.mark.asyncio -async def test_sequential_recovery_resumes_and_yields_output(): +async def test_sequential_recovery_resumes_and_yields_output(event_store): """The sequential path also resumes restored work and yields output.""" workflow, _input_topic, _node_name = _build_workflow() request_id = "recovery-sequential" - store = EventStoreInMemory() - await store.record_event(_pending_input_event(request_id)) - - fake_container = Mock() - fake_container.event_store = store + await event_store.record_event(_pending_input_event(request_id)) - with patch("grafi.workflows.impl.event_driven_workflow.container", fake_container): - outputs = [] - async for event in workflow.invoke( - _pending_input_event(request_id), is_sequential=True - ): - outputs.append(event) + outputs = [] + async for event in workflow.invoke( + _pending_input_event(request_id), is_sequential=True + ): + outputs.append(event) assert outputs assert any( diff --git a/tests/workflow/test_workflow_run.py b/tests/workflow/test_workflow_run.py index 7d53be7c..07173246 100644 --- a/tests/workflow/test_workflow_run.py +++ b/tests/workflow/test_workflow_run.py @@ -7,7 +7,6 @@ """ import uuid -from unittest.mock import Mock import pytest from openinference.semconv.trace import OpenInferenceSpanKindValues @@ -111,12 +110,6 @@ def _and_workflow() -> EventDrivenWorkflow: return EventDrivenWorkflow.builder().node(node).build() -def _patch_container(monkeypatch, store: EventStoreInMemory) -> None: - fake = Mock() - fake.event_store = store - monkeypatch.setattr("grafi.workflows.impl.event_driven_workflow.container", fake) - - # --------------------------------------------------------------------------- # # Construction / isolation invariants # --------------------------------------------------------------------------- # @@ -301,11 +294,8 @@ async def test_true_when_a_node_is_active(self): class TestRunErrorHandling: @pytest.mark.asyncio @pytest.mark.parametrize("sequential", [False, True]) - async def test_node_failure_raises_node_execution_error( - self, sequential, monkeypatch - ): + async def test_node_failure_raises_node_execution_error(self, sequential): wf = _workflow(tool=BoomTool()) - _patch_container(monkeypatch, EventStoreInMemory()) with pytest.raises(NodeExecutionError): async for _ in wf.invoke(_pub(), is_sequential=sequential): @@ -340,9 +330,8 @@ def test_stop_forwards_to_active_runs(self): assert run._stop_requested @pytest.mark.asyncio - async def test_active_runs_cleared_after_invoke(self, monkeypatch): + async def test_active_runs_cleared_after_invoke(self): wf = _workflow() - _patch_container(monkeypatch, EventStoreInMemory()) _ = [event async for event in wf.invoke(_pub(), is_sequential=False)] diff --git a/tests_integration/agents/react_agent_async_example.py b/tests_integration/agents/react_agent_async_example.py index c8be2a85..e15f4bf7 100644 --- a/tests_integration/agents/react_agent_async_example.py +++ b/tests_integration/agents/react_agent_async_example.py @@ -1,6 +1,8 @@ import asyncio from grafi.agents.react_agent import create_react_agent +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services react_agent = create_react_agent() @@ -10,4 +12,7 @@ async def run_agent() -> None: print(output) -asyncio.run(run_agent()) +# ReActAgent.a_run() calls the assistant's invoke under the hood, so it must run +# inside a bound runtime scope. ExecutionServices() supplies in-process defaults. +with bind_services(ExecutionServices()): + asyncio.run(run_agent()) diff --git a/tests_integration/agents/react_agent_example.py b/tests_integration/agents/react_agent_example.py index f6bd3f87..1d91ab23 100644 --- a/tests_integration/agents/react_agent_example.py +++ b/tests_integration/agents/react_agent_example.py @@ -1,6 +1,8 @@ import asyncio from grafi.agents.react_agent import create_react_agent +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services async def run_agent() -> None: @@ -11,4 +13,7 @@ async def run_agent() -> None: print(output) -asyncio.run(run_agent()) +# ReActAgent.run() calls the assistant's invoke under the hood, so it must run +# inside a bound runtime scope. ExecutionServices() supplies in-process defaults. +with bind_services(ExecutionServices()): + asyncio.run(run_agent()) diff --git a/tests_integration/agents/react_agent_mcp_tools_async_example_local.py b/tests_integration/agents/react_agent_mcp_tools_async_example_local.py index 7b069411..5c37233d 100644 --- a/tests_integration/agents/react_agent_mcp_tools_async_example_local.py +++ b/tests_integration/agents/react_agent_mcp_tools_async_example_local.py @@ -2,6 +2,8 @@ from grafi.agents.react_agent import create_react_agent from grafi.common.models.mcp_connections import StdioConnection +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.mcp_tool import MCPTool @@ -26,4 +28,5 @@ async def run_agent() -> None: print(output.content) -asyncio.run(run_agent()) +with bind_services(ExecutionServices()): + asyncio.run(run_agent()) diff --git a/tests_integration/embedding_assistant/simple_embedding_retrieval_assistant_async_example.py b/tests_integration/embedding_assistant/simple_embedding_retrieval_assistant_async_example.py index 033b9ac8..11082421 100644 --- a/tests_integration/embedding_assistant/simple_embedding_retrieval_assistant_async_example.py +++ b/tests_integration/embedding_assistant/simple_embedding_retrieval_assistant_async_example.py @@ -11,17 +11,19 @@ from chromadb import Collection from llama_index.embeddings.openai import OpenAIEmbedding -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.embedding_assistant.simple_embedding_retrieval_assistant import ( SimpleEmbeddingRetrievalAssistant, ) api_key = os.getenv("OPENAI_API_KEY", "") -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store CURRENT_DIR = Path(__file__).parent PERSIST_DIR = CURRENT_DIR / "storage" @@ -123,4 +125,5 @@ async def test_simple_embedding_retrieval_tool_async() -> None: assert len(await event_store.get_events()) == 12 -asyncio.run(test_simple_embedding_retrieval_tool_async()) +with bind_services(runtime.services): + asyncio.run(test_simple_embedding_retrieval_tool_async()) diff --git a/tests_integration/embedding_assistant/simple_embedding_retrieval_assistant_example.py b/tests_integration/embedding_assistant/simple_embedding_retrieval_assistant_example.py index 22d897f7..5ea08418 100644 --- a/tests_integration/embedding_assistant/simple_embedding_retrieval_assistant_example.py +++ b/tests_integration/embedding_assistant/simple_embedding_retrieval_assistant_example.py @@ -11,11 +11,12 @@ from chromadb import Collection from llama_index.embeddings.openai import OpenAIEmbedding -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.embedding_assistant.simple_embedding_retrieval_assistant import ( SimpleEmbeddingRetrievalAssistant, ) @@ -28,7 +29,8 @@ Scalar = Union[str, int, float, bool] Meta = Mapping[str, Scalar] -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store # Delete the PERSIST_DIR and all files in it if os.path.exists(PERSIST_DIR): @@ -127,4 +129,5 @@ async def test_simple_embedding_retrieval_tool() -> None: assert len(await event_store.get_events()) == 12 -asyncio.run(test_simple_embedding_retrieval_tool()) +with bind_services(runtime.services): + asyncio.run(test_simple_embedding_retrieval_tool()) diff --git a/tests_integration/event_store_postgres/simple_llm_assistant_postgres_integration.py b/tests_integration/event_store_postgres/simple_llm_assistant_postgres_integration.py index 3df34784..66936c44 100644 --- a/tests_integration/event_store_postgres/simple_llm_assistant_postgres_integration.py +++ b/tests_integration/event_store_postgres/simple_llm_assistant_postgres_integration.py @@ -4,12 +4,17 @@ import os import uuid -from grafi.common.containers.container import container +from opentelemetry.trace import NoOpTracer + from grafi.common.event_stores.event_store_postgres import EventStorePostgres from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import ErrorReporter +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services from tests_integration.event_store_postgres.simple_llm_assistant import ( SimpleLLMAssistant, ) @@ -37,9 +42,14 @@ db_url="postgresql://user:password@localhost:5432/grafi_test_db", ) -container.register_event_store(postgres_event_store) - -event_store = container.event_store +runtime = GrafiRuntime( + ExecutionServices( + event_store=postgres_event_store, + tracer=NoOpTracer(), + error_reporter=ErrorReporter(), + ) +) +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -109,4 +119,5 @@ async def test_simple_llm_assistant() -> None: assert len(await event_store.get_conversation_events(conversation_id)) == 24 -asyncio.run(test_simple_llm_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_llm_assistant()) diff --git a/tests_integration/function_assistant/simple_function_assistant_deserialize_assistant_example.py b/tests_integration/function_assistant/simple_function_assistant_deserialize_assistant_example.py index a85bd715..fc204635 100644 --- a/tests_integration/function_assistant/simple_function_assistant_deserialize_assistant_example.py +++ b/tests_integration/function_assistant/simple_function_assistant_deserialize_assistant_example.py @@ -6,11 +6,12 @@ from dotenv import load_dotenv from grafi.assistants.assistant import Assistant -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services # Load API keys (e.g. OPENAI_API_KEY) from .env before the assistant is # deserialized; OpenAITool.from_dict() reads the key from the environment since @@ -26,7 +27,8 @@ def get_invoke_context() -> InvokeContext: ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store async def test_deserialized_assistant() -> None: @@ -70,4 +72,5 @@ async def test_deserialized_assistant() -> None: if __name__ == "__main__": - asyncio.run(test_deserialized_assistant()) + with bind_services(runtime.services): + asyncio.run(test_deserialized_assistant()) diff --git a/tests_integration/function_assistant/simple_function_llm_assistant_example.py b/tests_integration/function_assistant/simple_function_llm_assistant_example.py index ce26200f..29743d93 100644 --- a/tests_integration/function_assistant/simple_function_llm_assistant_example.py +++ b/tests_integration/function_assistant/simple_function_llm_assistant_example.py @@ -5,17 +5,19 @@ from pydantic import BaseModel -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.common.models.message import Messages +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.function_assistant.simple_function_llm_assistant import ( SimpleFunctionLLMAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -111,4 +113,5 @@ async def test_simple_function_call_assistant() -> None: # Guard top-level execution so this module can be imported (e.g. to resolve the # `print_user_form` reference stored in the manifest) without running the agent. if __name__ == "__main__": - asyncio.run(test_simple_function_call_assistant()) + with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/complex_function_call_assistant_deserialize_assistant_example.py b/tests_integration/function_call_assistant/complex_function_call_assistant_deserialize_assistant_example.py index fb4d74db..63cbf5ee 100644 --- a/tests_integration/function_call_assistant/complex_function_call_assistant_deserialize_assistant_example.py +++ b/tests_integration/function_call_assistant/complex_function_call_assistant_deserialize_assistant_example.py @@ -4,11 +4,12 @@ from pathlib import Path from grafi.assistants.assistant import Assistant -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.tool_factory import ToolFactory from tests_integration.function_call_assistant.simple_function_call_assistant_complex_function_example import ( # noqa: E501 LocalFileMock, @@ -28,7 +29,8 @@ def get_invoke_context() -> InvokeContext: ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store async def test_deserialized_assistant() -> None: @@ -70,4 +72,5 @@ async def test_deserialized_assistant() -> None: if __name__ == "__main__": - asyncio.run(test_deserialized_assistant()) + with bind_services(runtime.services): + asyncio.run(test_deserialized_assistant()) diff --git a/tests_integration/function_call_assistant/concurrent_function_call_invocation_example.py b/tests_integration/function_call_assistant/concurrent_function_call_invocation_example.py index 47db96cf..85f5e44e 100644 --- a/tests_integration/function_call_assistant/concurrent_function_call_invocation_example.py +++ b/tests_integration/function_call_assistant/concurrent_function_call_invocation_example.py @@ -16,11 +16,12 @@ from dotenv import load_dotenv -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, @@ -28,7 +29,8 @@ load_dotenv() -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") # Distinct, non-overlapping postcodes so each answer is traceable to its request. @@ -98,4 +100,5 @@ async def test_concurrent_function_call_invokes_are_isolated() -> None: print("Concurrent function-call invocation isolation: OK") -asyncio.run(test_concurrent_function_call_invokes_are_isolated()) +with bind_services(runtime.services): + asyncio.run(test_concurrent_function_call_invokes_are_isolated()) diff --git a/tests_integration/function_call_assistant/multi_functions_call_assistant_async_example.py b/tests_integration/function_call_assistant/multi_functions_call_assistant_async_example.py index f4a1dad5..5cc97976 100644 --- a/tests_integration/function_call_assistant/multi_functions_call_assistant_async_example.py +++ b/tests_integration/function_call_assistant/multi_functions_call_assistant_async_example.py @@ -2,11 +2,12 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.multi_functions_call_assistant import ( MultiFunctionsCallAssistant, @@ -14,7 +15,8 @@ api_key = os.getenv("OPENAI_API_KEY", "") -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store class WeatherMock(FunctionCallTool): @@ -169,4 +171,5 @@ async def test_multi_functions_call_assistant_async() -> None: assert len(await event_store.get_events()) == 102 -asyncio.run(test_multi_functions_call_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_multi_functions_call_assistant_async()) diff --git a/tests_integration/function_call_assistant/multi_functions_call_assistant_example.py b/tests_integration/function_call_assistant/multi_functions_call_assistant_example.py index 8b77e326..f183b806 100644 --- a/tests_integration/function_call_assistant/multi_functions_call_assistant_example.py +++ b/tests_integration/function_call_assistant/multi_functions_call_assistant_example.py @@ -2,12 +2,13 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.multi_functions_call_assistant import ( MultiFunctionsCallAssistant, @@ -15,7 +16,8 @@ api_key = os.getenv("OPENAI_API_KEY", "") -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store class WeatherMock(FunctionCallTool): @@ -185,4 +187,5 @@ async def test_multi_functions_call_assistant() -> None: # assistant.generate_workflow_graph() -asyncio.run(test_multi_functions_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_multi_functions_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_claude_function_call_assistant_async_example.py b/tests_integration/function_call_assistant/simple_claude_function_call_assistant_async_example.py index 739d121d..85a72370 100644 --- a/tests_integration/function_call_assistant/simple_claude_function_call_assistant_async_example.py +++ b/tests_integration/function_call_assistant/simple_claude_function_call_assistant_async_example.py @@ -2,17 +2,19 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_claude_function_call_assistant import ( SimpleClaudeFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("ANTHROPIC_API_KEY", "") @@ -68,4 +70,5 @@ async def test_simple_function_call_assistant_async() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_async()) diff --git a/tests_integration/function_call_assistant/simple_claude_function_call_assistant_example.py b/tests_integration/function_call_assistant/simple_claude_function_call_assistant_example.py index 9d03b098..403530fb 100644 --- a/tests_integration/function_call_assistant/simple_claude_function_call_assistant_example.py +++ b/tests_integration/function_call_assistant/simple_claude_function_call_assistant_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_claude_function_call_assistant import ( SimpleClaudeFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("ANTHROPIC_API_KEY", "") @@ -71,4 +73,5 @@ async def test_simple_function_call_assistant() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant_example.py b/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant_example.py index 92bb976f..75931715 100644 --- a/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant_example.py +++ b/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant_example.py @@ -2,17 +2,19 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_deepseek_function_call_assistant import ( SimpleDeepseekFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("DEEPSEEK_API_KEY", "") @@ -86,4 +88,5 @@ async def test_simple_function_call_assistant() -> None: assert output == [] -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_agent_async_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_agent_async_example.py index 8e81bc57..076109c8 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_agent_async_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_agent_async_example.py @@ -3,10 +3,11 @@ import uuid from typing import Any -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.agent_calling_tool import AgentCallingTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, @@ -14,7 +15,8 @@ api_key = os.getenv("OPENAI_API_KEY", "") -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store def get_invoke_context() -> InvokeContext: @@ -69,4 +71,5 @@ async def test_simple_function_call_assistant_async() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_async()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_agent_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_agent_example.py index eba8d076..9117daf3 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_agent_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_agent_example.py @@ -3,11 +3,12 @@ import uuid from typing import Any -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.agent_calling_tool import AgentCallingTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, @@ -15,7 +16,8 @@ api_key = os.getenv("OPENAI_API_KEY", "") -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store def get_invoke_context() -> InvokeContext: @@ -72,4 +74,5 @@ async def test_simple_function_call_assistant() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_async_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_async_example.py index da2c4546..6c6b394d 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_async_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_async_example.py @@ -2,11 +2,12 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, @@ -14,7 +15,8 @@ api_key = os.getenv("OPENAI_API_KEY", "") -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store class WeatherMock(FunctionCallTool): @@ -73,4 +75,5 @@ async def test_simple_function_call_assistant_async() -> None: # assistant.generate_manifest() -asyncio.run(test_simple_function_call_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_async()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_complex_function_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_complex_function_example.py index 1427d962..c00f0da3 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_complex_function_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_complex_function_example.py @@ -3,18 +3,20 @@ import uuid from typing import Optional -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -95,4 +97,5 @@ async def test_simple_function_call_assistant() -> None: # register the LocalFileMock tool class when deserializing the manifest) without # running the agent. if __name__ == "__main__": - asyncio.run(test_simple_function_call_assistant()) + with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_deserialize_assistant_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_deserialize_assistant_example.py index a319785e..52a512ef 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_deserialize_assistant_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_deserialize_assistant_example.py @@ -4,11 +4,12 @@ from pathlib import Path from grafi.assistants.assistant import Assistant -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.tool_factory import ToolFactory from tests_integration.function_call_assistant.simple_function_call_assistant_multi_functions_example import ( # noqa: E501 LocalInfoMock, @@ -28,7 +29,8 @@ def get_invoke_context() -> InvokeContext: ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store async def test_deserialized_assistant() -> None: @@ -94,4 +96,5 @@ async def test_deserialized_assistant() -> None: if __name__ == "__main__": - asyncio.run(test_deserialized_assistant()) + with bind_services(runtime.services): + asyncio.run(test_deserialized_assistant()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_duckduckgo_example_local.py b/tests_integration/function_call_assistant/simple_function_call_assistant_duckduckgo_example_local.py index 2acb2eb5..e6e1d27f 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_duckduckgo_example_local.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_duckduckgo_example_local.py @@ -2,17 +2,19 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.duckduckgo_tool import DuckDuckGoTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -60,4 +62,5 @@ async def test_simple_function_call_assistant_with_duckduckgo() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant_with_duckduckgo()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_with_duckduckgo()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_example.py index a103b144..f3fa5811 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -98,4 +100,5 @@ async def test_simple_function_call_assistant() -> None: # break -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_google_search_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_google_search_example.py index 317c8dc7..008ab9bd 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_google_search_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_google_search_example.py @@ -2,17 +2,19 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.google_search_tool import GoogleSearchTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -65,4 +67,5 @@ async def test_simple_function_call_assistant_with_tavily() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant_with_tavily()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_with_tavily()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_multi_functions_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_multi_functions_example.py index ce21a6e0..25d0f84d 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_multi_functions_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_multi_functions_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -111,4 +113,5 @@ async def test_simple_function_call_assistant() -> None: # register the LocalInfoMock tool class when deserializing the manifest) without # running the agent. if __name__ == "__main__": - asyncio.run(test_simple_function_call_assistant()) + with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_no_func_call_async_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_no_func_call_async_example.py index dcca3f89..1e6f79db 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_no_func_call_async_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_no_func_call_async_example.py @@ -2,17 +2,19 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -67,4 +69,5 @@ async def test_simple_function_call_assistant() -> None: assert len(await event_store.get_events()) == 12 -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_no_func_call_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_no_func_call_example.py index 1af2920f..bc8b022f 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_no_func_call_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_no_func_call_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -70,4 +72,5 @@ async def test_simple_function_call_assistant() -> None: assert len(await event_store.get_events()) == 12 -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_synthetic_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_synthetic_example.py index 229ec918..a9ee73af 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_synthetic_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_synthetic_example.py @@ -7,11 +7,12 @@ from pydantic import BaseModel from pydantic import Field -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.synthetic_tool import SyntheticTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, @@ -69,7 +70,8 @@ class WeatherOutput(BaseModel): today=datetime.now().strftime("%Y-%m-%d") ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -128,4 +130,5 @@ async def test_simple_function_call_assistant_with_synthetic_weather_tool() -> N assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant_with_synthetic_weather_tool()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_with_synthetic_weather_tool()) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant_tavily_example.py b/tests_integration/function_call_assistant/simple_function_call_assistant_tavily_example.py index 7bc8ff02..fd0aeba9 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant_tavily_example.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant_tavily_example.py @@ -2,17 +2,19 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") tavily_api_key = os.getenv("TAVILY_API_KEY", "") @@ -68,4 +70,5 @@ async def test_simple_function_call_assistant_with_tavily() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant_with_tavily()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_with_tavily()) diff --git a/tests_integration/function_call_assistant/simple_gemini_function_call_assistant_example.py b/tests_integration/function_call_assistant/simple_gemini_function_call_assistant_example.py index 52728956..312220f9 100644 --- a/tests_integration/function_call_assistant/simple_gemini_function_call_assistant_example.py +++ b/tests_integration/function_call_assistant/simple_gemini_function_call_assistant_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_gemini_function_call_assistant import ( SimpleGeminiFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("GEMINI_API_KEY", "") @@ -71,4 +73,5 @@ async def test_simple_function_call_assistant() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_ollama_function_call_assistant_async_example.py b/tests_integration/function_call_assistant/simple_ollama_function_call_assistant_async_example.py index b6e3abae..3de74d35 100644 --- a/tests_integration/function_call_assistant/simple_ollama_function_call_assistant_async_example.py +++ b/tests_integration/function_call_assistant/simple_ollama_function_call_assistant_async_example.py @@ -2,17 +2,19 @@ import json import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_ollama_function_call_assistant import ( SimpleOllamaFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store class WeatherMock(FunctionCallTool): @@ -73,4 +75,5 @@ async def test_simple_function_call_assistant_async() -> None: # Run the test function asynchronously -asyncio.run(test_simple_function_call_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_async()) diff --git a/tests_integration/function_call_assistant/simple_ollama_function_call_assistant_example.py b/tests_integration/function_call_assistant/simple_ollama_function_call_assistant_example.py index aff6249e..d33c32f1 100644 --- a/tests_integration/function_call_assistant/simple_ollama_function_call_assistant_example.py +++ b/tests_integration/function_call_assistant/simple_ollama_function_call_assistant_example.py @@ -2,18 +2,20 @@ import json import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_ollama_function_call_assistant import ( SimpleOllamaFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store class WeatherMock(FunctionCallTool): @@ -76,4 +78,5 @@ async def test_simple_function_call_assistant() -> None: # Run the test function asynchronously -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant_example.py b/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant_example.py index 770e1e7b..48faedd6 100644 --- a/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant_example.py +++ b/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.function_call_assistant.simple_openrouter_function_call_assistant import ( SimpleOpenRouterFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENROUTER_API_KEY", "") @@ -87,4 +89,5 @@ async def test_simple_function_call_assistant() -> None: assert output == [] -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/hith_assistant/human_tool_approval_assistant_example.py b/tests_integration/hith_assistant/human_tool_approval_assistant_example.py index c3618c14..50a0284c 100644 --- a/tests_integration/hith_assistant/human_tool_approval_assistant_example.py +++ b/tests_integration/hith_assistant/human_tool_approval_assistant_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.hith_assistant.human_tool_approval_assistant import ( HumanApprovalAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -304,7 +306,9 @@ async def test_function_call_assistant_suggestion_mem() -> None: assert parsed_args["db_name"] == "staging.product_backup" -asyncio.run(test_function_call_assistant()) -asyncio.run(test_function_call_assistant_disapproval()) +with bind_services(runtime.services): + asyncio.run(test_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_function_call_assistant_disapproval()) # test_function_call_assistant_suggestion() # test_function_call_assistant_suggestion_mem() diff --git a/tests_integration/hith_assistant/kyc_assistant_example.py b/tests_integration/hith_assistant/kyc_assistant_example.py index 00777da5..8c63b042 100644 --- a/tests_integration/hith_assistant/kyc_assistant_example.py +++ b/tests_integration/hith_assistant/kyc_assistant_example.py @@ -8,6 +8,8 @@ from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.hith_assistant.kyc_assistant import KycAssistant @@ -138,4 +140,5 @@ async def test_kyc_assistant() -> None: print(output) -asyncio.run(test_kyc_assistant()) +with bind_services(ExecutionServices()): + asyncio.run(test_kyc_assistant()) diff --git a/tests_integration/hith_assistant/simple_hitl_assistant_async_example.py b/tests_integration/hith_assistant/simple_hitl_assistant_async_example.py index 1ab74b9b..47971f91 100644 --- a/tests_integration/hith_assistant/simple_hitl_assistant_async_example.py +++ b/tests_integration/hith_assistant/simple_hitl_assistant_async_example.py @@ -6,7 +6,6 @@ from loguru import logger -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, @@ -14,10 +13,13 @@ from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.hith_assistant.simple_hitl_assistant import SimpleHITLAssistant -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -138,4 +140,5 @@ async def test_simple_hitl_assistant() -> None: assert len(events) == 54 -asyncio.run(test_simple_hitl_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_hitl_assistant()) diff --git a/tests_integration/hith_assistant/simple_hitl_assistant_example.py b/tests_integration/hith_assistant/simple_hitl_assistant_example.py index 403325fe..28a81a42 100644 --- a/tests_integration/hith_assistant/simple_hitl_assistant_example.py +++ b/tests_integration/hith_assistant/simple_hitl_assistant_example.py @@ -3,16 +3,18 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.hith_assistant.simple_hitl_assistant import SimpleHITLAssistant -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -128,4 +130,5 @@ async def test_simple_hitl_assistant() -> None: assert len(events) == 54 -asyncio.run(test_simple_hitl_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_hitl_assistant()) diff --git a/tests_integration/input_output_topics/mimo_llm_assistant_example.py b/tests_integration/input_output_topics/mimo_llm_assistant_example.py index 0fa5cc7c..fb77ade6 100644 --- a/tests_integration/input_output_topics/mimo_llm_assistant_example.py +++ b/tests_integration/input_output_topics/mimo_llm_assistant_example.py @@ -4,17 +4,16 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent -from grafi.common.instrumentations.tracing import TracingOptions -from grafi.common.instrumentations.tracing import setup_tracing from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.input_output_topics.mimo_llm_assistant import MIMOLLMAssistant -container.register_tracer(setup_tracing(tracing_options=TracingOptions.IN_MEMORY)) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -111,4 +110,5 @@ async def test_mimo_llm_assistant() -> None: assert len(await event_store.get_events()) == 65 -asyncio.run(test_mimo_llm_assistant()) +with bind_services(runtime.services): + asyncio.run(test_mimo_llm_assistant()) diff --git a/tests_integration/invoke_kwargs/simple_llm_prompt_template_assistant_example.py b/tests_integration/invoke_kwargs/simple_llm_prompt_template_assistant_example.py index 3f85ae23..2332f03b 100644 --- a/tests_integration/invoke_kwargs/simple_llm_prompt_template_assistant_example.py +++ b/tests_integration/invoke_kwargs/simple_llm_prompt_template_assistant_example.py @@ -4,19 +4,18 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent -from grafi.common.instrumentations.tracing import TracingOptions -from grafi.common.instrumentations.tracing import setup_tracing from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.invoke_kwargs.simple_llm_prompt_template_assistant import ( SimpleLLMPromptTemplateAssistant, ) -container.register_tracer(setup_tracing(tracing_options=TracingOptions.IN_MEMORY)) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -95,4 +94,5 @@ async def test_simple_llm_assistant() -> None: assert len(await event_store.get_events()) == 12 -asyncio.run(test_simple_llm_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_llm_assistant()) diff --git a/tests_integration/mcp_assistant/mcp_deserialize_assistant_example_local.py b/tests_integration/mcp_assistant/mcp_deserialize_assistant_example_local.py index c48b6434..8363dc76 100644 --- a/tests_integration/mcp_assistant/mcp_deserialize_assistant_example_local.py +++ b/tests_integration/mcp_assistant/mcp_deserialize_assistant_example_local.py @@ -4,10 +4,11 @@ from pathlib import Path from grafi.assistants.assistant import Assistant -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.mcp_tool import MCPTool from grafi.tools.tool_factory import ToolFactory @@ -20,7 +21,8 @@ def get_invoke_context() -> InvokeContext: ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store async def test_deserialized_assistant() -> None: @@ -54,4 +56,5 @@ async def test_deserialized_assistant() -> None: if __name__ == "__main__": - asyncio.run(test_deserialized_assistant()) + with bind_services(runtime.services): + asyncio.run(test_deserialized_assistant()) diff --git a/tests_integration/mcp_assistant/mcp_function_tool_local.py b/tests_integration/mcp_assistant/mcp_function_tool_local.py index 5bdd1a2b..970e60d7 100644 --- a/tests_integration/mcp_assistant/mcp_function_tool_local.py +++ b/tests_integration/mcp_assistant/mcp_function_tool_local.py @@ -22,6 +22,8 @@ from grafi.common.models.mcp_connections import StreamableHttpConnection from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services from grafi.tools.functions.impl.mcp_function_tool import MCPFunctionTool from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic @@ -271,4 +273,5 @@ async def run_all_tests() -> None: if __name__ == "__main__": - asyncio.run(run_all_tests()) + with bind_services(ExecutionServices()): + asyncio.run(run_all_tests()) diff --git a/tests_integration/mcp_assistant/simple_function_call_assistant_fastmcp_example_local.py b/tests_integration/mcp_assistant/simple_function_call_assistant_fastmcp_example_local.py index 2644965a..7d3fd0d3 100644 --- a/tests_integration/mcp_assistant/simple_function_call_assistant_fastmcp_example_local.py +++ b/tests_integration/mcp_assistant/simple_function_call_assistant_fastmcp_example_local.py @@ -2,11 +2,12 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.mcp_connections import StreamableHttpConnection from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.mcp_tool import MCPTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, @@ -14,7 +15,8 @@ # Known issue: running on windows may cause asyncio error, due to the way subprocesses are handled. This is a known issue with the mcp library. -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -63,4 +65,5 @@ async def test_simple_function_call_assistant_with_mcp() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant_with_mcp()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_with_mcp()) diff --git a/tests_integration/mcp_assistant/simple_function_call_assistant_mcp_example_local.py b/tests_integration/mcp_assistant/simple_function_call_assistant_mcp_example_local.py index 7aaa8b34..6d0402a5 100644 --- a/tests_integration/mcp_assistant/simple_function_call_assistant_mcp_example_local.py +++ b/tests_integration/mcp_assistant/simple_function_call_assistant_mcp_example_local.py @@ -2,11 +2,12 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.mcp_connections import StdioConnection from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.mcp_tool import MCPTool from tests_integration.function_call_assistant.simple_function_call_assistant import ( SimpleFunctionCallAssistant, @@ -14,7 +15,8 @@ # Known issue: running on windows may cause asyncio error, due to the way subprocesses are handled. This is a known issue with the mcp library. -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -66,4 +68,5 @@ async def test_simple_function_call_assistant_with_mcp() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_function_call_assistant_with_mcp()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_with_mcp()) diff --git a/tests_integration/multimodal_assistant/simple_llm_assistant_image_rec_example.py b/tests_integration/multimodal_assistant/simple_llm_assistant_image_rec_example.py index 85435646..d833e41f 100644 --- a/tests_integration/multimodal_assistant/simple_llm_assistant_image_rec_example.py +++ b/tests_integration/multimodal_assistant/simple_llm_assistant_image_rec_example.py @@ -6,16 +6,18 @@ import uuid from pathlib import Path -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.multimodal_assistant.simple_llm_assistant import ( SimpleLLMAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -77,4 +79,5 @@ async def test_simple_image_llm_assistant() -> None: assert len(await event_store.get_events()) == 12 -asyncio.run(test_simple_image_llm_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_image_llm_assistant()) diff --git a/tests_integration/rag_assistant/simple_rag_assistant_async_example.py b/tests_integration/rag_assistant/simple_rag_assistant_async_example.py index 79600917..66f8f910 100644 --- a/tests_integration/rag_assistant/simple_rag_assistant_async_example.py +++ b/tests_integration/rag_assistant/simple_rag_assistant_async_example.py @@ -4,15 +4,17 @@ import uuid from pathlib import Path -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.rag_assistant.simple_rag_assistant import SimpleRagAssistant api_key = os.getenv("OPENAI_API_KEY", "") -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store try: from llama_index.core import SimpleDirectoryReader @@ -81,4 +83,5 @@ async def test_rag_tool_async() -> None: print(f"Deleted {PERSIST_DIR} and all its contents") -asyncio.run(test_rag_tool_async()) +with bind_services(runtime.services): + asyncio.run(test_rag_tool_async()) diff --git a/tests_integration/rag_assistant/simple_rag_assistant_example.py b/tests_integration/rag_assistant/simple_rag_assistant_example.py index 2206c13d..8eee89e4 100644 --- a/tests_integration/rag_assistant/simple_rag_assistant_example.py +++ b/tests_integration/rag_assistant/simple_rag_assistant_example.py @@ -4,16 +4,18 @@ import uuid from pathlib import Path -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.rag_assistant.simple_rag_assistant import SimpleRagAssistant api_key = os.getenv("OPENAI_API_KEY", "") -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store try: from llama_index.core import SimpleDirectoryReader @@ -82,4 +84,5 @@ async def test_rag_tool() -> None: print(f"Deleted {PERSIST_DIR} and all its contents") -asyncio.run(test_rag_tool()) +with bind_services(runtime.services): + asyncio.run(test_rag_tool()) diff --git a/tests_integration/react_assistant/react_assistant_async_example.py b/tests_integration/react_assistant/react_assistant_async_example.py index 52009314..7e93b45d 100644 --- a/tests_integration/react_assistant/react_assistant_async_example.py +++ b/tests_integration/react_assistant/react_assistant_async_example.py @@ -2,14 +2,16 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from tests_integration.react_assistant.react_assistant import ReActAssistant -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store tavily_api_key = os.getenv("TAVILY_API_KEY", "") api_key = os.getenv("OPENAI_API_KEY", "") @@ -91,4 +93,5 @@ async def test_react_assistant_async() -> None: # assistant.generate_manifest() -asyncio.run(test_react_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_react_assistant_async()) diff --git a/tests_integration/react_assistant/react_assistant_async_stop_workflow_example.py b/tests_integration/react_assistant/react_assistant_async_stop_workflow_example.py index 33274f07..7ed7969a 100644 --- a/tests_integration/react_assistant/react_assistant_async_stop_workflow_example.py +++ b/tests_integration/react_assistant/react_assistant_async_stop_workflow_example.py @@ -2,14 +2,16 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from tests_integration.react_assistant.react_assistant import ReActAssistant -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store tavily_api_key = os.getenv("TAVILY_API_KEY", "") api_key = os.getenv("OPENAI_API_KEY", "") @@ -99,4 +101,5 @@ async def run_concurrent_test() -> None: await asyncio.gather(test_react_assistant_async(), stop_workflow()) -asyncio.run(run_concurrent_test()) +with bind_services(runtime.services): + asyncio.run(run_concurrent_test()) diff --git a/tests_integration/react_assistant/react_assistant_deserialize_assistant_example.py b/tests_integration/react_assistant/react_assistant_deserialize_assistant_example.py index ecbe17e7..0af35c8b 100644 --- a/tests_integration/react_assistant/react_assistant_deserialize_assistant_example.py +++ b/tests_integration/react_assistant/react_assistant_deserialize_assistant_example.py @@ -7,6 +7,8 @@ from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime.execution_services import ExecutionServices +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from grafi.tools.tool_factory import ToolFactory @@ -55,4 +57,5 @@ async def test_deserialized_assistant() -> None: if __name__ == "__main__": - asyncio.run(test_deserialized_assistant()) + with bind_services(ExecutionServices()): + asyncio.run(test_deserialized_assistant()) diff --git a/tests_integration/react_assistant/react_assistant_example.py b/tests_integration/react_assistant/react_assistant_example.py index e3666745..1d1eeb21 100644 --- a/tests_integration/react_assistant/react_assistant_example.py +++ b/tests_integration/react_assistant/react_assistant_example.py @@ -2,15 +2,17 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from tests_integration.react_assistant.react_assistant import ReActAssistant -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") tavily_api_key = os.getenv("TAVILY_API_KEY", "") @@ -100,4 +102,5 @@ async def test_react_assistant() -> None: # print(f"Saved events to events.json") -asyncio.run(test_react_assistant()) +with bind_services(runtime.services): + asyncio.run(test_react_assistant()) diff --git a/tests_integration/react_assistant/react_assistant_recovery_example.py b/tests_integration/react_assistant/react_assistant_recovery_example.py index 0e5d1691..28bd06e8 100644 --- a/tests_integration/react_assistant/react_assistant_recovery_example.py +++ b/tests_integration/react_assistant/react_assistant_recovery_example.py @@ -4,15 +4,17 @@ import uuid from pathlib import Path -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from tests_integration.react_assistant.react_assistant import ReActAssistant -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") tavily_api_key = os.getenv("TAVILY_API_KEY", "") @@ -109,4 +111,5 @@ async def test_react_assistant() -> None: print("Assistant output:", output) -asyncio.run(test_react_assistant()) +with bind_services(runtime.services): + asyncio.run(test_react_assistant()) diff --git a/tests_integration/react_assistant/react_assistant_stop_workflow_example.py b/tests_integration/react_assistant/react_assistant_stop_workflow_example.py index e6dbc0a8..ca131216 100644 --- a/tests_integration/react_assistant/react_assistant_stop_workflow_example.py +++ b/tests_integration/react_assistant/react_assistant_stop_workflow_example.py @@ -4,15 +4,17 @@ import time import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from tests_integration.react_assistant.react_assistant import ReActAssistant -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") tavily_api_key = os.getenv("TAVILY_API_KEY", "") @@ -108,4 +110,5 @@ def stop_workflow() -> None: print("Workflow stopped.") -asyncio.run(test_react_assistant()) +with bind_services(runtime.services): + asyncio.run(test_react_assistant()) diff --git a/tests_integration/simple_llm_assistant/claude_tool_example.py b/tests_integration/simple_llm_assistant/claude_tool_example.py index 493b8daa..f206aee8 100644 --- a/tests_integration/simple_llm_assistant/claude_tool_example.py +++ b/tests_integration/simple_llm_assistant/claude_tool_example.py @@ -2,13 +2,14 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.llms.impl.claude_tool import ClaudeTool from grafi.tools.tool_factory import ToolFactory from grafi.topics.topic_types import TopicType @@ -16,7 +17,8 @@ # --------------------------------------------------------------------------- # # Shared helpers / fixtures # # --------------------------------------------------------------------------- # -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv( "ANTHROPIC_API_KEY", "", @@ -220,9 +222,15 @@ async def test_claude_tool_with_chat_param_serialization() -> None: # Run directly # # --------------------------------------------------------------------------- # -asyncio.run(test_claude_tool_with_chat_param()) -asyncio.run(test_claude_tool_stream()) -asyncio.run(test_claude_tool_async()) -asyncio.run(test_llm_stream_node_claude()) -asyncio.run(test_claude_tool_serialization()) -asyncio.run(test_claude_tool_with_chat_param_serialization()) +with bind_services(runtime.services): + asyncio.run(test_claude_tool_with_chat_param()) +with bind_services(runtime.services): + asyncio.run(test_claude_tool_stream()) +with bind_services(runtime.services): + asyncio.run(test_claude_tool_async()) +with bind_services(runtime.services): + asyncio.run(test_llm_stream_node_claude()) +with bind_services(runtime.services): + asyncio.run(test_claude_tool_serialization()) +with bind_services(runtime.services): + asyncio.run(test_claude_tool_with_chat_param_serialization()) diff --git a/tests_integration/simple_llm_assistant/concurrent_invocation_example.py b/tests_integration/simple_llm_assistant/concurrent_invocation_example.py index 946694f0..cb1ead7e 100644 --- a/tests_integration/simple_llm_assistant/concurrent_invocation_example.py +++ b/tests_integration/simple_llm_assistant/concurrent_invocation_example.py @@ -16,20 +16,19 @@ from dotenv import load_dotenv -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent -from grafi.common.instrumentations.tracing import TracingOptions -from grafi.common.instrumentations.tracing import setup_tracing from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.simple_llm_assistant.simple_llm_assistant import ( SimpleLLMAssistant, ) load_dotenv() -container.register_tracer(setup_tracing(tracing_options=TracingOptions.IN_MEMORY)) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -98,4 +97,5 @@ async def test_concurrent_invokes_are_isolated() -> None: print("Concurrent invocation isolation: OK") -asyncio.run(test_concurrent_invokes_are_isolated()) +with bind_services(runtime.services): + asyncio.run(test_concurrent_invokes_are_isolated()) diff --git a/tests_integration/simple_llm_assistant/concurrent_sequential_invocation_example.py b/tests_integration/simple_llm_assistant/concurrent_sequential_invocation_example.py index f10162cc..bce0b2a4 100644 --- a/tests_integration/simple_llm_assistant/concurrent_sequential_invocation_example.py +++ b/tests_integration/simple_llm_assistant/concurrent_sequential_invocation_example.py @@ -15,17 +15,19 @@ from dotenv import load_dotenv -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.simple_llm_assistant.simple_llm_assistant import ( SimpleLLMAssistant, ) load_dotenv() -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") WORDS = ["ALPHA", "BETA", "GAMMA"] @@ -82,4 +84,5 @@ async def test_concurrent_sequential_invokes_are_isolated() -> None: print("Concurrent sequential invocation isolation: OK") -asyncio.run(test_concurrent_sequential_invokes_are_isolated()) +with bind_services(runtime.services): + asyncio.run(test_concurrent_sequential_invokes_are_isolated()) diff --git a/tests_integration/simple_llm_assistant/deepseek_tool_example.py b/tests_integration/simple_llm_assistant/deepseek_tool_example.py index 8daf3427..f2c1171c 100644 --- a/tests_integration/simple_llm_assistant/deepseek_tool_example.py +++ b/tests_integration/simple_llm_assistant/deepseek_tool_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.llms.impl.deepseek_tool import DeepseekTool from grafi.tools.tool_factory import ToolFactory from grafi.topics.topic_types import TopicType -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store # DeepSeek key comes from the same environment style you used for OpenAI api_key = os.getenv("DEEPSEEK_API_KEY", "") @@ -203,9 +205,15 @@ async def test_deepseek_tool_with_chat_param_serialization() -> None: assert len(await event_store.get_events()) == 2 -asyncio.run(test_deepseek_tool_with_chat_param()) -asyncio.run(test_deepseek_tool_stream()) -asyncio.run(test_deepseek_tool_async()) -asyncio.run(test_llm_stream_node_deepseek()) -asyncio.run(test_deepseek_tool_serialization()) -asyncio.run(test_deepseek_tool_with_chat_param_serialization()) +with bind_services(runtime.services): + asyncio.run(test_deepseek_tool_with_chat_param()) +with bind_services(runtime.services): + asyncio.run(test_deepseek_tool_stream()) +with bind_services(runtime.services): + asyncio.run(test_deepseek_tool_async()) +with bind_services(runtime.services): + asyncio.run(test_llm_stream_node_deepseek()) +with bind_services(runtime.services): + asyncio.run(test_deepseek_tool_serialization()) +with bind_services(runtime.services): + asyncio.run(test_deepseek_tool_with_chat_param_serialization()) diff --git a/tests_integration/simple_llm_assistant/gemini_tool_example.py b/tests_integration/simple_llm_assistant/gemini_tool_example.py index c0f32c75..ce0a2aec 100644 --- a/tests_integration/simple_llm_assistant/gemini_tool_example.py +++ b/tests_integration/simple_llm_assistant/gemini_tool_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.llms.impl.gemini_tool import GeminiTool from grafi.tools.tool_factory import ToolFactory from grafi.topics.topic_types import TopicType -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("GEMINI_API_KEY", "") # set your Google AI Studio key here @@ -207,9 +209,15 @@ async def test_gemini_tool_with_chat_param_serialization() -> None: assert len(await event_store.get_events()) == 2 -asyncio.run(test_gemini_tool_with_chat_param()) -asyncio.run(test_gemini_tool_stream()) -asyncio.run(test_gemini_tool_async()) -asyncio.run(test_llm_stream_node_gemini()) -asyncio.run(test_gemini_tool_serialization()) -asyncio.run(test_gemini_tool_with_chat_param_serialization()) +with bind_services(runtime.services): + asyncio.run(test_gemini_tool_with_chat_param()) +with bind_services(runtime.services): + asyncio.run(test_gemini_tool_stream()) +with bind_services(runtime.services): + asyncio.run(test_gemini_tool_async()) +with bind_services(runtime.services): + asyncio.run(test_llm_stream_node_gemini()) +with bind_services(runtime.services): + asyncio.run(test_gemini_tool_serialization()) +with bind_services(runtime.services): + asyncio.run(test_gemini_tool_with_chat_param_serialization()) diff --git a/tests_integration/simple_llm_assistant/openai_tool_example.py b/tests_integration/simple_llm_assistant/openai_tool_example.py index efec9af9..50e31f6c 100644 --- a/tests_integration/simple_llm_assistant/openai_tool_example.py +++ b/tests_integration/simple_llm_assistant/openai_tool_example.py @@ -4,13 +4,14 @@ from pydantic import BaseModel -from grafi.common.containers.container import container from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.llms.impl.openai_tool import OpenAITool from grafi.tools.tool_factory import ToolFactory from grafi.topics.topic_types import TopicType @@ -27,7 +28,8 @@ class UserForm(BaseModel): gender: str -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -262,11 +264,19 @@ async def test_openai_tool_structured_output_serialization() -> None: assert len(await event_store.get_events()) == 2 -asyncio.run(test_openai_tool_with_chat_param()) -asyncio.run(test_openai_tool_with_structured_output()) -asyncio.run(test_openai_tool_stream()) -asyncio.run(test_openai_tool_async()) -asyncio.run(test_llm_stream_node()) -asyncio.run(test_openai_tool_serialization()) -asyncio.run(test_openai_tool_with_chat_param_serialization()) -asyncio.run(test_openai_tool_structured_output_serialization()) +with bind_services(runtime.services): + asyncio.run(test_openai_tool_with_chat_param()) +with bind_services(runtime.services): + asyncio.run(test_openai_tool_with_structured_output()) +with bind_services(runtime.services): + asyncio.run(test_openai_tool_stream()) +with bind_services(runtime.services): + asyncio.run(test_openai_tool_async()) +with bind_services(runtime.services): + asyncio.run(test_llm_stream_node()) +with bind_services(runtime.services): + asyncio.run(test_openai_tool_serialization()) +with bind_services(runtime.services): + asyncio.run(test_openai_tool_with_chat_param_serialization()) +with bind_services(runtime.services): + asyncio.run(test_openai_tool_structured_output_serialization()) diff --git a/tests_integration/simple_llm_assistant/openrouter_tool_example.py b/tests_integration/simple_llm_assistant/openrouter_tool_example.py index 2db9d79e..b20ed8be 100644 --- a/tests_integration/simple_llm_assistant/openrouter_tool_example.py +++ b/tests_integration/simple_llm_assistant/openrouter_tool_example.py @@ -2,18 +2,20 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.llms.impl.openrouter_tool import OpenRouterTool from grafi.tools.tool_factory import ToolFactory from grafi.topics.topic_types import TopicType -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENROUTER_API_KEY", "") # set your OpenRouter key @@ -197,9 +199,15 @@ async def test_openrouter_tool_with_chat_param_serialization() -> None: assert len(await event_store.get_events()) == 2 -asyncio.run(test_openrouter_tool_with_chat_param()) -asyncio.run(test_openrouter_tool_stream()) -asyncio.run(test_openrouter_tool_async()) -asyncio.run(test_llm_stream_node_openrouter()) -asyncio.run(test_openrouter_tool_serialization()) -asyncio.run(test_openrouter_tool_with_chat_param_serialization()) +with bind_services(runtime.services): + asyncio.run(test_openrouter_tool_with_chat_param()) +with bind_services(runtime.services): + asyncio.run(test_openrouter_tool_stream()) +with bind_services(runtime.services): + asyncio.run(test_openrouter_tool_async()) +with bind_services(runtime.services): + asyncio.run(test_llm_stream_node_openrouter()) +with bind_services(runtime.services): + asyncio.run(test_openrouter_tool_serialization()) +with bind_services(runtime.services): + asyncio.run(test_openrouter_tool_with_chat_param_serialization()) diff --git a/tests_integration/simple_llm_assistant/simple_llm_assistant_async_example.py b/tests_integration/simple_llm_assistant/simple_llm_assistant_async_example.py index 04a9675d..d2bc076c 100644 --- a/tests_integration/simple_llm_assistant/simple_llm_assistant_async_example.py +++ b/tests_integration/simple_llm_assistant/simple_llm_assistant_async_example.py @@ -4,15 +4,17 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.simple_llm_assistant.simple_llm_assistant import ( SimpleLLMAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -84,4 +86,5 @@ async def test_simple_llm_assistant_async() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_llm_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_simple_llm_assistant_async()) diff --git a/tests_integration/simple_llm_assistant/simple_llm_assistant_example.py b/tests_integration/simple_llm_assistant/simple_llm_assistant_example.py index bc11e10f..e041d9bc 100644 --- a/tests_integration/simple_llm_assistant/simple_llm_assistant_example.py +++ b/tests_integration/simple_llm_assistant/simple_llm_assistant_example.py @@ -4,19 +4,18 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent -from grafi.common.instrumentations.tracing import TracingOptions -from grafi.common.instrumentations.tracing import setup_tracing from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.simple_llm_assistant.simple_llm_assistant import ( SimpleLLMAssistant, ) -container.register_tracer(setup_tracing(tracing_options=TracingOptions.IN_MEMORY)) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -77,4 +76,5 @@ async def test_simple_llm_assistant() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_llm_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_llm_assistant()) diff --git a/tests_integration/simple_llm_assistant/simple_multi_llm_assistant_example_local.py b/tests_integration/simple_llm_assistant/simple_multi_llm_assistant_example_local.py index 691d4da7..bee35d2e 100644 --- a/tests_integration/simple_llm_assistant/simple_multi_llm_assistant_example_local.py +++ b/tests_integration/simple_llm_assistant/simple_multi_llm_assistant_example_local.py @@ -5,16 +5,18 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.common.models.message import Messages +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.simple_llm_assistant.simple_multi_llm_assistant import ( SimpleMultiLLMAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store def get_invoke_context() -> InvokeContext: @@ -102,4 +104,5 @@ async def test_simple_multi_llm_assistant_async() -> None: assert len(await event_store.get_events()) == 57 -asyncio.run(test_simple_multi_llm_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_simple_multi_llm_assistant_async()) diff --git a/tests_integration/simple_llm_assistant/simple_ollama_assistant_async_example.py b/tests_integration/simple_llm_assistant/simple_ollama_assistant_async_example.py index ecfb404c..b90fa4e9 100644 --- a/tests_integration/simple_llm_assistant/simple_ollama_assistant_async_example.py +++ b/tests_integration/simple_llm_assistant/simple_ollama_assistant_async_example.py @@ -3,15 +3,17 @@ import asyncio import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.simple_llm_assistant.simple_ollama_assistant import ( SimpleOllamaAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store def get_invoke_context() -> InvokeContext: @@ -75,4 +77,5 @@ async def test_simple_llm_assistant_async() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_llm_assistant_async()) +with bind_services(runtime.services): + asyncio.run(test_simple_llm_assistant_async()) diff --git a/tests_integration/simple_llm_assistant/simple_ollama_assistant_example.py b/tests_integration/simple_llm_assistant/simple_ollama_assistant_example.py index 02742359..325e8183 100644 --- a/tests_integration/simple_llm_assistant/simple_ollama_assistant_example.py +++ b/tests_integration/simple_llm_assistant/simple_ollama_assistant_example.py @@ -3,16 +3,18 @@ import asyncio import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.simple_llm_assistant.simple_ollama_assistant import ( SimpleOllamaAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store def get_invoke_context() -> InvokeContext: @@ -82,4 +84,5 @@ async def test_simple_llm_assistant() -> None: assert len(await event_store.get_events()) == 24 -asyncio.run(test_simple_llm_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_llm_assistant()) diff --git a/tests_integration/simple_stream_assistant/simple_stream_assistant_example.py b/tests_integration/simple_stream_assistant/simple_stream_assistant_example.py index 470293b6..e0a77a6d 100644 --- a/tests_integration/simple_stream_assistant/simple_stream_assistant_example.py +++ b/tests_integration/simple_stream_assistant/simple_stream_assistant_example.py @@ -4,15 +4,17 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from tests_integration.simple_stream_assistant.simple_stream_assistant import ( SimpleStreamAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -75,4 +77,5 @@ async def test_simple_llm_assistant() -> None: # print(f"Saved {len(events)} events to events.json") -asyncio.run(test_simple_llm_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_llm_assistant()) diff --git a/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant_example.py b/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant_example.py index a763041f..d08d2ec5 100644 --- a/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant_example.py +++ b/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant_example.py @@ -2,17 +2,19 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.simple_stream_assistant.simple_stream_function_call_assistant import ( SimpleStreamFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -73,4 +75,5 @@ async def test_simple_function_call_assistant() -> None: assert content is not None -asyncio.run(test_simple_function_call_assistant()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant()) diff --git a/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant_no_function_call_example.py b/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant_no_function_call_example.py index b47b5757..f0069caa 100644 --- a/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant_no_function_call_example.py +++ b/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant_no_function_call_example.py @@ -2,17 +2,19 @@ import os import uuid -from grafi.common.containers.container import container from grafi.common.decorators.llm_function import llm_function from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime +from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.function_call_tool import FunctionCallTool from tests_integration.simple_stream_assistant.simple_stream_function_call_assistant import ( SimpleStreamFunctionCallAssistant, ) -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store api_key = os.getenv("OPENAI_API_KEY", "") @@ -72,4 +74,5 @@ async def test_simple_function_call_assistant_no_function_call() -> None: assert content is not None -asyncio.run(test_simple_function_call_assistant_no_function_call()) +with bind_services(runtime.services): + asyncio.run(test_simple_function_call_assistant_no_function_call()) From d40c19c330518d7f118f74561642fc95d77b2e8c Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Mon, 22 Jun 2026 21:01:51 +0100 Subject: [PATCH 2/2] Agent runtime integration, error-path cleanup, and docs Follow-up to the runtime-DI change: make the agent convenience API a first-class runtime consumer, tidy the error path, and bring the docs in line. - ReActAgent.run()/a_run() now run through GrafiRuntime (optional `runtime=` param, defaulting to an in-process GrafiRuntime()), so callers no longer wrap them in bind_services(...). a_run() simply relays runtime.invoke()'s output. - _build_error_details walks the exception cause chain once (one _error_correlation pass returning error_id + root cause) instead of twice. - record_base: fix a stale comment describing the pre-simplification logging. - Docs: replace user-guide/containers.md with user-guide/runtime.md (request- scoped DI: ExecutionServices/GrafiRuntime/current_services/bind_services, with a migration table) and update mkdocs nav. Migrate the container/registration snippets in the event-store, OpenTelemetry, and MCP-server guides to GrafiRuntime/ExecutionServices; drop the removed `.event_store(...)` builder call from the builder-pattern and assistant guides; run direct workflow/tool/assistant examples inside a bound runtime scope. - gitignore root-level *_manifest.json (generate_manifest test artifact). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + docs/docs/guide/configuring-event-store.md | 23 +- ...figuring-integration-with-opentelemetry.md | 49 +- .../docs/guide/connecting-to-an-mcp-server.md | 7 +- docs/docs/guide/creating-a-simple-workflow.md | 18 +- .../guide/getting-started-with-assistants.md | 16 +- docs/docs/user-guide/assistant.md | 6 +- docs/docs/user-guide/builder-pattern.md | 4 +- docs/docs/user-guide/containers.md | 590 ------------------ docs/docs/user-guide/runtime.md | 170 +++++ docs/docs/user-guide/tools/ollama.md | 18 +- docs/docs/user-guide/tools/openai.md | 18 +- docs/mkdocs.yml | 2 +- grafi/agents/react_agent.py | 22 +- grafi/common/decorators/record_base.py | 54 +- grafi/runtime/runtime.py | 7 +- tests/runtime/test_runtime.py | 14 + .../agents/react_agent_async_example.py | 7 +- .../agents/react_agent_example.py | 7 +- ...act_agent_mcp_tools_async_example_local.py | 5 +- 20 files changed, 338 insertions(+), 702 deletions(-) delete mode 100644 docs/docs/user-guide/containers.md create mode 100644 docs/docs/user-guide/runtime.md diff --git a/.gitignore b/.gitignore index 7eff2635..31a403b2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ MANIFEST *.manifest *.spec +# Root-level assistant manifests generated by tests (generate_manifest default dir) +/*_manifest.json + # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/docs/docs/guide/configuring-event-store.md b/docs/docs/guide/configuring-event-store.md index b9e4f026..c5fb82b6 100644 --- a/docs/docs/guide/configuring-event-store.md +++ b/docs/docs/guide/configuring-event-store.md @@ -110,19 +110,19 @@ Nothing fancy here, just initialization of context and setting up of variables. ### Event Store Initialization -We create a PostgreSQL event store instance with the connection URL matching your Docker container configuration, we then register the event store with Graphite's dependency injection container to obtain a reference to the registered event store for later use. +We create a PostgreSQL event store instance with the connection URL matching your Docker container configuration, then place it in an `ExecutionServices` bundle and build a `GrafiRuntime` from it. Invocations run through that runtime; we keep a reference to the store for reading events later. ```python from grafi.common.event_stores.event_store_postgres import EventStorePostgres -from grafi.common.containers.container import container +from grafi.runtime import GrafiRuntime, ExecutionServices postgres_event_store = EventStorePostgres( # must match what is in docker-compose.yaml db_url="postgresql://postgres:postgres@localhost:5432/grafi_test_db", ) -container.register_event_store(postgres_event_store) -event_store = container.event_store +runtime = GrafiRuntime(ExecutionServices(event_store=postgres_event_store)) +event_store = runtime.services.event_store ``` ## Running the agent and Retrieving Events @@ -137,12 +137,13 @@ async def run_agent(): react_agent = create_react_agent() - result = await react_agent.run(user_input, invoke_context) + # Run through the runtime so the agent uses the Postgres store. + result = await react_agent.run(user_input, invoke_context, runtime=runtime) print("Output from React Agent:", result) - events = event_store.get_conversation_events(conversation_id) + events = await event_store.get_conversation_events(conversation_id) print(f"Events for conversation {conversation_id}:") @@ -170,19 +171,19 @@ import os import uuid from grafi.agents.react_agent import create_react_agent -from grafi.common.containers.container import container from grafi.common.event_stores.event_store_postgres import EventStorePostgres from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime, ExecutionServices postgres_event_store = EventStorePostgres( db_url="postgresql://postgres:postgres@localhost:5432/grafi_test_db", ) -container.register_event_store(postgres_event_store) +runtime = GrafiRuntime(ExecutionServices(event_store=postgres_event_store)) -event_store = container.event_store +event_store = runtime.services.event_store # Generate consistent IDs for the conversation conversation_id = uuid.uuid4().hex @@ -211,12 +212,12 @@ async def run_agent(): react_agent = create_react_agent() - result = await react_agent.run(user_input, invoke_context) + result = await react_agent.run(user_input, invoke_context, runtime=runtime) print("Output from React Agent:", result) - events = event_store.get_conversation_events(conversation_id) + events = await event_store.get_conversation_events(conversation_id) print(f"Events for conversation {conversation_id}:") diff --git a/docs/docs/guide/configuring-integration-with-opentelemetry.md b/docs/docs/guide/configuring-integration-with-opentelemetry.md index 5e086aed..d8f628b8 100644 --- a/docs/docs/guide/configuring-integration-with-opentelemetry.md +++ b/docs/docs/guide/configuring-integration-with-opentelemetry.md @@ -178,20 +178,20 @@ tracer = setup_tracing(tracing_options=TracingOptions.IN_MEMORY) ### Example 1: Basic Setup with AUTO Detection ```python -from grafi.common.containers.container import container +from grafi.runtime import GrafiRuntime, ExecutionServices from grafi.common.instrumentations.tracing import TracingOptions, setup_tracing -# Register the tracer with auto-detection +# Build a runtime that uses the auto-detected tracer tracer = setup_tracing(tracing_options=TracingOptions.AUTO) -container.register_tracer(tracer) +runtime = GrafiRuntime(ExecutionServices(tracer=tracer)) -# Your assistant code here +# Invoke assistants through `runtime` ``` ### Example 2: Export to an OTLP Collector ```python -from grafi.common.containers.container import container +from grafi.runtime import GrafiRuntime, ExecutionServices from grafi.common.instrumentations.tracing import TracingOptions, setup_tracing tracer = setup_tracing( @@ -200,22 +200,22 @@ tracer = setup_tracing( collector_port=4317, project_name="my-assistant", ) -container.register_tracer(tracer) +runtime = GrafiRuntime(ExecutionServices(tracer=tracer)) -# Your assistant code here +# Invoke assistants through `runtime` ``` ### Example 3: Testing with In-Memory Tracing ```python -from grafi.common.containers.container import container +from grafi.runtime import GrafiRuntime, ExecutionServices from grafi.common.instrumentations.tracing import TracingOptions, setup_tracing # Use in-memory tracing for tests tracer = setup_tracing(tracing_options=TracingOptions.IN_MEMORY) -container.register_tracer(tracer) +runtime = GrafiRuntime(ExecutionServices(tracer=tracer)) -# Your test code here +# Invoke assistants through `runtime` in your test ``` ### Example 4: Complete Assistant with Tracing @@ -224,7 +224,7 @@ container.register_tracer(tracer) import os import uuid import asyncio -from grafi.common.containers.container import container +from grafi.runtime import GrafiRuntime, ExecutionServices from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.instrumentations.tracing import TracingOptions, setup_tracing from grafi.common.models.async_result import async_func_wrapper @@ -232,12 +232,12 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.assistants.assistant_base import AssistantBase -# Setup tracing +# Setup tracing and build a runtime that uses it tracer = setup_tracing(tracing_options=TracingOptions.AUTO) -container.register_tracer(tracer) +runtime = GrafiRuntime(ExecutionServices(tracer=tracer)) -# Get event store -event_store = container.event_store +# Reference the event store (the default in-memory store here) +event_store = runtime.services.event_store # Create your assistant async def main(): @@ -264,7 +264,7 @@ async def main(): ) output = await async_func_wrapper( - assistant.invoke(input_data, is_sequential=True) + runtime.invoke(assistant, input_data, is_sequential=True) ) print(output) @@ -305,11 +305,11 @@ tracer = setup_tracing( Set up tracing early in your application lifecycle, before creating assistants: ```python -# Good: Setup tracing first +# Good: Setup tracing first, build the runtime from it tracer = setup_tracing(tracing_options=TracingOptions.AUTO) -container.register_tracer(tracer) +runtime = GrafiRuntime(ExecutionServices(tracer=tracer)) -# Then create assistants +# Then create assistants and invoke them through `runtime` assistant = MyAssistant.builder().build() ``` @@ -341,13 +341,14 @@ Use IN_MEMORY mode in tests to avoid external dependencies: ```python import pytest from grafi.common.instrumentations.tracing import TracingOptions, setup_tracing +from grafi.runtime import bind_services, ExecutionServices @pytest.fixture(autouse=True) def setup_test_tracing(): tracer = setup_tracing(tracing_options=TracingOptions.IN_MEMORY) - container.register_tracer(tracer) - yield - # Cleanup if needed + # Bind a scope for the test so component invocations resolve these services. + with bind_services(ExecutionServices(tracer=tracer)): + yield ``` ## Troubleshooting @@ -401,9 +402,9 @@ def setup_test_tracing(): **Solution**: 1. Ensure OpenAI is instrumented (done automatically by `setup_tracing`) -2. Verify the tracer is registered before creating assistants: +2. Build the runtime with your tracer and invoke through it: ```python - container.register_tracer(tracer) # Must be before assistant creation + runtime = GrafiRuntime(ExecutionServices(tracer=tracer)) ``` ### Issue: Traces showing in wrong project diff --git a/docs/docs/guide/connecting-to-an-mcp-server.md b/docs/docs/guide/connecting-to-an-mcp-server.md index 2dbbbfec..6c967014 100644 --- a/docs/docs/guide/connecting-to-an-mcp-server.md +++ b/docs/docs/guide/connecting-to-an-mcp-server.md @@ -346,16 +346,17 @@ import os import uuid from typing import Dict -from grafi.common.containers.container import container from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.mcp_connections import StreamableHttpConnection from grafi.common.models.message import Message +from grafi.runtime import GrafiRuntime, ExecutionServices from grafi.tools.function_calls.impl.mcp_tool import MCPTool from assistant import StockAssistant -event_store = container.event_store +runtime = GrafiRuntime() +event_store = runtime.services.event_store async def create_assistant(): api_key = os.getenv("OPENAI_API_KEY", "") @@ -397,7 +398,7 @@ async def main(): invoke_context=execution_context, data=input_messages ) - async for response in assistant.invoke(publish_event): + async for response in runtime.invoke(assistant, publish_event): print("Assistant output:") for output in response: print(output.content) diff --git a/docs/docs/guide/creating-a-simple-workflow.md b/docs/docs/guide/creating-a-simple-workflow.md index fe5331b0..c6d94909 100644 --- a/docs/docs/guide/creating-a-simple-workflow.md +++ b/docs/docs/guide/creating-a-simple-workflow.md @@ -131,12 +131,18 @@ workflow = ( With the `EventDrivenWorkflow` object created, we can invoke it by passing our `invoke_context` and a `List[Message]`. The workflow will execute and return the results, which we can then print. Save this complete code as `main.py`. ```python linenums="54" -async for result in workflow.invoke( - invoke_context, - [message] -): - for output_message in result: - print("Output message:", output_message.content) +# Bind a runtime scope so the workflow's components can resolve their services +# (event store / tracer / error reporter). ExecutionServices() uses in-process +# defaults; pass a durable store/tracer in production. +from grafi.runtime import bind_services, ExecutionServices + +with bind_services(ExecutionServices()): + async for result in workflow.invoke( + invoke_context, + [message] + ): + for output_message in result: + print("Output message:", output_message.content) ``` ### 6. Entry Point diff --git a/docs/docs/guide/getting-started-with-assistants.md b/docs/docs/guide/getting-started-with-assistants.md index 1efab49f..06c2f7c4 100644 --- a/docs/docs/guide/getting-started-with-assistants.md +++ b/docs/docs/guide/getting-started-with-assistants.md @@ -230,13 +230,21 @@ This function is not part of the framework, but rather a helper function used to class FinanceAssistant(Assistant): ... - async def run(self, question: str, invoke_context: Optional[InvokeContext] = None) -> str: + async def run( + self, + question: str, + invoke_context: Optional[InvokeContext] = None, + runtime: Optional[GrafiRuntime] = None, # from grafi.runtime import GrafiRuntime + ) -> str: """Run the assistant with a question and return the response.""" # Call helper function get_input() - input_event= self.get_input(question, invoke_context) - # This is the line that invokes the workflow + input_event = self.get_input(question, invoke_context) + # Run through a runtime, which binds the services (event store / tracer / + # error reporter) for the invocation. Pass a shared `runtime` to reuse a + # store across calls; otherwise a default in-process runtime is used. + runtime = runtime or GrafiRuntime() response_str = "" - async for output in super().invoke(input_event): + async for output in runtime.invoke(self, input_event): # Handle different content types if output and len(output) > 0: content = output.data[0].content diff --git a/docs/docs/user-guide/assistant.md b/docs/docs/user-guide/assistant.md index 0363382c..19c2345c 100644 --- a/docs/docs/user-guide/assistant.md +++ b/docs/docs/user-guide/assistant.md @@ -129,7 +129,6 @@ The `AssistantBaseBuilder` provides a fluent interface for constructing assistan ```python from grafi.assistants.assistant_base import AssistantBaseBuilder -from grafi.common.event_stores.in_memory_event_store import InMemoryEventStore from openinference.semconv.trace import OpenInferenceSpanKindValues builder = AssistantBaseBuilder(MyAssistant) @@ -137,6 +136,9 @@ assistant = (builder .name("Customer Support Assistant") .type("support") .oi_span_type(OpenInferenceSpanKindValues.AGENT) - .event_store(InMemoryEventStore()) .build()) + +# The event store / tracer are supplied at runtime, not on the assistant: +# runtime = GrafiRuntime(ExecutionServices(event_store=...)) +# async for event in runtime.invoke(assistant, input_data): ... ``` diff --git a/docs/docs/user-guide/builder-pattern.md b/docs/docs/user-guide/builder-pattern.md index 17d5c6d3..2d47263d 100644 --- a/docs/docs/user-guide/builder-pattern.md +++ b/docs/docs/user-guide/builder-pattern.md @@ -286,12 +286,12 @@ def build(self) -> MyComponent: When working with Graphite's existing components, use their provided builders: ```python -# Assistant construction +# Assistant construction (the event store is supplied at runtime via +# GrafiRuntime/ExecutionServices, not on the assistant builder). assistant = (MyAssistant.builder() .name("Customer Support") .type("support") .oi_span_type(OpenInferenceSpanKindValues.AGENT) - .event_store(InMemoryEventStore()) .build()) # Workflow construction diff --git a/docs/docs/user-guide/containers.md b/docs/docs/user-guide/containers.md deleted file mode 100644 index 6ad68a1a..00000000 --- a/docs/docs/user-guide/containers.md +++ /dev/null @@ -1,590 +0,0 @@ -# Containers - -The Graphite container system provides a thread-safe singleton-based dependency injection container for managing shared resources throughout the application. It handles the registration and lifecycle of core components like event stores and tracers. - -## Overview - -The container system provides: - -- **Singleton Pattern**: Thread-safe singleton implementation for global access -- **Dependency Injection**: Centralized registration and retrieval of dependencies -- **Event Store Management**: Registration and default setup of event storage -- **Tracing Integration**: Registration and configuration of OpenTelemetry tracing -- **Lazy Initialization**: On-demand creation of default implementations -- **Production Safety**: Warnings for development-only components - -## Core Components - -### SingletonMeta - -A thread-safe meta-class that implements the singleton pattern. - -#### Features - -- **Thread Safety**: Uses threading locks to prevent race conditions -- **Instance Management**: Maintains a dictionary of singleton instances per class -- **Memory Efficiency**: Ensures only one instance exists per class type - -```python -class SingletonMeta(type): - _instances: dict[type, object] = {} - _lock: threading.Lock = threading.Lock() - - def __call__(cls: "SingletonMeta", *args: Any, **kwargs: Any) -> Any: - # Ensure thread-safe singleton creation - with cls._lock: - if cls not in cls._instances: - cls._instances[cls] = super().__call__(*args, **kwargs) - return cls._instances[cls] -``` - -### Container - -The main dependency injection container using singleton pattern. - -#### Properties - -| Property | Type | Description | -|----------|------|-------------| -| `event_store` | `EventStore` | Returns registered event store or creates default | -| `tracer` | `Tracer` | Returns registered tracer or creates default | - -#### Methods - -| Method | Signature | Description | -|--------|-----------|-------------| -| `register_event_store` | `(event_store: EventStore) -> None` | Register custom event store implementation | -| `register_tracer` | `(tracer: Tracer) -> None` | Register custom tracer implementation | - -#### Global Instance - -```python -container: Container = Container() -``` - -A pre-instantiated global container instance available throughout the application. - -## Usage Examples - -### Basic Container Usage - -```python -from grafi.common.containers.container import container - -# Access the global container instance -event_store = container.event_store -tracer = container.tracer - -print(f"Event store type: {type(event_store)}") -print(f"Tracer type: {type(tracer)}") -``` - -### Custom Event Store Registration - -```python -from grafi.common.containers.container import container -from grafi.common.event_stores.event_store_postgres import EventStorePostgres - -# Create custom event store -postgres_store = EventStorePostgres( - connection_string="postgresql://user:pass@localhost:5432/events" -) - -# Register with container -container.register_event_store(postgres_store) - -# Now all access will use the custom store -event_store = container.event_store -assert isinstance(event_store, EventStorePostgres) -``` - -### Custom Tracer Registration - -```python -from grafi.common.containers.container import container -from opentelemetry import trace -from opentelemetry.exporter.jaeger.thrift import JaegerExporter -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -# Create custom tracer with Jaeger export -tracer_provider = TracerProvider() -jaeger_exporter = JaegerExporter( - agent_host_name="localhost", - agent_port=6831, -) -span_processor = BatchSpanProcessor(jaeger_exporter) -tracer_provider.add_span_processor(span_processor) - -# Set as global tracer provider -trace.set_tracer_provider(tracer_provider) -custom_tracer = trace.get_tracer(__name__) - -# Register with container -container.register_tracer(custom_tracer) - -# Now all access will use the custom tracer -tracer = container.tracer -``` - -## Event Store Integration - -### Default Behavior - -```python -# First access creates default in-memory store -event_store = container.event_store -# Logs warning: "Using EventStoreInMemory. This is ONLY suitable for local testing..." -``` - -### Production Setup - -```python -def setup_production_container(): - """Setup container for production environment.""" - from grafi.common.event_stores.event_store_postgres import EventStorePostgres - import os - - # Get database connection from environment - db_url = os.getenv("DATABASE_URL") - if not db_url: - raise ValueError("DATABASE_URL environment variable required") - - # Create and register production event store - prod_store = EventStorePostgres(connection_string=db_url) - container.register_event_store(prod_store) - - print("Production event store registered") - -# Call during application startup -setup_production_container() -``` - -### Event Store Validation - -```python -def validate_event_store(): - """Validate that production event store is configured.""" - from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory - - event_store = container.event_store - - if isinstance(event_store, EventStoreInMemory): - raise RuntimeError( - "Production environment detected with in-memory event store. " - "Please configure a persistent event store." - ) - - print(f"Using production event store: {type(event_store).__name__}") -``` - -## Tracing Integration - -### Default Tracing Setup - -```python -# First access creates default tracer with auto-configuration -tracer = container.tracer -# Uses setup_tracing with default parameters: -# - tracing_options=TracingOptions.AUTO -# - collector_endpoint="localhost" -# - collector_port=4317 -# - project_name="grafi-trace" -``` - -### Custom Tracing Configuration - -```python -def setup_custom_tracing(): - """Setup custom tracing configuration.""" - from grafi.common.instrumentations.tracing import setup_tracing, TracingOptions - import os - - # Get tracing configuration from environment - collector_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost") - collector_port = int(os.getenv("OTEL_EXPORTER_OTLP_PORT", "4317")) - service_name = os.getenv("SERVICE_NAME", "grafi-service") - - # Setup custom tracer - custom_tracer = setup_tracing( - tracing_options=TracingOptions.ENABLED, - collector_endpoint=collector_endpoint, - collector_port=collector_port, - project_name=service_name - ) - - # Register with container - container.register_tracer(custom_tracer) - - print(f"Custom tracing configured for {service_name}") - -setup_custom_tracing() -``` - -### Distributed Tracing - -```python -def create_span_example(): - """Example of using container tracer for distributed tracing.""" - tracer = container.tracer - - with tracer.start_as_current_span("process_user_request") as span: - span.set_attribute("user.id", "12345") - span.set_attribute("request.type", "get_profile") - - # Simulate processing - process_request() - - span.set_attribute("response.status", "success") - -def process_request(): - """Nested span example.""" - tracer = container.tracer - - with tracer.start_as_current_span("database_query") as span: - span.set_attribute("db.operation", "SELECT") - span.set_attribute("db.table", "users") - - # Database operation simulation - result = query_database() - - span.set_attribute("db.rows_affected", len(result)) -``` - -## Application Lifecycle Integration - -### Startup Configuration - -```python -class Application: - def __init__(self): - self.container = container - - def configure_dependencies(self): - """Configure all application dependencies.""" - self._setup_event_store() - self._setup_tracing() - self._validate_configuration() - - def _setup_event_store(self): - """Setup event store based on environment.""" - import os - - if os.getenv("ENVIRONMENT") == "production": - from grafi.common.event_stores.event_store_postgres import EventStorePostgres - - db_url = os.getenv("DATABASE_URL") - if not db_url: - raise ValueError("DATABASE_URL required in production") - - event_store = EventStorePostgres(connection_string=db_url) - self.container.register_event_store(event_store) - else: - # Development - use default in-memory store - pass - - def _setup_tracing(self): - """Setup tracing based on environment.""" - import os - from grafi.common.instrumentations.tracing import setup_tracing, TracingOptions - - if os.getenv("TRACING_ENABLED", "false").lower() == "true": - tracer = setup_tracing( - tracing_options=TracingOptions.ENABLED, - collector_endpoint=os.getenv("OTEL_ENDPOINT", "localhost"), - collector_port=int(os.getenv("OTEL_PORT", "4317")), - project_name=os.getenv("SERVICE_NAME", "grafi-app") - ) - self.container.register_tracer(tracer) - - def _validate_configuration(self): - """Validate container configuration.""" - # Access properties to trigger initialization - event_store = self.container.event_store - tracer = self.container.tracer - - print(f"Event store: {type(event_store).__name__}") - print(f"Tracer: {type(tracer).__name__}") - - def start(self): - """Start the application.""" - self.configure_dependencies() - print("Application started with configured dependencies") - -# Usage -app = Application() -app.start() -``` - -### Graceful Shutdown - -```python -class ApplicationManager: - def __init__(self): - self.container = container - - async def shutdown(self): - """Gracefully shutdown application resources.""" - print("Shutting down application...") - - # Close event store connections - event_store = self.container.event_store - if hasattr(event_store, 'close'): - await event_store.close() - - # Flush tracer spans - tracer = self.container.tracer - if hasattr(tracer, 'force_flush'): - tracer.force_flush() - - print("Application shutdown complete") -``` - -## Testing with Containers - -### Test Container Setup - -```python -import pytest -from grafi.common.containers.container import Container -from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory - -@pytest.fixture -def test_container(): - """Create a test container with in-memory components.""" - test_container = Container() - - # Use in-memory event store for tests - test_store = EventStoreInMemory() - test_container.register_event_store(test_store) - - # Use no-op tracer for tests - from opentelemetry.trace import NoOpTracer - test_tracer = NoOpTracer() - test_container.register_tracer(test_tracer) - - yield test_container - - # Cleanup if needed - if hasattr(test_store, 'clear'): - test_store.clear() - -def test_event_store_integration(test_container): - """Test event store integration.""" - event_store = test_container.event_store - - # Verify it's the test store - assert isinstance(event_store, EventStoreInMemory) - - # Test basic operations - from grafi.common.events.event import Event - test_event = Event(event_id="test-123") - - event_store.record_event(test_event) - retrieved = await event_store.get_event("test-123") - - assert retrieved.event_id == "test-123" -``` - -### Mock Container - -```python -from unittest.mock import Mock, patch - -def test_with_mocked_container(): - """Test using mocked container dependencies.""" - # Create mock event store - mock_event_store = Mock() - mock_event_store.record_event.return_value = None - mock_event_store.get_events.return_value = [] - - # Create mock tracer - mock_tracer = Mock() - mock_span = Mock() - mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span - - # Patch container properties - with patch('grafi.common.containers.container.container.event_store', mock_event_store), \ - patch('grafi.common.containers.container.container.tracer', mock_tracer): - - # Test code using container - from grafi.common.containers.container import container - - event_store = container.event_store - tracer = container.tracer - - # Verify mocks are used - assert event_store is mock_event_store - assert tracer is mock_tracer -``` - -## Thread Safety - -### Concurrent Access - -```python -import threading -import time -from grafi.common.containers.container import container - -def worker_function(worker_id: int, results: dict): - """Worker function to test thread safety.""" - # Access container from multiple threads - event_store = container.event_store - tracer = container.tracer - - # Store results for verification - results[worker_id] = { - 'event_store_id': id(event_store), - 'tracer_id': id(tracer) - } - -def test_thread_safety(): - """Test that container is thread-safe.""" - results = {} - threads = [] - - # Create multiple threads - for i in range(10): - thread = threading.Thread( - target=worker_function, - args=(i, results) - ) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # Verify all threads got the same instances - event_store_ids = {r['event_store_id'] for r in results.values()} - tracer_ids = {r['tracer_id'] for r in results.values()} - - assert len(event_store_ids) == 1, "Event store should be singleton" - assert len(tracer_ids) == 1, "Tracer should be singleton" - - print("Thread safety test passed") - -# Run the test -test_thread_safety() -``` - -## Best Practices - -### Container Configuration - -1. **Early Registration**: Register dependencies during application startup -2. **Environment-Based Setup**: Use environment variables for configuration -3. **Validation**: Validate container configuration before starting main logic -4. **Production Safety**: Never use in-memory stores in production - -### Dependency Management - -1. **Single Responsibility**: Keep container focused on dependency injection -2. **Lazy Loading**: Let container handle lazy initialization of defaults -3. **Type Safety**: Use proper type hints for registered dependencies -4. **Error Handling**: Handle missing dependencies gracefully - -### Testing Strategies - -1. **Test Containers**: Use separate container instances for tests -2. **Mock Dependencies**: Mock container dependencies for unit tests -3. **Integration Tests**: Test with real dependencies in integration tests -4. **Cleanup**: Always clean up test resources - -### Performance Considerations - -1. **Singleton Benefits**: Leverage singleton pattern for shared resources -2. **Thread Safety**: Container is thread-safe by design -3. **Memory Efficiency**: Single instances reduce memory overhead -4. **Initialization Cost**: Lazy initialization spreads startup cost - -## Error Handling - -### Common Issues - -```python -def handle_container_errors(): - """Examples of handling container-related errors.""" - try: - # This might fail if dependencies are not available - event_store = container.event_store - - except Exception as e: - print(f"Failed to get event store: {e}") - # Fallback to in-memory store - from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory - fallback_store = EventStoreInMemory() - container.register_event_store(fallback_store) - -def validate_production_setup(): - """Validate that production dependencies are properly configured.""" - import os - from grafi.common.event_stores.event_store_in_memory import EventStoreInMemory - - if os.getenv("ENVIRONMENT") == "production": - event_store = container.event_store - - if isinstance(event_store, EventStoreInMemory): - raise RuntimeError( - "Production environment using in-memory event store. " - "Configure persistent storage." - ) - - # Additional validation - if not hasattr(event_store, 'connection_pool'): - raise RuntimeError("Event store missing connection pool") -``` - -## Migration Guide - -### From Direct Dependencies to Container - -```python -# Before: Direct dependency instantiation -# event_store = EventStorePostgres(connection_string) -# tracer = setup_tracing(...) - -# After: Using container -from grafi.common.containers.container import container - -# Setup once during application startup -container.register_event_store(event_store) -container.register_tracer(tracer) - -# Use throughout application -event_store = container.event_store -tracer = container.tracer -``` - -### Existing Code Integration - -```python -class ExistingService: - def __init__(self): - # Old way - direct instantiation - # self.event_store = EventStoreInMemory() - - # New way - use container - from grafi.common.containers.container import container - self.event_store = container.event_store - self.tracer = container.tracer - - def process_data(self, data): - # Use tracer from container - with self.tracer.start_as_current_span("process_data") as span: - span.set_attribute("data.size", len(data)) - - # Process data - result = self._transform_data(data) - - # Record event using container's event store - from grafi.common.events.event import Event - event = Event(event_id=f"processed-{result.id}") - self.event_store.record_event(event) - - return result -``` - -The container system provides a robust foundation for dependency injection in Graphite applications, ensuring thread-safe access to shared resources while maintaining flexibility for different deployment environments and testing scenarios. diff --git a/docs/docs/user-guide/runtime.md b/docs/docs/user-guide/runtime.md new file mode 100644 index 00000000..8a768c24 --- /dev/null +++ b/docs/docs/user-guide/runtime.md @@ -0,0 +1,170 @@ +# Runtime & Dependency Injection + +Graphite uses explicit, **request-scoped dependency injection** for the live +services an invocation needs — the event store, the tracer, and the error +reporter. The application constructs these once at startup, hands them to a +`GrafiRuntime`, and invokes assistants through it. There is no process-global +service locator. + +> **Migration note.** Earlier versions used a global `container` singleton +> (`container.register_event_store(...)`, `container.event_store`). That has been +> removed. Construct a `GrafiRuntime` (or `GrafiRuntime()` for in-process +> defaults) and invoke through it. See [Migrating from the container](#migrating-from-the-container). + +## Overview + +| Concept | What it is | +|---------|------------| +| `ExecutionServices` | An immutable bundle of the three runtime dependencies (`event_store`, `tracer`, `error_reporter`). Not serializable. | +| `GrafiRuntime` | The composition root. Owns one `ExecutionServices` and exposes `invoke(...)`. | +| `current_services()` | Resolves the services bound for the current invocation (used inside the framework). | +| `bind_services(...)` | Context manager that binds an `ExecutionServices` to the current scope. | + +The two halves are kept strictly separate: + +- **Request metadata** (`InvokeContext`: conversation/invoke/assistant-request ids) + is serializable and is persisted into every event. +- **Runtime services** (`ExecutionServices`) are live infrastructure and are + **never** serialized into an event, a manifest, or an `InvokeContext`. + +## `ExecutionServices` + +`ExecutionServices` is a frozen dataclass. Every field has an in-process default, +so `ExecutionServices()` is a ready dev/test bundle, and any field can be +overridden with a normal keyword: + +```python +from grafi.runtime import ExecutionServices +from grafi.common.event_stores.event_store_postgres import EventStorePostgres + +# All in-process defaults (in-memory store, no-op tracer, Loguru error reporter). +services = ExecutionServices() + +# Override only what you need; the rest keep their defaults. +services = ExecutionServices(event_store=EventStorePostgres(db_url="postgresql://...")) +``` + +| Field | Default | Notes | +|-------|---------|-------| +| `event_store` | `EventStoreInMemory()` | In-memory is lost on exit — pass a durable store in production. | +| `tracer` | `NoOpTracer()` | Spans are discarded — pass a real tracer for observability. | +| `error_reporter` | `ErrorReporter()` | Logs one concise line per failure via Loguru. | + +It is immutable, exposes no `to_dict`/`model_dump`, and its `repr` hides the +dependencies so a stray log line can't leak a database URL. + +## `GrafiRuntime` + +`GrafiRuntime` owns an `ExecutionServices` and is the way to run an assistant. +`GrafiRuntime()` uses the defaults; production passes its own bundle: + +```python +from grafi.runtime import GrafiRuntime, ExecutionServices +from grafi.common.event_stores.event_store_postgres import EventStorePostgres + +# Local / tests +runtime = GrafiRuntime() + +# Production: durable store + your tracer +runtime = GrafiRuntime( + ExecutionServices( + event_store=EventStorePostgres(db_url="postgresql://user:pass@host/db"), + tracer=my_tracer, + ) +) +``` + +### Running an assistant + +```python +from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent + +input_data = PublishToTopicEvent(invoke_context=invoke_context, data=messages) + +async for event in runtime.invoke(assistant, input_data): + print(event) +``` + +`runtime.invoke(...)` binds its services for the duration of the invocation; +components resolve them through `current_services()`. **No `invoke()` signature +carries a `services` parameter** — tools, nodes, and commands are unchanged. + +Reuse one runtime across calls when you want shared conversation history (a +shared event store): + +```python +runtime = GrafiRuntime(ExecutionServices(event_store=shared_store)) +await consume(runtime.invoke(assistant, first_turn)) +await consume(runtime.invoke(assistant, second_turn)) # sees the first turn's history +``` + +### Running an agent + +`ReActAgent.run()` / `a_run()` run through a runtime too. By default they create +an in-process runtime; pass `runtime=` to share a store/tracer: + +```python +agent = create_react_agent() + +answer = await agent.run("What is Graphite?") # default runtime +answer = await agent.run("...", runtime=production_runtime) # shared/durable +``` + +## Binding a scope directly + +The public entry point is `runtime.invoke(...)`. If you call a component's +`invoke(...)` directly (tests, advanced use), wrap it in `bind_services(...)` +so `current_services()` resolves: + +```python +from grafi.runtime import bind_services, ExecutionServices + +with bind_services(ExecutionServices(event_store=store)): + async for event in assistant.invoke(input_data): + ... +``` + +Outside any bound scope, `current_services()` raises a clear `RuntimeError` +rather than silently constructing a default — so a forgotten runtime fails loudly. + +### How propagation works + +`bind_services` sets a `ContextVar` for the block. Because `asyncio` tasks copy +the current context when created, every node task, output listener, and streaming +producer spawned during the invocation inherits the binding automatically. +Concurrent invocations run as separate tasks, so each gets its own services with +no cross-talk — genuine per-invocation isolation. (This relies on `asyncio` +context propagation; Graphite uses no thread offloads. Code that hands work to a +thread — `run_in_executor`/`to_thread` — must re-bind the context explicitly.) + +## Custom error reporting + +`ErrorReporter` logs one concise, id-bearing line per failure (the full +structured record — cause chain, traceback, component fields — is persisted to +the event store, so the log only points at it). Subclass it to route errors +elsewhere; it must not raise: + +```python +from grafi.runtime import ErrorReporter, ExecutionServices, GrafiRuntime + +class SentryErrorReporter(ErrorReporter): + def report(self, message: str, *, level: str = "error") -> None: + super().report(message, level=level) # keep the log line + sentry_sdk.capture_message(message, level=level) + +runtime = GrafiRuntime(ExecutionServices(error_reporter=SentryErrorReporter())) +``` + +## Migrating from the container + +| Old (`container`) | New | +|-------------------|-----| +| `container.register_event_store(store)` | `GrafiRuntime(ExecutionServices(event_store=store))` | +| `container.register_tracer(tracer)` | `GrafiRuntime(ExecutionServices(tracer=tracer))` | +| `container.event_store` (inside the framework) | `current_services().event_store` | +| `assistant.invoke(input)` | `runtime.invoke(assistant, input)` | +| `AssistantBaseBuilder.event_store(...)` | removed — pass via `ExecutionServices` | + +`SingletonMeta`, `Container`, the global `container`, `register_event_store`, +`register_tracer`, and the assistant builder's `event_store(...)` are all +removed. diff --git a/docs/docs/user-guide/tools/ollama.md b/docs/docs/user-guide/tools/ollama.md index f3c24b27..edf7c2d5 100644 --- a/docs/docs/user-guide/tools/ollama.md +++ b/docs/docs/user-guide/tools/ollama.md @@ -83,9 +83,14 @@ ollama_tool = ( # Create input messages messages = [Message(role="user", content="Hello, how are you?")] +# Invoking a tool directly goes through the lifecycle decorator, which resolves +# the runtime services -- so bind a scope first (ExecutionServices() = defaults). +from grafi.runtime import bind_services, ExecutionServices + # Asynchronous invocation -async for response in ollama_tool.invoke(invoke_context, messages): - print(response[0].content) +with bind_services(ExecutionServices()): + async for response in ollama_tool.invoke(invoke_context, messages): + print(response[0].content) ``` ### Streaming Usage @@ -103,10 +108,11 @@ ollama_tool = ( # Asynchronous streaming async def stream_example(): messages = [Message(role="user", content="Tell me a story")] - async for message_batch in ollama_tool.invoke(invoke_context, messages): - for message in message_batch: - if message.content: - print(message.content, end="", flush=True) + with bind_services(ExecutionServices()): + async for message_batch in ollama_tool.invoke(invoke_context, messages): + for message in message_batch: + if message.content: + print(message.content, end="", flush=True) ``` ### Function Calling diff --git a/docs/docs/user-guide/tools/openai.md b/docs/docs/user-guide/tools/openai.md index 88acdab7..a6215f33 100644 --- a/docs/docs/user-guide/tools/openai.md +++ b/docs/docs/user-guide/tools/openai.md @@ -81,9 +81,14 @@ openai_tool = ( # Create input messages messages = [Message(role="user", content="Hello, how are you?")] +# Invoking a tool directly goes through the lifecycle decorator, which resolves +# the runtime services -- so bind a scope first (ExecutionServices() = defaults). +from grafi.runtime import bind_services, ExecutionServices + # Asynchronous invocation -async for response in openai_tool.invoke(invoke_context, messages): - print(response[0].content) +with bind_services(ExecutionServices()): + async for response in openai_tool.invoke(invoke_context, messages): + print(response[0].content) ``` ### Streaming Usage @@ -101,10 +106,11 @@ openai_tool = ( # Asynchronous streaming async def stream_example(): messages = [Message(role="user", content="Tell me a story")] - async for message_batch in openai_tool.invoke(invoke_context, messages): - for message in message_batch: - if message.content: - print(message.content, end="", flush=True) + with bind_services(ExecutionServices()): + async for message_batch in openai_tool.invoke(invoke_context, messages): + for message in message_batch: + if message.content: + print(message.content, end="", flush=True) ``` ### Function Calling diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 0099b031..bcd98f98 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -72,7 +72,7 @@ nav: - Event Store: user-guide/events/event_store.md - Event Graph: user-guide/events/event_graph.md - Infrastructure: - - Containers: user-guide/containers.md + - Runtime & Dependency Injection: user-guide/runtime.md - Models: user-guide/models.md - Builder Pattern: user-guide/builder-pattern.md - Invoke Decorators: user-guide/invoke-decorators.md diff --git a/grafi/agents/react_agent.py b/grafi/agents/react_agent.py index 9da1bb02..4a1c634d 100644 --- a/grafi/agents/react_agent.py +++ b/grafi/agents/react_agent.py @@ -15,6 +15,7 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.runtime import GrafiRuntime from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from grafi.tools.llms.impl.openai_tool import OpenAITool @@ -150,18 +151,31 @@ def get_input( ) async def run( - self, question: str, invoke_context: Optional[InvokeContext] = None + self, + question: str, + invoke_context: Optional[InvokeContext] = None, + runtime: Optional[GrafiRuntime] = None, ) -> str: + # Run through the runtime, which binds the services scope. Pass a shared + # ``runtime`` to reuse a store/tracer across calls; otherwise a default + # in-process runtime is used for this call. + runtime = runtime or GrafiRuntime() output = await async_func_wrapper( - super().invoke(self.get_input(question, invoke_context)) + runtime.invoke(self, self.get_input(question, invoke_context)) ) return output[0].data[0].content async def a_run( - self, question: str, invoke_context: Optional[InvokeContext] = None + self, + question: str, + invoke_context: Optional[InvokeContext] = None, + runtime: Optional[GrafiRuntime] = None, ) -> AsyncGenerator[Message, None]: - async for output in super().invoke(self.get_input(question, invoke_context)): + runtime = runtime or GrafiRuntime() + async for output in runtime.invoke( + self, self.get_input(question, invoke_context) + ): for message in output.data: yield message diff --git a/grafi/common/decorators/record_base.py b/grafi/common/decorators/record_base.py index caa5ec76..2bf64c1f 100644 --- a/grafi/common/decorators/record_base.py +++ b/grafi/common/decorators/record_base.py @@ -129,31 +129,34 @@ def _include_traceback() -> bool: return env_bool("GRAFI_ERROR_INCLUDE_TRACEBACK", default=True) -def _error_id_for(exc: Exception) -> str: - """Return the correlation id for ``exc``, creating one if the chain has none. +def _error_correlation(exc: Exception) -> tuple[str, BaseException]: + """Walk the cause chain once and return ``(error_id, root_cause)``. - Scans the cause chain so a re-wrapped outer exception inherits the id minted - at the layer closest to the root failure; the id is stashed on the exception - so the next decorator layer reuses it. + The error id is the one already stashed on any link of the chain (so a + re-wrapped outer exception inherits the id minted at the layer closest to the + root failure); if the chain has none, mint one and stash it on ``exc`` for + the next decorator layer. The root cause is the innermost link. """ - for link in iter_cause_chain(exc, max_depth=1000): - existing = getattr(link, _ERROR_ID_ATTR, None) - if existing: - return str(existing) - error_id = uuid.uuid4().hex[:12] - try: - setattr(exc, _ERROR_ID_ATTR, error_id) - except Exception: # pragma: no cover - builtins generally allow attributes - pass - return error_id - - -def _root_cause(exc: Exception) -> BaseException: - """Return the innermost exception in the cause chain (the root failure).""" + error_id: Optional[str] = None root: BaseException = exc for link in iter_cause_chain(exc, max_depth=1000): + if error_id is None: + existing = getattr(link, _ERROR_ID_ATTR, None) + if existing: + error_id = str(existing) root = link - return root + if error_id is None: + error_id = uuid.uuid4().hex[:12] + try: + setattr(exc, _ERROR_ID_ATTR, error_id) + except Exception: # pragma: no cover - builtins generally allow attributes + pass + return error_id, root + + +def _error_id_for(exc: Exception) -> str: + """Return the correlation id for ``exc`` (minting one if the chain has none).""" + return _error_correlation(exc)[0] def _build_error_details( @@ -166,7 +169,9 @@ def _build_error_details( cause, and a stable ``error_id`` shared across the wrapper chain. """ details = error_to_dict(exc, include_traceback=_include_traceback()) - details["error_id"] = _error_id_for(exc) + # One pass over the cause chain yields both the correlation id and the root. + error_id, root = _error_correlation(exc) + details["error_id"] = error_id # Identify which component failed (the "where"). details["component_id"] = metadata.id details["component_name"] = metadata.name @@ -178,7 +183,6 @@ def _build_error_details( invoke_context, "assistant_request_id", None ) # The original failure at the bottom of the chain (the "why, ultimately"). - root = _root_cause(exc) details["root_error_type"] = type(root).__name__ details["root_error_message"] = error_message(root) return details @@ -394,9 +398,9 @@ async def wrapper( if error_details is None: error_details = _build_error_details(e, metadata, invoke_context) - # Emit one authoritative record (full traceback once, at the layer - # closest to the failure; outer layers emit a debug propagation - # record). See _report_component_exception. + # Log one concise line for this failure -- once, at the layer + # closest to it; outer layers add nothing. See + # _report_component_exception. _report_component_exception(e, metadata, error_details) # Record failed event with both the human-readable string (kept diff --git a/grafi/runtime/runtime.py b/grafi/runtime/runtime.py index a13772ba..0c823719 100644 --- a/grafi/runtime/runtime.py +++ b/grafi/runtime/runtime.py @@ -47,8 +47,11 @@ async def invoke( ) -> AsyncGenerator[ConsumeFromTopicEvent, None]: """Bind this runtime's services and stream the assistant's output. - The binding is active for the whole iteration and reset on exit; child - ``asyncio`` tasks spawned during execution inherit it. + The binding is active while the result is iterated and is released when + iteration finishes or the generator is closed. ``asyncio`` tasks spawned + during execution inherit it. Fully draining the stream releases it + promptly; if you break early, the binding is released when the generator + is closed (``aclose``, or when it goes out of scope). """ with bind_services(self._services): async for event in assistant.invoke(input_data, is_sequential): diff --git a/tests/runtime/test_runtime.py b/tests/runtime/test_runtime.py index c6934e07..630c2bbe 100644 --- a/tests/runtime/test_runtime.py +++ b/tests/runtime/test_runtime.py @@ -71,3 +71,17 @@ async def _drain(rt): assert a_direct is rt_a.services and a_task is rt_a.services assert b_direct is rt_b.services and b_task is rt_b.services assert rt_a.services is not rt_b.services + + @pytest.mark.asyncio + async def test_binding_released_after_close(self, bound_services): + # A distinct runtime from the test's autouse-bound services. + rt = GrafiRuntime() + assert rt.services is not bound_services + + agen = rt.invoke(_ProbeAssistant(), None) + direct, _ = await agen.__anext__() + # The runtime's services are bound while the stream is iterated... + assert direct is rt.services + # ...and the prior (outer) binding is restored once it is closed. + await agen.aclose() + assert current_services() is bound_services diff --git a/tests_integration/agents/react_agent_async_example.py b/tests_integration/agents/react_agent_async_example.py index e15f4bf7..c8be2a85 100644 --- a/tests_integration/agents/react_agent_async_example.py +++ b/tests_integration/agents/react_agent_async_example.py @@ -1,8 +1,6 @@ import asyncio from grafi.agents.react_agent import create_react_agent -from grafi.runtime.execution_services import ExecutionServices -from grafi.runtime.execution_services import bind_services react_agent = create_react_agent() @@ -12,7 +10,4 @@ async def run_agent() -> None: print(output) -# ReActAgent.a_run() calls the assistant's invoke under the hood, so it must run -# inside a bound runtime scope. ExecutionServices() supplies in-process defaults. -with bind_services(ExecutionServices()): - asyncio.run(run_agent()) +asyncio.run(run_agent()) diff --git a/tests_integration/agents/react_agent_example.py b/tests_integration/agents/react_agent_example.py index 1d91ab23..f6bd3f87 100644 --- a/tests_integration/agents/react_agent_example.py +++ b/tests_integration/agents/react_agent_example.py @@ -1,8 +1,6 @@ import asyncio from grafi.agents.react_agent import create_react_agent -from grafi.runtime.execution_services import ExecutionServices -from grafi.runtime.execution_services import bind_services async def run_agent() -> None: @@ -13,7 +11,4 @@ async def run_agent() -> None: print(output) -# ReActAgent.run() calls the assistant's invoke under the hood, so it must run -# inside a bound runtime scope. ExecutionServices() supplies in-process defaults. -with bind_services(ExecutionServices()): - asyncio.run(run_agent()) +asyncio.run(run_agent()) diff --git a/tests_integration/agents/react_agent_mcp_tools_async_example_local.py b/tests_integration/agents/react_agent_mcp_tools_async_example_local.py index 5c37233d..7b069411 100644 --- a/tests_integration/agents/react_agent_mcp_tools_async_example_local.py +++ b/tests_integration/agents/react_agent_mcp_tools_async_example_local.py @@ -2,8 +2,6 @@ from grafi.agents.react_agent import create_react_agent from grafi.common.models.mcp_connections import StdioConnection -from grafi.runtime.execution_services import ExecutionServices -from grafi.runtime.execution_services import bind_services from grafi.tools.function_calls.impl.mcp_tool import MCPTool @@ -28,5 +26,4 @@ async def run_agent() -> None: print(output.content) -with bind_services(ExecutionServices()): - asyncio.run(run_agent()) +asyncio.run(run_agent())