ai-agents 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -106
  3. data/docs/Gemfile +14 -0
  4. data/docs/Gemfile.lock +183 -0
  5. data/docs/_config.yml +53 -0
  6. data/docs/_sass/color_schemes/ruby.scss +72 -0
  7. data/docs/_sass/custom/custom.scss +93 -0
  8. data/docs/architecture.md +353 -0
  9. data/docs/assets/fonts/InterVariable.woff2 +0 -0
  10. data/docs/concepts/agent-tool.md +166 -0
  11. data/docs/concepts/agents.md +43 -0
  12. data/docs/concepts/context.md +110 -0
  13. data/docs/concepts/handoffs.md +81 -0
  14. data/docs/concepts/runner.md +87 -0
  15. data/docs/concepts/tools.md +62 -0
  16. data/docs/concepts.md +21 -0
  17. data/docs/guides/agent-as-tool-pattern.md +242 -0
  18. data/docs/guides/multi-agent-systems.md +261 -0
  19. data/docs/guides/rails-integration.md +440 -0
  20. data/docs/guides/state-persistence.md +451 -0
  21. data/docs/guides.md +18 -0
  22. data/docs/index.md +95 -0
  23. data/examples/collaborative-copilot/README.md +169 -0
  24. data/examples/collaborative-copilot/agents/analysis_agent.rb +48 -0
  25. data/examples/collaborative-copilot/agents/answer_suggestion_agent.rb +50 -0
  26. data/examples/collaborative-copilot/agents/copilot_orchestrator.rb +85 -0
  27. data/examples/collaborative-copilot/agents/integrations_agent.rb +58 -0
  28. data/examples/collaborative-copilot/agents/research_agent.rb +52 -0
  29. data/examples/collaborative-copilot/data/contacts.json +47 -0
  30. data/examples/collaborative-copilot/data/conversations.json +170 -0
  31. data/examples/collaborative-copilot/data/knowledge_base.json +58 -0
  32. data/examples/collaborative-copilot/data/linear_issues.json +83 -0
  33. data/examples/collaborative-copilot/data/stripe_billing.json +71 -0
  34. data/examples/collaborative-copilot/interactive.rb +90 -0
  35. data/examples/collaborative-copilot/tools/create_linear_ticket_tool.rb +58 -0
  36. data/examples/collaborative-copilot/tools/get_article_tool.rb +41 -0
  37. data/examples/collaborative-copilot/tools/get_contact_tool.rb +51 -0
  38. data/examples/collaborative-copilot/tools/get_conversation_tool.rb +53 -0
  39. data/examples/collaborative-copilot/tools/get_stripe_billing_tool.rb +44 -0
  40. data/examples/collaborative-copilot/tools/search_contacts_tool.rb +57 -0
  41. data/examples/collaborative-copilot/tools/search_conversations_tool.rb +54 -0
  42. data/examples/collaborative-copilot/tools/search_knowledge_base_tool.rb +55 -0
  43. data/examples/collaborative-copilot/tools/search_linear_issues_tool.rb +60 -0
  44. data/lib/agents/agent.rb +34 -0
  45. data/lib/agents/agent_tool.rb +113 -0
  46. data/lib/agents/handoff.rb +8 -34
  47. data/lib/agents/version.rb +1 -1
  48. data/lib/agents.rb +1 -0
  49. metadata +43 -1
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Copilot
6
+ # Tool for searching knowledge base articles and documentation
7
+ class SearchKnowledgeBaseTool < Agents::Tool
8
+ description "Search help documentation and knowledge base for solutions"
9
+ param :query, type: "string", desc: "Search terms or keywords to find relevant articles"
10
+ param :category, type: "string",
11
+ desc: "Optional: filter by category (troubleshooting, account, development, billing)", required: false
12
+
13
+ def perform(_tool_context, query:, category: nil)
14
+ data_file = File.join(__dir__, "../data/knowledge_base.json")
15
+ return "Knowledge base unavailable" unless File.exist?(data_file)
16
+
17
+ begin
18
+ kb_data = JSON.parse(File.read(data_file))
19
+ articles = kb_data["articles"]
20
+ query_text = query.downcase
21
+ matches = []
22
+
23
+ articles.each do |article_id, article|
24
+ # Skip if category filter is specified and doesn't match
25
+ next if category && article["category"] != category.downcase
26
+
27
+ # Simple keyword matching
28
+ text_to_search = [
29
+ article["title"],
30
+ article["tags"].join(" "),
31
+ article["content"]
32
+ ].join(" ").downcase
33
+
34
+ next unless text_to_search.include?(query_text)
35
+
36
+ matches << {
37
+ article_id: article_id,
38
+ title: article["title"],
39
+ category: article["category"],
40
+ tags: article["tags"],
41
+ content_preview: "#{article["content"][0...200]}..."
42
+ }
43
+ end
44
+
45
+ if matches.empty?
46
+ "No knowledge base articles found for: #{query}"
47
+ else
48
+ JSON.pretty_generate({ query: query, articles: matches })
49
+ end
50
+ rescue StandardError => e
51
+ "Error searching knowledge base: #{e.message}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Copilot
6
+ # Tool for searching Linear issues for development context and bug reports
7
+ class SearchLinearIssuesTool < Agents::Tool
8
+ description "Search Linear issues for bug reports, feature requests, and development context"
9
+ param :query, type: "string", desc: "Search terms (keywords, error messages, or feature descriptions)"
10
+ param :status, type: "string", desc: "Optional: filter by status (backlog, in_progress, completed, resolved)",
11
+ required: false
12
+ param :priority, type: "string", desc: "Optional: filter by priority (low, medium, high)", required: false
13
+
14
+ def perform(_tool_context, query:, status: nil, priority: nil)
15
+ data_file = File.join(__dir__, "../data/linear_issues.json")
16
+ return "Linear issues database unavailable" unless File.exist__(data_file)
17
+
18
+ begin
19
+ linear_data = JSON.parse(File.read(data_file))
20
+ issues = linear_data["issues"]
21
+ query_text = query.downcase
22
+ matches = []
23
+
24
+ issues.each do |issue_id, issue|
25
+ # Apply filters
26
+ next if status && issue["status"] != status.downcase
27
+ next if priority && issue["priority"] != priority.downcase
28
+
29
+ # Simple keyword matching
30
+ text_to_search = [
31
+ issue["title"],
32
+ issue["description"],
33
+ issue["labels"].join(" "),
34
+ issue["resolution"]
35
+ ].compact.join(" ").downcase
36
+
37
+ next unless text_to_search.include?(query_text)
38
+
39
+ matches << {
40
+ issue_id: issue_id,
41
+ title: issue["title"],
42
+ status: issue["status"],
43
+ priority: issue["priority"],
44
+ labels: issue["labels"],
45
+ description: "#{issue["description"][0...200]}...",
46
+ resolution: issue["resolution"]
47
+ }
48
+ end
49
+
50
+ if matches.empty?
51
+ "No Linear issues found for: #{query}"
52
+ else
53
+ JSON.pretty_generate({ query: query, issues: matches })
54
+ end
55
+ rescue StandardError => e
56
+ "Error searching Linear issues: #{e.message}"
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/agents/agent.rb CHANGED
@@ -178,5 +178,39 @@ module Agents
178
178
  instructions.call(context)
179
179
  end
180
180
  end
181
+
182
+ # Transform this agent into a tool, callable by other agents.
183
+ # This enables agent-to-agent collaboration without conversation handoffs.
184
+ #
185
+ # Agent-as-tool is different from handoffs in two key ways:
186
+ # 1. The wrapped agent receives generated input, not conversation history
187
+ # 2. The wrapped agent returns a result to the calling agent, rather than taking over
188
+ #
189
+ # @param name [String, nil] Override the tool name (defaults to snake_case agent name)
190
+ # @param description [String, nil] Override the tool description
191
+ # @param output_extractor [Proc, nil] Custom proc to extract/transform the agent's output
192
+ # @param params [Hash] Additional parameter definitions for the tool
193
+ # @return [Agents::AgentTool] A tool that wraps this agent
194
+ #
195
+ # @example Basic agent-as-tool
196
+ # research_agent = Agent.new(name: "Researcher", instructions: "Research topics")
197
+ # research_tool = research_agent.as_tool(
198
+ # name: "research_topic",
199
+ # description: "Research a topic using company knowledge base"
200
+ # )
201
+ #
202
+ # @example Custom output extraction
203
+ # analyzer_tool = analyzer_agent.as_tool(
204
+ # output_extractor: ->(result) { result.context[:extracted_data]&.to_json || result.output }
205
+ # )
206
+ #
207
+ def as_tool(name: nil, description: nil, output_extractor: nil)
208
+ AgentTool.new(
209
+ agent: self,
210
+ name: name,
211
+ description: description,
212
+ output_extractor: output_extractor
213
+ )
214
+ end
181
215
  end
182
216
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ # AgentTool wraps an agent as a tool, enabling agent-to-agent collaboration
5
+ # without conversation handoffs. This implementation constrains wrapped agents
6
+ # for safety and predictability.
7
+ #
8
+ # Key constraints:
9
+ # 1. Wrapped agents cannot perform handoffs (empty registry)
10
+ # 2. Limited turn count to prevent infinite loops
11
+ # 3. Isolated context (only shared state, no conversation history)
12
+ # 4. Always returns to calling agent
13
+ #
14
+ # @example Customer support copilot with specialized agents
15
+ # conversation_agent = Agent.new(
16
+ # name: "ConversationAnalyzer",
17
+ # instructions: "Extract order IDs and customer intent from conversation history"
18
+ # )
19
+ #
20
+ # actions_agent = Agent.new(
21
+ # name: "ShopifyActions",
22
+ # instructions: "Perform Shopify operations",
23
+ # tools: [shopify_tool]
24
+ # )
25
+ #
26
+ # copilot = Agent.new(
27
+ # name: "SupportCopilot",
28
+ # tools: [
29
+ # conversation_agent.as_tool(
30
+ # name: "analyze_conversation",
31
+ # description: "Extract key info from conversation history"
32
+ # ),
33
+ # actions_agent.as_tool(
34
+ # name: "shopify_action",
35
+ # description: "Perform Shopify operations like refunds"
36
+ # )
37
+ # ]
38
+ # )
39
+ class AgentTool < Tool
40
+ attr_reader :wrapped_agent, :tool_name, :tool_description, :output_extractor
41
+
42
+ # Default parameter for agent tools
43
+ param :input, type: "string", desc: "Input message for the agent"
44
+
45
+ # Initialize an AgentTool that wraps an agent as a callable tool
46
+ #
47
+ # @param agent [Agents::Agent] The agent to wrap as a tool
48
+ # @param name [String, nil] Override the tool name (defaults to snake_case agent name)
49
+ # @param description [String, nil] Override the tool description
50
+ # @param output_extractor [Proc, nil] Custom proc to extract/transform the agent's output
51
+ def initialize(agent:, name: nil, description: nil, output_extractor: nil)
52
+ @wrapped_agent = agent
53
+ @tool_name = name || transform_agent_name(agent.name)
54
+ @tool_description = description || "Execute #{agent.name} agent"
55
+ @output_extractor = output_extractor
56
+
57
+ super()
58
+ end
59
+
60
+ def name
61
+ @tool_name
62
+ end
63
+
64
+ def description
65
+ @tool_description
66
+ end
67
+
68
+ # Execute the wrapped agent with constraints to prevent handoffs and recursion
69
+ def perform(tool_context, input:)
70
+ # Create isolated context for the wrapped agent
71
+ isolated_context = create_isolated_context(tool_context.context)
72
+
73
+ # Execute with explicit constraints:
74
+ # 1. Empty registry prevents handoffs
75
+ # 2. Low max_turns prevents infinite loops
76
+ # 3. Isolated context prevents history access
77
+ result = Runner.new.run(
78
+ @wrapped_agent,
79
+ input,
80
+ context: isolated_context,
81
+ registry: {}, # CONSTRAINT: No handoffs allowed
82
+ max_turns: 3 # CONSTRAINT: Limited turns for tool execution
83
+ )
84
+
85
+ return "Agent execution failed: #{result.error.message}" if result.error
86
+
87
+ # Extract output
88
+ if @output_extractor
89
+ @output_extractor.call(result)
90
+ else
91
+ result.output || "No output from #{@wrapped_agent.name}"
92
+ end
93
+ rescue StandardError => e
94
+ "Error executing #{@wrapped_agent.name}: #{e.message}"
95
+ end
96
+
97
+ private
98
+
99
+ def transform_agent_name(name)
100
+ name.downcase.gsub(/\s+/, "_").gsub(/[^a-z0-9_]/, "")
101
+ end
102
+
103
+ # Create isolated context that only shares state, not conversation artifacts
104
+ def create_isolated_context(parent_context)
105
+ isolated = {}
106
+
107
+ # Only share the state - everything else is conversation-specific
108
+ isolated[:state] = parent_context[:state] if parent_context[:state]
109
+
110
+ isolated
111
+ end
112
+ end
113
+ end
@@ -12,35 +12,10 @@ module Agents
12
12
  # 4. The tool signals the handoff through context
13
13
  # 5. The Runner detects this and switches to the new agent
14
14
  #
15
- # ## First-Call-Wins Implementation
16
- # This implementation uses "first-call-wins" semantics to prevent infinite handoff loops.
17
- #
18
- # ### The Problem We Solved
19
- # During development, we discovered that LLMs could call the same handoff tool multiple times
20
- # in a single response, leading to infinite loops:
21
- #
22
- # 1. User: "My internet isn't working but my account shows active"
23
- # 2. Triage Agent hands off to Support Agent
24
- # 3. Support Agent sees account info is needed, hands back to Triage Agent
25
- # 4. Triage Agent sees technical issue, hands off to Support Agent again
26
- # 5. This creates an infinite ping-pong loop
27
- #
28
- # ### Root Cause Analysis
29
- # Unlike OpenAI's SDK which processes tool calls before execution, RubyLLM automatically
30
- # executes all tool calls in a response. This meant:
31
- # - LLM calls handoff tool 10+ times in one response
32
- # - Each call sets context[:pending_handoff], overwriting previous values
33
- # - Runner processes handoffs after tool execution, seeing only the last one
34
- # - Multiple handoff signals created conflicting state
35
- #
36
- # TODO: Overall, this problem can be tackled better if we replace the RubyLLM chat
37
- # program with our own implementation.
38
- #
39
- # ### The Solution
40
- # We implemented first-call-wins semantics inspired by OpenAI's approach:
41
- # - First handoff call in a response sets the pending handoff
42
- # - Subsequent calls are ignored with a "transfer in progress" message
43
- # - This prevents loops and mirrors OpenAI SDK behavior
15
+ # ## Loop Prevention
16
+ # The library prevents infinite handoff loops by processing only the first handoff
17
+ # tool call in any LLM response. This is handled automatically by the Chat class
18
+ # which detects handoff tools and processes them separately from regular tools.
44
19
  #
45
20
  # ## Why Tools Instead of Instructions
46
21
  # Using tools for handoffs has several advantages:
@@ -66,12 +41,11 @@ module Agents
66
41
  # # LLM calls: handoff_to_billing()
67
42
  # # Runner switches to billing_agent for the next turn
68
43
  #
69
- # @example First-call-wins in action
44
+ # @example Multiple handoff handling
70
45
  # # Single LLM response with multiple handoff calls:
71
- # # Call 1: handoff_to_support() -> Sets pending_handoff, returns "Transferring to Support"
72
- # # Call 2: handoff_to_support() -> Ignored, returns "Transfer already in progress"
73
- # # Call 3: handoff_to_billing() -> Ignored, returns "Transfer already in progress"
74
- # # Result: Only transfers to Support Agent (first call wins)
46
+ # # Call 1: handoff_to_support() -> Processed and executed
47
+ # # Call 2: handoff_to_billing() -> Ignored (only first handoff processed)
48
+ # # Result: Only transfers to Support Agent
75
49
  class HandoffTool < Tool
76
50
  attr_reader :target_agent
77
51
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/agents.rb CHANGED
@@ -82,3 +82,4 @@ require_relative "agents/chat"
82
82
  require_relative "agents/tool_wrapper"
83
83
  require_relative "agents/agent_runner"
84
84
  require_relative "agents/runner"
85
+ require_relative "agents/agent_tool"
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.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shivam Mishra
@@ -37,7 +37,48 @@ files:
37
37
  - LICENSE
38
38
  - README.md
39
39
  - Rakefile
40
+ - docs/Gemfile
41
+ - docs/Gemfile.lock
42
+ - docs/_config.yml
43
+ - docs/_sass/color_schemes/ruby.scss
44
+ - docs/_sass/custom/custom.scss
45
+ - docs/architecture.md
46
+ - docs/assets/fonts/InterVariable.woff2
47
+ - docs/concepts.md
48
+ - docs/concepts/agent-tool.md
49
+ - docs/concepts/agents.md
50
+ - docs/concepts/context.md
51
+ - docs/concepts/handoffs.md
52
+ - docs/concepts/runner.md
53
+ - docs/concepts/tools.md
54
+ - docs/guides.md
55
+ - docs/guides/agent-as-tool-pattern.md
56
+ - docs/guides/multi-agent-systems.md
57
+ - docs/guides/rails-integration.md
58
+ - docs/guides/state-persistence.md
59
+ - docs/index.md
40
60
  - examples/README.md
61
+ - examples/collaborative-copilot/README.md
62
+ - examples/collaborative-copilot/agents/analysis_agent.rb
63
+ - examples/collaborative-copilot/agents/answer_suggestion_agent.rb
64
+ - examples/collaborative-copilot/agents/copilot_orchestrator.rb
65
+ - examples/collaborative-copilot/agents/integrations_agent.rb
66
+ - examples/collaborative-copilot/agents/research_agent.rb
67
+ - examples/collaborative-copilot/data/contacts.json
68
+ - examples/collaborative-copilot/data/conversations.json
69
+ - examples/collaborative-copilot/data/knowledge_base.json
70
+ - examples/collaborative-copilot/data/linear_issues.json
71
+ - examples/collaborative-copilot/data/stripe_billing.json
72
+ - examples/collaborative-copilot/interactive.rb
73
+ - examples/collaborative-copilot/tools/create_linear_ticket_tool.rb
74
+ - examples/collaborative-copilot/tools/get_article_tool.rb
75
+ - examples/collaborative-copilot/tools/get_contact_tool.rb
76
+ - examples/collaborative-copilot/tools/get_conversation_tool.rb
77
+ - examples/collaborative-copilot/tools/get_stripe_billing_tool.rb
78
+ - examples/collaborative-copilot/tools/search_contacts_tool.rb
79
+ - examples/collaborative-copilot/tools/search_conversations_tool.rb
80
+ - examples/collaborative-copilot/tools/search_knowledge_base_tool.rb
81
+ - examples/collaborative-copilot/tools/search_linear_issues_tool.rb
41
82
  - examples/isp-support/README.md
42
83
  - examples/isp-support/agents_factory.rb
43
84
  - examples/isp-support/data/customers.json
@@ -52,6 +93,7 @@ files:
52
93
  - lib/agents.rb
53
94
  - lib/agents/agent.rb
54
95
  - lib/agents/agent_runner.rb
96
+ - lib/agents/agent_tool.rb
55
97
  - lib/agents/chat.rb
56
98
  - lib/agents/handoff.rb
57
99
  - lib/agents/result.rb