railtracks

The Railtracks Framework for building resilient agentic systems in simple python

  1#   -------------------------------------------------------------
  2#   Copyright (c) Railtown AI. All rights reserved.
  3#   Licensed under the MIT License. See LICENSE in project root for information.
  4#   -------------------------------------------------------------
  5"""The Railtracks Framework for building resilient agentic systems in simple python"""
  6
  7from __future__ import annotations
  8
  9import importlib
 10import logging
 11from typing import TYPE_CHECKING
 12
 13from dotenv import load_dotenv
 14
 15if TYPE_CHECKING:
 16    from railtracks import retrieval
 17    from railtracks.interaction import interactive as interactive
 18
 19__all__ = [
 20    "Session",
 21    "session",
 22    "call",
 23    "astream",
 24    "broadcast",
 25    "call_batch",
 26    "ExecutionInfo",
 27    "ExecutorConfig",
 28    "llm",
 29    "guardrails",
 30    "middleware",
 31    "context",
 32    "set_config",
 33    "context",
 34    "function_node",
 35    "agent_node",
 36    "integrations",
 37    "prebuilt",
 38    "MCPStdioParams",
 39    "MCPHttpParams",
 40    "connect_mcp",
 41    "create_mcp_server",
 42    "ToolManifest",
 43    "session_id",
 44    "evaluations",
 45    "observability",
 46    "retrieval",
 47    "Flow",
 48    "FlowConnection",
 49    "NodeMessageHistory",
 50    "enable_logging",
 51    "wrap_node",
 52    "post_node",
 53    "after_node",
 54    "couple",
 55    "pre_llm",
 56    "post_llm",
 57    "before_llm",
 58    "after_llm",
 59    "wrap_llm",
 60    "input_guard",
 61    "output_guard",
 62    "escape_braces",
 63]
 64
 65
 66from railtracks.built_nodes.function import (
 67    function_node,
 68)
 69from railtracks.built_nodes.llm import agent_node
 70
 71from . import (
 72    context,
 73    evaluations,
 74    guardrails,
 75    integrations,
 76    llm,
 77    middleware,
 78    observability,
 79    prebuilt,
 80    retrieval,
 81)
 82from ._session import Session, session
 83from .built_nodes.llm.middleware import (
 84    after_llm,
 85    before_llm,
 86    post_llm,
 87    pre_llm,
 88    wrap_llm,
 89)
 90from .context.central import session_id, set_config
 91from .guardrails import input_guard, output_guard
 92from .interaction import astream, broadcast, call, call_batch, couple
 93from .llm.context_injection_utils import escape_braces
 94from .middleware import after_node, post_node, wrap_node
 95from .nodes.manifest import ToolManifest
 96from .orchestration.connection import FlowConnection, NodeMessageHistory
 97from .orchestration.flow import Flow
 98from .rt_mcp import MCPHttpParams, MCPStdioParams, connect_mcp, create_mcp_server
 99from .state.info import ExecutionInfo
100from .utils.config import ExecutorConfig
101from .utils.deprecation import warn_pending_change
102from .utils.logging.config import enable_logging
103
104load_dotenv()
105
106# Library does not configure logging by default. Add NullHandler so the RT logger
107# never emits "No handlers could be found". Call enable_logging() to opt in.
108logging.getLogger("RT").addHandler(logging.NullHandler())
109
110# Do not worry about changing this version number manually. It will updated on release.
111__version__ = "1.0.0"
112
113
114def __getattr__(name: str):
115    if name == "interactive":
116        # Not cached in globals()
117        warn_pending_change(
118            "rt.interactive",
119            change="is removed",
120            detail="There is no replacement; the local chat UI is going away.",
121        )
122        return importlib.import_module("railtracks.interaction.interactive")
123    if name == "retrieval":
124        try:
125            module = importlib.import_module("railtracks.retrieval")
126        except ImportError as exc:
127            raise ImportError(
128                "railtracks.retrieval requires the retrieval extras. "
129                "Install with: pip install 'railtracks[retrieval]'"
130            ) from exc
131        globals()[name] = module
132        return module
133    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
134
135
136def __dir__() -> list[str]:
137    # "interactive" is not in __all__ but is still reachable
138    return sorted({*__all__, "interactive"})
class Session:
 44class Session:
 45    """
 46    The main class for managing an execution session.
 47
 48    This class is responsible for setting up all the necessary components for running a Railtracks execution, including the coordinator, publisher, and state management.
 49
 50    For the configuration parameters of the setting. It will follow this precedence:
 51    1. The parameters in the `Session` constructor.
 52    2. The parameters in global context variables.
 53    3. The default values.
 54
 55    Default Values:
 56    - `name`: None
 57    - `timeout`: 150.0 seconds
 58    - `end_on_error`: False
 59    - `broadcast_callback`: None (no event listener)
 60    - `save_state`: True (the state of the execution will be saved to a file at the end of the run in the `.railtracks/data/sessions/` directory)
 61
 62
 63    Args:
 64        name (str | None, optional): Optional name for the session. This name will be included in the saved state file if `save_state` is True.
 65        context (Dict[str, Any], optional): A dictionary of global context variables to be used during the execution.
 66        flow_name (str | None, optional): The name of the flow this session is associated with.
 67        flow_id (str | None, optional): The unique identifier of the flow this session is associated with.
 68        timeout (float, optional): The maximum number of seconds to wait for a response to your top-level request.
 69        end_on_error (bool, optional): If True, the execution will stop when an exception is encountered.
 70        broadcast_callback (Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None, optional): A passive listener for one-off events published with `rt.broadcast`.
 71        save_state (bool, optional): If True, the state of the execution will be saved to a file at the end of the run in the `.railtracks/data/sessions/` directory.
 72    """
 73
 74    def __init__(
 75        self,
 76        context: Dict[str, Any] | None = None,
 77        *,
 78        flow_name: str | None = None,
 79        flow_id: str | None = None,
 80        name: str | None = None,
 81        timeout: float | None = None,
 82        end_on_error: bool | None = None,
 83        broadcast_callback: (
 84            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
 85        ) = None,
 86        save_state: bool | None = None,
 87        payload_callback: Callable[[dict[str, Any]], None] | None = None,
 88    ):
 89        # first lets read from defaults if nessecary for the provided input config
 90
 91        if flow_name is None:
 92            warnings.warn(
 93                "Sessions should be tied to a flow for better observability and state management. Please use the Flow object to create and manage your sessions (see __ for more details). This warning will become an error in future versions.",
 94                DeprecationWarning,
 95            )
 96
 97        self.executor_config = self.global_config_precedence(
 98            timeout=timeout,
 99            end_on_error=end_on_error,
100            broadcast_callback=broadcast_callback,
101            save_state=save_state,
102            payload_callback=payload_callback,
103        )
104
105        if context is None:
106            context = {}
107
108        self.name = name
109        self.flow_name = flow_name
110        self.flow_id = flow_id
111
112        self.publisher: RTPublisher = RTPublisher()
113
114        self._identifier = str(uuid.uuid4())
115
116        executor_info = ExecutionInfo.create_new()
117        self.coordinator = Coordinator(
118            execution_modes={
119                "async": AsyncioExecutionStrategy(
120                    scope_manager=ContextVarScopeManager()
121                )
122            }
123        )
124        self.rt_state = RTState(
125            executor_info, self.executor_config, self.coordinator, self.publisher
126        )
127
128        self.coordinator.start(self.publisher)
129        self._setup_subscriber()
130
131        # NOTE: `payload` still reports per-node details.internals
132        add_inline_listener(_node_internals.record)
133
134        # held so the context survives `delete_globals()` on close
135        self.context = register_globals(
136            session_id=self._identifier,
137            rt_publisher=self.publisher,
138            executor_config=self.executor_config,
139            global_context_vars=context,
140            flow_name=self.flow_name,
141            flow_id=self.flow_id,
142            session_name=self.name,
143        )
144
145        # set at exit, so payload() still works once the events have been released
146        self._internals: dict[str, Any] | None = None
147
148        self._start_time = time.time()
149
150        logger.debug("Session %s is initialized" % self._identifier)
151
152    @classmethod
153    def global_config_precedence(
154        cls,
155        timeout: float | None,
156        end_on_error: bool | None,
157        broadcast_callback: (
158            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
159        ),
160        save_state: bool | None,
161        payload_callback: Callable[[dict[str, Any]], None] | None,
162    ) -> ExecutorConfig:
163        """
164        Uses the following precedence order to determine the configuration parameters:
165        1. The parameters in the method parameters.
166        2. The parameters in global context variables.
167        3. The default values.
168        """
169        global_executor_config = get_global_config()
170
171        return global_executor_config.precedence_overwritten(
172            timeout=timeout,
173            end_on_error=end_on_error,
174            subscriber=broadcast_callback,
175            save_state=save_state,
176            payload_callback=payload_callback,
177        )
178
179    def __enter__(self):
180        return self
181
182    def __exit__(self, exc_type, exc_val, exc_tb):
183        if self.executor_config.save_state:
184            try:
185                railtracks_dir = resolve_railtracks_home()
186                sessions_dir = railtracks_dir / "data" / "sessions"
187                sessions_dir.mkdir(
188                    parents=True, exist_ok=True
189                )  # Creates directory structure if doesn't exist, skips otherwise.
190
191                # Try to create file path with name, fallback to identifier only if there's an issue
192                if self.flow_name is not None:
193                    name = self.flow_name
194                elif self.name is not None:
195                    name = self.name
196                else:
197                    name = ""
198
199                candidate = sessions_dir / f"{name}_{self._identifier}.json"
200                try:
201                    candidate.touch()
202                    candidate.unlink()
203                    file_path = candidate
204                except OSError:
205                    logger.warning(
206                        get_message(
207                            ExceptionMessageKey.INVALID_SESSION_FILE_NAME_WARN
208                        ).format(name=name, identifier=self._identifier)
209                    )
210                    file_path = sessions_dir / f"{self._identifier}.json"
211
212                logger.info("Saving execution info to %s" % file_path)
213
214                content = json.dumps(self.payload())
215                file_path.write_text(content)
216
217            except OSError as exc:
218                logger.warning(
219                    "Could not persist session state to disk (%s: %s). "
220                    "Set RAILTRACKS_DISABLE_EVENTS=True to silence this warning.",
221                    type(exc).__name__,
222                    exc,
223                )
224            except Exception as e:
225                logger.error(
226                    "Error while saving execution info to file: %s",
227                    e,
228                    exc_info=True,
229                )
230        try:
231            if self.executor_config.payload_callback is not None:
232                self.executor_config.payload_callback(self.payload())
233        except Exception:
234            # TODO: add logging here.
235            pass
236
237        # Keep the folded result and release only the buffered events (the collector is shared)
238        self._internals = _node_internals.internals_for(self._identifier)
239        _node_internals.discard(self._identifier)
240
241        self._close()
242
243    def _setup_subscriber(self):
244        """
245        Prepares and attaches the saved `broadcast_callback` to the publisher: it listens on
246        the event lane for one-off `rt.broadcast` items.
247        """
248
249        if self.executor_config.subscriber is not None:
250            self.publisher.subscribe(
251                event_subscriber(self.executor_config.subscriber),
252                name="Broadcast Callback Subscriber",
253            )
254
255    def _close(self):
256        """
257        Closes the runner and cleans up all resources.
258
259        - Shuts down the state object
260        - Deletes all the global variables that were registered in the context
261        """
262        # FIX: Resource leak - publisher background task wasn't being shut down on Session exit
263        # VISION: Session owns publisher lifecycle and must clean up all resources when exiting
264        if self.publisher.is_running():
265            try:
266                # Signal shutdown by setting the flag - the loop will check this and exit
267                self.publisher._running = False
268
269                # Try to cancel the background task if it exists and isn't done
270                if (
271                    self.publisher.pub_loop is not None
272                    and not self.publisher.pub_loop.done()
273                ):
274                    try:
275                        # Cancel the task - it will check _running and exit naturally
276                        self.publisher.pub_loop.cancel()
277                    except Exception:
278                        # Task might be done or in a different loop, that's okay
279                        pass
280            except Exception:
281                # If shutdown fails for any reason, log it but don't crash
282                logger.warning(
283                    "Failed to shutdown publisher during Session cleanup. "
284                    "This may indicate a resource leak.",
285                    exc_info=True,
286                )
287
288        self.rt_state.shutdown()
289
290        delete_globals()
291        # by deleting all of the state variables we are ensuring that the next time we create a runner it is fresh
292
293    @property
294    def identifier(self) -> str:
295        """The unique identifier assigned to this session."""
296        return self._identifier
297
298    @property
299    def info(self) -> ExecutionInfo:
300        """
301        Returns the current state of the runner.
302
303        This is useful for debugging and viewing the current state of the run.
304        """
305        return self.rt_state.info
306
307    def payload(self) -> Dict[str, Any]:
308        """
309        Gets the complete json payload tied to this session.
310
311        The outputted json schema is maintained in (link here)
312        """
313        info = self.info
314
315        run_list = info.graph_serialization()
316        self._attach_node_internals(run_list)
317
318        full_dict = {
319            "flow_name": self.flow_name,
320            "flow_id": self.flow_id,
321            "session_id": self._identifier,
322            "session_name": self.name,
323            "start_time": self._start_time,
324            "end_time": time.time(),
325            "runs": run_list,
326        }
327
328        return json.loads(json.dumps(full_dict, cls=RTJSONEncoder))
329
330    def _attach_node_internals(self, runs: list[dict[str, Any]]) -> None:
331        """Refill each serialized node's ``details.internals`` from the event stream.
332
333        NOTE: Backward compatibility shim
334        """
335
336        if not isinstance(runs, list):
337            return
338
339        internals = (
340            self._internals
341            if self._internals is not None
342            else _node_internals.internals_for(self._identifier)
343        )
344
345        for run in runs:
346            if not isinstance(run, dict):
347                continue
348            for node in run.get("nodes", []):
349                block = internals.get(node.get("identifier")) or {}
350                node.setdefault("details", {})["internals"] = block
351
352                earlier = {k: v for k, v in block.items() if k != "latency"}
353                snapshot = node.get("parent")
354                while snapshot is not None:
355                    snapshot.setdefault("details", {})["internals"] = earlier
356                    snapshot = snapshot.get("parent")

The main class for managing an execution session.

This class is responsible for setting up all the necessary components for running a Railtracks execution, including the coordinator, publisher, and state management.

For the configuration parameters of the setting. It will follow this precedence:

  1. The parameters in the Session constructor.
  2. The parameters in global context variables.
  3. The default values.

Default Values:

  • name: None
  • timeout: 150.0 seconds
  • end_on_error: False
  • broadcast_callback: None (no event listener)
  • save_state: True (the state of the execution will be saved to a file at the end of the run in the .railtracks/data/sessions/ directory)
Arguments:
  • name (str | None, optional): Optional name for the session. This name will be included in the saved state file if save_state is True.
  • context (Dict[str, Any], optional): A dictionary of global context variables to be used during the execution.
  • flow_name (str | None, optional): The name of the flow this session is associated with.
  • flow_id (str | None, optional): The unique identifier of the flow this session is associated with.
  • timeout (float, optional): The maximum number of seconds to wait for a response to your top-level request.
  • end_on_error (bool, optional): If True, the execution will stop when an exception is encountered.
  • broadcast_callback (Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None, optional): A passive listener for one-off events published with rt.broadcast.
  • save_state (bool, optional): If True, the state of the execution will be saved to a file at the end of the run in the .railtracks/data/sessions/ directory.
Session( context: Optional[Dict[str, Any]] = None, *, flow_name: str | None = None, flow_id: str | None = None, name: str | None = None, timeout: float | None = None, end_on_error: bool | None = None, broadcast_callback: Union[Callable[[str], NoneType], Callable[[str], Coroutine[NoneType, NoneType, NoneType]], NoneType] = None, save_state: bool | None = None, payload_callback: Optional[Callable[[dict[str, Any]], NoneType]] = None)
 74    def __init__(
 75        self,
 76        context: Dict[str, Any] | None = None,
 77        *,
 78        flow_name: str | None = None,
 79        flow_id: str | None = None,
 80        name: str | None = None,
 81        timeout: float | None = None,
 82        end_on_error: bool | None = None,
 83        broadcast_callback: (
 84            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
 85        ) = None,
 86        save_state: bool | None = None,
 87        payload_callback: Callable[[dict[str, Any]], None] | None = None,
 88    ):
 89        # first lets read from defaults if nessecary for the provided input config
 90
 91        if flow_name is None:
 92            warnings.warn(
 93                "Sessions should be tied to a flow for better observability and state management. Please use the Flow object to create and manage your sessions (see __ for more details). This warning will become an error in future versions.",
 94                DeprecationWarning,
 95            )
 96
 97        self.executor_config = self.global_config_precedence(
 98            timeout=timeout,
 99            end_on_error=end_on_error,
100            broadcast_callback=broadcast_callback,
101            save_state=save_state,
102            payload_callback=payload_callback,
103        )
104
105        if context is None:
106            context = {}
107
108        self.name = name
109        self.flow_name = flow_name
110        self.flow_id = flow_id
111
112        self.publisher: RTPublisher = RTPublisher()
113
114        self._identifier = str(uuid.uuid4())
115
116        executor_info = ExecutionInfo.create_new()
117        self.coordinator = Coordinator(
118            execution_modes={
119                "async": AsyncioExecutionStrategy(
120                    scope_manager=ContextVarScopeManager()
121                )
122            }
123        )
124        self.rt_state = RTState(
125            executor_info, self.executor_config, self.coordinator, self.publisher
126        )
127
128        self.coordinator.start(self.publisher)
129        self._setup_subscriber()
130
131        # NOTE: `payload` still reports per-node details.internals
132        add_inline_listener(_node_internals.record)
133
134        # held so the context survives `delete_globals()` on close
135        self.context = register_globals(
136            session_id=self._identifier,
137            rt_publisher=self.publisher,
138            executor_config=self.executor_config,
139            global_context_vars=context,
140            flow_name=self.flow_name,
141            flow_id=self.flow_id,
142            session_name=self.name,
143        )
144
145        # set at exit, so payload() still works once the events have been released
146        self._internals: dict[str, Any] | None = None
147
148        self._start_time = time.time()
149
150        logger.debug("Session %s is initialized" % self._identifier)
executor_config
name
flow_name
flow_id
publisher: railtracks.pubsub.publisher.RTPublisher
coordinator
rt_state
context
@classmethod
def global_config_precedence( cls, timeout: float | None, end_on_error: bool | None, broadcast_callback: Union[Callable[[str], NoneType], Callable[[str], Coroutine[NoneType, NoneType, NoneType]], NoneType], save_state: bool | None, payload_callback: Optional[Callable[[dict[str, Any]], NoneType]]) -> ExecutorConfig:
152    @classmethod
153    def global_config_precedence(
154        cls,
155        timeout: float | None,
156        end_on_error: bool | None,
157        broadcast_callback: (
158            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
159        ),
160        save_state: bool | None,
161        payload_callback: Callable[[dict[str, Any]], None] | None,
162    ) -> ExecutorConfig:
163        """
164        Uses the following precedence order to determine the configuration parameters:
165        1. The parameters in the method parameters.
166        2. The parameters in global context variables.
167        3. The default values.
168        """
169        global_executor_config = get_global_config()
170
171        return global_executor_config.precedence_overwritten(
172            timeout=timeout,
173            end_on_error=end_on_error,
174            subscriber=broadcast_callback,
175            save_state=save_state,
176            payload_callback=payload_callback,
177        )

Uses the following precedence order to determine the configuration parameters:

  1. The parameters in the method parameters.
  2. The parameters in global context variables.
  3. The default values.
identifier: str
293    @property
294    def identifier(self) -> str:
295        """The unique identifier assigned to this session."""
296        return self._identifier

The unique identifier assigned to this session.

info: ExecutionInfo
298    @property
299    def info(self) -> ExecutionInfo:
300        """
301        Returns the current state of the runner.
302
303        This is useful for debugging and viewing the current state of the run.
304        """
305        return self.rt_state.info

Returns the current state of the runner.

This is useful for debugging and viewing the current state of the run.

def payload(self) -> Dict[str, Any]:
307    def payload(self) -> Dict[str, Any]:
308        """
309        Gets the complete json payload tied to this session.
310
311        The outputted json schema is maintained in (link here)
312        """
313        info = self.info
314
315        run_list = info.graph_serialization()
316        self._attach_node_internals(run_list)
317
318        full_dict = {
319            "flow_name": self.flow_name,
320            "flow_id": self.flow_id,
321            "session_id": self._identifier,
322            "session_name": self.name,
323            "start_time": self._start_time,
324            "end_time": time.time(),
325            "runs": run_list,
326        }
327
328        return json.loads(json.dumps(full_dict, cls=RTJSONEncoder))

Gets the complete json payload tied to this session.

The outputted json schema is maintained in (link here)

def session( func: Optional[Callable[~_P, Coroutine[Any, Any, ~_TOutput]]] = None, *, name: str | None = None, context: Optional[Dict[str, Any]] = None, timeout: float | None = None, end_on_error: bool | None = None, broadcast_callback: Union[Callable[[str], NoneType], Callable[[str], Coroutine[NoneType, NoneType, NoneType]], NoneType] = None, save_state: bool | None = None) -> Union[Callable[~_P, Coroutine[Any, Any, Tuple[~_TOutput, Session]]], Callable[[Callable[~_P, Coroutine[Any, Any, ~_TOutput]]], Callable[~_P, Coroutine[Any, Any, Tuple[~_TOutput, Session]]]]]:
411def session(
412    func: Callable[_P, Coroutine[Any, Any, _TOutput]] | None = None,
413    *,
414    name: str | None = None,
415    context: Dict[str, Any] | None = None,
416    timeout: float | None = None,
417    end_on_error: bool | None = None,
418    broadcast_callback: (
419        Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
420    ) = None,
421    save_state: bool | None = None,
422) -> (
423    Callable[_P, Coroutine[Any, Any, Tuple[_TOutput, Session]]]
424    | Callable[
425        [Callable[_P, Coroutine[Any, Any, _TOutput]]],
426        Callable[_P, Coroutine[Any, Any, Tuple[_TOutput, Session]]],
427    ]
428):
429    """
430    This decorator automatically creates and manages a Session context for the decorated function,
431    allowing async functions to use Railtracks operations without manually managing the session lifecycle.
432
433    Can be used as:
434    - @session (without parentheses) - uses default settings
435    - @session() (with empty parentheses) - uses default settings
436    - @session(name="my_task", timeout=30) (with configuration parameters)
437
438    When using this decorator, the function returns a tuple containing:
439    1. The original function's return value
440    2. The Session object used during execution
441
442    This allows access to session information (like execution state, logs, etc.) after the function completes,
443    while maintaining the simplicity of decorator usage.
444
445    Args:
446        name (str | None, optional): Optional name for the session. This name will be included in the saved state file if `save_state` is True.
447        context (Dict[str, Any], optional): A dictionary of global context variables to be used during the execution.
448        timeout (float, optional): The maximum number of seconds to wait for a response to your top-level request.
449        end_on_error (bool, optional): If True, the execution will stop when an exception is encountered.
450        broadcast_callback (Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None, optional): A callback function that will be called with the broadcast messages.
451        save_state (bool, optional): If True, the state of the execution will be saved to a file at the end of the run in the `.railtracks/data/sessions/` directory.
452
453    Returns:
454        When used as @session (without parentheses): Returns the decorated function that returns (result, session).
455        When used as @session(...) (with parameters): Returns a decorator function that takes an async function
456        and returns a new async function that returns (result, session).
457    """
458
459    def decorator(
460        target_func: Callable[_P, Coroutine[Any, Any, _TOutput]],
461    ) -> Callable[_P, Coroutine[Any, Any, Tuple[_TOutput, Session]]]:
462        # Validate that the decorated function is async
463        if not inspect.iscoroutinefunction(target_func):
464            raise TypeError(
465                f"@session decorator can only be applied to async functions. "
466                f"Function '{target_func.__name__}' is not async. "
467                f"Add 'async' keyword to your function definition."
468            )
469
470        @wraps(target_func)
471        async def wrapper(
472            *args: _P.args, **kwargs: _P.kwargs
473        ) -> Tuple[_TOutput, Session]:
474            session_obj = Session(
475                context=context,
476                timeout=timeout,
477                end_on_error=end_on_error,
478                broadcast_callback=broadcast_callback,
479                name=name,
480                save_state=save_state,
481            )
482
483            with session_obj:
484                result = await target_func(*args, **kwargs)
485                return result, session_obj
486
487        return wrapper
488
489    # If used as @session without parentheses
490    if func is not None:
491        return decorator(func)
492
493    # If used as @session(...)
494    return decorator

This decorator automatically creates and manages a Session context for the decorated function, allowing async functions to use Railtracks operations without manually managing the session lifecycle.

Can be used as:

  • @session (without parentheses) - uses default settings
  • @session() (with empty parentheses) - uses default settings
  • @session(name="my_task", timeout=30) (with configuration parameters)

When using this decorator, the function returns a tuple containing:

  1. The original function's return value
  2. The Session object used during execution

This allows access to session information (like execution state, logs, etc.) after the function completes, while maintaining the simplicity of decorator usage.

Arguments:
  • name (str | None, optional): Optional name for the session. This name will be included in the saved state file if save_state is True.
  • context (Dict[str, Any], optional): A dictionary of global context variables to be used during the execution.
  • timeout (float, optional): The maximum number of seconds to wait for a response to your top-level request.
  • end_on_error (bool, optional): If True, the execution will stop when an exception is encountered.
  • broadcast_callback (Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None, optional): A callback function that will be called with the broadcast messages.
  • save_state (bool, optional): If True, the state of the execution will be saved to a file at the end of the run in the .railtracks/data/sessions/ directory.
Returns:

When used as @session (without parentheses): Returns the decorated function that returns (result, session). When used as @session(...) (with parameters): Returns a decorator function that takes an async function and returns a new async function that returns (result, session).

async def call( node_: Union[type[railtracks.nodes.nodes.Node[~_P, ~_TOutput]], railtracks.built_nodes.function.base.RTFunction[~_P, ~_TOutput]], *args: _P.args, **kwargs: _P.kwargs) -> ~_TOutput:
 64async def call(
 65    node_: type[Node[_P, _TOutput]] | RTFunction[_P, _TOutput],
 66    *args: _P.args,
 67    **kwargs: _P.kwargs,
 68) -> _TOutput:
 69    """
 70    Call a node from within a node inside the framework. This will return a coroutine that you can interact with
 71    in whatever way using async/await logic.
 72
 73    Usage:
 74    ```python
 75    # for sequential operation
 76    result = await call(NodeA, "hello world", 42)
 77
 78    # for parallel operation
 79    tasks = [call(NodeA, "hello world", i) for i in range(10)]
 80    results = await asyncio.gather(*tasks)
 81    ```
 82
 83    Args:
 84        node: The node type you would like to create. This could be a function decorated with `@function_node`, a function, or a Node instance.
 85        *args: The arguments to pass to the node
 86        **kwargs: The keyword arguments to pass to the node
 87    """
 88    node: type[Node[_P, _TOutput]]
 89
 90    if hasattr(node_, "node_type"):
 91        # local import to prevent circular import issues (note it is a purely type checking import)
 92        from railtracks.built_nodes.function.base import RTFunction
 93
 94        assert isinstance(node_, RTFunction)
 95        node = extract_node_from_function(node_)
 96    else:
 97        node = node_
 98
 99    # if the context is none then we will need to create a wrapper for the state object to work with.
100    if not is_context_present():
101        # we have to use lazy import here to prevent a circular import issue. This is a must have unfortunately.
102        from railtracks import Session
103
104        with Session():
105            result = await _start(node, args=args, kwargs=kwargs)
106            return result
107
108    # if the context is not active then we know this is the top level request
109    if not is_context_active():
110        result = await _start(node, args=args, kwargs=kwargs)
111        return result
112
113    # if the context is active then we can just run the node
114    result = await _run(node, args=args, kwargs=kwargs)
115    return result

Call a node from within a node inside the framework. This will return a coroutine that you can interact with in whatever way using async/await logic.

Usage:

# for sequential operation
result = await call(NodeA, "hello world", 42)

# for parallel operation
tasks = [call(NodeA, "hello world", i) for i in range(10)]
results = await asyncio.gather(*tasks)
Arguments:
  • node: The node type you would like to create. This could be a function decorated with @function_node, a function, or a Node instance.
  • *args: The arguments to pass to the node
  • **kwargs: The keyword arguments to pass to the node
def astream( node_: type[railtracks.nodes.nodes.Node[~_P, ~_TOutput]], *args: _P.args, **kwargs: _P.kwargs) -> railtracks.interaction._astream.Stream[~_TOutput]:
265def astream(
266    node_: type[Node[_P, _TOutput]],
267    *args: _P.args,
268    **kwargs: _P.kwargs,
269) -> Stream[_TOutput]:
270    """
271    Invoke an agent node with streaming enabled and return a `Stream` over its emitted chunks.
272
273    Async-iterate the returned `Stream` for token chunks and read `.result` for the final
274    return value, or `await` it directly when you only want the result. Streaming is
275    frame-local: only the node invoked here streams its LLM responses; nested `rt.call`
276    children run buffered.
277
278    Args:
279        node_: The agent node class to invoke (built with `rt.agent_node(...)`). Only agent
280            nodes are accepted; a `@function_node` / tool node has no token stream to
281            surface, so use `rt.call` for those.
282        *args: The positional arguments to pass to the node.
283        **kwargs: The keyword arguments to pass to the node.
284
285    Returns:
286        Stream[_TOutput]: An async iterator over the chunks, with the final result available
287            via `.result` (or by awaiting the stream).
288
289    Raises:
290        NodeCreationError: If `node_` is not an agent node.
291    """
292    # local import to prevent circular import issues (mirrors rt.call)
293    from railtracks.nodes.nodes import Node
294
295    # rt.astream streams an agent's LLM tokens, so it only accepts agent nodes built with
296    # rt.agent_node(...). Validate the input up front: anything that is not an agent `Node`
297    # subclass (a @function_node / tool node, or a raw value) has no token stream to surface,
298    # so it is rejected here rather than unpacked use rt.call instead.
299    is_agent_node = (
300        isinstance(node_, type) and issubclass(node_, Node) and node_.type() == "Agent"
301    )
302    if not is_agent_node:
303        name = getattr(node_, "__name__", None) or repr(node_)
304        raise NodeCreationError(
305            message=f"rt.astream only supports agent nodes, but {name!r} is not one.",
306            notes=[
307                "Pass an agent built with rt.agent_node(...) to rt.astream(...).",
308                "To run a function/tool node, use rt.call(...) instead.",
309            ],
310        )
311    node = node_
312
313    # Session lifecycle is owned here, not by the Stream handle. When called outside any
314    # session we open one and hold it in this closure; the Stream calls `_close` back once
315    # it finishes (see Stream.on_start / on_close), so the session's open/close logic lives
316    # with astream rather than inside the returned object.
317    owned: list[Session] = []
318
319    def _open() -> None:
320        if not is_context_present():
321            from railtracks import Session  # lazy import to avoid a circular import
322
323            owned.append(Session())
324
325    def _close() -> None:
326        while owned:
327            owned.pop().__exit__(None, None, None)
328
329    return Stream(node, args, kwargs, on_start=_open, on_close=_close)

Invoke an agent node with streaming enabled and return a Stream over its emitted chunks.

Async-iterate the returned Stream for token chunks and read .result for the final return value, or await it directly when you only want the result. Streaming is frame-local: only the node invoked here streams its LLM responses; nested rt.call children run buffered.

Arguments:
  • node_: The agent node class to invoke (built with rt.agent_node(...)). Only agent nodes are accepted; a @function_node / tool node has no token stream to surface, so use rt.call for those.
  • *args: The positional arguments to pass to the node.
  • **kwargs: The keyword arguments to pass to the node.
Returns:

Stream[_TOutput]: An async iterator over the chunks, with the final result available via .result (or by awaiting the stream).

Raises:
  • NodeCreationError: If node_ is not an agent node.
async def broadcast(item: str):
 8async def broadcast(item: str):
 9    """
10    Broadcasts a one-off **event** to the session bus.
11
12    This triggers the `broadcast_callback` you have provided to the `Session` (or via
13    `rt.set_config`). Each broadcast is a discrete event, independent of any LLM token
14    output an agent produces.
15
16    Args:
17        item (str): The item you want to broadcast.
18    """
19    publisher = get_publisher()
20
21    await publisher.publish(BroadcastEvent(node_id=get_parent_id(), item=item))

Broadcasts a one-off event to the session bus.

This triggers the broadcast_callback you have provided to the Session (or via rt.set_config). Each broadcast is a discrete event, independent of any LLM token output an agent produces.

Arguments:
  • item (str): The item you want to broadcast.
async def call_batch( node: Union[type[railtracks.nodes.nodes.Node[..., ~_TOutput]], railtracks.built_nodes.function.base.RTFunction[..., ~_TOutput]], *iterables: Iterable[Any], return_exceptions: bool = True):
19async def call_batch(
20    node: type[Node[..., _TOutput]] | RTFunction[..., _TOutput],
21    *iterables: Iterable[Any],
22    return_exceptions: bool = True,
23):
24    """
25    Complete a node over multiple iterables, allowing for parallel execution.
26
27    Note the results will be returned in the order of the iterables, not the order of completion.
28
29    If one of the nodes returns an exception, the thrown exception will be included as a response.
30
31    Args:
32        node: The node type to create.
33        *iterables: The iterables to map the node over.
34        return_exceptions: If True, exceptions will be returned as part of the results.
35            If False, exceptions will be raised immediately, and you will lose access to the results.
36            Defaults to true.
37
38    Returns:
39        An iterable of results from the node.
40
41    Usage:
42        ```python
43        results = await batch(NodeA, ["hello world"] * 10)
44        for result in results:
45            handle(result)
46        ```
47    """
48    # this is big typing disaster but there is no way around it. Try if if you want to.
49    contracts = [call(node, *args) for args in zip(*iterables)]
50
51    results = await asyncio.gather(*contracts, return_exceptions=return_exceptions)
52    return results

Complete a node over multiple iterables, allowing for parallel execution.

Note the results will be returned in the order of the iterables, not the order of completion.

If one of the nodes returns an exception, the thrown exception will be included as a response.

Arguments:
  • node: The node type to create.
  • *iterables: The iterables to map the node over.
  • return_exceptions: If True, exceptions will be returned as part of the results. If False, exceptions will be raised immediately, and you will lose access to the results. Defaults to true.
Returns:

An iterable of results from the node.

Usage:
results = await batch(NodeA, ["hello world"] * 10)
for result in results:
    handle(result)
class ExecutionInfo:
 19class ExecutionInfo:
 20    """
 21    A class that contains the full details of the state of a run at any given point in time.
 22
 23    The class is designed to be used as a snapshot of state that can be used to display the state of the run, or to
 24    create a graphical representation of the system.
 25    """
 26
 27    def __init__(
 28        self,
 29        request_forest: RequestForest,
 30        node_forest: NodeForest,
 31        stamper: StampManager,
 32    ):
 33        self.request_forest = request_forest
 34        self.node_forest = node_forest
 35        self.stamper = stamper
 36
 37    @classmethod
 38    def default(cls) -> ExecutionInfo:
 39        """Creates a new "empty" instance of the ExecutionInfo class with the default values."""
 40        return cls.create_new()
 41
 42    @classmethod
 43    def create_new(
 44        cls,
 45    ) -> ExecutionInfo:
 46        """
 47        Creates a new empty instance of state variables with the provided executor configuration.
 48
 49        """
 50        request_heap = RequestForest()
 51        node_heap = NodeForest()
 52        stamper = StampManager()
 53
 54        return ExecutionInfo(
 55            request_forest=request_heap,
 56            node_forest=node_heap,
 57            stamper=stamper,
 58        )
 59
 60    @property
 61    def answer(self):
 62        """Convenience method to access the answer of the run."""
 63        return self.request_forest.answer
 64
 65    @property
 66    def all_stamps(self) -> List[Stamp]:
 67        """Convenience method to access all the stamps of the run."""
 68        return self.stamper.all_stamps
 69
 70    @property
 71    def name(self):
 72        """
 73        Gets the name of the graph by pulling the name of the insertion request. It will raise a ValueError if the insertion
 74        request is not present or there are multiple insertion requests.
 75        """
 76        insertion_requests = self.insertion_requests
 77
 78        # The name is only defined for the length of 1.
 79        # NOTE: Maybe we should send a warning once to user in other cases.
 80        if len(insertion_requests) != 1:
 81            return None
 82
 83        i_r = insertion_requests[0]
 84
 85        return self.node_forest.get_node_type(i_r.sink_id).name()
 86
 87    @property
 88    def insertion_requests(self):
 89        """A convenience method to access all the insertion requests of the run."""
 90        return self.request_forest.insertion_request
 91
 92    def _get_info(self, ids: List[str] | str | None = None) -> ExecutionInfo:
 93        """
 94        Gets a subset of the current state based on the provided node ids. It will contain all the children of the provided node ids
 95
 96        Note: If no ids are provided, the full state is returned.
 97
 98        Args:
 99            ids (List[str] | str | None): A list of node ids to filter the state by. If None, the full state is returned.
100
101        Returns:
102            ExecutionInfo: A new instance of ExecutionInfo containing only the children of the provided ids.
103
104        """
105        if ids is None:
106            return self
107        else:
108            # firstly lets
109            if isinstance(ids, str):
110                ids = [ids]
111
112            # we need to quickly check to make sure these ids are valid
113            for identifier in ids:
114                if identifier not in self.request_forest:
115                    raise ValueError(
116                        f"Identifier '{identifier}' not found in the current state."
117                    )
118
119            new_node_forest, new_request_forest = create_sub_state_info(
120                self.node_forest.heap(),
121                self.request_forest.heap(),
122                ids,
123            )
124            return ExecutionInfo(
125                node_forest=new_node_forest,
126                request_forest=new_request_forest,
127                stamper=self.stamper,
128            )
129
130    def _to_graph(self) -> Tuple[List[Vertex], List[Edge]]:
131        """
132        Converts the current state into its graph representation.
133
134        Returns:
135            List[Node]: An iterable of nodes in the graph.
136            List[Edge]: An iterable of edges in the graph.
137        """
138        return self.node_forest.to_vertices(), self.request_forest.to_edges()
139
140    def graph_serialization(self) -> dict[str, Any]:
141        """
142                Creates a string (JSON) representation of this info object designed to be used to construct a graph for this
143                info object.
144
145                Some important notes about its structure are outlined below:
146                - The `nodes` key contains a list of all the nodes in the graph, represented as `Vertex` objects.
147                - The `edges` key contains a list of all the edges in the graph, represented as `Edge` objects.
148                - The `stamps` key contains an ease of use list of all the stamps associated with the run, represented as `Stamp` objects.
149
150                - The "nodes" and "requests" key will be outlined with normal graph details like connections and identifiers in addition to a loose details object.
151                - However, both will carry an addition param called "stamp" which is a timestamp style object.
152                - They also will carry a "parent" param which is a recursive structure that allows you to traverse the graph in time.
153
154
155        ```
156        """
157        parent_nodes = [x.identifier for x in self.insertion_requests]
158
159        infos = [self._get_info(parent_node) for parent_node in parent_nodes]
160
161        runs = []
162
163        for info, parent_node_id in zip(infos, parent_nodes):
164            insertion_requests = info.request_forest.insertion_request
165
166            assert len(insertion_requests) == 1
167            parent_request = insertion_requests[0]
168
169            all_parents = parent_request.get_all_parents()
170
171            start_time = all_parents[-1].stamp.time
172
173            assert len([x for x in all_parents if x.status == "Completed"]) <= 1
174            end_time = None
175            for req in all_parents:
176                if req.status in ["Completed", "Failed"]:
177                    end_time = req.stamp.time
178                    break
179
180            entry = {
181                "name": info.name,
182                "run_id": parent_node_id,
183                "nodes": info.node_forest.to_vertices(),
184                "status": parent_request.status,
185                "edges": info.request_forest.to_edges(),
186                "steps": _get_stamps_from_forests(
187                    info.node_forest, info.request_forest
188                ),
189                "start_time": start_time,
190                "end_time": end_time,
191            }
192            runs.append(entry)
193
194        return json.loads(
195            json.dumps(
196                runs,
197                cls=RTJSONEncoder,
198            )
199        )

A class that contains the full details of the state of a run at any given point in time.

The class is designed to be used as a snapshot of state that can be used to display the state of the run, or to create a graphical representation of the system.

ExecutionInfo( request_forest: railtracks.state.request.RequestForest, node_forest: railtracks.state.node.NodeForest, stamper: railtracks.utils.profiling.StampManager)
27    def __init__(
28        self,
29        request_forest: RequestForest,
30        node_forest: NodeForest,
31        stamper: StampManager,
32    ):
33        self.request_forest = request_forest
34        self.node_forest = node_forest
35        self.stamper = stamper
request_forest
node_forest
stamper
@classmethod
def default(cls) -> ExecutionInfo:
37    @classmethod
38    def default(cls) -> ExecutionInfo:
39        """Creates a new "empty" instance of the ExecutionInfo class with the default values."""
40        return cls.create_new()

Creates a new "empty" instance of the ExecutionInfo class with the default values.

@classmethod
def create_new(cls) -> ExecutionInfo:
42    @classmethod
43    def create_new(
44        cls,
45    ) -> ExecutionInfo:
46        """
47        Creates a new empty instance of state variables with the provided executor configuration.
48
49        """
50        request_heap = RequestForest()
51        node_heap = NodeForest()
52        stamper = StampManager()
53
54        return ExecutionInfo(
55            request_forest=request_heap,
56            node_forest=node_heap,
57            stamper=stamper,
58        )

Creates a new empty instance of state variables with the provided executor configuration.

answer
60    @property
61    def answer(self):
62        """Convenience method to access the answer of the run."""
63        return self.request_forest.answer

Convenience method to access the answer of the run.

all_stamps: List[railtracks.utils.profiling.Stamp]
65    @property
66    def all_stamps(self) -> List[Stamp]:
67        """Convenience method to access all the stamps of the run."""
68        return self.stamper.all_stamps

Convenience method to access all the stamps of the run.

name
70    @property
71    def name(self):
72        """
73        Gets the name of the graph by pulling the name of the insertion request. It will raise a ValueError if the insertion
74        request is not present or there are multiple insertion requests.
75        """
76        insertion_requests = self.insertion_requests
77
78        # The name is only defined for the length of 1.
79        # NOTE: Maybe we should send a warning once to user in other cases.
80        if len(insertion_requests) != 1:
81            return None
82
83        i_r = insertion_requests[0]
84
85        return self.node_forest.get_node_type(i_r.sink_id).name()

Gets the name of the graph by pulling the name of the insertion request. It will raise a ValueError if the insertion request is not present or there are multiple insertion requests.

insertion_requests
87    @property
88    def insertion_requests(self):
89        """A convenience method to access all the insertion requests of the run."""
90        return self.request_forest.insertion_request

A convenience method to access all the insertion requests of the run.

def graph_serialization(self) -> dict[str, typing.Any]:
140    def graph_serialization(self) -> dict[str, Any]:
141        """
142                Creates a string (JSON) representation of this info object designed to be used to construct a graph for this
143                info object.
144
145                Some important notes about its structure are outlined below:
146                - The `nodes` key contains a list of all the nodes in the graph, represented as `Vertex` objects.
147                - The `edges` key contains a list of all the edges in the graph, represented as `Edge` objects.
148                - The `stamps` key contains an ease of use list of all the stamps associated with the run, represented as `Stamp` objects.
149
150                - The "nodes" and "requests" key will be outlined with normal graph details like connections and identifiers in addition to a loose details object.
151                - However, both will carry an addition param called "stamp" which is a timestamp style object.
152                - They also will carry a "parent" param which is a recursive structure that allows you to traverse the graph in time.
153
154
155        ```
156        """
157        parent_nodes = [x.identifier for x in self.insertion_requests]
158
159        infos = [self._get_info(parent_node) for parent_node in parent_nodes]
160
161        runs = []
162
163        for info, parent_node_id in zip(infos, parent_nodes):
164            insertion_requests = info.request_forest.insertion_request
165
166            assert len(insertion_requests) == 1
167            parent_request = insertion_requests[0]
168
169            all_parents = parent_request.get_all_parents()
170
171            start_time = all_parents[-1].stamp.time
172
173            assert len([x for x in all_parents if x.status == "Completed"]) <= 1
174            end_time = None
175            for req in all_parents:
176                if req.status in ["Completed", "Failed"]:
177                    end_time = req.stamp.time
178                    break
179
180            entry = {
181                "name": info.name,
182                "run_id": parent_node_id,
183                "nodes": info.node_forest.to_vertices(),
184                "status": parent_request.status,
185                "edges": info.request_forest.to_edges(),
186                "steps": _get_stamps_from_forests(
187                    info.node_forest, info.request_forest
188                ),
189                "start_time": start_time,
190                "end_time": end_time,
191            }
192            runs.append(entry)
193
194        return json.loads(
195            json.dumps(
196                runs,
197                cls=RTJSONEncoder,
198            )
199        )

Creates a string (JSON) representation of this info object designed to be used to construct a graph for this info object.

    Some important notes about its structure are outlined below:
    - The `nodes` key contains a list of all the nodes in the graph, represented as `Vertex` objects.
    - The `edges` key contains a list of all the edges in the graph, represented as `Edge` objects.
    - The `stamps` key contains an ease of use list of all the stamps associated with the run, represented as `Stamp` objects.

    - The "nodes" and "requests" key will be outlined with normal graph details like connections and identifiers in addition to a loose details object.
    - However, both will carry an addition param called "stamp" which is a timestamp style object.
    - They also will carry a "parent" param which is a recursive structure that allows you to traverse the graph in time.

```

class ExecutorConfig:
14class ExecutorConfig:
15    def __init__(
16        self,
17        *,
18        timeout: float | None = None,
19        end_on_error: bool = False,
20        broadcast_callback: (
21            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
22        ) = None,
23        save_state: bool | None = None,
24        payload_callback: Callable[[dict[str, Any]], None] | None = None,
25    ):
26        """
27        ExecutorConfig is special configuration object designed to allow customization of the executor in the RT system.
28
29        Args:
30            timeout (float | None): The maximum number of seconds to wait for a response to your top level request. Pass None (or omit) to disable the timeout entirely.
31            end_on_error (bool): If true, the executor will stop execution when an exception is encountered.
32            broadcast_callback (Callable or Coroutine): A function or coroutine that receives items published with `rt.broadcast`.
33            save_state (bool | None): Deprecated at the user-facing API layer (see Flow). `RAILTRACKS_DISABLE_EVENTS=True` skips the write regardless. Otherwise, explicit value wins; when unset, defaults to True (save).
34        """
35        self.timeout = timeout
36        self.end_on_error = end_on_error
37        self.subscriber = broadcast_callback
38        # During test runs, disable save_state by default unless
39        # RAILTRACKS_ALLOW_PERSISTENCE is set (see `save_state` property).
40        self._user_save_state = save_state
41
42        self.payload_callback = payload_callback
43
44    # this is done because if we try to lock the save_state in init
45    # later when we want to allow a few tests to actually run persistance, they wont be able to do so
46    @property
47    def save_state(self) -> bool:
48        if os.getenv("RAILTRACKS_TEST_MODE") and not os.getenv(
49            "RAILTRACKS_ALLOW_PERSISTENCE"
50        ):
51            return False
52        if _disable_events():
53            return False
54        return True if self._user_save_state is None else self._user_save_state
55
56    def precedence_overwritten(
57        self,
58        *,
59        timeout: float | None = None,
60        end_on_error: bool | None = None,
61        subscriber: (
62            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
63        ) = None,
64        save_state: bool | None = None,
65        payload_callback: Callable[[dict[str, Any]], None] | None = None,
66    ):
67        """
68        If any of the parameters are provided (not None), it will create a new update the current instance with the new values and return a deep copied reference to it.
69        """
70        return ExecutorConfig(
71            timeout=timeout,
72            end_on_error=end_on_error
73            if end_on_error is not None
74            else self.end_on_error,
75            broadcast_callback=subscriber
76            if subscriber is not None
77            else self.subscriber,
78            save_state=save_state if save_state is not None else self._user_save_state,
79            payload_callback=payload_callback
80            if payload_callback is not None
81            else self.payload_callback,
82        )
83
84    def __repr__(self):
85        return (
86            f"ExecutorConfig(timeout={self.timeout}, end_on_error={self.end_on_error}, "
87            f"save_state={self._user_save_state}, payload_callback={self.payload_callback})"
88        )
ExecutorConfig( *, timeout: float | None = None, end_on_error: bool = False, broadcast_callback: Union[Callable[[str], NoneType], Callable[[str], Coroutine[NoneType, NoneType, NoneType]], NoneType] = None, save_state: bool | None = None, payload_callback: Optional[Callable[[dict[str, Any]], NoneType]] = None)
15    def __init__(
16        self,
17        *,
18        timeout: float | None = None,
19        end_on_error: bool = False,
20        broadcast_callback: (
21            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
22        ) = None,
23        save_state: bool | None = None,
24        payload_callback: Callable[[dict[str, Any]], None] | None = None,
25    ):
26        """
27        ExecutorConfig is special configuration object designed to allow customization of the executor in the RT system.
28
29        Args:
30            timeout (float | None): The maximum number of seconds to wait for a response to your top level request. Pass None (or omit) to disable the timeout entirely.
31            end_on_error (bool): If true, the executor will stop execution when an exception is encountered.
32            broadcast_callback (Callable or Coroutine): A function or coroutine that receives items published with `rt.broadcast`.
33            save_state (bool | None): Deprecated at the user-facing API layer (see Flow). `RAILTRACKS_DISABLE_EVENTS=True` skips the write regardless. Otherwise, explicit value wins; when unset, defaults to True (save).
34        """
35        self.timeout = timeout
36        self.end_on_error = end_on_error
37        self.subscriber = broadcast_callback
38        # During test runs, disable save_state by default unless
39        # RAILTRACKS_ALLOW_PERSISTENCE is set (see `save_state` property).
40        self._user_save_state = save_state
41
42        self.payload_callback = payload_callback

ExecutorConfig is special configuration object designed to allow customization of the executor in the RT system.

Arguments:
  • timeout (float | None): The maximum number of seconds to wait for a response to your top level request. Pass None (or omit) to disable the timeout entirely.
  • end_on_error (bool): If true, the executor will stop execution when an exception is encountered.
  • broadcast_callback (Callable or Coroutine): A function or coroutine that receives items published with rt.broadcast.
  • save_state (bool | None): Deprecated at the user-facing API layer (see Flow). RAILTRACKS_DISABLE_EVENTS=True skips the write regardless. Otherwise, explicit value wins; when unset, defaults to True (save).
timeout
end_on_error
subscriber
payload_callback
save_state: bool
46    @property
47    def save_state(self) -> bool:
48        if os.getenv("RAILTRACKS_TEST_MODE") and not os.getenv(
49            "RAILTRACKS_ALLOW_PERSISTENCE"
50        ):
51            return False
52        if _disable_events():
53            return False
54        return True if self._user_save_state is None else self._user_save_state
def precedence_overwritten( self, *, timeout: float | None = None, end_on_error: bool | None = None, subscriber: Union[Callable[[str], NoneType], Callable[[str], Coroutine[NoneType, NoneType, NoneType]], NoneType] = None, save_state: bool | None = None, payload_callback: Optional[Callable[[dict[str, Any]], NoneType]] = None):
56    def precedence_overwritten(
57        self,
58        *,
59        timeout: float | None = None,
60        end_on_error: bool | None = None,
61        subscriber: (
62            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
63        ) = None,
64        save_state: bool | None = None,
65        payload_callback: Callable[[dict[str, Any]], None] | None = None,
66    ):
67        """
68        If any of the parameters are provided (not None), it will create a new update the current instance with the new values and return a deep copied reference to it.
69        """
70        return ExecutorConfig(
71            timeout=timeout,
72            end_on_error=end_on_error
73            if end_on_error is not None
74            else self.end_on_error,
75            broadcast_callback=subscriber
76            if subscriber is not None
77            else self.subscriber,
78            save_state=save_state if save_state is not None else self._user_save_state,
79            payload_callback=payload_callback
80            if payload_callback is not None
81            else self.payload_callback,
82        )

If any of the parameters are provided (not None), it will create a new update the current instance with the new values and return a deep copied reference to it.

def set_config( *, timeout: float | None = None, end_on_error: bool | None = None, broadcast_callback: Union[Callable[[str], NoneType], Callable[[str], Coroutine[NoneType, NoneType, NoneType]], NoneType] = None, save_state: bool | None = None) -> None:
572def set_config(
573    *,
574    timeout: float | None = None,
575    end_on_error: bool | None = None,
576    broadcast_callback: (
577        Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
578    ) = None,
579    save_state: bool | None = None,
580) -> None:
581    """
582    Sets the global configuration for the executor. This will be propagated to all new runners created after this call.
583
584    - If you call this function after the runner has been created, it will not affect the current runner.
585    - This function will only overwrite the values that are provided, leaving the rest unchanged.
586
587    Args:
588        broadcast_callback: A passive listener for one-off events published with `rt.broadcast`.
589    """
590
591    if is_context_active():
592        warnings.warn(
593            "The executor config is being set after the runner has been created, this is not recommended"
594        )
595
596    config = global_executor_config.get()
597
598    new_config = config.precedence_overwritten(
599        timeout=timeout,
600        end_on_error=end_on_error,
601        subscriber=broadcast_callback,
602        save_state=save_state,
603    )
604
605    global_executor_config.set(new_config)

Sets the global configuration for the executor. This will be propagated to all new runners created after this call.

  • If you call this function after the runner has been created, it will not affect the current runner.
  • This function will only overwrite the values that are provided, leaving the rest unchanged.
Arguments:
  • broadcast_callback: A passive listener for one-off events published with rt.broadcast.
def function_node( func: Union[Callable[~_P, Coroutine[NoneType, NoneType, ~_TOutput]], Callable[~_P, ~_TOutput], List[Union[Callable[~_P, Coroutine[NoneType, NoneType, ~_TOutput]], Callable[~_P, ~_TOutput]]], NoneType] = None, /, *, name: str | None = None, manifest: ToolManifest | None = None, middleware: Optional[Iterable[railtracks.middleware.Middleware[~_P, ~_TOutput]]] = None) -> Union[railtracks.built_nodes.function.base.CallableAsyncRTFunction[~_P, ~_TOutput], railtracks.built_nodes.function.base.CallableSyncRTFunction[~_P, ~_TOutput], List[Union[railtracks.built_nodes.function.base.CallableAsyncRTFunction[~_P, ~_TOutput], railtracks.built_nodes.function.base.CallableSyncRTFunction[~_P, ~_TOutput]]], Callable[[Union[Callable[~_P, Coroutine[NoneType, NoneType, ~_TOutput]], Callable[~_P, ~_TOutput]]], railtracks.built_nodes.function.base.RTFunction[~_P, ~_TOutput]], NoneType]:
256def function_node(
257    func: Callable[_P, Coroutine[None, None, _TOutput]]
258    | Callable[_P, _TOutput]
259    | List[Callable[_P, Coroutine[None, None, _TOutput]] | Callable[_P, _TOutput]]
260    | None = None,
261    /,
262    *,
263    name: str | None = None,
264    manifest: ToolManifest | None = None,
265    middleware: Iterable[Middleware[_P, _TOutput]] | None = None,
266) -> (
267    CallableAsyncRTFunction[_P, _TOutput]
268    | CallableSyncRTFunction[_P, _TOutput]
269    | List[CallableAsyncRTFunction[_P, _TOutput] | CallableSyncRTFunction[_P, _TOutput]]
270    | Callable[
271        [Callable[_P, Coroutine[None, None, _TOutput]] | Callable[_P, _TOutput]],
272        RTFunction[_P, _TOutput],
273    ]
274    | None
275):
276    """
277    Creates a new Node type from a function that can be used in `rt.call()`.
278
279    By default, it will parse the function's docstring and turn them into tool details and parameters. However, if
280    you provide custom ToolManifest it will override that logic.
281
282    Can be used three ways::
283
284        # 1. direct call
285        node = rt.function_node(my_fn, middleware=[guard])
286
287        # 2. bare decorator
288        @rt.function_node
289        def my_fn(...): ...
290
291        # 3. parametrized decorator (attach middleware / guardrails declaratively)
292        @rt.function_node(middleware=[guard], name="echo")
293        def my_fn(...): ...
294
295    WARNING: If you overriding tool parameters. It is on you to make sure they will work with your function.
296
297    NOTE: If you have already converted this function to a node this function will do nothing
298
299    Args:
300        func (Callable, optional): The function to convert into a Node. Omit it to use the
301            parametrized-decorator form, which returns a decorator that takes the function.
302        name (str, optional): Human-readable name for the node/tool.
303        manifest (ToolManifest, optional): The details you would like to override the tool with.
304        middleware (list[Middleware] | None): Middleware applied around the node boundary.
305    """
306
307    # No function yet -> parametrized-decorator form: bind the options and return
308    # a decorator that finishes the job once the function is supplied.
309    if func is None:
310
311        def _decorator(
312            f: Callable[_P, Coroutine[None, None, _TOutput]] | Callable[_P, _TOutput],
313        ) -> (
314            CallableAsyncRTFunction[_P, _TOutput] | CallableSyncRTFunction[_P, _TOutput]
315        ):
316            return function_node(f, name=name, manifest=manifest, middleware=middleware)
317
318        return _decorator
319
320    # handle the case where a list of functions is provided
321    if isinstance(func, list):
322        return [
323            function_node(f, name=name, manifest=manifest, middleware=middleware)
324            for f in func
325        ]
326    else:
327        return _single_function_node(
328            func, name=name, manifest=manifest, middleware=middleware
329        )

Creates a new Node type from a function that can be used in rt.call().

By default, it will parse the function's docstring and turn them into tool details and parameters. However, if you provide custom ToolManifest it will override that logic.

Can be used three ways::

# 1. direct call
node = rt.function_node(my_fn, middleware=[guard])

# 2. bare decorator
@rt.function_node
def my_fn(...): ...

# 3. parametrized decorator (attach middleware / guardrails declaratively)
@rt.function_node(middleware=[guard], name="echo")
def my_fn(...): ...

WARNING: If you overriding tool parameters. It is on you to make sure they will work with your function.

NOTE: If you have already converted this function to a node this function will do nothing

Arguments:
  • func (Callable, optional): The function to convert into a Node. Omit it to use the parametrized-decorator form, which returns a decorator that takes the function.
  • name (str, optional): Human-readable name for the node/tool.
  • manifest (ToolManifest, optional): The details you would like to override the tool with.
  • middleware (list[Middleware] | None): Middleware applied around the node boundary.
def agent_node( name: str | None = None, *, tool_nodes: Optional[Iterable[Union[Type[railtracks.nodes.nodes.Node], railtracks.built_nodes.function.base.RTFunction]]] = None, output_schema: Optional[Type[~_TBaseModel]] = None, llm: Union[railtracks.llm.ModelBase, Callable[[], railtracks.llm.ModelBase]], system_message: railtracks.llm.SystemMessage | str | None = None, manifest: ToolManifest | None = None, middleware: Union[Iterable[railtracks.middleware.Middleware[~_P, railtracks.built_nodes.llm.response.StringResponse]], Iterable[railtracks.middleware.Middleware[~_P, railtracks.built_nodes.llm.response.StructuredResponse[~_TBaseModel]]], NoneType] = None, model_middleware: Optional[Iterable[railtracks.middleware.Middleware[(<class 'railtracks.llm.MessageHistory'>, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None), railtracks.llm.Response]]] = None, _shape: Callable[~_P, object] = <function _user_input_shape>) -> type[railtracks.nodes.nodes.Node[~_P, railtracks.built_nodes.llm.response.StringResponse]] | type[railtracks.nodes.nodes.Node[~_P, railtracks.built_nodes.llm.response.StructuredResponse[~_TBaseModel]]]:
141def agent_node(
142    name: str | None = None,
143    *,
144    tool_nodes: Iterable[Type[Node] | RTFunction] | None = None,
145    output_schema: Type[_TBaseModel] | None = None,
146    llm: ModelSource,
147    system_message: SystemMessage | str | None = None,
148    manifest: ToolManifest | None = None,
149    middleware: Iterable[Middleware[_P, StructuredResponse[_TBaseModel]]]
150    | Iterable[Middleware[_P, StringResponse]]
151    | None = None,
152    model_middleware: Iterable[ModelMiddleware] | None = None,
153    _shape: Callable[_P, object] = _user_input_shape,
154) -> type[Node[_P, StringResponse]] | type[Node[_P, StructuredResponse[_TBaseModel]]]:
155    """
156    Dynamically creates an agent based on the provided parameters.
157
158    Args:
159        name (str | None): The name of the agent. If none the default will be used.
160        tool_nodes (Iterable[Type[Node] | RTFunction] | None): If your agent has access to tools, what does it have access to?
161            Cannot be combined with output_schema -- see below.
162        output_schema (Type[_TBaseModel] | None): If your agent should return a structured output, what is the output_schema?
163            Cannot be combined with tool_nodes: providing both raises NodeCreationError, since the model does not
164            reliably call tools when a structured output_schema is also requested (it may fabricate a plausible
165            tool result instead of actually invoking the tool).
166        llm (ModelBase | Callable[[], ModelBase]): The LLM model to use, or a no-arg
167            factory resolved fresh on every model call (lets the agent pick its model
168            at invocation time, e.g. from config or rt.context).
169        system_message (SystemMessage | str | None): System message for the agent.
170        manifest (ToolManifest | None): If you want to use this as a tool in other agents you can pass in a ToolManifest.
171        middleware (list[Middleware] | None): Middleware applied around the agent's node boundary
172            (user_input -> Response).
173        model_middleware (list[Middleware] | None): Middleware applied around each raw model call
174            (messages/schema/tools -> Response), inside the tool-calling loop.
175        _shape (Callable[_P, object]): Internal use only. Used to infer the ParamSpec for the agent's input shape.
176
177    NOTE: Supplying a parameter `_shape` will break typing and you will be responsible for it. DO NOT USE THIS!!
178    """
179    _check_no_combined_tools_and_schema(tool_nodes, output_schema)
180
181    unpacked_tool_nodes = _unpack_tool_nodes(tool_nodes)
182
183    # See issue (___) this logic should be migrated soon.
184    if manifest is not None:
185        tool_details = manifest.description
186        tool_params = manifest.parameters
187    else:
188        tool_details = None
189        tool_params = None
190
191    return _build_dynamic_agent(
192        unpacked_tool_nodes=unpacked_tool_nodes,
193        output_schema=output_schema,
194        name=name,
195        llm=llm,
196        system_message=system_message,
197        tool_details=tool_details,
198        tool_params=tool_params,
199        middleware=middleware,
200        model_middleware=model_middleware,
201    )

Dynamically creates an agent based on the provided parameters.

Arguments:
  • name (str | None): The name of the agent. If none the default will be used.
  • tool_nodes (Iterable[Type[Node] | RTFunction] | None): If your agent has access to tools, what does it have access to? Cannot be combined with output_schema -- see below.
  • output_schema (Type[_TBaseModel] | None): If your agent should return a structured output, what is the output_schema? Cannot be combined with tool_nodes: providing both raises NodeCreationError, since the model does not reliably call tools when a structured output_schema is also requested (it may fabricate a plausible tool result instead of actually invoking the tool).
  • llm (ModelBase | Callable[[], ModelBase]): The LLM model to use, or a no-arg factory resolved fresh on every model call (lets the agent pick its model at invocation time, e.g. from config or rt.context).
  • system_message (SystemMessage | str | None): System message for the agent.
  • manifest (ToolManifest | None): If you want to use this as a tool in other agents you can pass in a ToolManifest.
  • middleware (list[Middleware] | None): Middleware applied around the agent's node boundary (user_input -> Response).
  • model_middleware (list[Middleware] | None): Middleware applied around each raw model call (messages/schema/tools -> Response), inside the tool-calling loop.
  • _shape (Callable[_P, object]): Internal use only. Used to infer the ParamSpec for the agent's input shape.

NOTE: Supplying a parameter _shape will break typing and you will be responsible for it. DO NOT USE THIS!!

class MCPStdioParams(mcp.client.stdio.StdioServerParameters):
25class MCPStdioParams(StdioServerParameters):
26    """
27    Configuration parameters for STDIO-based MCP server connections.
28
29    Extends the standard StdioServerParameters with a timeout field.
30
31    Attributes:
32        timeout: Maximum time to wait for operations (default: 30 seconds)
33    """
34
35    timeout: timedelta = timedelta(seconds=30)
36
37    def as_stdio_params(self) -> StdioServerParameters:
38        """
39        Convert to standard StdioServerParameters, excluding the timeout field.
40
41        Returns:
42            StdioServerParameters without the timeout attribute
43        """
44        stdio_kwargs = self.dict(exclude={"timeout"})
45        return StdioServerParameters(**stdio_kwargs)

Configuration parameters for STDIO-based MCP server connections.

Extends the standard StdioServerParameters with a timeout field.

Attributes:
  • timeout: Maximum time to wait for operations (default: 30 seconds)
timeout: datetime.timedelta
def as_stdio_params(self) -> mcp.client.stdio.StdioServerParameters:
37    def as_stdio_params(self) -> StdioServerParameters:
38        """
39        Convert to standard StdioServerParameters, excluding the timeout field.
40
41        Returns:
42            StdioServerParameters without the timeout attribute
43        """
44        stdio_kwargs = self.dict(exclude={"timeout"})
45        return StdioServerParameters(**stdio_kwargs)

Convert to standard StdioServerParameters, excluding the timeout field.

Returns:

StdioServerParameters without the timeout attribute

model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class MCPHttpParams(pydantic.main.BaseModel):
48class MCPHttpParams(BaseModel):
49    """
50    Configuration parameters for HTTP-based MCP server connections.
51
52    Supports both SSE (Server-Sent Events) and streamable HTTP transports.
53    The transport type is automatically determined based on the URL.
54
55    Attributes:
56        url: The MCP server URL (use /sse suffix for SSE transport)
57        headers: Optional HTTP headers for authentication
58        timeout: Connection timeout (default: 30 seconds)
59        sse_read_timeout: SSE read timeout (default: 5 minutes)
60        terminate_on_close: Whether to terminate connection on close (default: True)
61        auth: Optional HTTPX authentication handler
62    """
63
64    model_config = {"arbitrary_types_allowed": True}
65
66    url: str
67    headers: dict[str, Any] | None = None
68    timeout: timedelta = timedelta(seconds=30)
69    sse_read_timeout: timedelta = timedelta(seconds=60 * 5)
70    terminate_on_close: bool = True
71    auth: httpx.Auth | None = None

Configuration parameters for HTTP-based MCP server connections.

Supports both SSE (Server-Sent Events) and streamable HTTP transports. The transport type is automatically determined based on the URL.

Attributes:
  • url: The MCP server URL (use /sse suffix for SSE transport)
  • headers: Optional HTTP headers for authentication
  • timeout: Connection timeout (default: 30 seconds)
  • sse_read_timeout: SSE read timeout (default: 5 minutes)
  • terminate_on_close: Whether to terminate connection on close (default: True)
  • auth: Optional HTTPX authentication handler
model_config = {'arbitrary_types_allowed': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

url: str
headers: dict[str, typing.Any] | None
timeout: datetime.timedelta
sse_read_timeout: datetime.timedelta
terminate_on_close: bool
auth: httpx.Auth | None
def connect_mcp( config: MCPStdioParams | MCPHttpParams, client_session: mcp.client.session.ClientSession | None = None, setup_timeout: float = 30) -> railtracks.rt_mcp.main.MCPServer:
 8def connect_mcp(
 9    config: MCPStdioParams | MCPHttpParams,
10    client_session: ClientSession | None = None,
11    setup_timeout: float = 30,
12) -> MCPServer:
13    """
14    Connect to an MCP server and return a server instance with available tools.
15
16    This is the primary entry point for using MCP servers in Railtracks.
17    The server will connect in the background, discover available tools,
18    and convert them to Railtracks Node classes.
19
20    The connection remains active until explicitly closed or the context exits.
21
22    Usage Examples:
23        # STDIO connection (local MCP server)
24        config = rt.MCPStdioParams(
25            command="uvx",
26            args=["mcp-server-time"]
27        )
28        server = rt.connect_mcp(config)
29
30        # HTTP connection (remote MCP server)
31        config = rt.MCPHttpParams(
32            url="https://mcp.example.com/sse",
33            headers={"Authorization": "Bearer token"}
34        )
35        server = rt.connect_mcp(config)
36
37        # Context manager (recommended)
38        with rt.connect_mcp(config) as server:
39            tools = server.tools
40            # Use tools...
41        # Automatically closed
42
43        # Access tools
44        for tool in server.tools:
45            print(f"Tool: {tool.name()}")
46            print(f"Description: {tool.tool_info().description}")
47
48    Args:
49        config: Server configuration:
50            - MCPStdioParams: For local servers via stdin/stdout
51            - MCPHttpParams: For remote servers via HTTP/SSE
52        client_session: Optional pre-configured ClientSession for advanced use cases.
53                       If not provided, a new session will be created automatically.
54        setup_timeout: Maximum seconds to wait for connection (default: 30).
55                      Increase for slow servers or complex authentication flows.
56
57    Returns:
58        MCPServer: Connected server instance with:
59            - tools: List of Node classes representing MCP tools
60            - close(): Method to explicitly close the connection
61            - Context manager support for automatic cleanup
62
63    Raises:
64        FileNotFoundError: If STDIO command not found. Verify the command is:
65                          - Installed and in your PATH
66                          - Spelled correctly (check for typos)
67                          - Executable (check permissions on Unix)
68        ConnectionError: If connection to server fails. Check:
69                        - Server URL is correct and accessible
70                        - Network connectivity and firewall settings
71                        - Authentication credentials are valid
72                        - Server is running and accepting connections
73        TimeoutError: If connection exceeds setup_timeout. Try:
74                     - Increasing setup_timeout parameter
75                     - Checking server performance/load
76                     - Verifying server is responding
77        RuntimeError: For other setup failures (e.g., protocol errors, config issues)
78
79    Note:
80        - The connection runs in a background thread for sync/async bridging
81        - Tools are cached after first retrieval for performance
82        - Always close() the server when done or use context manager
83        - Jupyter compatibility patches are applied automatically
84    """
85    # Apply Jupyter compatibility patches if needed
86    apply_patches()
87
88    return MCPServer(
89        config=config, client_session=client_session, setup_timeout=setup_timeout
90    )

Connect to an MCP server and return a server instance with available tools.

This is the primary entry point for using MCP servers in Railtracks. The server will connect in the background, discover available tools, and convert them to Railtracks Node classes.

The connection remains active until explicitly closed or the context exits.

Usage Examples:

STDIO connection (local MCP server)

config = rt.MCPStdioParams( command="uvx", args=["mcp-server-time"] ) server = rt.connect_mcp(config)

HTTP connection (remote MCP server)

config = rt.MCPHttpParams( url="https://mcp.example.com/sse", headers={"Authorization": "Bearer token"} ) server = rt.connect_mcp(config)

Context manager (recommended)

with rt.connect_mcp(config) as server: tools = server.tools # Use tools...

Automatically closed

Access tools

for tool in server.tools: print(f"Tool: {tool.name()}") print(f"Description: {tool.tool_info().description}")

Arguments:
  • config: Server configuration:
    • MCPStdioParams: For local servers via stdin/stdout
    • MCPHttpParams: For remote servers via HTTP/SSE
  • client_session: Optional pre-configured ClientSession for advanced use cases. If not provided, a new session will be created automatically.
  • setup_timeout: Maximum seconds to wait for connection (default: 30). Increase for slow servers or complex authentication flows.
Returns:

MCPServer: Connected server instance with: - tools: List of Node classes representing MCP tools - close(): Method to explicitly close the connection - Context manager support for automatic cleanup

Raises:
  • FileNotFoundError: If STDIO command not found. Verify the command is:
    • Installed and in your PATH
    • Spelled correctly (check for typos)
    • Executable (check permissions on Unix)
  • ConnectionError: If connection to server fails. Check:
    • Server URL is correct and accessible
    • Network connectivity and firewall settings
    • Authentication credentials are valid
    • Server is running and accepting connections
  • TimeoutError: If connection exceeds setup_timeout. Try:
    • Increasing setup_timeout parameter
    • Checking server performance/load
    • Verifying server is responding
  • RuntimeError: For other setup failures (e.g., protocol errors, config issues)
Note:
  • The connection runs in a background thread for sync/async bridging
  • Tools are cached after first retrieval for performance
  • Always close() the server when done or use context manager
  • Jupyter compatibility patches are applied automatically
def create_mcp_server( nodes: List[railtracks.nodes.nodes.Node | railtracks.built_nodes.function.base.RTFunction], server_name: str = 'MCP Server', fastmcp: mcp.server.fastmcp.server.FastMCP | None = None):
 89def create_mcp_server(
 90    nodes: List[Node | RTFunction],
 91    server_name: str = "MCP Server",
 92    fastmcp: FastMCP | None = None,
 93):
 94    """
 95    Create a FastMCP server that can be used to run nodes as MCP tools.
 96
 97    Args:
 98        nodes: List of Node classes to be registered as tools with the MCP server.
 99        server_name: Name of the MCP server instance.
100        fastmcp: Optional FastMCP instance to use instead of creating a new one.
101
102    Returns:
103        A FastMCP server instance.
104    """
105    if fastmcp is not None:
106        if not isinstance(fastmcp, FastMCP):
107            raise ValueError("Provided fastmcp must be an instance of FastMCP.")
108        mcp = fastmcp
109    else:
110        mcp = FastMCP(server_name)
111
112    for node in [n if not hasattr(n, "node_type") else n.node_type for n in nodes]:
113        node_info = node.tool_info()
114        func = _create_tool_function(node, node_info)
115
116        mcp._tool_manager._tools[node_info.name] = MCPTool(
117            fn=func,
118            name=node_info.name,
119            description=node_info.detail,
120            parameters=(
121                _parameters_to_json_schema(node_info.parameters)
122                if node_info.parameters is not None
123                else {}
124            ),
125            fn_metadata=func_metadata(func, []),
126            is_async=True,
127            context_kwarg=None,
128            annotations=None,
129        )  # Register the node as a tool
130
131    return mcp

Create a FastMCP server that can be used to run nodes as MCP tools.

Arguments:
  • nodes: List of Node classes to be registered as tools with the MCP server.
  • server_name: Name of the MCP server instance.
  • fastmcp: Optional FastMCP instance to use instead of creating a new one.
Returns:

A FastMCP server instance.

class ToolManifest:
 7class ToolManifest:
 8    """
 9    Creates a manifest for a tool, which includes its description and parameters.
10
11    Args:
12        description (str): A description of the tool.
13        parameters (Iterable[Parameter] | None): An iterable of parameters for the tool. If None, there are no paramerters.
14    """
15
16    def __init__(
17        self,
18        description: str,
19        parameters: Iterable[Parameter] | None = None,
20    ):
21        self.description = description
22        self.parameters: List[Parameter] = (
23            list(parameters) if parameters is not None else []
24        )

Creates a manifest for a tool, which includes its description and parameters.

Arguments:
  • description (str): A description of the tool.
  • parameters (Iterable[Parameter] | None): An iterable of parameters for the tool. If None, there are no paramerters.
ToolManifest( description: str, parameters: Optional[Iterable[railtracks.llm.Parameter]] = None)
16    def __init__(
17        self,
18        description: str,
19        parameters: Iterable[Parameter] | None = None,
20    ):
21        self.description = description
22        self.parameters: List[Parameter] = (
23            list(parameters) if parameters is not None else []
24        )
description
parameters: List[railtracks.llm.Parameter]
def session_id() -> str | None:
655def session_id() -> str | None:
656    """
657    Gets the current session ID if it exists, otherwise returns None.
658    """
659    try:
660        return get_session_id()
661    except ContextError:
662        return None

Gets the current session ID if it exists, otherwise returns None.

class Flow(typing.Generic[~_P, ~_TOutput]):
 19class Flow(Generic[_P, _TOutput]):
 20    """A reusable, configured entry point for running an agent graph.
 21
 22    Binds an entry-point node to a fixed set of runtime options so the same
 23    configuration can be invoked repeatedly.  Each invocation is fully isolated.
 24
 25    Typical usage::
 26
 27        flow = Flow("my-agent", entry_point=my_node, context={"user": "alice"})
 28        result = await flow.ainvoke(query)  # async (preferred)
 29        result = flow.invoke(query)  # sync
 30
 31    Args:
 32        name (str): A unique name for the flow. This is used for logging and state management.
 33        entry_point (Callable | RTSyncFunction | RTAsyncFunction): The starting point of your flow.
 34        context (dict[str, Any], optional): Context to be passed to all instantiations (or runs) of this flow. Note that the context can be overridden at invocation time.
 35        timeout (float, optional): The maximum number of seconds to wait for a response to your top-level request.
 36        end_on_error (bool, optional): If True, the execution will stop when an exception is encountered.
 37        broadcast_callback (Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None, optional): A passive listener for one-off events published with `rt.broadcast`.
 38        save_state (bool, optional): If True, the state of the execution will be saved to a file at the end of the run in the `.railtracks/data/sessions/` directory. This argument emits a DeprecationWarning.
 39            Default: True. Set `RAILTRACKS_DISABLE_EVENTS=True` to skip saving state regardless of this argument. If both are set, the environment variable takes precedence.
 40        payload_callback (Callable[[dict[str, Any]], None], optional): A callback function that will run upon completion of the flow with the final payload as an argument.
 41    """
 42
 43    def __init__(
 44        self,
 45        name: str,
 46        entry_point: (type[Node[_P, _TOutput]] | RTFunction[_P, _TOutput]),
 47        *,
 48        context: dict[str, Any] | None = None,
 49        timeout: float | None = None,
 50        end_on_error: bool | None = None,
 51        broadcast_callback: (
 52            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
 53        ) = None,
 54        save_state: bool | None = None,
 55        payload_callback: Callable[[dict[str, Any]], Any] | None = None,
 56    ) -> None:
 57        self.entry_point: type[Node[_P, _TOutput]]
 58
 59        if hasattr(entry_point, "node_type"):
 60            self.entry_point = entry_point.node_type
 61        else:
 62            self.entry_point = entry_point
 63
 64        if save_state is not None:
 65            warnings.warn(
 66                "The save_state parameter is being deprecated. Use the "
 67                "RAILTRACKS_DISABLE_EVENTS env var instead",
 68                DeprecationWarning,
 69                stacklevel=2,
 70            )
 71
 72        self.name = name
 73        self._context: dict[str, Any] = context or {}
 74        self._timeout = timeout
 75        self._end_on_error = end_on_error
 76        self._broadcast_callback = broadcast_callback
 77        self._save_state = save_state
 78        self._payload_callback = payload_callback
 79
 80    def update_context(self, context: dict[str, Any]) -> Flow[_P, _TOutput]:
 81        """Return a new Flow with additional context values merged in.
 82
 83        The original flow is not modified.  Values in ``context`` override
 84        any existing keys; keys not present in ``context`` are preserved.
 85
 86        Args:
 87            context: Entries to add or override in the flow's context.
 88
 89        Returns:
 90            A new :class:`Flow` instance with the merged context.
 91        """
 92        new_obj = deepcopy(self)
 93        new_obj._context.update(context)
 94        return new_obj
 95
 96    def connect(self) -> FlowConnection[_P, _TOutput]:
 97        """
 98        Opens a connection to this flow.
 99
100        A `FlowConnection` invokes the flow exactly as `invoke`/`ainvoke` do, and
101        additionally keeps the run's context reachable.
102
103            conn = flow.connect()
104            result = await conn.ainvoke("text") # not flow.ainvoke if context is desired
105
106        Returns:
107            FlowConnection: A connection to current flow.
108        """
109        return FlowConnection(self)
110
111    async def ainvoke(self, *args: _P.args, **kwargs: _P.kwargs) -> _TOutput:
112        return await self.connect().ainvoke(*args, **kwargs)
113
114    def invoke(self, *args: _P.args, **kwargs: _P.kwargs) -> _TOutput:
115        return self.connect().invoke(*args, **kwargs)
116
117    def equality_hash(self) -> str:
118        """Return a stable hash that identifies this flow's configuration.
119
120        Two flows with the same name produce the same hash regardless of
121        other parameters (timeout, context, etc.).
122        """
123        config_string = json.dumps(self._get_hash_content(), sort_keys=True)
124        return hashlib.sha256(config_string.encode()).hexdigest()
125
126    def _get_hash_content(self) -> dict:
127        return {
128            "name": self.name,
129        }

A reusable, configured entry point for running an agent graph.

Binds an entry-point node to a fixed set of runtime options so the same configuration can be invoked repeatedly. Each invocation is fully isolated.

Typical usage::

flow = Flow("my-agent", entry_point=my_node, context={"user": "alice"})
result = await flow.ainvoke(query)  # async (preferred)
result = flow.invoke(query)  # sync
Arguments:
  • name (str): A unique name for the flow. This is used for logging and state management.
  • entry_point (Callable | RTSyncFunction | RTAsyncFunction): The starting point of your flow.
  • context (dict[str, Any], optional): Context to be passed to all instantiations (or runs) of this flow. Note that the context can be overridden at invocation time.
  • timeout (float, optional): The maximum number of seconds to wait for a response to your top-level request.
  • end_on_error (bool, optional): If True, the execution will stop when an exception is encountered.
  • broadcast_callback (Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None, optional): A passive listener for one-off events published with rt.broadcast.
  • save_state (bool, optional): If True, the state of the execution will be saved to a file at the end of the run in the .railtracks/data/sessions/ directory. This argument emits a DeprecationWarning. Default: True. Set RAILTRACKS_DISABLE_EVENTS=True to skip saving state regardless of this argument. If both are set, the environment variable takes precedence.
  • payload_callback (Callable[[dict[str, Any]], None], optional): A callback function that will run upon completion of the flow with the final payload as an argument.
Flow( name: str, entry_point: Union[type[railtracks.nodes.nodes.Node[~_P, ~_TOutput]], railtracks.built_nodes.function.base.RTFunction[~_P, ~_TOutput]], *, context: dict[str, typing.Any] | None = None, timeout: float | None = None, end_on_error: bool | None = None, broadcast_callback: Union[Callable[[str], NoneType], Callable[[str], Coroutine[NoneType, NoneType, NoneType]], NoneType] = None, save_state: bool | None = None, payload_callback: Optional[Callable[[dict[str, Any]], Any]] = None)
43    def __init__(
44        self,
45        name: str,
46        entry_point: (type[Node[_P, _TOutput]] | RTFunction[_P, _TOutput]),
47        *,
48        context: dict[str, Any] | None = None,
49        timeout: float | None = None,
50        end_on_error: bool | None = None,
51        broadcast_callback: (
52            Callable[[str], None] | Callable[[str], Coroutine[None, None, None]] | None
53        ) = None,
54        save_state: bool | None = None,
55        payload_callback: Callable[[dict[str, Any]], Any] | None = None,
56    ) -> None:
57        self.entry_point: type[Node[_P, _TOutput]]
58
59        if hasattr(entry_point, "node_type"):
60            self.entry_point = entry_point.node_type
61        else:
62            self.entry_point = entry_point
63
64        if save_state is not None:
65            warnings.warn(
66                "The save_state parameter is being deprecated. Use the "
67                "RAILTRACKS_DISABLE_EVENTS env var instead",
68                DeprecationWarning,
69                stacklevel=2,
70            )
71
72        self.name = name
73        self._context: dict[str, Any] = context or {}
74        self._timeout = timeout
75        self._end_on_error = end_on_error
76        self._broadcast_callback = broadcast_callback
77        self._save_state = save_state
78        self._payload_callback = payload_callback
entry_point: type[railtracks.nodes.nodes.Node[~_P, ~_TOutput]]
name
def update_context( self, context: dict[str, typing.Any]) -> Flow[~_P, ~_TOutput]:
80    def update_context(self, context: dict[str, Any]) -> Flow[_P, _TOutput]:
81        """Return a new Flow with additional context values merged in.
82
83        The original flow is not modified.  Values in ``context`` override
84        any existing keys; keys not present in ``context`` are preserved.
85
86        Args:
87            context: Entries to add or override in the flow's context.
88
89        Returns:
90            A new :class:`Flow` instance with the merged context.
91        """
92        new_obj = deepcopy(self)
93        new_obj._context.update(context)
94        return new_obj

Return a new Flow with additional context values merged in.

The original flow is not modified. Values in context override any existing keys; keys not present in context are preserved.

Arguments:
  • context: Entries to add or override in the flow's context.
Returns:

A new Flow instance with the merged context.

def connect( self) -> FlowConnection[~_P, ~_TOutput]:
 96    def connect(self) -> FlowConnection[_P, _TOutput]:
 97        """
 98        Opens a connection to this flow.
 99
100        A `FlowConnection` invokes the flow exactly as `invoke`/`ainvoke` do, and
101        additionally keeps the run's context reachable.
102
103            conn = flow.connect()
104            result = await conn.ainvoke("text") # not flow.ainvoke if context is desired
105
106        Returns:
107            FlowConnection: A connection to current flow.
108        """
109        return FlowConnection(self)

Opens a connection to this flow.

A FlowConnection invokes the flow exactly as invoke/ainvoke do, and additionally keeps the run's context reachable.

conn = flow.connect()
result = await conn.ainvoke("text") # not flow.ainvoke if context is desired
Returns:

FlowConnection: A connection to current flow.

async def ainvoke(self, *args: _P.args, **kwargs: _P.kwargs) -> ~_TOutput:
111    async def ainvoke(self, *args: _P.args, **kwargs: _P.kwargs) -> _TOutput:
112        return await self.connect().ainvoke(*args, **kwargs)
def invoke(self, *args: _P.args, **kwargs: _P.kwargs) -> ~_TOutput:
114    def invoke(self, *args: _P.args, **kwargs: _P.kwargs) -> _TOutput:
115        return self.connect().invoke(*args, **kwargs)
def equality_hash(self) -> str:
117    def equality_hash(self) -> str:
118        """Return a stable hash that identifies this flow's configuration.
119
120        Two flows with the same name produce the same hash regardless of
121        other parameters (timeout, context, etc.).
122        """
123        config_string = json.dumps(self._get_hash_content(), sort_keys=True)
124        return hashlib.sha256(config_string.encode()).hexdigest()

Return a stable hash that identifies this flow's configuration.

Two flows with the same name produce the same hash regardless of other parameters (timeout, context, etc.).

class FlowConnection(typing.Generic[~_P, ~_TOutput]):
 45class FlowConnection(Generic[_P, _TOutput]):
 46    """
 47    A connection to a flow, through which it can be invoked.
 48
 49    Same invoke and behaviour as `Flow` object.
 50    """
 51
 52    def __init__(self, flow: Flow[_P, _TOutput]) -> None:
 53        self._flow = flow
 54        self._session: Session | None = None
 55        self._in_flight = False
 56
 57    async def ainvoke(self, *args: _P.args, **kwargs: _P.kwargs) -> _TOutput:
 58        """
 59        Runs the flow, leaving its context reachable on this connection.
 60
 61        Raises:
 62            RuntimeError: If this connection is already running an invocation.
 63        """
 64        if self._in_flight:
 65            raise RuntimeError(
 66                "This connection is already running an invocation. A connection "
 67                "handles one at a time\n use a separate `flow.connect()`"
 68            )
 69
 70        flow = self._flow
 71        self._in_flight = True
 72        try:
 73            with Session(
 74                context=deepcopy(flow._context),
 75                flow_name=flow.name,
 76                flow_id=flow.equality_hash(),
 77                name=None,
 78                timeout=flow._timeout,
 79                end_on_error=flow._end_on_error,
 80                broadcast_callback=flow._broadcast_callback,
 81                save_state=flow._save_state,
 82                payload_callback=flow._payload_callback,
 83            ) as session:
 84                # bound before the entry point runs, so the context of a failed
 85                # invocation is still reachable afterwards
 86                self._session = session
 87                return await call(flow.entry_point, *args, **kwargs)
 88        finally:
 89            self._in_flight = False
 90
 91    def invoke(self, *args: _P.args, **kwargs: _P.kwargs) -> _TOutput:
 92        """
 93        Synchronous `ainvoke`.
 94
 95        Note:
 96            When no event loop is running, blocks until the flow finishes.
 97            When called from inside a running event loop (e.g. a notebook or
 98            async framework), the run is dispatched to a worker thread with its
 99            own event loop; `contextvars.copy_context()` keeps Session and
100            logging context visible there.
101        """
102        try:
103            asyncio.get_running_loop()
104        except RuntimeError:
105            return asyncio.run(self.ainvoke(*args, **kwargs))
106
107        ctx = contextvars.copy_context()
108        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
109            future = pool.submit(ctx.run, asyncio.run, self.ainvoke(*args, **kwargs))
110            return future.result()
111
112    @property
113    def connected(self) -> bool:
114        """Whether this connection has begun an invocation yet."""
115        return self._session is not None
116
117    def _require_session(self) -> Session:
118        if self._session is None:
119            raise RuntimeError(
120                "Nothing has run on this connection yet, so it has no context to read.\n"
121                "\n"
122                "Ensure FlowConnection is invoked instead of base Flow instance:\n"
123                "\n"
124                "    conn = flow.connect()\n"
125                '    result = await conn.ainvoke("text") # NOT flow.ainvoke if context is desired\n'
126                '    conn.context.get("progress")\n'
127            )
128        return self._session
129
130    @property
131    def context(self) -> MutableExternalContext:
132        """
133        The context of the most recent invocation.
134
135        A live reference, not a copy. Context is not intended to be modified.
136
137        """
138        return self._require_session().context
139
140    @property
141    def session_id(self) -> str:
142        """Identifier of the session backing the most recent invocation."""
143        return self._require_session().identifier
144
145    @property
146    def session(self) -> Session:
147        """
148        The session backing the most recent invocation, for inspection.
149
150        Already closed; the connection owns its lifecycle. `Session.payload()`
151        and `Session.info` expose the run's state representation.
152        """
153        return self._require_session()
154
155    def message_histories(self) -> List[NodeMessageHistory]:
156        """
157        Every model conversation from the most recent invocation, in the order the
158        runs were recorded. Concurrently called nodes have no guaranteed order.
159
160        Covers nested agents, unlike an `LLMResponse`, which carries only its
161        own. Nodes that made no model calls are omitted.
162
163        Walks the run's requests on every call rather than caching, so hold the
164        result if you need it more than once.
165
166            for h in conn.message_histories():
167                print(h.node_name, len(h.message_history))
168
169        Returns:
170            List[NodeMessageHistory]: One entry per node that called a model.
171        """
172        info = self._require_session().info
173        node_forest = info.node_forest
174
175        histories: List[NodeMessageHistory] = []
176        # a request is inserted into the heap when it is opened
177        for request in info.request_forest.heap().values():
178            history = getattr(request.output, "message_history", None)
179            if history is None:
180                continue
181            node_type = node_forest.get_node_type(request.sink_id)
182            histories.append(
183                NodeMessageHistory(
184                    node_name=node_type.name()
185                    if node_type is not None
186                    else "<unknown>",
187                    node_id=request.sink_id,
188                    request_id=request.identifier,
189                    message_history=history,
190                )
191            )
192
193        return histories
194
195    def __repr__(self) -> str:
196        if self._session is None:
197            return f"FlowConnection(flow={self._flow.name!r}, not yet invoked)"
198        return (
199            f"FlowConnection(flow={self._flow.name!r}, session_id={self.session_id!r})"
200        )

A connection to a flow, through which it can be invoked.

Same invoke and behaviour as Flow object.

FlowConnection(flow: Flow[~_P, ~_TOutput])
52    def __init__(self, flow: Flow[_P, _TOutput]) -> None:
53        self._flow = flow
54        self._session: Session | None = None
55        self._in_flight = False
async def ainvoke(self, *args: _P.args, **kwargs: _P.kwargs) -> ~_TOutput:
57    async def ainvoke(self, *args: _P.args, **kwargs: _P.kwargs) -> _TOutput:
58        """
59        Runs the flow, leaving its context reachable on this connection.
60
61        Raises:
62            RuntimeError: If this connection is already running an invocation.
63        """
64        if self._in_flight:
65            raise RuntimeError(
66                "This connection is already running an invocation. A connection "
67                "handles one at a time\n use a separate `flow.connect()`"
68            )
69
70        flow = self._flow
71        self._in_flight = True
72        try:
73            with Session(
74                context=deepcopy(flow._context),
75                flow_name=flow.name,
76                flow_id=flow.equality_hash(),
77                name=None,
78                timeout=flow._timeout,
79                end_on_error=flow._end_on_error,
80                broadcast_callback=flow._broadcast_callback,
81                save_state=flow._save_state,
82                payload_callback=flow._payload_callback,
83            ) as session:
84                # bound before the entry point runs, so the context of a failed
85                # invocation is still reachable afterwards
86                self._session = session
87                return await call(flow.entry_point, *args, **kwargs)
88        finally:
89            self._in_flight = False

Runs the flow, leaving its context reachable on this connection.

Raises:
  • RuntimeError: If this connection is already running an invocation.
def invoke(self, *args: _P.args, **kwargs: _P.kwargs) -> ~_TOutput:
 91    def invoke(self, *args: _P.args, **kwargs: _P.kwargs) -> _TOutput:
 92        """
 93        Synchronous `ainvoke`.
 94
 95        Note:
 96            When no event loop is running, blocks until the flow finishes.
 97            When called from inside a running event loop (e.g. a notebook or
 98            async framework), the run is dispatched to a worker thread with its
 99            own event loop; `contextvars.copy_context()` keeps Session and
100            logging context visible there.
101        """
102        try:
103            asyncio.get_running_loop()
104        except RuntimeError:
105            return asyncio.run(self.ainvoke(*args, **kwargs))
106
107        ctx = contextvars.copy_context()
108        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
109            future = pool.submit(ctx.run, asyncio.run, self.ainvoke(*args, **kwargs))
110            return future.result()

Synchronous ainvoke.

Note:

When no event loop is running, blocks until the flow finishes. When called from inside a running event loop (e.g. a notebook or async framework), the run is dispatched to a worker thread with its own event loop; contextvars.copy_context() keeps Session and logging context visible there.

connected: bool
112    @property
113    def connected(self) -> bool:
114        """Whether this connection has begun an invocation yet."""
115        return self._session is not None

Whether this connection has begun an invocation yet.

context: railtracks.context.external.MutableExternalContext
130    @property
131    def context(self) -> MutableExternalContext:
132        """
133        The context of the most recent invocation.
134
135        A live reference, not a copy. Context is not intended to be modified.
136
137        """
138        return self._require_session().context

The context of the most recent invocation.

A live reference, not a copy. Context is not intended to be modified.

session_id: str
140    @property
141    def session_id(self) -> str:
142        """Identifier of the session backing the most recent invocation."""
143        return self._require_session().identifier

Identifier of the session backing the most recent invocation.

session: Session
145    @property
146    def session(self) -> Session:
147        """
148        The session backing the most recent invocation, for inspection.
149
150        Already closed; the connection owns its lifecycle. `Session.payload()`
151        and `Session.info` expose the run's state representation.
152        """
153        return self._require_session()

The session backing the most recent invocation, for inspection.

Already closed; the connection owns its lifecycle. Session.payload() and Session.info expose the run's state representation.

def message_histories(self) -> List[NodeMessageHistory]:
155    def message_histories(self) -> List[NodeMessageHistory]:
156        """
157        Every model conversation from the most recent invocation, in the order the
158        runs were recorded. Concurrently called nodes have no guaranteed order.
159
160        Covers nested agents, unlike an `LLMResponse`, which carries only its
161        own. Nodes that made no model calls are omitted.
162
163        Walks the run's requests on every call rather than caching, so hold the
164        result if you need it more than once.
165
166            for h in conn.message_histories():
167                print(h.node_name, len(h.message_history))
168
169        Returns:
170            List[NodeMessageHistory]: One entry per node that called a model.
171        """
172        info = self._require_session().info
173        node_forest = info.node_forest
174
175        histories: List[NodeMessageHistory] = []
176        # a request is inserted into the heap when it is opened
177        for request in info.request_forest.heap().values():
178            history = getattr(request.output, "message_history", None)
179            if history is None:
180                continue
181            node_type = node_forest.get_node_type(request.sink_id)
182            histories.append(
183                NodeMessageHistory(
184                    node_name=node_type.name()
185                    if node_type is not None
186                    else "<unknown>",
187                    node_id=request.sink_id,
188                    request_id=request.identifier,
189                    message_history=history,
190                )
191            )
192
193        return histories

Every model conversation from the most recent invocation, in the order the runs were recorded. Concurrently called nodes have no guaranteed order.

Covers nested agents, unlike an LLMResponse, which carries only its own. Nodes that made no model calls are omitted.

Walks the run's requests on every call rather than caching, so hold the result if you need it more than once.

for h in conn.message_histories():
    print(h.node_name, len(h.message_history))
Returns:

List[NodeMessageHistory]: One entry per node that called a model.

@dataclass(frozen=True)
class NodeMessageHistory:
26@dataclass(frozen=True)
27class NodeMessageHistory:
28    """
29    One node's conversation with its model.
30
31    Args:
32        node_name: Node that held the conversation, or
33            `"<unknown>"` if cannot resolve type.
34        node_id: Identifier of that node within the run.
35        request_id: Identifier of the request that produced it.
36        message_history: The messages exchanged, system prompt first.
37    """
38
39    node_name: str
40    node_id: str
41    request_id: str
42    message_history: MessageHistory

One node's conversation with its model.

Arguments:
  • node_name: Node that held the conversation, or "<unknown>" if cannot resolve type.
  • node_id: Identifier of that node within the run.
  • request_id: Identifier of the request that produced it.
  • message_history: The messages exchanged, system prompt first.
NodeMessageHistory( node_name: str, node_id: str, request_id: str, message_history: railtracks.llm.MessageHistory)
node_name: str
node_id: str
request_id: str
def enable_logging( level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL', 'NONE'] = 'INFO', log_file: str | os.PathLike | None = None, *, name_style: Literal['full', 'short'] = 'short') -> None:
324def enable_logging(
325    level: AllowableLogLevels = "INFO",
326    log_file: str | os.PathLike | None = None,
327    *,
328    name_style: LoggerNameDisplay = "short",
329) -> None:
330    """
331    Opt-in helper to enable Railtracks logging. Call this explicitly from your
332    application entry point (CLI, main.py, server startup); the library never
333    calls it automatically.
334
335    Uses the given level and log_file; when None, reads RT_LOG_LEVEL and
336    RT_LOG_FILE from the environment. Sets up console output (and optional file)
337    with a ThreadAwareFilter for per-thread level control.
338
339    Args:
340        level: Logging level (default "INFO"). Overridden by RT_LOG_LEVEL when None.
341        log_file: Optional path for a log file. Overridden by RT_LOG_FILE when None.
342        name_style: Console column for logger name: ``full`` (dotted name) or
343            ``short`` (``RT.<Label>``: last segment with leading non-letters stripped,
344            then capitalized). Default ``short``.
345    """
346    initialize_module_logging(
347        level=level,
348        log_file=log_file,
349        name_style=name_style,
350    )

Opt-in helper to enable Railtracks logging. Call this explicitly from your application entry point (CLI, main.py, server startup); the library never calls it automatically.

Uses the given level and log_file; when None, reads RT_LOG_LEVEL and RT_LOG_FILE from the environment. Sets up console output (and optional file) with a ThreadAwareFilter for per-thread level control.

Arguments:
  • level: Logging level (default "INFO"). Overridden by RT_LOG_LEVEL when None.
  • log_file: Optional path for a log file. Overridden by RT_LOG_FILE when None.
  • name_style: Console column for logger name: full (dotted name) or short (RT.<Label>: last segment with leading non-letters stripped, then capitalized). Default short.
def wrap_node( fn: Optional[Callable[Concatenate[Callable[~_P, Awaitable[~_R]], ~_P], Awaitable[~_R]]] = None, /, *, name: str | None = None) -> Union[railtracks.middleware.Middleware[~_P, ~_R], Callable[[Callable[Concatenate[Callable[~_P, Awaitable[~_R]], ~_P], Awaitable[~_R]]], railtracks.middleware.Middleware[~_P, ~_R]]]:
115def wrap_node(
116    fn: _Wrapper[_P, _R] | None = None,
117    /,
118    *,
119    name: str | None = None,
120) -> Middleware[_P, _R] | Callable[[_Wrapper[_P, _R]], Middleware[_P, _R]]:
121    if fn is None:
122        return lambda f: Middleware(f, name=name)
123    return Middleware(fn, name=name)
def post_node( fn: Union[Callable[[~_R], Awaitable[~_R]], Callable[[~_R], ~_R], NoneType] = None, /, *, name: str | None = None) -> Union[railtracks.middleware.Middleware[..., ~_R], Callable[[Union[Callable[[~_R], Awaitable[~_R]], Callable[[~_R], ~_R]]], railtracks.middleware.Middleware[..., ~_R]]]:
31def post_node(
32    fn: Callable[[_R], Awaitable[_R]] | Callable[[_R], _R] | None = None,
33    /,
34    *,
35    name: str | None = None,
36) -> (
37    Middleware[..., _R]
38    | Callable[
39        [Callable[[_R], Awaitable[_R]] | Callable[[_R], _R]], Middleware[..., _R]
40    ]
41):
42    """
43    Special decorator to create a middleware that runs after the node completes. The wrapped function will run and then your post function will be called upon successful completion of the function.
44
45    NOTE: This middleware will not run if the node raises an exception.
46    """
47
48    if fn is None:
49        return lambda f: wrap_node(_wrapper(f), name=name)
50
51    return wrap_node(_wrapper(fn), name=name)

Special decorator to create a middleware that runs after the node completes. The wrapped function will run and then your post function will be called upon successful completion of the function.

NOTE: This middleware will not run if the node raises an exception.

def after_node( fn: Union[Callable[[~_R], Awaitable[~_R]], Callable[[~_R], ~_R], NoneType] = None, /, *, name: str | None = None) -> Any:
28def after_node(
29    fn: Callable[[_R], Awaitable[_R]] | Callable[[_R], _R] | None = None,
30    /,
31    *,
32    name: str | None = None,
33) -> Any:
34    """Deprecated: Use ``rt.post_node`` instead."""
35    warn_pending_change(
36        "rt.after_node",
37        change="is renamed",
38        instead="rt.post_node",
39        detail="The function itself is unchanged.",
40    )
41    if fn is None:
42        return post_node(name=name)
43    return post_node(fn, name=name)

Deprecated: Use rt.post_node instead.

def couple( node: Union[type[railtracks.nodes.nodes.Node[~_P, ~_R]], railtracks.built_nodes.function.base.RTFunction[~_P, ~_R]], *, middleware: Optional[Iterable[railtracks.middleware.Middleware[~_P, ~_R]]] = None, model_middleware: Optional[Iterable[railtracks.middleware.Middleware[(<class 'railtracks.llm.MessageHistory'>, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None), railtracks.llm.Response]]] = None) -> Union[type[railtracks.nodes.nodes.Node[~_P, ~_R]], railtracks.built_nodes.function.base.RTFunction[~_P, ~_R]]:
 60def couple(
 61    node: type[Node[_P, _R]] | RTFunction[_P, _R],
 62    *,
 63    middleware: Iterable[Middleware[_P, _R]] | None = None,
 64    model_middleware: Iterable[ModelMiddleware] | None = None,
 65) -> type[Node[_P, _R]] | RTFunction[_P, _R]:
 66    """
 67    Attaches middleware to a Node or RTFunction. Returns a new deepcopied Node or RTFunction; the one passed in is never modified.
 68
 69    Args:
 70        node: The Node or RTFunction to attach middleware to.
 71        middleware: Middleware instances to attach to the node.
 72        model_middleware: ModelMiddleware instances to attach to the node.
 73
 74    Ordering:
 75    - Middleware = [A, B, C] -> A wraps B, B wraps C, C wraps the node. A -> B -> C -> Node -> C -> B -> A
 76    - Newly added middleware wraps around any existing middleware already on `node`
 77      (the new middleware ends up outermost, farthest from the node). Call `couple()`
 78      again on the result to add another, even-more-outer layer.
 79    """
 80    from railtracks.built_nodes.function.base import RTFunction
 81
 82    if middleware is None and model_middleware is None:
 83        return node
 84
 85    if isinstance(node, RTFunction):
 86        if model_middleware is not None:
 87            raise ValueError("Your function node does not have a model to wrap")
 88        if middleware:
 89            new_node_type = node.node_type.extend_middleware(*middleware)
 90        else:
 91            return node
 92
 93        return node.with_node_type(new_node_type)
 94
 95    new_klass = node
 96
 97    if middleware:
 98        new_klass = new_klass.extend_middleware(*middleware)
 99    if model_middleware:
100        new_klass = new_klass.extend_model_middleware(*model_middleware)
101
102    return new_klass

Attaches middleware to a Node or RTFunction. Returns a new deepcopied Node or RTFunction; the one passed in is never modified.

Arguments:
  • node: The Node or RTFunction to attach middleware to.
  • middleware: Middleware instances to attach to the node.
  • model_middleware: ModelMiddleware instances to attach to the node.

Ordering:

  • Middleware = [A, B, C] -> A wraps B, B wraps C, C wraps the node. A -> B -> C -> Node -> C -> B -> A
  • Newly added middleware wraps around any existing middleware already on node (the new middleware ends up outermost, farthest from the node). Call couple() again on the result to add another, even-more-outer layer.
def pre_llm( fn: Optional[Callable[[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Union[tuple[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Awaitable[tuple[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None]]]]] = None, /, *, name: str | None = None) -> Union[railtracks.middleware.Middleware[(<class 'railtracks.llm.MessageHistory'>, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None), railtracks.llm.Response], Callable[[Callable[[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Union[tuple[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Awaitable[tuple[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None]]]]], railtracks.middleware.Middleware[(<class 'railtracks.llm.MessageHistory'>, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None), railtracks.llm.Response]]]:
 51def pre_llm(
 52    fn: Callable[
 53        [MessageHistory, type[BaseModel] | None, list[Tool] | None],
 54        tuple[MessageHistory, type[BaseModel] | None, list[Tool] | None]
 55        | Awaitable[tuple[MessageHistory, type[BaseModel] | None, list[Tool] | None]],
 56    ]
 57    | None = None,
 58    /,
 59    *,
 60    name: str | None = None,
 61) -> (
 62    ModelMiddleware
 63    | Callable[
 64        [
 65            Callable[
 66                [MessageHistory, type[BaseModel] | None, list[Tool] | None],
 67                tuple[MessageHistory, type[BaseModel] | None, list[Tool] | None]
 68                | Awaitable[
 69                    tuple[MessageHistory, type[BaseModel] | None, list[Tool] | None]
 70                ],
 71            ]
 72        ],
 73        ModelMiddleware,
 74    ]
 75):
 76    """
 77    A special decorator to create a middleware that maps the inputs to a new input before every call to a model
 78
 79    Example usage:
 80    ```python
 81    @pre_llm
 82    async def my_middleware(message_history, schema, tools):
 83        # do something with the inputs
 84        return message_history, schema, tools
 85    ```
 86    """
 87
 88    def decorator(fn):
 89        @wrap_llm(name=name)
 90        @functools.wraps(fn)
 91        async def wrapper(
 92            llm_call: LLM_CALL,
 93            message_history: MessageHistory,
 94            schema: type[BaseModel] | None,
 95            tools: list[Tool] | None,
 96        ):
 97            invocation_event = MiddlewareModelInputInvocationEvent(
 98                message_history=message_history,
 99                schema=schema,
100                tools=tools,
101            )
102            await emit(invocation_event)
103
104            message_history, schema, tools = await unpack_async_sync(
105                fn(message_history, schema, tools)
106            )
107
108            response_event = MiddlewareModelInputResponseEvent(
109                message_history=message_history,
110                schema=schema,
111                tools=tools,
112            )
113            await emit(response_event)
114
115            return await llm_call(message_history, schema, tools)
116
117        return wrapper
118
119    if fn is None:
120        return decorator
121    return decorator(fn)

A special decorator to create a middleware that maps the inputs to a new input before every call to a model

Example usage:

@pre_llm
async def my_middleware(message_history, schema, tools):
    # do something with the inputs
    return message_history, schema, tools
def post_llm( fn: Optional[Callable[[railtracks.llm.Response], Union[railtracks.llm.Response, Awaitable[railtracks.llm.Response]]]] = None, /, *, name: str | None = None) -> Union[railtracks.middleware.Middleware[(<class 'railtracks.llm.MessageHistory'>, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None), railtracks.llm.Response], Callable[[Callable[[railtracks.llm.Response], Union[railtracks.llm.Response, Awaitable[railtracks.llm.Response]]]], railtracks.middleware.Middleware[(<class 'railtracks.llm.MessageHistory'>, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None), railtracks.llm.Response]]]:
37def post_llm(
38    fn: Callable[[Response], Response | Awaitable[Response]] | None = None,
39    /,
40    *,
41    name: str | None = None,
42) -> (
43    ModelMiddleware
44    | Callable[[Callable[[Response], Response | Awaitable[Response]]], ModelMiddleware]
45):
46    """
47    A special decorator to create a middleware that runs after every successful call to the model.
48
49    Example usage:
50    ```python
51    @post_llm
52    async def my_middleware(response):
53        # do something with the response
54        return response
55    ```
56    """
57
58    def decorator(fn):
59        @wrap_llm(name=name)
60        @functools.wraps(fn)
61        async def wrapper(
62            llm_call: LLM_CALL,
63            message_history: MessageHistory,
64            schema: type[BaseModel] | None,
65            tools: list[Tool] | None,
66        ):
67            response = await llm_call(message_history, schema, tools)
68
69            invocation_event = MiddlewareModelOutputInvocationEvent(
70                response=response,
71            )
72            await emit(invocation_event)
73
74            try:
75                response = await unpack_async_sync(fn(response))
76            except Exception as e:
77                failure_event = MiddlewareModelOutputFailureEvent.from_exception(e)
78                await emit(failure_event)
79                raise e
80
81            response_event = MiddlewareModelOutputResponseEvent(
82                response=response,
83            )
84            await emit(response_event)
85
86            return response
87
88        return wrapper
89
90    if fn is None:
91        return decorator
92    return decorator(fn)

A special decorator to create a middleware that runs after every successful call to the model.

Example usage:

@post_llm
async def my_middleware(response):
    # do something with the response
    return response
def before_llm( fn: Optional[Callable[[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Union[tuple[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Awaitable[tuple[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None]]]]] = None, /, *, name: str | None = None) -> Any:
46def before_llm(
47    fn: Callable[
48        [MessageHistory, type[BaseModel] | None, list[Tool] | None],
49        tuple[MessageHistory, type[BaseModel] | None, list[Tool] | None]
50        | Awaitable[tuple[MessageHistory, type[BaseModel] | None, list[Tool] | None]],
51    ]
52    | None = None,
53    /,
54    *,
55    name: str | None = None,
56) -> Any:
57    """Deprecated: Use ``rt.pre_llm`` instead."""
58    warn_pending_change(
59        "rt.before_llm",
60        change="is renamed",
61        instead="rt.pre_llm",
62        detail="The function itself is unchanged.",
63    )
64    if fn is None:
65        return pre_llm(name=name)
66    return pre_llm(fn, name=name)

Deprecated: Use rt.pre_llm instead.

def after_llm( fn: Optional[Callable[[railtracks.llm.Response], Union[railtracks.llm.Response, Awaitable[railtracks.llm.Response]]]] = None, /, *, name: str | None = None) -> Any:
27def after_llm(
28    fn: Callable[[Response], Response | Awaitable[Response]] | None = None,
29    /,
30    *,
31    name: str | None = None,
32) -> Any:
33    """Deprecated: Use ``rt.post_llm`` instead."""
34    warn_pending_change(
35        "rt.after_llm",
36        change="is renamed",
37        instead="rt.post_llm",
38        detail="The function itself is unchanged.",
39    )
40    if fn is None:
41        return post_llm(name=name)
42    return post_llm(fn, name=name)

Deprecated: Use rt.post_llm instead.

def wrap_llm( fn: Optional[Callable[[Callable[[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Awaitable[railtracks.llm.Response]], railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Awaitable[railtracks.llm.Response]]] = None, /, *, name: str | None = None) -> Union[railtracks.middleware.Middleware[(<class 'railtracks.llm.MessageHistory'>, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None), railtracks.llm.Response], Callable[[Callable[[Callable[[railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Awaitable[railtracks.llm.Response]], railtracks.llm.MessageHistory, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None], Awaitable[railtracks.llm.Response]]], railtracks.middleware.Middleware[(<class 'railtracks.llm.MessageHistory'>, type[pydantic.main.BaseModel] | None, list[railtracks.llm.Tool] | None), railtracks.llm.Response]]]:
 48def wrap_llm(
 49    fn: Callable[
 50        [LLM_CALL, MessageHistory, type[BaseModel] | None, list[Tool] | None],
 51        Awaitable[Response],
 52    ]
 53    | None = None,
 54    /,
 55    *,
 56    name: str | None = None,
 57) -> (
 58    ModelMiddleware
 59    | Callable[
 60        [
 61            Callable[
 62                [LLM_CALL, MessageHistory, type[BaseModel] | None, list[Tool] | None],
 63                Awaitable[Response],
 64            ]
 65        ],
 66        ModelMiddleware,
 67    ]
 68):
 69    """
 70    A special decorator to create a middleware wrapper that wraps every call to an llm
 71
 72    Example usage:
 73    ```python
 74    @wrap_llm
 75    async def my_middleware(llm_call, message_history, schema, tools):
 76        # do something with the inputs
 77        response = await llm_call(message_history, schema, tools)
 78        # do something with the response
 79        return response
 80    ```
 81    """
 82
 83    def decorator(fn):
 84        @wrap_node(name=name)
 85        @functools.wraps(fn)
 86        async def wrapped(
 87            llm_call: LLM_CALL,
 88            message_history: MessageHistory,
 89            schema: type[BaseModel] | None,
 90            tools: list[Tool] | None,
 91        ):
 92            invocation_event = MiddlewareModelInvocationEvent(
 93                message_history=message_history,
 94                schema=schema,
 95                tools=tools,
 96            )
 97            await emit(invocation_event)
 98
 99            try:
100                response = await fn(llm_call, message_history, schema, tools)
101            except Exception as e:
102                failure_event = MiddlewareModelFailureEvent.from_exception(e)
103                await emit(failure_event)
104                raise e
105
106            response_event = MiddlewareModelResponseEvent(
107                response=response,
108            )
109            await emit(response_event)
110
111            return response
112
113        return wrapped
114
115    if fn is None:
116        return decorator
117    return decorator(fn)

A special decorator to create a middleware wrapper that wraps every call to an llm

Example usage:

@wrap_llm
async def my_middleware(llm_call, message_history, schema, tools):
    # do something with the inputs
    response = await llm_call(message_history, schema, tools)
    # do something with the response
    return response
def input_guard( fn: Optional[Callable[[railtracks.guardrails.LLMGuardrailEvent], Union[railtracks.guardrails.GuardrailDecision, Awaitable[railtracks.guardrails.GuardrailDecision]]]] = None, *, name: str | None = None, fail_open: bool = False):
109def input_guard(
110    fn: _GuardFn | None = None,
111    *,
112    name: str | None = None,
113    fail_open: bool = False,
114):
115    """Turn a function into an :class:`InputGuard` instance.
116
117    The function receives an :class:`LLMGuardrailEvent` (INPUT phase; inspect
118    ``event.messages``) and returns a :class:`GuardrailDecision`. It may be sync or
119    ``async def``; an async rail is awaited, so it can ``await rt.call(...)``.
120
121    Usable bare or parameterized::
122
123        @rt.input_guard
124        def guard(event): ...
125
126
127        @rt.input_guard(name="my_rail", fail_open=True)
128        async def guard(event): ...
129
130    Args:
131        fn: The guard function (supplied automatically in the bare form).
132        name: Rail name for traces; defaults to the function name.
133        fail_open: Allow the request through if the guard raises unexpectedly.
134
135    Returns:
136        An :class:`InputGuard` instance in the bare form, or a decorator in the
137        parameterized form.
138    """
139
140    def decorate(func: _GuardFn, /) -> InputGuard:
141        return _make_guard(InputGuard, func, name=name, fail_open=fail_open)
142
143    if fn is not None:
144        return decorate(fn)
145    return decorate

Turn a function into an InputGuard instance.

The function receives an LLMGuardrailEvent (INPUT phase; inspect event.messages) and returns a GuardrailDecision. It may be sync or async def; an async rail is awaited, so it can await rt.call(...).

Usable bare or parameterized::

@rt.input_guard
def guard(event): ...


@rt.input_guard(name="my_rail", fail_open=True)
async def guard(event): ...
Arguments:
  • fn: The guard function (supplied automatically in the bare form).
  • name: Rail name for traces; defaults to the function name.
  • fail_open: Allow the request through if the guard raises unexpectedly.
Returns:

An InputGuard instance in the bare form, or a decorator in the parameterized form.

def output_guard( fn: Optional[Callable[[railtracks.guardrails.LLMGuardrailEvent], Union[railtracks.guardrails.GuardrailDecision, Awaitable[railtracks.guardrails.GuardrailDecision]]]] = None, *, name: str | None = None, fail_open: bool = False):
154def output_guard(
155    fn: _GuardFn | None = None,
156    *,
157    name: str | None = None,
158    fail_open: bool = False,
159):
160    """Turn a function into an :class:`OutputGuard` instance.
161
162    The function receives an :class:`LLMGuardrailEvent` (OUTPUT phase; inspect
163    ``event.output_message``) and returns a :class:`GuardrailDecision`. It may be
164    sync or ``async def``; an async rail is awaited, so it can ``await rt.call(...)``.
165    Intermediate tool-call turns are skipped by :class:`OutputGuard`, so the
166    function fires only on the final reply.
167
168    Usable bare or parameterized::
169
170        @rt.output_guard
171        def guard(event): ...
172
173
174        @rt.output_guard(name="my_rail", fail_open=True)
175        async def guard(event): ...
176
177    Args:
178        fn: The guard function (supplied automatically in the bare form).
179        name: Rail name for traces; defaults to the function name.
180        fail_open: Allow the response through if the guard raises unexpectedly.
181
182    Returns:
183        An :class:`OutputGuard` instance in the bare form, or a decorator in the
184        parameterized form.
185    """
186
187    def decorate(func: _GuardFn, /) -> OutputGuard:
188        return _make_guard(OutputGuard, func, name=name, fail_open=fail_open)
189
190    if fn is not None:
191        return decorate(fn)
192    return decorate

Turn a function into an OutputGuard instance.

The function receives an LLMGuardrailEvent (OUTPUT phase; inspect event.output_message) and returns a GuardrailDecision. It may be sync or async def; an async rail is awaited, so it can await rt.call(...). Intermediate tool-call turns are skipped by OutputGuard, so the function fires only on the final reply.

Usable bare or parameterized::

@rt.output_guard
def guard(event): ...


@rt.output_guard(name="my_rail", fail_open=True)
async def guard(event): ...
Arguments:
  • fn: The guard function (supplied automatically in the bare form).
  • name: Rail name for traces; defaults to the function name.
  • fail_open: Allow the response through if the guard raises unexpectedly.
Returns:

An OutputGuard instance in the bare form, or a decorator in the parameterized form.

def escape_braces(text: str) -> str:
10def escape_braces(text: str) -> str:
11    """
12    Escape the braces in `text` so that context injection treats it as data.
13
14    Apply this to untrusted or arbitrary strings before embedding them in a message that
15    will have context values injected into it. Injecting the returned string yields
16    `text` back unchanged.
17
18    Args:
19        text: The string to escape.
20
21    Returns:
22        `text` with every `{` and `}` doubled.
23    """
24    return text.replace("{", "{{").replace("}", "}}")

Escape the braces in text so that context injection treats it as data.

Apply this to untrusted or arbitrary strings before embedding them in a message that will have context values injected into it. Injecting the returned string yields text back unchanged.

Arguments:
  • text: The string to escape.
Returns:

text with every { and } doubled.