ai-agents 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/bump-version.md +44 -0
  3. data/CHANGELOG.md +35 -0
  4. data/CLAUDE.md +59 -15
  5. data/README.md +29 -106
  6. data/docs/Gemfile +14 -0
  7. data/docs/Gemfile.lock +183 -0
  8. data/docs/_config.yml +53 -0
  9. data/docs/_sass/color_schemes/ruby.scss +72 -0
  10. data/docs/_sass/custom/custom.scss +93 -0
  11. data/docs/architecture.md +353 -0
  12. data/docs/assets/fonts/InterVariable.woff2 +0 -0
  13. data/docs/concepts/agent-tool.md +166 -0
  14. data/docs/concepts/agents.md +43 -0
  15. data/docs/concepts/callbacks.md +42 -0
  16. data/docs/concepts/context.md +110 -0
  17. data/docs/concepts/handoffs.md +81 -0
  18. data/docs/concepts/runner.md +87 -0
  19. data/docs/concepts/tools.md +62 -0
  20. data/docs/concepts.md +22 -0
  21. data/docs/guides/agent-as-tool-pattern.md +242 -0
  22. data/docs/guides/multi-agent-systems.md +261 -0
  23. data/docs/guides/rails-integration.md +440 -0
  24. data/docs/guides/state-persistence.md +451 -0
  25. data/docs/guides.md +18 -0
  26. data/docs/index.md +97 -0
  27. data/examples/collaborative-copilot/README.md +169 -0
  28. data/examples/collaborative-copilot/agents/analysis_agent.rb +48 -0
  29. data/examples/collaborative-copilot/agents/answer_suggestion_agent.rb +50 -0
  30. data/examples/collaborative-copilot/agents/copilot_orchestrator.rb +85 -0
  31. data/examples/collaborative-copilot/agents/integrations_agent.rb +58 -0
  32. data/examples/collaborative-copilot/agents/research_agent.rb +52 -0
  33. data/examples/collaborative-copilot/data/contacts.json +47 -0
  34. data/examples/collaborative-copilot/data/conversations.json +170 -0
  35. data/examples/collaborative-copilot/data/knowledge_base.json +58 -0
  36. data/examples/collaborative-copilot/data/linear_issues.json +83 -0
  37. data/examples/collaborative-copilot/data/stripe_billing.json +71 -0
  38. data/examples/collaborative-copilot/interactive.rb +90 -0
  39. data/examples/collaborative-copilot/tools/create_linear_ticket_tool.rb +58 -0
  40. data/examples/collaborative-copilot/tools/get_article_tool.rb +41 -0
  41. data/examples/collaborative-copilot/tools/get_contact_tool.rb +51 -0
  42. data/examples/collaborative-copilot/tools/get_conversation_tool.rb +53 -0
  43. data/examples/collaborative-copilot/tools/get_stripe_billing_tool.rb +44 -0
  44. data/examples/collaborative-copilot/tools/search_contacts_tool.rb +57 -0
  45. data/examples/collaborative-copilot/tools/search_conversations_tool.rb +54 -0
  46. data/examples/collaborative-copilot/tools/search_knowledge_base_tool.rb +55 -0
  47. data/examples/collaborative-copilot/tools/search_linear_issues_tool.rb +60 -0
  48. data/examples/isp-support/interactive.rb +43 -4
  49. data/lib/agents/agent.rb +34 -0
  50. data/lib/agents/agent_runner.rb +66 -1
  51. data/lib/agents/agent_tool.rb +113 -0
  52. data/lib/agents/callback_manager.rb +54 -0
  53. data/lib/agents/handoff.rb +8 -34
  54. data/lib/agents/message_extractor.rb +82 -0
  55. data/lib/agents/run_context.rb +5 -2
  56. data/lib/agents/runner.rb +16 -27
  57. data/lib/agents/tool_wrapper.rb +11 -1
  58. data/lib/agents/version.rb +1 -1
  59. data/lib/agents.rb +3 -0
  60. metadata +48 -1
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Copilot
6
+ # Tool for searching knowledge base articles and documentation
7
+ class SearchKnowledgeBaseTool < Agents::Tool
8
+ description "Search help documentation and knowledge base for solutions"
9
+ param :query, type: "string", desc: "Search terms or keywords to find relevant articles"
10
+ param :category, type: "string",
11
+ desc: "Optional: filter by category (troubleshooting, account, development, billing)", required: false
12
+
13
+ def perform(_tool_context, query:, category: nil)
14
+ data_file = File.join(__dir__, "../data/knowledge_base.json")
15
+ return "Knowledge base unavailable" unless File.exist?(data_file)
16
+
17
+ begin
18
+ kb_data = JSON.parse(File.read(data_file))
19
+ articles = kb_data["articles"]
20
+ query_text = query.downcase
21
+ matches = []
22
+
23
+ articles.each do |article_id, article|
24
+ # Skip if category filter is specified and doesn't match
25
+ next if category && article["category"] != category.downcase
26
+
27
+ # Simple keyword matching
28
+ text_to_search = [
29
+ article["title"],
30
+ article["tags"].join(" "),
31
+ article["content"]
32
+ ].join(" ").downcase
33
+
34
+ next unless text_to_search.include?(query_text)
35
+
36
+ matches << {
37
+ article_id: article_id,
38
+ title: article["title"],
39
+ category: article["category"],
40
+ tags: article["tags"],
41
+ content_preview: "#{article["content"][0...200]}..."
42
+ }
43
+ end
44
+
45
+ if matches.empty?
46
+ "No knowledge base articles found for: #{query}"
47
+ else
48
+ JSON.pretty_generate({ query: query, articles: matches })
49
+ end
50
+ rescue StandardError => e
51
+ "Error searching knowledge base: #{e.message}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Copilot
6
+ # Tool for searching Linear issues for development context and bug reports
7
+ class SearchLinearIssuesTool < Agents::Tool
8
+ description "Search Linear issues for bug reports, feature requests, and development context"
9
+ param :query, type: "string", desc: "Search terms (keywords, error messages, or feature descriptions)"
10
+ param :status, type: "string", desc: "Optional: filter by status (backlog, in_progress, completed, resolved)",
11
+ required: false
12
+ param :priority, type: "string", desc: "Optional: filter by priority (low, medium, high)", required: false
13
+
14
+ def perform(_tool_context, query:, status: nil, priority: nil)
15
+ data_file = File.join(__dir__, "../data/linear_issues.json")
16
+ return "Linear issues database unavailable" unless File.exist__(data_file)
17
+
18
+ begin
19
+ linear_data = JSON.parse(File.read(data_file))
20
+ issues = linear_data["issues"]
21
+ query_text = query.downcase
22
+ matches = []
23
+
24
+ issues.each do |issue_id, issue|
25
+ # Apply filters
26
+ next if status && issue["status"] != status.downcase
27
+ next if priority && issue["priority"] != priority.downcase
28
+
29
+ # Simple keyword matching
30
+ text_to_search = [
31
+ issue["title"],
32
+ issue["description"],
33
+ issue["labels"].join(" "),
34
+ issue["resolution"]
35
+ ].compact.join(" ").downcase
36
+
37
+ next unless text_to_search.include?(query_text)
38
+
39
+ matches << {
40
+ issue_id: issue_id,
41
+ title: issue["title"],
42
+ status: issue["status"],
43
+ priority: issue["priority"],
44
+ labels: issue["labels"],
45
+ description: "#{issue["description"][0...200]}...",
46
+ resolution: issue["resolution"]
47
+ }
48
+ end
49
+
50
+ if matches.empty?
51
+ "No Linear issues found for: #{query}"
52
+ else
53
+ JSON.pretty_generate({ query: query, issues: matches })
54
+ end
55
+ rescue StandardError => e
56
+ "Error searching Linear issues: #{e.message}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -14,16 +14,20 @@ class ISPSupportDemo
14
14
  end
15
15
 
16
16
  # Create agents
17
- agents = ISPSupport::AgentsFactory.create_agents
17
+ @agents = ISPSupport::AgentsFactory.create_agents
18
18
 
19
19
  # Create thread-safe runner with all agents (triage first = default entry point)
20
20
  @runner = Agents::Runner.with_agents(
21
- agents[:triage],
22
- agents[:sales],
23
- agents[:support]
21
+ @agents[:triage],
22
+ @agents[:sales],
23
+ @agents[:support]
24
24
  )
25
25
 
26
+ # Setup real-time callbacks for UI feedback
27
+ setup_callbacks
28
+
26
29
  @context = {}
30
+ @current_status = ""
27
31
 
28
32
  puts "🏢 Welcome to ISP Customer Support!"
29
33
  puts "Type '/help' for commands or 'exit' to quit."
@@ -39,12 +43,18 @@ class ISPSupportDemo
39
43
  break if command_result == :exit
40
44
  next if command_result == :handled || user_input.empty?
41
45
 
46
+ # Clear any previous status and show agent is working
47
+ clear_status_line
48
+ print "🤖 Processing..."
49
+
42
50
  # Use the runner - it automatically determines the right agent from context
43
51
  result = @runner.run(user_input, context: @context)
44
52
 
45
53
  # Update our context with the returned context from Runner
46
54
  @context = result.context if result.respond_to?(:context) && result.context
47
55
 
56
+ # Clear status and show response
57
+ clear_status_line
48
58
  puts "🤖 #{result.output || "[No output]"}"
49
59
 
50
60
  puts
@@ -53,6 +63,35 @@ class ISPSupportDemo
53
63
 
54
64
  private
55
65
 
66
+ def setup_callbacks
67
+ @runner.on_agent_thinking do |agent_name, _input|
68
+ update_status("🧠 #{agent_name} is thinking...")
69
+ end
70
+
71
+ @runner.on_tool_start do |tool_name, _args|
72
+ update_status("🔧 Using #{tool_name}...")
73
+ end
74
+
75
+ @runner.on_tool_complete do |tool_name, _result|
76
+ update_status("✅ #{tool_name} completed")
77
+ end
78
+
79
+ @runner.on_agent_handoff do |from_agent, to_agent, _reason|
80
+ update_status("🔄 Handoff: #{from_agent} → #{to_agent}")
81
+ end
82
+ end
83
+
84
+ def update_status(message)
85
+ clear_status_line
86
+ print message
87
+ $stdout.flush
88
+ end
89
+
90
+ def clear_status_line
91
+ print "\r#{" " * 80}\r" # Clear the current line
92
+ $stdout.flush
93
+ end
94
+
56
95
  def handle_command(input)
57
96
  case input.downcase
58
97
  when "exit", "quit"
data/lib/agents/agent.rb CHANGED
@@ -178,5 +178,39 @@ module Agents
178
178
  instructions.call(context)
179
179
  end
180
180
  end
181
+
182
+ # Transform this agent into a tool, callable by other agents.
183
+ # This enables agent-to-agent collaboration without conversation handoffs.
184
+ #
185
+ # Agent-as-tool is different from handoffs in two key ways:
186
+ # 1. The wrapped agent receives generated input, not conversation history
187
+ # 2. The wrapped agent returns a result to the calling agent, rather than taking over
188
+ #
189
+ # @param name [String, nil] Override the tool name (defaults to snake_case agent name)
190
+ # @param description [String, nil] Override the tool description
191
+ # @param output_extractor [Proc, nil] Custom proc to extract/transform the agent's output
192
+ # @param params [Hash] Additional parameter definitions for the tool
193
+ # @return [Agents::AgentTool] A tool that wraps this agent
194
+ #
195
+ # @example Basic agent-as-tool
196
+ # research_agent = Agent.new(name: "Researcher", instructions: "Research topics")
197
+ # research_tool = research_agent.as_tool(
198
+ # name: "research_topic",
199
+ # description: "Research a topic using company knowledge base"
200
+ # )
201
+ #
202
+ # @example Custom output extraction
203
+ # analyzer_tool = analyzer_agent.as_tool(
204
+ # output_extractor: ->(result) { result.context[:extracted_data]&.to_json || result.output }
205
+ # )
206
+ #
207
+ def as_tool(name: nil, description: nil, output_extractor: nil)
208
+ AgentTool.new(
209
+ agent: self,
210
+ name: name,
211
+ description: description,
212
+ output_extractor: output_extractor
213
+ )
214
+ end
181
215
  end
182
216
  end
@@ -11,6 +11,8 @@ module Agents
11
11
  # ## Usage Pattern
12
12
  # # Create once (typically at application startup)
13
13
  # runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
14
+ # .on_tool_start { |tool_name, args| broadcast_event('tool_start', tool_name, args) }
15
+ # .on_tool_complete { |tool_name, result| broadcast_event('tool_complete', tool_name, result) }
14
16
  #
15
17
  # # Use safely from multiple threads
16
18
  # result = runner.run("I need billing help") # New conversation
@@ -22,6 +24,10 @@ module Agents
22
24
  # - Each run() call creates independent execution context
23
25
  # - No shared mutable state between concurrent executions
24
26
  #
27
+ # ## Callback Thread Safety
28
+ # Callback registration is thread-safe using internal synchronization. Multiple threads
29
+ # can safely register callbacks concurrently without data races.
30
+ #
25
31
  class AgentRunner
26
32
  # Initialize with a list of agents. The first agent becomes the default entry point.
27
33
  #
@@ -30,10 +36,19 @@ module Agents
30
36
  raise ArgumentError, "At least one agent must be provided" if agents.empty?
31
37
 
32
38
  @agents = agents.dup.freeze
39
+ @callbacks_mutex = Mutex.new
33
40
  @default_agent = agents.first
34
41
 
35
42
  # Build simple registry from provided agents - developer controls what's available
36
43
  @registry = build_registry(agents).freeze
44
+
45
+ # Initialize callback storage - use thread-safe arrays
46
+ @callbacks = {
47
+ tool_start: [],
48
+ tool_complete: [],
49
+ agent_thinking: [],
50
+ agent_handoff: []
51
+ }
37
52
  end
38
53
 
39
54
  # Execute a conversation turn with automatic agent selection.
@@ -50,15 +65,65 @@ module Agents
50
65
  current_agent = determine_conversation_agent(context)
51
66
 
52
67
  # Execute using stateless Runner - each execution is independent and thread-safe
68
+ # Pass callbacks to enable real-time event notifications
53
69
  Runner.new.run(
54
70
  current_agent,
55
71
  input,
56
72
  context: context,
57
73
  registry: @registry,
58
- max_turns: max_turns
74
+ max_turns: max_turns,
75
+ callbacks: @callbacks
59
76
  )
60
77
  end
61
78
 
79
+ # Register a callback for tool start events.
80
+ # Called when an agent is about to execute a tool.
81
+ #
82
+ # @param block [Proc] Callback block that receives (tool_name, args)
83
+ # @return [self] For method chaining
84
+ def on_tool_start(&block)
85
+ return self unless block
86
+
87
+ @callbacks_mutex.synchronize { @callbacks[:tool_start] << block }
88
+ self
89
+ end
90
+
91
+ # Register a callback for tool completion events.
92
+ # Called when an agent has finished executing a tool.
93
+ #
94
+ # @param block [Proc] Callback block that receives (tool_name, result)
95
+ # @return [self] For method chaining
96
+ def on_tool_complete(&block)
97
+ return self unless block
98
+
99
+ @callbacks_mutex.synchronize { @callbacks[:tool_complete] << block }
100
+ self
101
+ end
102
+
103
+ # Register a callback for agent thinking events.
104
+ # Called when an agent is about to make an LLM call.
105
+ #
106
+ # @param block [Proc] Callback block that receives (agent_name, input)
107
+ # @return [self] For method chaining
108
+ def on_agent_thinking(&block)
109
+ return self unless block
110
+
111
+ @callbacks_mutex.synchronize { @callbacks[:agent_thinking] << block }
112
+ self
113
+ end
114
+
115
+ # Register a callback for agent handoff events.
116
+ # Called when control is transferred from one agent to another.
117
+ #
118
+ # @param block [Proc] Callback block that receives (from_agent, to_agent, reason)
119
+ # @return [self] For method chaining
120
+ def on_agent_handoff(&block)
121
+ return self unless block
122
+
123
+ @callbacks_mutex.synchronize { @callbacks[:agent_handoff] << block }
124
+ self
125
+ end
126
+
62
127
  private
63
128
 
64
129
  # Build agent registry from provided agents only.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ # AgentTool wraps an agent as a tool, enabling agent-to-agent collaboration
5
+ # without conversation handoffs. This implementation constrains wrapped agents
6
+ # for safety and predictability.
7
+ #
8
+ # Key constraints:
9
+ # 1. Wrapped agents cannot perform handoffs (empty registry)
10
+ # 2. Limited turn count to prevent infinite loops
11
+ # 3. Isolated context (only shared state, no conversation history)
12
+ # 4. Always returns to calling agent
13
+ #
14
+ # @example Customer support copilot with specialized agents
15
+ # conversation_agent = Agent.new(
16
+ # name: "ConversationAnalyzer",
17
+ # instructions: "Extract order IDs and customer intent from conversation history"
18
+ # )
19
+ #
20
+ # actions_agent = Agent.new(
21
+ # name: "ShopifyActions",
22
+ # instructions: "Perform Shopify operations",
23
+ # tools: [shopify_tool]
24
+ # )
25
+ #
26
+ # copilot = Agent.new(
27
+ # name: "SupportCopilot",
28
+ # tools: [
29
+ # conversation_agent.as_tool(
30
+ # name: "analyze_conversation",
31
+ # description: "Extract key info from conversation history"
32
+ # ),
33
+ # actions_agent.as_tool(
34
+ # name: "shopify_action",
35
+ # description: "Perform Shopify operations like refunds"
36
+ # )
37
+ # ]
38
+ # )
39
+ class AgentTool < Tool
40
+ attr_reader :wrapped_agent, :tool_name, :tool_description, :output_extractor
41
+
42
+ # Default parameter for agent tools
43
+ param :input, type: "string", desc: "Input message for the agent"
44
+
45
+ # Initialize an AgentTool that wraps an agent as a callable tool
46
+ #
47
+ # @param agent [Agents::Agent] The agent to wrap as a tool
48
+ # @param name [String, nil] Override the tool name (defaults to snake_case agent name)
49
+ # @param description [String, nil] Override the tool description
50
+ # @param output_extractor [Proc, nil] Custom proc to extract/transform the agent's output
51
+ def initialize(agent:, name: nil, description: nil, output_extractor: nil)
52
+ @wrapped_agent = agent
53
+ @tool_name = name || transform_agent_name(agent.name)
54
+ @tool_description = description || "Execute #{agent.name} agent"
55
+ @output_extractor = output_extractor
56
+
57
+ super()
58
+ end
59
+
60
+ def name
61
+ @tool_name
62
+ end
63
+
64
+ def description
65
+ @tool_description
66
+ end
67
+
68
+ # Execute the wrapped agent with constraints to prevent handoffs and recursion
69
+ def perform(tool_context, input:)
70
+ # Create isolated context for the wrapped agent
71
+ isolated_context = create_isolated_context(tool_context.context)
72
+
73
+ # Execute with explicit constraints:
74
+ # 1. Empty registry prevents handoffs
75
+ # 2. Low max_turns prevents infinite loops
76
+ # 3. Isolated context prevents history access
77
+ result = Runner.new.run(
78
+ @wrapped_agent,
79
+ input,
80
+ context: isolated_context,
81
+ registry: {}, # CONSTRAINT: No handoffs allowed
82
+ max_turns: 3 # CONSTRAINT: Limited turns for tool execution
83
+ )
84
+
85
+ return "Agent execution failed: #{result.error.message}" if result.error
86
+
87
+ # Extract output
88
+ if @output_extractor
89
+ @output_extractor.call(result)
90
+ else
91
+ result.output || "No output from #{@wrapped_agent.name}"
92
+ end
93
+ rescue StandardError => e
94
+ "Error executing #{@wrapped_agent.name}: #{e.message}"
95
+ end
96
+
97
+ private
98
+
99
+ def transform_agent_name(name)
100
+ name.downcase.gsub(/\s+/, "_").gsub(/[^a-z0-9_]/, "")
101
+ end
102
+
103
+ # Create isolated context that only shares state, not conversation artifacts
104
+ def create_isolated_context(parent_context)
105
+ isolated = {}
106
+
107
+ # Only share the state - everything else is conversation-specific
108
+ isolated[:state] = parent_context[:state] if parent_context[:state]
109
+
110
+ isolated
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ # Manager for handling and emitting callback events in a thread-safe manner.
5
+ # Provides both generic emit() method and typed convenience methods.
6
+ #
7
+ # @example Using generic emit
8
+ # manager.emit(:tool_start, tool_name, args)
9
+ #
10
+ # @example Using typed methods
11
+ # manager.emit_tool_start(tool_name, args)
12
+ # manager.emit_agent_thinking(agent_name, input)
13
+ class CallbackManager
14
+ # Supported callback event types
15
+ EVENT_TYPES = %i[
16
+ tool_start
17
+ tool_complete
18
+ agent_thinking
19
+ agent_handoff
20
+ ].freeze
21
+
22
+ def initialize(callbacks = {})
23
+ @callbacks = callbacks.dup.freeze
24
+ end
25
+
26
+ # Generic method to emit any callback event type
27
+ #
28
+ # @param event_type [Symbol] The type of event to emit
29
+ # @param args [Array] Arguments to pass to callbacks
30
+ def emit(event_type, *args)
31
+ callback_list = @callbacks[event_type] || []
32
+
33
+ callback_list.each do |callback|
34
+ callback.call(*args)
35
+ rescue StandardError => e
36
+ # Log callback errors but don't let them crash execution
37
+ warn "Callback error for #{event_type}: #{e.message}"
38
+ end
39
+ end
40
+
41
+ # Metaprogramming: Create typed emit methods for each event type
42
+ #
43
+ # This generates methods like:
44
+ # emit_tool_start(tool_name, args)
45
+ # emit_tool_complete(tool_name, result)
46
+ # emit_agent_thinking(agent_name, input)
47
+ # emit_agent_handoff(from_agent, to_agent, reason)
48
+ EVENT_TYPES.each do |event_type|
49
+ define_method("emit_#{event_type}") do |*args|
50
+ emit(event_type, *args)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -12,35 +12,10 @@ module Agents
12
12
  # 4. The tool signals the handoff through context
13
13
  # 5. The Runner detects this and switches to the new agent
14
14
  #
15
- # ## First-Call-Wins Implementation
16
- # This implementation uses "first-call-wins" semantics to prevent infinite handoff loops.
17
- #
18
- # ### The Problem We Solved
19
- # During development, we discovered that LLMs could call the same handoff tool multiple times
20
- # in a single response, leading to infinite loops:
21
- #
22
- # 1. User: "My internet isn't working but my account shows active"
23
- # 2. Triage Agent hands off to Support Agent
24
- # 3. Support Agent sees account info is needed, hands back to Triage Agent
25
- # 4. Triage Agent sees technical issue, hands off to Support Agent again
26
- # 5. This creates an infinite ping-pong loop
27
- #
28
- # ### Root Cause Analysis
29
- # Unlike OpenAI's SDK which processes tool calls before execution, RubyLLM automatically
30
- # executes all tool calls in a response. This meant:
31
- # - LLM calls handoff tool 10+ times in one response
32
- # - Each call sets context[:pending_handoff], overwriting previous values
33
- # - Runner processes handoffs after tool execution, seeing only the last one
34
- # - Multiple handoff signals created conflicting state
35
- #
36
- # TODO: Overall, this problem can be tackled better if we replace the RubyLLM chat
37
- # program with our own implementation.
38
- #
39
- # ### The Solution
40
- # We implemented first-call-wins semantics inspired by OpenAI's approach:
41
- # - First handoff call in a response sets the pending handoff
42
- # - Subsequent calls are ignored with a "transfer in progress" message
43
- # - This prevents loops and mirrors OpenAI SDK behavior
15
+ # ## Loop Prevention
16
+ # The library prevents infinite handoff loops by processing only the first handoff
17
+ # tool call in any LLM response. This is handled automatically by the Chat class
18
+ # which detects handoff tools and processes them separately from regular tools.
44
19
  #
45
20
  # ## Why Tools Instead of Instructions
46
21
  # Using tools for handoffs has several advantages:
@@ -66,12 +41,11 @@ module Agents
66
41
  # # LLM calls: handoff_to_billing()
67
42
  # # Runner switches to billing_agent for the next turn
68
43
  #
69
- # @example First-call-wins in action
44
+ # @example Multiple handoff handling
70
45
  # # Single LLM response with multiple handoff calls:
71
- # # Call 1: handoff_to_support() -> Sets pending_handoff, returns "Transferring to Support"
72
- # # Call 2: handoff_to_support() -> Ignored, returns "Transfer already in progress"
73
- # # Call 3: handoff_to_billing() -> Ignored, returns "Transfer already in progress"
74
- # # Result: Only transfers to Support Agent (first call wins)
46
+ # # Call 1: handoff_to_support() -> Processed and executed
47
+ # # Call 2: handoff_to_billing() -> Ignored (only first handoff processed)
48
+ # # Result: Only transfers to Support Agent
75
49
  class HandoffTool < Tool
76
50
  attr_reader :target_agent
77
51
 
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ # Service object responsible for extracting and formatting conversation messages
5
+ # from RubyLLM chat objects into a format suitable for persistence and context restoration.
6
+ #
7
+ # Handles different message types:
8
+ # - User messages: Basic content preservation
9
+ # - Assistant messages: Includes agent attribution and tool calls
10
+ # - Tool result messages: Links back to original tool calls
11
+ #
12
+ # @example Extract messages from a chat
13
+ # messages = MessageExtractor.extract_messages(chat, current_agent)
14
+ # #=> [
15
+ # { role: :user, content: "Hello" },
16
+ # { role: :assistant, content: "Hi!", agent_name: "Support", tool_calls: [...] },
17
+ # { role: :tool, content: "Result", tool_call_id: "call_123" }
18
+ # ]
19
+ class MessageExtractor
20
+ # Extract messages from a chat object for conversation history persistence
21
+ #
22
+ # @param chat [Object] Chat object that responds to :messages
23
+ # @param current_agent [Agent] The agent currently handling the conversation
24
+ # @return [Array<Hash>] Array of message hashes suitable for persistence
25
+ def self.extract_messages(chat, current_agent)
26
+ new(chat, current_agent).extract
27
+ end
28
+
29
+ def initialize(chat, current_agent)
30
+ @chat = chat
31
+ @current_agent = current_agent
32
+ end
33
+
34
+ def extract
35
+ return [] unless @chat.respond_to?(:messages)
36
+
37
+ @chat.messages.filter_map do |msg|
38
+ case msg.role
39
+ when :user, :assistant
40
+ extract_user_or_assistant_message(msg)
41
+ when :tool
42
+ extract_tool_message(msg)
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def extract_user_or_assistant_message(msg)
50
+ return nil unless msg.content && !msg.content.strip.empty?
51
+
52
+ message = {
53
+ role: msg.role,
54
+ content: msg.content
55
+ }
56
+
57
+ if msg.role == :assistant
58
+ # Add agent attribution for conversation continuity
59
+ message[:agent_name] = @current_agent.name if @current_agent
60
+
61
+ # Add tool calls if present
62
+ if msg.tool_call? && msg.tool_calls
63
+ # RubyLLM stores tool_calls as Hash with call_id => ToolCall object
64
+ # Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
65
+ message[:tool_calls] = msg.tool_calls.values.map(&:to_h)
66
+ end
67
+ end
68
+
69
+ message
70
+ end
71
+
72
+ def extract_tool_message(msg)
73
+ return nil unless msg.tool_result?
74
+
75
+ {
76
+ role: msg.role,
77
+ content: msg.content,
78
+ tool_call_id: msg.tool_call_id
79
+ }
80
+ end
81
+ end
82
+ end
@@ -56,14 +56,17 @@
56
56
  # # - No race conditions or data leakage between runs
57
57
  module Agents
58
58
  class RunContext
59
- attr_reader :context, :usage
59
+ attr_reader :context, :usage, :callbacks, :callback_manager
60
60
 
61
61
  # Initialize a new RunContext with execution context and usage tracking
62
62
  #
63
63
  # @param context [Hash] The execution context data (will be duplicated for isolation)
64
- def initialize(context)
64
+ # @param callbacks [Hash] Optional callbacks for real-time event notifications
65
+ def initialize(context, callbacks: {})
65
66
  @context = context
66
67
  @usage = Usage.new
68
+ @callbacks = callbacks || {}
69
+ @callback_manager = CallbackManager.new(@callbacks)
67
70
  end
68
71
 
69
72
  # Usage tracks token consumption across all LLM calls within a single run.