ai-agents 0.1.0 → 0.1.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d23007ce65c6dae4c20025ad5cacb683652452a973472fa3c9fba1166606e57
4
- data.tar.gz: 39b4522bd4ee7e329285a3806a4e06e5e312526e9139d4369bb7a12f4ede2d06
3
+ metadata.gz: cc088132bc2b8dafa23669d0e2e15d110f6a8cb982f54ba026247008a90322ca
4
+ data.tar.gz: 93086217d9aa75ebf072a299aeadacf688cede7216bd41d64dc539072ec1469b
5
5
  SHA512:
6
- metadata.gz: bc45c4328b0ec188abb77249ee3da9de9515251f2c872b8468c1704d49ea3765cb1dae285cad84c6fd1b644d6adde35073ddf09a6071c82b8ae7671ebbc67d52
7
- data.tar.gz: 354a10db6fab8d2885c1bdd6e20e72086a9cd87e1a16b99c1b744f0c35b962b19b07aafcf7c0ca0301f30bccc0a8e3ba00de13807178e7d02ec16466ba002a79
6
+ metadata.gz: 7ac77c92850152aa9bb716d3d1ecd37444e7cab76c9ab5e4e63aef20ad337a2416f9b4a1d55895ff0d0af97d4e8903c1e73f03c3e7a8f9bd6de0b8ed2f4279d6
7
+ data.tar.gz: 4b3055a233c48902fdb570ca8dce896d09cdbd30dc52a0a5f03fb290c7f60725804d574e5df3e3d4428c61ad1af58b97d0f173c5b576a324ae84598f4d48fbbc
data/.rubocop.yml CHANGED
@@ -24,3 +24,6 @@ RSpec/SpecFilePathFormat:
24
24
 
25
25
  Metrics/MethodLength:
26
26
  Max: 20
27
+
28
+ RSpec/MultipleDescribes:
29
+ Enabled: false
data/CLAUDE.md CHANGED
@@ -2,187 +2,196 @@
2
2
 
3
3
  This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
4
 
5
- ## Repository Overview
5
+ ## Project Purpose
6
6
 
7
- This is a Ruby AI Agents SDK that provides multi-agent orchestration capabilities, similar to OpenAI's Agents SDK but built for Ruby. The SDK enables the creation of sophisticated AI workflows with specialized agents, tool execution, and conversation handoffs.
7
+ This project is a Ruby SDK for building multi-agent AI workflows. It allows developers to create specialized AI agents that can collaborate to solve complex tasks. The key features include:
8
8
 
9
- **IMPORTANT**: This is a generic agent library. When implementing library code, ensure that:
10
- - No domain-specific logic from examples (airline booking, FAQ, seat management, etc.) leaks into the core library
11
- - The library remains agnostic to specific use cases
12
- - All domain-specific implementations belong in the examples directory only
9
+ - **Multi-Agent Orchestration**: Defining and managing multiple AI agents with distinct roles.
10
+ - **Seamless Handoffs**: Transferring conversations between agents without the user's knowledge.
11
+ - **Tool Integration**: Allowing agents to use custom tools to interact with external systems.
12
+ - **Shared Context**: Maintaining state and conversation history across agent interactions with full persistence support.
13
+ - **Thread-Safe Architecture**: Reusable agent runners that work safely across multiple threads.
14
+ - **Provider Agnostic**: Supporting various LLM providers like OpenAI, Anthropic, and Gemini.
13
15
 
14
- ## Development Commands
16
+ ## Key Technologies
15
17
 
16
- ### Building and Testing
17
- ```bash
18
- # Install dependencies
19
- bundle install
18
+ - **Ruby**: The primary programming language.
19
+ - **RubyLLM**: The underlying library for interacting with Large Language Models.
20
+ - **RSpec**: The testing framework.
21
+ - **RuboCop**: The code style linter.
22
+ - **GitHub Actions**: For continuous integration (testing and linting).
20
23
 
21
- # Run tests with RSpec
22
- rake spec
23
- # OR
24
- bundle exec rspec
24
+ ## Project Structure
25
25
 
26
- # Run specific spec file
27
- bundle exec rspec spec/agents/agent_spec.rb
26
+ - `lib/`: The core source code of the `ai-agents` gem.
27
+ - `lib/agents.rb`: The main entry point, handling configuration and loading other components.
28
+ - `lib/agents/agent.rb`: Defines the `Agent` class, which represents an individual AI agent.
29
+ - `lib/agents/tool.rb`: Defines the `Tool` class, the base for creating custom tools for agents.
30
+ - `lib/agents/agent_runner.rb`: Thread-safe agent execution manager for multi-agent conversations.
31
+ - `lib/agents/runner.rb`: Internal orchestrator that handles individual conversation turns.
32
+ - `spec/`: Contains the RSpec tests for the project.
33
+ - `examples/`: Includes example implementations of multi-agent systems, such as an ISP customer support demo.
34
+ - `Gemfile`: Manages the project's Ruby dependencies.
35
+ - `.rubocop.yml`: Configures the code style rules for RuboCop.
36
+ - `.github/workflows/main.yml`: Defines the CI pipeline for running tests and linting on push and pull requests.
28
37
 
29
- # Run tests with coverage report
30
- bundle exec rspec # SimpleCov will generate coverage/index.html
38
+ ## Development Workflow
31
39
 
32
- # Lint code (includes RSpec cops)
33
- rake rubocop
34
- # OR
35
- bundle exec rubocop
40
+ 1. **Dependencies**: Managed by Bundler (`bundle install`).
41
+ 2. **Testing**: Run tests with `bundle exec rspec`.
42
+ 3. **Linting**: Check code style with `bundle exec rubocop`.
43
+ 4. **CI/CD**: GitHub Actions automatically runs tests and linting for all pushes and pull requests to the `main` branch.
36
44
 
37
- # Auto-fix linting issues
38
- bundle exec rubocop -a
45
+ ## How to Run the Example
39
46
 
40
- # Run all checks (spec + lint)
41
- rake
42
- ```
47
+ The project includes an interactive example of an ISP customer support system. To run it:
43
48
 
44
- ### Interactive Development
45
49
  ```bash
46
- # Start interactive console
47
- bin/console
48
-
49
- # Run the airline booking demo
50
- ruby examples/booking/interactive.rb
51
-
52
- # Run automatic booking demo
53
- ruby examples/booking/automatic.rb
50
+ ruby examples/isp-support/interactive.rb
54
51
  ```
55
52
 
56
- ## Architecture and Code Structure
53
+ This will start a command-line interface where you can interact with the multi-agent system. The example demonstrates:
54
+ - Thread-safe agent runner creation
55
+ - Automatic agent selection based on conversation history
56
+ - Context persistence that works across process boundaries
57
+ - Seamless handoffs between triage, sales, and support agents
57
58
 
58
- ### Core Components (Generic Library)
59
+ ## Key Concepts
59
60
 
60
- **lib/agents.rb** - Main module and configuration entry point. Configures both the Agents SDK and underlying RubyLLM library. Contains the `RECOMMENDED_HANDOFF_PROMPT_PREFIX` for multi-agent workflows.
61
+ - **Agent**: An AI assistant with a specific role, instructions, and tools.
62
+ - **Tool**: A custom function that an agent can use to perform actions (e.g., look up customer data, send an email).
63
+ - **Handoff**: The process of transferring a conversation from one agent to another. This is a core feature of the SDK.
64
+ - **AgentRunner**: The thread-safe execution manager that coordinates multi-agent conversations and provides the main API.
65
+ - **Runner**: Internal component that manages individual conversation turns (used by AgentRunner).
66
+ - **Context**: A shared state object that stores conversation history and agent information, fully serializable for persistence.
61
67
 
62
- **lib/agents/agent.rb** - The core `Agent` class with Ruby-like DSL for defining AI agents. Key features:
63
- - Class-level configuration: `name`, `instructions`, `provider`, `model`, `uses`, `handoffs`
64
- - Instance execution via `call` method with conversation history management
65
- - Tool and handoff tool registration at runtime
66
- - Context-aware execution with proper conversation history restoration using `chat.add_message`
67
- - **No domain-specific logic** - remains completely generic
68
+ ## Development Commands
68
69
 
69
- **lib/agents/tool.rb** - Base class for tools that agents can use. Inherits from `RubyLLM::Tool` and adds:
70
- - Ruby-style parameter definitions with automatic JSON schema conversion
71
- - Context injection through `perform` method (called by `execute`)
72
- - Enhanced parameter definition supporting Ruby types (String, Integer, etc.)
73
- - **Domain-agnostic** - specific tool implementations belong in user code
70
+ ### Testing
71
+ ```bash
72
+ # Run all tests with RSpec
73
+ bundle exec rspec
74
74
 
75
- **lib/agents/handoff.rb** - Contains the handoff system classes:
76
- - `HandoffResult` - Represents a handoff decision
77
- - `AgentResponse` - Wraps agent responses with optional handoff results
78
- - `HandoffTool` - Generic tool for transferring between agents using context-based signaling
79
- - **Generic handoff mechanism** - no assumptions about specific agent types
75
+ # Run tests with coverage report (uses SimpleCov)
76
+ bundle exec rake spec
80
77
 
81
- **lib/agents/context.rb** - Base context class for sharing state between agents and tools across handoffs. Must be subclassed for domain-specific context. The base class provides only generic state management capabilities.
78
+ # Run specific test file
79
+ bundle exec rspec spec/agents/agent_spec.rb
82
80
 
83
- **lib/agents/runner.rb** - Execution engine for orchestrating multi-agent workflows (future implementation).
81
+ # Run specific test with line number
82
+ bundle exec rspec spec/agents/agent_spec.rb:25
83
+ ```
84
84
 
85
- ### Key Design Patterns
85
+ ### Code Quality
86
+ ```bash
87
+ # Run RuboCop linter
88
+ bundle exec rubocop
86
89
 
87
- #### Agent Definition Pattern (Generic)
88
- ```ruby
89
- class MyAgent < Agents::Agent
90
- name "Agent Name"
91
- instructions "Behavior description" # Can be dynamic via Proc
92
- provider :openai # Optional, defaults to configured provider
93
- model "gpt-4o" # Optional, defaults to configured model
94
-
95
- uses SomeTool # Register tools by class
96
- handoffs OtherAgent, AnotherAgent # Define possible handoff targets
97
- end
90
+ # Run RuboCop with auto-correction
91
+ bundle exec rubocop -a
92
+
93
+ # Run both tests and linting (default rake task)
94
+ bundle exec rake
98
95
  ```
99
96
 
100
- #### Tool Definition Pattern (Generic)
101
- ```ruby
102
- class MyTool < Agents::Tool
103
- description "What this tool does"
104
- param :input_param, String, "Parameter description"
105
- param :optional_param, Integer, "Optional param", required: false
106
-
107
- def perform(input_param:, optional_param: nil, context:)
108
- # context is always available for state management
109
- # Must implement perform, not execute
110
- # Tool logic should be domain-specific in user implementations
111
- "Tool result"
112
- end
113
- end
97
+ ### Development
98
+ ```bash
99
+ # Install dependencies
100
+ bundle install
101
+
102
+ # Interactive Ruby console with gem loaded
103
+ bundle exec irb -r ./lib/agents
104
+
105
+ # Run ISP support example interactively
106
+ ruby examples/isp-support/interactive.rb
114
107
  ```
115
108
 
116
- #### Context-Based Handoff System
117
- The handoff system uses context signaling rather than text parsing:
118
- 1. `HandoffTool` instances are created automatically from `handoffs` declarations
119
- 2. When called, `HandoffTool.perform` sets `context[:pending_handoff]`
120
- 3. `Agent.detect_handoff_from_context` checks for pending handoffs after LLM responses
121
- 4. Interactive systems handle handoffs by switching to the target agent class
109
+ ## Architecture
122
110
 
123
- #### Conversation History Management
124
- Critical for multi-turn conversations:
125
- - Agents maintain `@conversation_history` as array of `{user:, assistant:, timestamp:}` hashes
126
- - `restore_conversation_history(chat)` uses `chat.add_message(role:, content:)` to restore RubyLLM chat state
127
- - This prevents agents from "forgetting" previous conversation turns
111
+ ### Core Components
128
112
 
129
- ### Configuration Rules
113
+ - **Agents::Agent**: Individual AI agents with specific roles, instructions, and tools
114
+ - **Agents::Runner**: Orchestrates multi-agent conversations with automatic handoffs
115
+ - **Agents::Tool**: Base class for custom tools that agents can execute
116
+ - **Agents::Context**: Shared state management across agent interactions
117
+ - **Agents::Handoff**: Manages seamless transfers between agents
130
118
 
131
- #### Class Naming
132
- - Use flat naming like `class Agents::Tool` instead of nested declarations
133
- - Follow Ruby naming conventions for agent and tool classes
119
+ ### Key Design Principles
134
120
 
135
- #### Documentation
136
- - Always write doc strings when writing functions
137
- - Use YARD format for documentation
138
- - Always write RDoc for new methods
139
- - When creating a new file, start the file with a description comment on what the file has and where does it fit in the project
121
+ 1. **Thread Safety**: All components are designed to be thread-safe. Tools receive context as parameters, not instance variables.
140
122
 
141
- #### Model Defaults
142
- - Default model for OpenAI provider is `gpt-4.1-mini` (configured in lib/agents.rb)
143
- - Can be overridden in agent classes or runtime configuration
123
+ 2. **Immutable Agents**: Agents are configured once and can be cloned with modifications. No execution state is stored in agent instances.
144
124
 
145
- ### Examples Structure
125
+ 3. **Provider Agnostic**: Built on RubyLLM, supports OpenAI, Anthropic, and Gemini through configuration.
146
126
 
147
- **examples/booking/** - Complete airline booking demo showcasing multi-agent workflows. This is just one example of how the SDK can be used. The example demonstrates:
148
- - Multi-agent workflow patterns
149
- - Context sharing between agents
150
- - Tool usage patterns
151
- - Interactive CLI and automatic execution modes
152
- - Proper handoff handling
153
127
 
154
- Note: The airline booking scenario is purely demonstrative. The SDK is not limited to or designed specifically for airline systems.
128
+ ### File Structure
155
129
 
156
- ### Dependencies and Integration
130
+ ```
131
+ lib/agents/
132
+ ├── agent.rb # Core agent definition and configuration
133
+ ├── agent_runner.rb # Thread-safe execution manager (main API)
134
+ ├── runner.rb # Internal execution engine for conversation turns
135
+ ├── tool.rb # Base class for custom tools
136
+ ├── handoff.rb # Agent handoff management
137
+ ├── chat.rb # Chat message handling
138
+ ├── result.rb # Result object for agent responses
139
+ ├── run_context.rb # Execution context management
140
+ ├── tool_context.rb # Tool execution context
141
+ ├── tool_wrapper.rb # Thread-safe tool wrapping
142
+ └── version.rb # Gem version
143
+ ```
157
144
 
158
- **RubyLLM Integration** - Built on top of RubyLLM library for LLM communication:
159
- - Agents SDK configures RubyLLM automatically via `Agents.configure`
160
- - Tools inherit from `RubyLLM::Tool` but use enhanced `perform` method
161
- - Conversation history restored using `chat.add_message`
162
- - Debug mode available via `ENV["RUBYLLM_DEBUG"] = "true"`
145
+ ### Configuration
163
146
 
164
- **Provider Support** - Currently supports OpenAI through RubyLLM, extensible to other providers
147
+ The SDK requires at least one LLM provider API key:
165
148
 
166
- ### Important Implementation Details
149
+ ```ruby
150
+ Agents.configure do |config|
151
+ config.openai_api_key = ENV['OPENAI_API_KEY']
152
+ config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
153
+ config.gemini_api_key = ENV['GEMINI_API_KEY']
154
+ config.default_model = 'gpt-4o-mini'
155
+ config.debug = true
156
+ end
157
+ ```
167
158
 
168
- 1. **Conversation History**: Must call `restore_conversation_history(chat)` before each agent execution to maintain conversation state across turns.
159
+ ### Basic Usage Pattern
169
160
 
170
- 2. **Tool Context Flow**: `RubyLLM.execute()` → `Agents::Tool.execute()` → `Tool.perform(context:, **args)` - the context injection happens in the base `Tool.execute` method.
161
+ ```ruby
162
+ # Create agents with handoff relationships
163
+ triage = Agent.new(name: "Triage", instructions: "Route users...")
164
+ billing = Agent.new(name: "Billing", instructions: "Handle billing...")
165
+ support = Agent.new(name: "Support", instructions: "Technical support...")
166
+
167
+ triage.register_handoffs(billing, support)
168
+
169
+ # Create thread-safe runner (first agent is default entry point)
170
+ runner = Agents::Runner.with_agents(triage, billing, support)
171
+
172
+ # Use for conversations - automatically handles agent selection and persistence
173
+ result = runner.run("I have a billing question")
174
+ result = runner.run("What about technical support?", context: result.context)
175
+ ```
171
176
 
172
- 3. **Handoff Detection**: Uses context-based detection (`@context[:pending_handoff]`) rather than parsing LLM responses for tool calls.
177
+ ### Tool Development
173
178
 
174
- 4. **Model Configuration**: Default model is `gpt-4.1-mini` but examples use `gpt-4o` for better performance.
179
+ When creating custom tools:
180
+ - Extend `Agents::Tool`
181
+ - Use `tool_context` parameter for all state
182
+ - Never store execution state in instance variables
183
+ - Follow the thread-safe design pattern shown in examples
175
184
 
176
- 5. **Thread Safety**: Agents are designed to be stateless with context passed through execution rather than stored in instance variables.
185
+ ### Testing Strategy
177
186
 
178
- 6. **Library vs Example Code**: The core library (lib/agents/*) must remain completely generic and free of domain-specific logic. All domain-specific implementations (airline booking, FAQ systems, etc.) belong exclusively in the examples directory.
187
+ - SimpleCov tracks coverage with 50% minimum overall, 40% per file
188
+ - WebMock is used for HTTP mocking in tests
189
+ - RSpec is the testing framework with standard configuration
190
+ - Tests are organized by component in `spec/agents/`
179
191
 
180
- ### Testing Guidelines
192
+ ### Examples
181
193
 
182
- When writing tests, follow the rules:
183
- 1. Avoid stubbing using allow_any_instance_of`
184
- 2. Each example block `it ... end` should have less than 20 lines
185
- 3. Example group should not have more than 10 memoized helpers, not more than 10 except statements
186
- 4. Never use `receive_message_chain`
187
- 5. When writing tests, always use verifying doubles and never normal doubles
188
- ```
194
+ The `examples/` directory contains complete working examples:
195
+ - `isp-support/`: Multi-agent ISP customer support system
196
+ - Shows hub-and-spoke architecture patterns
197
+ - Demonstrates tool integration and handoff workflows
data/README.md CHANGED
@@ -40,15 +40,18 @@ Agents.configure do |config|
40
40
  config.openai_api_key = ENV['OPENAI_API_KEY']
41
41
  end
42
42
 
43
- # Create a simple agent
44
- agent = Agents::Agent.new(
43
+ # Create agents
44
+ weather_agent = Agents::Agent.new(
45
45
  name: "Weather Assistant",
46
46
  instructions: "Help users get weather information",
47
47
  tools: [WeatherTool.new]
48
48
  )
49
49
 
50
- # Use the agent with the Runner
51
- result = Agents::Runner.run(agent, "What's the weather like today?")
50
+ # Create a thread-safe runner (reusable across conversations)
51
+ runner = Agents::Runner.with_agents(weather_agent)
52
+
53
+ # Use the runner for conversations
54
+ result = runner.run("What's the weather like today?")
52
55
  puts result.output
53
56
  ```
54
57
 
@@ -64,14 +67,14 @@ triage = Agents::Agent.new(
64
67
  )
65
68
 
66
69
  faq = Agents::Agent.new(
67
- name: "FAQ Agent",
70
+ name: "FAQ Agent",
68
71
  instructions: "Answer frequently asked questions",
69
72
  tools: [FaqLookupTool.new]
70
73
  )
71
74
 
72
75
  support = Agents::Agent.new(
73
76
  name: "Support Agent",
74
- instructions: "Handle technical issues",
77
+ instructions: "Handle technical issues",
75
78
  tools: [TicketTool.new]
76
79
  )
77
80
 
@@ -80,9 +83,16 @@ triage.register_handoffs(faq, support)
80
83
  faq.register_handoffs(triage) # Can route back to triage
81
84
  support.register_handoffs(triage) # Hub-and-spoke pattern
82
85
 
83
- # Run a conversation with automatic handoffs
84
- result = Agents::Runner.run(triage, "How many seats are on the plane?")
86
+ # Create runner with all agents (triage is default entry point)
87
+ runner = Agents::Runner.with_agents(triage, faq, support)
88
+
89
+ # Run conversations with automatic handoffs and persistence
90
+ result = runner.run("How many seats are on the plane?")
85
91
  # User gets direct answer from FAQ agent without knowing about the handoff!
92
+
93
+ # Continue the conversation seamlessly
94
+ result = runner.run("What about technical support?", context: result.context)
95
+ # Context automatically tracks conversation history and current agent
86
96
  ```
87
97
 
88
98
  ## 🏗️ Architecture
@@ -90,8 +100,9 @@ result = Agents::Runner.run(triage, "How many seats are on the plane?")
90
100
  ### Core Components
91
101
 
92
102
  - **Agent**: Individual AI agents with specific roles and capabilities
93
- - **Runner**: Orchestrates multi-agent conversations with automatic handoffs
94
- - **Context**: Shared state management across agents
103
+ - **AgentRunner**: Thread-safe execution manager for multi-agent conversations
104
+ - **Runner**: Internal orchestrator that handles conversation turns (used by AgentRunner)
105
+ - **Context**: Shared state management with automatic persistence across agents
95
106
  - **Tools**: Custom functions that agents can use
96
107
  - **Handoffs**: Seamless transfers between specialized agents
97
108
 
@@ -134,11 +145,11 @@ end
134
145
  ```ruby
135
146
  class EmailTool < Agents::Tool
136
147
  description "Send emails to customers"
137
- param :to, String, "Email address"
138
- param :subject, String, "Email subject"
139
- param :body, String, "Email body"
148
+ param :to, type: "string", desc: "Email address"
149
+ param :subject, type: "string", desc: "Email subject"
150
+ param :body, type: "string", desc: "Email body"
140
151
 
141
- def perform(to:, subject:, body:, context:)
152
+ def perform(tool_context, to:, subject:, body:)
142
153
  # Send email logic here
143
154
  "Email sent to #{to}"
144
155
  end
@@ -175,16 +186,27 @@ sales.register_handoffs(customer_info)
175
186
  customer_info.register_handoffs(sales)
176
187
  ```
177
188
 
178
- ### Context Management
189
+ ### Context Management & Persistence
179
190
 
180
191
  ```ruby
181
- # Context is automatically managed by the Runner
182
- context = {}
183
- result = Agents::Runner.run(agent, "Hello", context: context)
192
+ # Context is automatically managed and serializable
193
+ runner = Agents::Runner.with_agents(triage, billing, support)
184
194
 
185
- # Access conversation history and agent state
195
+ # Start a conversation
196
+ result = runner.run("I need billing help")
197
+
198
+ # Context is automatically updated with conversation history and current agent
199
+ context = result.context
186
200
  puts context[:conversation_history]
187
- puts context[:current_agent].name
201
+ puts context[:current_agent] # Agent name (string), not object!
202
+
203
+ # Serialize context for persistence (Rails, databases, etc.)
204
+ json_context = JSON.dump(context) # ✅ Works! No object references
205
+
206
+ # Later: restore and continue conversation
207
+ restored_context = JSON.parse(json_context, symbolize_names: true)
208
+ result = runner.run("Actually, I need technical support too", context: restored_context)
209
+ # System automatically determines correct agent from conversation history
188
210
  ```
189
211
 
190
212
  ## 📋 Examples
@@ -48,7 +48,6 @@ module ISPSupport
48
48
  )
49
49
  end
50
50
 
51
-
52
51
  def create_sales_agent
53
52
  Agents::Agent.new(
54
53
  name: "Sales Agent",
@@ -65,7 +64,7 @@ module ISPSupport
65
64
  model: "gpt-4.1-mini",
66
65
  tools: [
67
66
  ISPSupport::CrmLookupTool.new,
68
- ISPSupport::SearchDocsTool.new,
67
+ ISPSupport::SearchDocsTool.new,
69
68
  ISPSupport::EscalateToHumanTool.new
70
69
  ]
71
70
  )
@@ -89,7 +88,6 @@ module ISPSupport
89
88
  INSTRUCTIONS
90
89
  end
91
90
 
92
-
93
91
  def sales_instructions
94
92
  <<~INSTRUCTIONS
95
93
  You are the Sales Agent for an ISP. You handle new customer acquisition, service upgrades,
@@ -101,16 +99,17 @@ module ISPSupport
101
99
  - Handoff tools: Route back to triage when needed
102
100
 
103
101
  **When to hand off:**
104
- - Need account verification or billing info → Triage Agent for re-routing
105
- - Technical questions → Triage Agent for re-routing
106
- - Non-sales requests → Triage Agent
102
+ - Pure technical support questions → Triage Agent for re-routing
103
+ - Customer needs to speak with human agent → Triage Agent for re-routing
107
104
 
108
105
  **Instructions:**
109
106
  - Be enthusiastic but not pushy
110
107
  - Gather required info: name, email, desired plan for leads
111
- - For existing customers wanting upgrades, ask them to verify account first
108
+ - For account verification, ask customer for their account details directly
109
+ - For existing customers wanting upgrades, collect account info and proceed
112
110
  - Create checkout links for confirmed purchases
113
111
  - Always explain next steps after creating leads or checkout links
112
+ - Handle billing questions yourself - don't hand off for account verification
114
113
  INSTRUCTIONS
115
114
  end
116
115
 
@@ -126,8 +125,8 @@ module ISPSupport
126
125
  - Handoff tools: Route back to triage when needed
127
126
 
128
127
  **When to hand off:**
129
- - Customer wants to buy/upgrade plans → Triage Agent to route to Sales
130
- - Non-support requests (new purchases)Triage Agent
128
+ - Customer wants to buy new service or upgrade plans → Triage Agent to route to Sales
129
+ - Complex issues requiring human intervention Use escalate_to_human tool instead
131
130
 
132
131
  **Instructions:**
133
132
  - For account questions: Always ask for account ID and use crm_lookup
@@ -136,6 +135,7 @@ module ISPSupport
136
135
  - Be patient and provide step-by-step guidance
137
136
  - If customer gets frustrated or issue persists, escalate to human
138
137
  - Present account information clearly and protect sensitive data
138
+ - Handle account verification requests directly - don't hand off back to triage
139
139
  INSTRUCTIONS
140
140
  end
141
141
  end
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "json"
4
5
  require_relative "../../lib/agents"
5
6
  require_relative "agents_factory"
6
7
 
@@ -13,8 +14,15 @@ class ISPSupportDemo
13
14
  end
14
15
 
15
16
  # Create agents
16
- @agents = ISPSupport::AgentsFactory.create_agents
17
- @triage_agent = @agents[:triage]
17
+ agents = ISPSupport::AgentsFactory.create_agents
18
+
19
+ # Create thread-safe runner with all agents (triage first = default entry point)
20
+ @runner = Agents::Runner.with_agents(
21
+ agents[:triage],
22
+ agents[:sales],
23
+ agents[:support]
24
+ )
25
+
18
26
  @context = {}
19
27
 
20
28
  puts "🏢 Welcome to ISP Customer Support!"
@@ -31,10 +39,8 @@ class ISPSupportDemo
31
39
  break if command_result == :exit
32
40
  next if command_result == :handled || user_input.empty?
33
41
 
34
- # Determine which agent to use - either from context or triage agent
35
- current_agent = @context[:current_agent] || @triage_agent
36
-
37
- result = Agents::Runner.run(current_agent, user_input, context: @context)
42
+ # Use the runner - it automatically determines the right agent from context
43
+ result = @runner.run(user_input, context: @context)
38
44
 
39
45
  # Update our context with the returned context from Runner
40
46
  @context = result.context if result.respond_to?(:context) && result.context
@@ -50,6 +56,7 @@ class ISPSupportDemo
50
56
  def handle_command(input)
51
57
  case input.downcase
52
58
  when "exit", "quit"
59
+ dump_context_and_quit
53
60
  puts "👋 Goodbye!"
54
61
  :exit
55
62
  when "/help"
@@ -73,6 +80,21 @@ class ISPSupportDemo
73
80
  end
74
81
  end
75
82
 
83
+ def dump_context_and_quit
84
+ project_root = File.expand_path("../..", __dir__)
85
+ tmp_directory = File.join(project_root, "tmp")
86
+
87
+ # Ensure tmp directory exists
88
+ Dir.mkdir(tmp_directory) unless Dir.exist?(tmp_directory)
89
+
90
+ timestamp = Time.now.to_i
91
+ context_filename = File.join(tmp_directory, "context-#{timestamp}.json")
92
+
93
+ File.write(context_filename, JSON.pretty_generate(@context))
94
+
95
+ puts "💾 Context saved to tmp/context-#{timestamp}.json"
96
+ end
97
+
76
98
  def show_help
77
99
  puts "📋 Available Commands:"
78
100
  puts " /help - Show this help message"
@@ -6,7 +6,7 @@ module ISPSupport
6
6
  # Tool for creating checkout links for new service subscriptions.
7
7
  class CreateCheckoutTool < Agents::Tool
8
8
  description "Create a secure checkout link for a service plan"
9
- param :plan_name, String, "Name of the plan to purchase"
9
+ param :plan_name, type: "string", desc: "Name of the plan to purchase"
10
10
 
11
11
  def perform(_tool_context, plan_name:)
12
12
  session_id = SecureRandom.hex(8)
@@ -4,9 +4,9 @@ module ISPSupport
4
4
  # Tool for creating sales leads in the CRM system.
5
5
  class CreateLeadTool < Agents::Tool
6
6
  description "Create a new sales lead with customer information"
7
- param :name, String, "Customer's full name"
8
- param :email, String, "Customer's email address"
9
- param :desired_plan, String, "Plan the customer is interested in"
7
+ param :name, type: "string", desc: "Customer's full name"
8
+ param :email, type: "string", desc: "Customer's email address"
9
+ param :desired_plan, type: "string", desc: "Plan the customer is interested in"
10
10
 
11
11
  def perform(_tool_context, name:, email:, desired_plan:)
12
12
  "Lead created for #{name} (#{email}) interested in #{desired_plan} plan. Sales team will contact within 24 hours."
@@ -6,7 +6,7 @@ module ISPSupport
6
6
  # Tool for looking up customer information from the CRM system.
7
7
  class CrmLookupTool < Agents::Tool
8
8
  description "Look up customer account information by account ID"
9
- param :account_id, String, "Customer account ID (e.g., CUST001)"
9
+ param :account_id, type: "string", desc: "Customer account ID (e.g., CUST001)"
10
10
 
11
11
  def perform(_tool_context, account_id:)
12
12
  data_file = File.join(__dir__, "../data/customers.json")
@@ -4,7 +4,7 @@ module ISPSupport
4
4
  # Tool for searching the knowledge base documentation.
5
5
  class SearchDocsTool < Agents::Tool
6
6
  description "Search knowledge base for troubleshooting steps and solutions"
7
- param :query, String, "Search terms or description of the issue"
7
+ param :query, type: "string", desc: "Search terms or description of the issue"
8
8
 
9
9
  def perform(_tool_context, query:)
10
10
  case query.downcase
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ # Thread-safe agent execution manager that provides a clean API for multi-agent conversations.
5
+ # This class is designed to be created once and reused across multiple threads safely.
6
+ #
7
+ # The key insight here is separating agent registry/configuration (this class) from
8
+ # execution state (Runner instances). This allows the same AgentRunner to be used
9
+ # concurrently without thread safety issues.
10
+ #
11
+ # ## Usage Pattern
12
+ # # Create once (typically at application startup)
13
+ # runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
14
+ #
15
+ # # Use safely from multiple threads
16
+ # result = runner.run("I need billing help") # New conversation
17
+ # result = runner.run("More help", context: context) # Continue conversation
18
+ #
19
+ # ## Thread Safety Design
20
+ # - All instance variables are frozen after initialization (immutable state)
21
+ # - Agent registry is built once and never modified
22
+ # - Each run() call creates independent execution context
23
+ # - No shared mutable state between concurrent executions
24
+ #
25
+ class AgentRunner
26
+ # Initialize with a list of agents. The first agent becomes the default entry point.
27
+ #
28
+ # @param agents [Array<Agents::Agent>] List of agents, first one is the default entry point
29
+ def initialize(agents)
30
+ raise ArgumentError, "At least one agent must be provided" if agents.empty?
31
+
32
+ @agents = agents.dup.freeze
33
+ @default_agent = agents.first
34
+
35
+ # Build simple registry from provided agents - developer controls what's available
36
+ @registry = build_registry(agents).freeze
37
+ end
38
+
39
+ # Execute a conversation turn with automatic agent selection.
40
+ # For new conversations, uses the default agent (first in the list).
41
+ # For continuing conversations, determines the appropriate agent from conversation history.
42
+ #
43
+ # @param input [String] User's message
44
+ # @param context [Hash] Conversation context (will be restored if continuing conversation)
45
+ # @param max_turns [Integer] Maximum turns before stopping (default: 10)
46
+ # @return [RunResult] Execution result with output, messages, and updated context
47
+ def run(input, context: {}, max_turns: Runner::DEFAULT_MAX_TURNS)
48
+ # Determine which agent should handle this conversation
49
+ # Uses conversation history to maintain continuity across handoffs
50
+ current_agent = determine_conversation_agent(context)
51
+
52
+ # Execute using stateless Runner - each execution is independent and thread-safe
53
+ Runner.new.run(
54
+ current_agent,
55
+ input,
56
+ context: context,
57
+ registry: @registry,
58
+ max_turns: max_turns
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ # Build agent registry from provided agents only.
65
+ # Developer explicitly controls which agents are available for handoffs.
66
+ #
67
+ # @param agents [Array<Agents::Agent>] Agents to register
68
+ # @return [Hash<String, Agents::Agent>] Registry mapping agent names to agent instances
69
+ def build_registry(agents)
70
+ registry = {}
71
+ agents.each { |agent| registry[agent.name] = agent }
72
+ registry
73
+ end
74
+
75
+ # Determine which agent should handle the current conversation.
76
+ # For new conversations (empty context), uses the default agent.
77
+ # For continuing conversations, analyzes history to find the last agent that spoke.
78
+ #
79
+ # This implements Google ADK-style session continuation logic where the system
80
+ # automatically maintains conversation continuity without requiring manual agent tracking.
81
+ #
82
+ # @param context [Hash] Conversation context with potential history
83
+ # @return [Agents::Agent] Agent that should handle this conversation turn
84
+ def determine_conversation_agent(context)
85
+ history = context[:conversation_history] || []
86
+
87
+ # For new conversations, use the default (first) agent
88
+ return @default_agent if history.empty?
89
+
90
+ # Find the last assistant message with agent attribution
91
+ # We traverse in reverse to find the most recent agent that spoke
92
+ last_agent_name = history.reverse.find do |msg|
93
+ msg[:role] == :assistant && msg[:agent_name]
94
+ end&.dig(:agent_name)
95
+
96
+ # Try to resolve from registry, fall back to default if agent not found
97
+ # This handles cases where agent names in history don't match current registry
98
+ if last_agent_name && @registry[last_agent_name]
99
+ @registry[last_agent_name]
100
+ else
101
+ @default_agent
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_context"
4
+
5
+ module Agents
6
+ # Extended chat class that inherits from RubyLLM::Chat but adds proper handoff handling.
7
+ # This solves the infinite handoff loop problem by treating handoffs as turn-ending
8
+ # operations rather than allowing auto-continuation.
9
+ class Chat < RubyLLM::Chat
10
+ # Response object that indicates a handoff occurred
11
+ class HandoffResponse
12
+ attr_reader :target_agent, :response, :handoff_message
13
+
14
+ def initialize(target_agent:, response:, handoff_message:)
15
+ @target_agent = target_agent
16
+ @response = response
17
+ @handoff_message = handoff_message
18
+ end
19
+
20
+ def tool_call?
21
+ true
22
+ end
23
+
24
+ def content
25
+ @handoff_message
26
+ end
27
+ end
28
+
29
+ def initialize(model: nil, handoff_tools: [], context_wrapper: nil, **options)
30
+ super(model: model, **options)
31
+ @handoff_tools = handoff_tools
32
+ @context_wrapper = context_wrapper
33
+
34
+ # Register handoff tools with RubyLLM for schema generation
35
+ @handoff_tools.each { |tool| with_tool(tool) }
36
+ end
37
+
38
+ # Override the problematic auto-execution method from RubyLLM::Chat
39
+ def complete(&block)
40
+ @on[:new_message]&.call
41
+ response = @provider.complete(
42
+ messages,
43
+ tools: @tools,
44
+ temperature: @temperature,
45
+ model: @model.id,
46
+ connection: @connection,
47
+ &block
48
+ )
49
+ @on[:end_message]&.call(response)
50
+
51
+ add_message(response)
52
+
53
+ if response.tool_call?
54
+ handle_tools_with_handoff_detection(response, &block)
55
+ else
56
+ response
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def handle_tools_with_handoff_detection(response, &block)
63
+ handoff_calls, regular_calls = classify_tool_calls(response.tool_calls)
64
+
65
+ if handoff_calls.any?
66
+ # Execute first handoff only
67
+ handoff_result = execute_handoff_tool(handoff_calls.first)
68
+
69
+ # Add tool result to conversation
70
+ add_tool_result(handoff_calls.first.id, handoff_result[:message])
71
+
72
+ # Return handoff response to signal agent switch (ends turn)
73
+ HandoffResponse.new(
74
+ target_agent: handoff_result[:target_agent],
75
+ response: response,
76
+ handoff_message: handoff_result[:message]
77
+ )
78
+ else
79
+ # Use RubyLLM's original tool execution for regular tools
80
+ execute_regular_tools_and_continue(regular_calls, &block)
81
+ end
82
+ end
83
+
84
+ def classify_tool_calls(tool_calls)
85
+ handoff_tool_names = @handoff_tools.map(&:name).map(&:to_s)
86
+
87
+ handoff_calls = []
88
+ regular_calls = []
89
+
90
+ tool_calls.each_value do |tool_call|
91
+ if handoff_tool_names.include?(tool_call.name)
92
+ handoff_calls << tool_call
93
+ else
94
+ regular_calls << tool_call
95
+ end
96
+ end
97
+
98
+ [handoff_calls, regular_calls]
99
+ end
100
+
101
+ def execute_handoff_tool(tool_call)
102
+ tool = @handoff_tools.find { |t| t.name.to_s == tool_call.name }
103
+ raise "Handoff tool not found: #{tool_call.name}" unless tool
104
+
105
+ # Execute the handoff tool directly with context
106
+ tool_context = ToolContext.new(run_context: @context_wrapper)
107
+ result = tool.execute(tool_context, **{}) # Handoff tools take no additional params
108
+
109
+ {
110
+ target_agent: tool.target_agent,
111
+ message: result.to_s
112
+ }
113
+ end
114
+
115
+ def execute_regular_tools_and_continue(tool_calls, &block)
116
+ # Execute each regular tool call
117
+ tool_calls.each do |tool_call|
118
+ @on[:new_message]&.call
119
+ result = execute_tool(tool_call)
120
+ message = add_tool_result(tool_call.id, result)
121
+ @on[:end_message]&.call(message)
122
+ end
123
+
124
+ # Continue conversation after tool execution
125
+ complete(&block)
126
+ end
127
+
128
+ # Reuse RubyLLM's existing tool execution logic
129
+ def execute_tool(tool_call)
130
+ tool = tools[tool_call.name.to_sym]
131
+ args = tool_call.arguments
132
+ tool.call(args)
133
+ end
134
+
135
+ def add_tool_result(tool_use_id, result)
136
+ add_message(
137
+ role: :tool,
138
+ content: result.is_a?(Hash) && result[:error] ? result[:error] : result.to_s,
139
+ tool_call_id: tool_use_id
140
+ )
141
+ end
142
+ end
143
+ end
@@ -95,18 +95,10 @@ module Agents
95
95
  @tool_description
96
96
  end
97
97
 
98
- # Handoff tools implement first-call-wins semantics to prevent infinite loops
99
- # Multiple handoff calls in a single response are ignored (like OpenAI SDK)
100
- def perform(tool_context)
101
- # First-call-wins: only set handoff if not already set
102
- if tool_context.context[:pending_handoff]
103
- return "Transfer request noted (already processing a handoff)."
104
- end
105
-
106
- # Set the handoff target
107
- tool_context.context[:pending_handoff] = @target_agent
108
-
109
- # Return a message that will be shown to the user
98
+ # Handoff tools now work with the extended Chat class for proper handoff handling
99
+ # No longer need context signaling - the Chat class detects handoffs directly
100
+ def perform(_tool_context)
101
+ # Simply return the transfer message - Chat class will handle the handoff
110
102
  "I'll transfer you to #{@target_agent.name} who can better assist you with this."
111
103
  end
112
104
 
data/lib/agents/runner.rb CHANGED
@@ -54,21 +54,33 @@ module Agents
54
54
 
55
55
  class MaxTurnsExceeded < StandardError; end
56
56
 
57
- # Convenience class method for running agents
58
- def self.run(agent, input, context: {}, max_turns: DEFAULT_MAX_TURNS)
59
- new.run(agent, input, context: context, max_turns: max_turns)
57
+ # Create a thread-safe agent runner for multi-agent conversations.
58
+ # The first agent becomes the default entry point for new conversations.
59
+ # All agents must be explicitly provided - no automatic discovery.
60
+ #
61
+ # @param agents [Array<Agents::Agent>] All agents that should be available for handoffs
62
+ # @return [AgentRunner] Thread-safe runner that can be reused across multiple conversations
63
+ #
64
+ # @example
65
+ # runner = Agents::Runner.with_agents(triage_agent, billing_agent, support_agent)
66
+ # result = runner.run("I need help") # Uses triage_agent for new conversation
67
+ # result = runner.run("More help", context: stored_context) # Continues with appropriate agent
68
+ def self.with_agents(*agents)
69
+ AgentRunner.new(agents)
60
70
  end
61
71
 
62
- # Execute an agent with the given input and context
72
+ # Execute an agent with the given input and context.
73
+ # This is now called internally by AgentRunner and should not be used directly.
63
74
  #
64
- # @param starting_agent [Agents::Agent] The initial agent to run
75
+ # @param starting_agent [Agents::Agent] The agent to run
65
76
  # @param input [String] The user's input message
66
77
  # @param context [Hash] Shared context data accessible to all tools
78
+ # @param registry [Hash] Registry of agents for handoff resolution
67
79
  # @param max_turns [Integer] Maximum conversation turns before stopping
68
80
  # @return [RunResult] The result containing output, messages, and usage
69
- def run(starting_agent, input, context: {}, max_turns: DEFAULT_MAX_TURNS)
70
- # Determine current agent from context or use starting agent
71
- current_agent = context[:current_agent] || starting_agent
81
+ def run(starting_agent, input, context: {}, registry: {}, max_turns: DEFAULT_MAX_TURNS)
82
+ # The starting_agent is already determined by AgentRunner based on conversation history
83
+ current_agent = starting_agent
72
84
 
73
85
  # Create context wrapper with deep copy for thread safety
74
86
  context_copy = deep_copy_context(context)
@@ -83,44 +95,55 @@ module Agents
83
95
  current_turn += 1
84
96
  raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
85
97
 
86
- # Get response from LLM (RubyLLM handles tool execution)
87
- response = if current_turn == 1
88
- chat.ask(input)
89
- else
90
- chat.complete
91
- end
98
+ # Get response from LLM (Extended Chat handles tool execution with handoff detection)
99
+ result = if current_turn == 1
100
+ chat.ask(input)
101
+ else
102
+ chat.complete
103
+ end
104
+ response = result
92
105
 
93
- # Update usage
94
- context_wrapper.usage.add(response.usage) if response.respond_to?(:usage) && response.usage
106
+ # Check for handoff response from our extended chat
107
+ if response.is_a?(Agents::Chat::HandoffResponse)
108
+ next_agent = response.target_agent
95
109
 
96
- # Check for handoff via context (set by HandoffTool)
97
- if context_wrapper.context[:pending_handoff]
98
- next_agent = context_wrapper.context[:pending_handoff]
99
- puts "[Agents] Handoff from #{current_agent.name} to #{next_agent.name}"
110
+ # Validate that the target agent is in our registry
111
+ # This prevents handoffs to agents that weren't explicitly provided
112
+ unless registry[next_agent.name]
113
+ puts "[Agents] Warning: Handoff to unregistered agent '#{next_agent.name}', continuing with current agent"
114
+ next if response.tool_call?
115
+
116
+ next
117
+ end
100
118
 
101
119
  # Save current conversation state before switching
102
120
  save_conversation_state(chat, context_wrapper, current_agent)
103
121
 
104
- # Switch to new agent
122
+ # Switch to new agent - store agent name for persistence
105
123
  current_agent = next_agent
106
- context_wrapper.context[:current_agent] = next_agent
107
- context_wrapper.context.delete(:pending_handoff)
124
+ context_wrapper.context[:current_agent] = next_agent.name
108
125
 
109
126
  # Create new chat for new agent with restored history
110
127
  chat = create_chat(current_agent, context_wrapper)
111
128
  restore_conversation_history(chat, context_wrapper)
129
+
130
+ # Force the new agent to respond to the conversation context
131
+ # This ensures the user gets a response from the new agent
132
+ input = nil
112
133
  next
113
134
  end
114
135
 
115
- # If no tools were called, we have our final response
136
+ # If tools were called, continue the loop to let them execute
116
137
  next if response.tool_call?
117
138
 
139
+ # If no tools were called, we have our final response
140
+
118
141
  # Save final state before returning
119
142
  save_conversation_state(chat, context_wrapper, current_agent)
120
143
 
121
144
  return RunResult.new(
122
145
  output: response.content,
123
- messages: extract_messages(chat),
146
+ messages: extract_messages(chat, current_agent),
124
147
  usage: context_wrapper.usage,
125
148
  context: context_wrapper.context
126
149
  )
@@ -131,7 +154,7 @@ module Agents
131
154
 
132
155
  RunResult.new(
133
156
  output: "Conversation ended: #{e.message}",
134
- messages: chat ? extract_messages(chat) : [],
157
+ messages: chat ? extract_messages(chat, current_agent) : [],
135
158
  usage: context_wrapper.usage,
136
159
  error: e,
137
160
  context: context_wrapper.context
@@ -142,7 +165,7 @@ module Agents
142
165
 
143
166
  RunResult.new(
144
167
  output: nil,
145
- messages: chat ? extract_messages(chat) : [],
168
+ messages: chat ? extract_messages(chat, current_agent) : [],
146
169
  usage: context_wrapper.usage,
147
170
  error: e,
148
171
  context: context_wrapper.context
@@ -185,11 +208,11 @@ module Agents
185
208
 
186
209
  def save_conversation_state(chat, context_wrapper, current_agent)
187
210
  # Extract messages from chat
188
- messages = extract_messages(chat)
211
+ messages = extract_messages(chat, current_agent)
189
212
 
190
213
  # Update context with latest state
191
214
  context_wrapper.context[:conversation_history] = messages
192
- context_wrapper.context[:current_agent] = current_agent
215
+ context_wrapper.context[:current_agent] = current_agent.name
193
216
  context_wrapper.context[:turn_count] = (context_wrapper.context[:turn_count] || 0) + 1
194
217
  context_wrapper.context[:last_updated] = Time.now
195
218
 
@@ -203,19 +226,26 @@ module Agents
203
226
  # Get system prompt (may be dynamic)
204
227
  system_prompt = agent.get_system_prompt(context_wrapper)
205
228
 
206
- # Wrap tools with context for thread-safe execution
207
- wrapped_tools = agent.all_tools.map do |tool|
208
- ToolWrapper.new(tool, context_wrapper)
209
- end
229
+ # Separate handoff tools from regular tools
230
+ handoff_tools = agent.handoff_agents.map { |target_agent| HandoffTool.new(target_agent) }
231
+ regular_tools = agent.tools
232
+
233
+ # Only wrap regular tools - handoff tools will be handled directly by Chat
234
+ wrapped_regular_tools = regular_tools.map { |tool| ToolWrapper.new(tool, context_wrapper) }
235
+
236
+ # Create extended chat with handoff awareness and context
237
+ chat = Agents::Chat.new(
238
+ model: agent.model,
239
+ handoff_tools: handoff_tools, # Direct tools, no wrapper
240
+ context_wrapper: context_wrapper # Pass context directly
241
+ )
210
242
 
211
- # Create chat with proper RubyLLM API
212
- chat = RubyLLM.chat(model: agent.model)
213
243
  chat.with_instructions(system_prompt) if system_prompt
214
- chat.with_tools(*wrapped_tools) if wrapped_tools.any?
244
+ chat.with_tools(*wrapped_regular_tools) if wrapped_regular_tools.any?
215
245
  chat
216
246
  end
217
247
 
218
- def extract_messages(chat)
248
+ def extract_messages(chat, current_agent)
219
249
  return [] unless chat.respond_to?(:messages)
220
250
 
221
251
  chat.messages.filter_map do |msg|
@@ -223,10 +253,16 @@ module Agents
223
253
  next unless %i[user assistant].include?(msg.role)
224
254
  next unless msg.content && !msg.content.strip.empty?
225
255
 
226
- {
256
+ message = {
227
257
  role: msg.role,
228
258
  content: msg.content
229
259
  }
260
+
261
+ # Add agent attribution for assistant messages to enable conversation continuity
262
+ # This allows AgentRunner to determine which agent should continue the conversation
263
+ message[:agent_name] = current_agent.name if msg.role == :assistant && current_agent
264
+
265
+ message
230
266
  end
231
267
  end
232
268
  end
data/lib/agents/tool.rb CHANGED
@@ -36,52 +36,8 @@
36
36
  # end
37
37
  # end
38
38
  #
39
- # @example Using the functional tool definition
40
- # # Define a calculator tool
41
- # calculator = Agents::Tool.tool(
42
- # "calculate",
43
- # description: "Perform mathematical calculations"
44
- # ) do |tool_context, expression:|
45
- # begin
46
- # result = eval(expression)
47
- # result.to_s
48
- # rescue => e
49
- # "Calculation error: #{e.message}"
50
- # end
51
- # end
52
- #
53
- # # Use the tool in an agent
54
- # agent = Agents::Agent.new(
55
- # name: "Math Assistant",
56
- # instructions: "You are a helpful math assistant",
57
- # tools: [calculator]
58
- # )
59
- #
60
- # # During execution, the runner would call it like this:
61
- # run_context = Agents::RunContext.new({ user_id: 123 })
62
- # tool_context = Agents::ToolContext.new(run_context: run_context)
63
- #
64
- # result = calculator.execute(tool_context, expression: "2 + 2 * 3")
65
- # # => "8"
66
39
  module Agents
67
40
  class Tool < RubyLLM::Tool
68
- class << self
69
- def param(name, type = String, desc = nil, required: true, **options)
70
- # Convert Ruby types to JSON schema types
71
- json_type = case type
72
- when String, "string" then "string"
73
- when Integer, "integer" then "integer"
74
- when Float, "number" then "number"
75
- when TrueClass, FalseClass, "boolean" then "boolean"
76
- when Array, "array" then "array"
77
- when Hash, "object" then "object"
78
- else "string"
79
- end
80
-
81
- # Call parent param method
82
- super(name, type: json_type, desc: desc, required: required, **options)
83
- end
84
- end
85
41
  # Execute the tool with context injection.
86
42
  # This method is called by the runner and handles the thread-safe
87
43
  # execution pattern by passing all state through parameters.
@@ -112,42 +68,5 @@ module Agents
112
68
  def perform(tool_context, **params)
113
69
  raise NotImplementedError, "Tools must implement #perform(tool_context, **params)"
114
70
  end
115
-
116
- # Create a tool instance using a functional style definition.
117
- # This is an alternative to creating a full class for simple tools.
118
- # The block becomes the tool's perform method.
119
- #
120
- # @param name [String] The tool's name (used in function calling)
121
- # @param description [String] Brief description of what the tool does
122
- # @yield [tool_context, **params] The block that implements the tool's logic
123
- # @return [Agents::Tool] A new tool instance
124
- # @example Creating a simple tool functionally
125
- # math_tool = Agents::Tool.tool(
126
- # "add_numbers",
127
- # description: "Add two numbers together"
128
- # ) do |tool_context, a:, b:|
129
- # (a + b).to_s
130
- # end
131
- #
132
- # @example Tool accessing context with error handling
133
- # greeting_tool = Agents::Tool.tool("greet", description: "Greet a user") do |tool_context, name:|
134
- # language = tool_context.context[:language] || "en"
135
- # case language
136
- # when "es" then "¡Hola, #{name}!"
137
- # when "fr" then "Bonjour, #{name}!"
138
- # else "Hello, #{name}!"
139
- # end
140
- # rescue => e
141
- # "Sorry, I couldn't greet you: #{e.message}"
142
- # end
143
- def self.tool(name, description: "", &block)
144
- # Create anonymous class that extends Tool
145
- Class.new(Tool) do
146
- self.name = name
147
- self.description = description
148
-
149
- define_method :perform, &block
150
- end.new
151
- end
152
71
  end
153
72
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/agents.rb CHANGED
@@ -11,6 +11,18 @@ require_relative "agents/version"
11
11
  module Agents
12
12
  class Error < StandardError; end
13
13
 
14
+ # OpenAI's recommended system prompt prefix for multi-agent workflows
15
+ # This helps agents understand they're part of a coordinated system
16
+ RECOMMENDED_HANDOFF_PROMPT_PREFIX =
17
+ "# System context\n" \
18
+ "You are part of a multi-agent system called the Ruby Agents SDK, designed to make agent " \
19
+ "coordination and execution easy. Agents uses two primary abstraction: **Agents** and " \
20
+ "**Handoffs**. An agent encompasses instructions and tools and can hand off a " \
21
+ "conversation to another agent when appropriate. " \
22
+ "Handoffs are achieved by calling a handoff function, generally named " \
23
+ "`handoff_to_<agent_name>`. Transfers between agents are handled seamlessly in the background; " \
24
+ "do not mention or draw attention to these transfers in your conversation with the user.\n"
25
+
14
26
  class << self
15
27
  # Logger for debugging (can be set by users)
16
28
  attr_accessor :logger
@@ -66,5 +78,7 @@ require_relative "agents/handoff"
66
78
  require_relative "agents/agent"
67
79
 
68
80
  # Execution components
81
+ require_relative "agents/chat"
69
82
  require_relative "agents/tool_wrapper"
83
+ require_relative "agents/agent_runner"
70
84
  require_relative "agents/runner"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shivam Mishra
@@ -51,6 +51,8 @@ files:
51
51
  - examples/isp-support/tools/search_docs_tool.rb
52
52
  - lib/agents.rb
53
53
  - lib/agents/agent.rb
54
+ - lib/agents/agent_runner.rb
55
+ - lib/agents/chat.rb
54
56
  - lib/agents/handoff.rb
55
57
  - lib/agents/result.rb
56
58
  - lib/agents/run_context.rb