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"})
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:
- The parameters in the
Sessionconstructor. - The parameters in global context variables.
- The default values.
Default Values:
name: Nonetimeout: 150.0 secondsend_on_error: Falsebroadcast_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_stateis 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.
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)
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:
- The parameters in the method parameters.
- The parameters in global context variables.
- The default values.
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.
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.
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)
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:
- The original function's return value
- 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_stateis 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).
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
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 usert.callfor 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.
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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
```
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 )
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=Trueskips the write regardless. Otherwise, explicit value wins; when unset, defaults to True (save).
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.
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.
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.
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!!
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)
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
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
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
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.
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.
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.
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. SetRAILTRACKS_DISABLE_EVENTS=Trueto 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.
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
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
Flowinstance with the merged context.
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.
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.).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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) orshort(RT.<Label>: last segment with leading non-letters stripped, then capitalized). Defaultshort.
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.
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.
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). Callcouple()again on the result to add another, even-more-outer layer.
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
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
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.
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.
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
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
InputGuardinstance in the bare form, or a decorator in the parameterized form.
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
OutputGuardinstance in the bare form, or a decorator in the parameterized form.
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:
textwith every{and}doubled.