Of course. Building a robust remote procedure call (RPC) bridge between two different language runtimes requires a clear plan. Here is a detailed technical specification for creating a tool bridge that allows Python’s dspy.ReAct
module, running via Snakepit, to securely and efficiently call back into Elixir functions.
Technical Specification: DSPex Elixir-Python Tool Bridge
1. Overview
1.1. Purpose
The primary goal of this specification is to define an architecture for a bidirectional communication bridge that enables the dspy.ReAct
module in Python to invoke and receive results from tool functions written in Elixir. This allows developers to leverage the BEAM’s concurrency and fault tolerance for tool implementations while using DSPy’s advanced reasoning capabilities.
1.2. Problem Statement
The dspy.ReAct
module requires its tools
to be Python callable
objects (e.g., functions or class instances with a __call__
method). Elixir functions are not directly callable from the Python runtime. A simple placeholder, as identified in the debugging session, results in a ValidationError
.
1.3. Solution Architecture The proposed solution is a synchronous, in-band RPC mechanism built on top of the existing Snakepit port communication protocol. The bridge will:
- Register Elixir functions in a secure, session-aware registry on the Elixir side.
- Proxy these registered functions as callable Python objects (
RPCProxyTool
) on the Python side. - When a Python proxy tool is called, it will send a special RPC request back to the Elixir worker process that initiated the
dspy.ReAct
call. - The Elixir worker will dispatch the call to the correct Elixir function, execute it, and send the result back to the Python process.
- The Python process will receive the result and return it, unblocking the
dspy.ReAct
module to continue its reasoning loop.
This architecture avoids the complexity of out-of-band communication (e.g., separate HTTP servers or message queues) by reusing the existing, performant Port connection.
2. Core Components
2.1. Elixir-Side Components (dspex
)
DSPex.ToolRegistry
(New Module)- Type:
GenServer
. - Responsibility: Securely stores mappings from a unique
tool_id
(string) to an Elixir Module-Function-Arity (MFA
). - State: A
Map
holding{tool_id => {module, function, arity}}
. - API:
start_link/1
: Starts the GenServer.register(fun :: function()) :: {:ok, tool_id :: String.t()}
: Accepts an Elixir function, captures its MFA, generates a unique and secure ID, stores the mapping, and returns the ID.lookup(tool_id :: String.t()) :: {:ok, mfa} | {:error, :not_found}
: Retrieves the MFA for a given ID.
- Type:
Snakepit.Pool.Worker
(Modification)- Responsibility: This existing GenServer needs to be enhanced to handle the new in-band RPC calls from the Python bridge.
- Logic: Its
handle_info({port, {:data, data}}, state)
function will be modified to recognize a new message type for RPC calls, dispatch the execution, and send the result back through the port.
2.2. Python-Side Components (enhanced_bridge.py
)
RPCProxyTool
(New Class)- Type: A standard Python class.
- Responsibility: Acts as the callable proxy that
dspy.Tool
will wrap. It will be instantiated with atool_id
and a reference to theProtocolHandler
. - Methods:
__init__(self, tool_id, protocol_handler)
: Stores thetool_id
and the handler for communication.__call__(self, *args, **kwargs)
: The core RPC logic. This method will:- Construct an
rpc_tool_call
request packet. - Send it to Elixir using
protocol_handler.write_message()
. - Block and wait for the corresponding
rpc_tool_response
usingprotocol_handler.read_message()
. - Return the result from the response.
- Construct an
EnhancedCommandHandler
(Modification)- Responsibility: The
_execute_dynamic_call
method will be updated to detect when it’s creating adspy.ReAct
module. It will transform the placeholder tool definitions from Elixir into instances ofRPCProxyTool
.
- Responsibility: The
3. Communication Protocol Extension
The existing Snakepit length-prefixed protocol will be extended with two new message types for the RPC mechanism.
3.1. Python -> Elixir: RPC Call Request
When the RPCProxyTool
is called, it sends this message to the Elixir worker’s port.
- Format: JSON or MessagePack
- Schema:
{ "type": "rpc_call", "rpc_id": "<unique_id_for_this_call>", "tool_id": "<id_from_tool_registry>", "args": [...], // Positional arguments "kwargs": {...} // Keyword arguments }
3.2. Elixir -> Python: RPC Call Response
The Elixir worker sends this message back through the port after executing the tool function.
- Format: JSON or MessagePack
- Schema (Success):
{ "type": "rpc_response", "rpc_id": "<same_id_as_request>", "status": "ok", "result": <return_value_from_elixir_function> }
- Schema (Error):
{ "type": "rpc_response", "rpc_id": "<same_id_as_request>", "status": "error", "error": { "type": "<elixir_exception_type>", // e.g., "RuntimeError" "message": "<exception_message>", "stacktrace": "<optional_stacktrace_string>" } }
4. End-to-End Workflow
Setup (
DSPex.Modules.ReAct.create/3
):- An Elixir developer defines a list of tools:
tools = [%{name: "search", func: &MyApp.search/1, ...}]
. DSPex.Modules.ReAct.create/3
is called. It iterates through thetools
.- For each tool, it calls
DSPex.ToolRegistry.register(tool.func)
, which returns a uniquetool_id
. - It constructs the
kwargs
for thedspy.ReAct
Python call, replacing the Elixir function with thetool_id
:python_tools = [%{name: "search", tool_id: "tool_abc123", ...}]
. - The
Snakepit.Python.call("dspy.ReAct", %{tools: python_tools, ...})
command is sent.
- An Elixir developer defines a list of tools:
Python-Side Instantiation (
EnhancedCommandHandler
):- The
_execute_dynamic_call
method receives the request to instantiatedspy.ReAct
. - It sees the
tool_id
in the tool definitions. For each one, it creates anRPCProxyTool(tool_id, self.protocol_handler)
. - It then creates the final
dspy.Tool
object, passing theRPCProxyTool
instance as thefunc
argument:dspy.Tool(func=rpc_proxy_instance, ...)
. - The
dspy.ReAct
module is instantiated with these valid, callable Python tool objects and stored.
- The
Runtime Execution (
dspy.ReAct
calls a tool):- The
dspy.ReAct
module decides to use the “search” tool and calls it:search_tool("what is elixir")
. - This invokes the
__call__
method on the correspondingRPCProxyTool
instance.
- The
RPC Call (Python -> Elixir):
RPCProxyTool.__call__
generates a uniquerpc_id
and sends anrpc_call
message through itsprotocol_handler
to the Elixir port.- The Python bridge then blocks, waiting for a response message with the matching
rpc_id
.
RPC Dispatch (Elixir):
- The
Snakepit.Pool.Worker
’shandle_info
receives the port data. - It decodes the message and sees
%{type: "rpc_call"}
. - It calls
DSPex.ToolRegistry.lookup(tool_id)
to get theMFA
. - It safely executes the function using
apply(module, function, args)
. It wraps this call in atry/catch
block to handle Elixir exceptions.
- The
RPC Response (Elixir -> Python):
- If the Elixir function succeeds, the worker constructs and sends an
rpc_response
message withstatus: "ok"
and theresult
. - If the function fails, it sends an
rpc_response
withstatus: "error"
and the exception details. - The worker then goes back to waiting for more port messages (either another RPC call or the final response from
dspy.ReAct
).
- If the Elixir function succeeds, the worker constructs and sends an
Runtime Resumption (Python):
- The
RPCProxyTool.__call__
method receives therpc_response
, extracts the result (or raises a Python exception if an error was returned), and returns it. - The
dspy.ReAct
module receives the result from the tool call and continues its execution.
- The
Final Response:
- Eventually,
dspy.ReAct
finishes its work and returns a finalPrediction
object. - The
EnhancedCommandHandler
serializes this final result and sends it back to theSnakepit.Pool.Worker
as the response to the originalexecute
call, completing the entire workflow.
- Eventually,
5. Security Considerations
- Function Whitelisting: The
ToolRegistry
acts as a natural whitelist. Only functions explicitly registered during setup can be called from Python. Python cannot invoke arbitrary Elixir code. - Data Serialization: All arguments and return values are passed as standard data types (strings, numbers, lists, maps), not executable code, preventing code injection vulnerabilities across the boundary.
- Resource Limits: The execution happens within the context of the Elixir worker process. Standard BEAM process limits and timeouts can be applied to prevent runaway tool functions from destabilizing the system.
6. Future Enhancements
- Asynchronous Tools: The initial design is synchronous. A future version could support async tools by having the Elixir worker immediately acknowledge the RPC call and send the result later in a separate message, requiring a more complex state machine on the Python side.
- Streaming Tools: For tools that produce continuous output (e.g., tailing a log file), the protocol could be extended to support multiple
rpc_stream_chunk
messages followed by a finalrpc_stream_end
message. - Argument/Return Type Marshalling: Add support for more complex Elixir types (structs, tuples) by defining a clear serialization/deserialization scheme.