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 +4 -4
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +146 -137
- data/README.md +42 -20
- data/examples/isp-support/agents_factory.rb +9 -9
- data/examples/isp-support/interactive.rb +28 -6
- data/examples/isp-support/tools/create_checkout_tool.rb +1 -1
- data/examples/isp-support/tools/create_lead_tool.rb +3 -3
- data/examples/isp-support/tools/crm_lookup_tool.rb +1 -1
- data/examples/isp-support/tools/search_docs_tool.rb +1 -1
- data/lib/agents/agent_runner.rb +105 -0
- data/lib/agents/chat.rb +143 -0
- data/lib/agents/handoff.rb +4 -12
- data/lib/agents/runner.rb +74 -38
- data/lib/agents/tool.rb +0 -81
- data/lib/agents/version.rb +1 -1
- data/lib/agents.rb +14 -0
- metadata +3 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cc088132bc2b8dafa23669d0e2e15d110f6a8cb982f54ba026247008a90322ca
|
4
|
+
data.tar.gz: 93086217d9aa75ebf072a299aeadacf688cede7216bd41d64dc539072ec1469b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7ac77c92850152aa9bb716d3d1ecd37444e7cab76c9ab5e4e63aef20ad337a2416f9b4a1d55895ff0d0af97d4e8903c1e73f03c3e7a8f9bd6de0b8ed2f4279d6
|
7
|
+
data.tar.gz: 4b3055a233c48902fdb570ca8dce896d09cdbd30dc52a0a5f03fb290c7f60725804d574e5df3e3d4428c61ad1af58b97d0f173c5b576a324ae84598f4d48fbbc
|
data/.rubocop.yml
CHANGED
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
|
-
##
|
5
|
+
## Project Purpose
|
6
6
|
|
7
|
-
This is a Ruby
|
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
|
-
**
|
10
|
-
-
|
11
|
-
-
|
12
|
-
-
|
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
|
-
##
|
16
|
+
## Key Technologies
|
15
17
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
22
|
-
rake spec
|
23
|
-
# OR
|
24
|
-
bundle exec rspec
|
24
|
+
## Project Structure
|
25
25
|
|
26
|
-
|
27
|
-
|
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
|
-
|
30
|
-
bundle exec rspec # SimpleCov will generate coverage/index.html
|
38
|
+
## Development Workflow
|
31
39
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
38
|
-
bundle exec rubocop -a
|
45
|
+
## How to Run the Example
|
39
46
|
|
40
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
59
|
+
## Key Concepts
|
59
60
|
|
60
|
-
**
|
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
|
-
|
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
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
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
|
-
|
76
|
-
|
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
|
-
|
78
|
+
# Run specific test file
|
79
|
+
bundle exec rspec spec/agents/agent_spec.rb
|
82
80
|
|
83
|
-
|
81
|
+
# Run specific test with line number
|
82
|
+
bundle exec rspec spec/agents/agent_spec.rb:25
|
83
|
+
```
|
84
84
|
|
85
|
-
###
|
85
|
+
### Code Quality
|
86
|
+
```bash
|
87
|
+
# Run RuboCop linter
|
88
|
+
bundle exec rubocop
|
86
89
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
|
101
|
-
```
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
128
|
+
### File Structure
|
155
129
|
|
156
|
-
|
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
|
-
|
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
|
-
|
147
|
+
The SDK requires at least one LLM provider API key:
|
165
148
|
|
166
|
-
|
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
|
-
|
159
|
+
### Basic Usage Pattern
|
169
160
|
|
170
|
-
|
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
|
-
|
177
|
+
### Tool Development
|
173
178
|
|
174
|
-
|
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
|
-
|
185
|
+
### Testing Strategy
|
177
186
|
|
178
|
-
|
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
|
-
###
|
192
|
+
### Examples
|
181
193
|
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
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
|
44
|
-
|
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
|
-
#
|
51
|
-
|
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
|
-
#
|
84
|
-
|
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
|
-
- **
|
94
|
-
- **
|
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,
|
138
|
-
param :subject,
|
139
|
-
param :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
|
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
|
182
|
-
|
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
|
-
#
|
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]
|
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
|
-
-
|
105
|
-
-
|
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
|
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
|
130
|
-
-
|
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
|
-
|
17
|
-
|
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
|
-
#
|
35
|
-
|
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,
|
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,
|
8
|
-
param :email,
|
9
|
-
param :desired_plan,
|
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,
|
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,
|
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
|
data/lib/agents/chat.rb
ADDED
@@ -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
|
data/lib/agents/handoff.rb
CHANGED
@@ -95,18 +95,10 @@ module Agents
|
|
95
95
|
@tool_description
|
96
96
|
end
|
97
97
|
|
98
|
-
# Handoff tools
|
99
|
-
#
|
100
|
-
def perform(
|
101
|
-
#
|
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
|
-
#
|
58
|
-
|
59
|
-
|
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
|
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
|
-
#
|
71
|
-
current_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 (
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
#
|
94
|
-
|
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
|
-
|
97
|
-
|
98
|
-
next_agent
|
99
|
-
|
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
|
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
|
-
#
|
207
|
-
|
208
|
-
|
209
|
-
|
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(*
|
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
|
data/lib/agents/version.rb
CHANGED
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.
|
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
|