@@ -55,6 +55,7 @@ class Runner
5555    DEFAULT_MAX_TURNS  =  10 
5656
5757    class  MaxTurnsExceeded  < StandardError ;  end 
58+     class  AgentNotFoundError  < StandardError ;  end 
5859
5960    # Create a thread-safe agent runner for multi-agent conversations. 
6061    # The first agent becomes the default entry point for new conversations. 
@@ -91,14 +92,15 @@ def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX
9192      current_turn  =  0 
9293
9394      # Create chat and restore conversation history 
94-       chat  =  create_chat ( current_agent ,  context_wrapper ) 
95+       chat  =  RubyLLM ::Chat . new ( model : current_agent . model ) 
96+       configure_chat_for_agent ( chat ,  current_agent ,  context_wrapper ,  replace : false ) 
9597      restore_conversation_history ( chat ,  context_wrapper ) 
9698
9799      loop  do 
98100        current_turn  += 1 
99101        raise  MaxTurnsExceeded ,  "Exceeded maximum turns: #{ max_turns }  "  if  current_turn  > max_turns 
100102
101-         # Get response from LLM (Extended Chat  handles tool execution with handoff detection) 
103+         # Get response from LLM (RubyLLM  handles tool execution with halting based  handoff detection) 
102104        result  =  if  current_turn  == 1 
103105                   # Emit agent thinking event for initial message 
104106                   context_wrapper . callback_manager . emit_agent_thinking ( current_agent . name ,  input ) 
@@ -118,14 +120,14 @@ def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX
118120          # Validate that the target agent is in our registry 
119121          # This prevents handoffs to agents that weren't explicitly provided 
120122          unless  registry [ next_agent . name ] 
121-             puts  "[Agents] Warning: Handoff to unregistered agent '#{ next_agent . name }  ', continuing with current agent" 
122-             # Return the halt content as the final response 
123123            save_conversation_state ( chat ,  context_wrapper ,  current_agent ) 
124+             error  =  AgentNotFoundError . new ( "Handoff failed: Agent '#{ next_agent . name }  ' not found in registry" ) 
124125            return  RunResult . new ( 
125-               output : response . content , 
126+               output : nil , 
126127              messages : MessageExtractor . extract_messages ( chat ,  current_agent ) , 
127128              usage : context_wrapper . usage , 
128-               context : context_wrapper . context 
129+               context : context_wrapper . context , 
130+               error : error 
129131            ) 
130132          end 
131133
@@ -139,9 +141,8 @@ def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX
139141          current_agent  =  next_agent 
140142          context_wrapper . context [ :current_agent ]  =  next_agent . name 
141143
142-           # Create new chat for new agent with restored history 
143-           chat  =  create_chat ( current_agent ,  context_wrapper ) 
144-           restore_conversation_history ( chat ,  context_wrapper ) 
144+           # Reconfigure existing chat for new agent - preserves conversation history automatically 
145+           configure_chat_for_agent ( chat ,  current_agent ,  context_wrapper ,  replace : true ) 
145146
146147          # Force the new agent to respond to the conversation context 
147148          # This ensures the user gets a response from the new agent 
@@ -201,6 +202,11 @@ def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX
201202
202203    private 
203204
205+     # Creates a deep copy of context data for thread safety. 
206+     # Preserves conversation history array structure while avoiding agent mutation. 
207+     # 
208+     # @param context [Hash] The context to copy 
209+     # @return [Hash] Thread-safe deep copy of the context 
204210    def  deep_copy_context ( context ) 
205211      # Handle deep copying for thread safety 
206212      context . dup . tap  do  |copied |
@@ -211,6 +217,11 @@ def deep_copy_context(context)
211217      end 
212218    end 
213219
220+     # Restores conversation history from context into RubyLLM chat. 
221+     # Converts stored message hashes back into RubyLLM::Message objects with proper content handling. 
222+     # 
223+     # @param chat [RubyLLM::Chat] The chat instance to restore history into 
224+     # @param context_wrapper [RunContext] Context containing conversation history 
214225    def  restore_conversation_history ( chat ,  context_wrapper ) 
215226      history  =  context_wrapper . context [ :conversation_history ]  || [ ] 
216227
@@ -228,18 +239,15 @@ def restore_conversation_history(chat, context_wrapper)
228239          content : content 
229240        ) 
230241        chat . add_message ( message ) 
231-       rescue  StandardError  =>  e 
232-         # Continue with partial history on error 
233-         # TODO: Remove this, and let the error propagate up the call stack 
234-         puts  "[Agents] Failed to restore message: #{ e . message } \n #{ e . backtrace . join ( "\n " ) }  " 
235242      end 
236-     rescue  StandardError  =>  e 
237-       # If history restoration completely fails, continue with empty history 
238-       # TODO: Remove this, and let the error propagate up the call stack 
239-       puts  "[Agents] Failed to restore conversation history: #{ e . message }  " 
240-       context_wrapper . context [ :conversation_history ]  =  [ ] 
241243    end 
242244
245+     # Saves current conversation state from RubyLLM chat back to context for persistence. 
246+     # Maintains conversation continuity across agent handoffs and process boundaries. 
247+     # 
248+     # @param chat [RubyLLM::Chat] The chat instance to extract state from 
249+     # @param context_wrapper [RunContext] Context to save state into 
250+     # @param current_agent [Agents::Agent] The currently active agent 
243251    def  save_conversation_state ( chat ,  context_wrapper ,  current_agent ) 
244252      # Extract messages from chat 
245253      messages  =  MessageExtractor . extract_messages ( chat ,  current_agent ) 
@@ -254,14 +262,39 @@ def save_conversation_state(chat, context_wrapper, current_agent)
254262      context_wrapper . context . delete ( :pending_handoff ) 
255263    end 
256264
257-     def  create_chat ( agent ,  context_wrapper ) 
265+     # Configures a RubyLLM chat instance with agent-specific settings. 
266+     # Uses RubyLLM's replace option to swap agent context while preserving conversation history during handoffs. 
267+     # 
268+     # @param chat [RubyLLM::Chat] The chat instance to configure 
269+     # @param agent [Agents::Agent] The agent whose configuration to apply 
270+     # @param context_wrapper [RunContext] Thread-safe context wrapper 
271+     # @param replace [Boolean] Whether to replace existing configuration (true for handoffs, false for initial setup) 
272+     # @return [RubyLLM::Chat] The configured chat instance 
273+     def  configure_chat_for_agent ( chat ,  agent ,  context_wrapper ,  replace : false ) 
258274      # Get system prompt (may be dynamic) 
259275      system_prompt  =  agent . get_system_prompt ( context_wrapper ) 
260276
261-       # Create standard RubyLLM chat 
262-       chat  =  RubyLLM ::Chat . new ( model : agent . model ) 
263- 
264277      # Combine all tools - both handoff and regular tools need wrapping 
278+       all_tools  =  build_agent_tools ( agent ,  context_wrapper ) 
279+ 
280+       # Switch model if different (important for handoffs between agents using different models) 
281+       chat . with_model ( agent . model )  if  replace 
282+ 
283+       # Configure chat with instructions, temperature, tools, and schema 
284+       chat . with_instructions ( system_prompt ,  replace : replace )  if  system_prompt 
285+       chat . with_temperature ( agent . temperature )  if  agent . temperature 
286+       chat . with_tools ( *all_tools ,  replace : replace ) 
287+       chat . with_schema ( agent . response_schema )  if  agent . response_schema 
288+ 
289+       chat 
290+     end 
291+ 
292+     # Builds thread-safe tool wrappers for an agent's tools and handoff tools. 
293+     # 
294+     # @param agent [Agents::Agent] The agent whose tools to wrap 
295+     # @param context_wrapper [RunContext] Thread-safe context wrapper for tool execution 
296+     # @return [Array<ToolWrapper>] Array of wrapped tools ready for RubyLLM 
297+     def  build_agent_tools ( agent ,  context_wrapper ) 
265298      all_tools  =  [ ] 
266299
267300      # Add handoff tools 
@@ -275,13 +308,7 @@ def create_chat(agent, context_wrapper)
275308        all_tools  << ToolWrapper . new ( tool ,  context_wrapper ) 
276309      end 
277310
278-       # Configure chat with instructions, temperature, tools, and schema 
279-       chat . with_instructions ( system_prompt )  if  system_prompt 
280-       chat . with_temperature ( agent . temperature )  if  agent . temperature 
281-       chat . with_tools ( *all_tools )  if  all_tools . any? 
282-       chat . with_schema ( agent . response_schema )  if  agent . response_schema 
283- 
284-       chat 
311+       all_tools 
285312    end 
286313  end 
287314end 
0 commit comments