Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default defineConfig({
{ text: 'Actions', link: '/docs/action-prompt/actions' },
{ text: 'Prompts', link: '/docs/action-prompt/prompts' },
{ text: 'Tools', link: '/docs/action-prompt/tools' },
{ text: 'Tool Calling', link: '/docs/action-prompt/tool-calling' },
]
},
{ text: 'Agents',
Expand Down
93 changes: 93 additions & 0 deletions docs/docs/action-prompt/tool-calling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Tool Calling

ActiveAgent supports multi-turn tool calling, allowing agents to:
- Call tools (agent actions) during generation
- Receive tool results as part of the conversation
- Continue generation with tool results to provide final answers
- Chain multiple tool calls to solve complex tasks

## How Tool Calling Works

When an agent needs to use a tool during generation:

1. The agent requests a tool call with specific parameters
2. ActiveAgent executes the corresponding action method
3. The tool result is added to the conversation as a "tool" message
4. Generation continues automatically with the tool result
5. The agent can make additional tool calls or provide a final response

## Basic Example

Here's a simple calculator agent that can perform arithmetic operations:

<<< @/../test/dummy/app/agents/calculator_agent.rb#1-10 {ruby:line-numbers}

When asked to add numbers, the agent will:

<<< @/../test/agents/multi_turn_tool_test.rb#multi_turn_basic {ruby:line-numbers}

The conversation flow includes:

::: details Response Example
<!-- @include: @/parts/examples/multi-turn-tool-test-agent-performs-tool-call-and-continues-generation-with-result.md -->
:::

## Chaining Multiple Tool Calls

Agents can chain multiple tool calls to solve complex tasks:

<<< @/../test/agents/multi_turn_tool_test.rb#multi_turn_chain {ruby:line-numbers}

This results in a sequence of tool calls:

::: details Response Example
<!-- @include: @/parts/examples/multi-turn-tool-test-agent-chains-multiple-tool-calls-for-complex-task.md -->
:::

## Tool Response Formats

Tools can return different types of content:

### Plain Text Responses

<<< @/../test/dummy/app/agents/calculator_agent.rb#5-10 {ruby:line-numbers}

### HTML/View Responses

<<< @/../test/dummy/app/agents/weather_agent.rb#10-14 {ruby:line-numbers}

The weather report view:

<<< @/../test/dummy/app/views/weather_agent/weather_report.html.erb {html:line-numbers}

## Error Handling

Tools should handle errors gracefully:

<<< @/../test/dummy/app/agents/calculator_agent.rb#23-34 {ruby:line-numbers}

When an error occurs, the agent receives the error message and can provide appropriate guidance to the user.

## Tool Schemas

Define tool schemas using JSON views to describe parameters:

<<< @/../test/dummy/app/views/calculator_agent/add.json.jbuilder {ruby:line-numbers}

This schema tells the AI model:
- The tool name and description
- Required and optional parameters
- Parameter types and descriptions

## Implementation Details

The tool calling flow is handled by the `perform_generation` method:

1. **Initial Generation**: The agent receives the user message and generates a response
2. **Tool Request**: If the response includes `requested_actions`, tools are called
3. **Tool Execution**: Each action is executed via `perform_action`
4. **Result Handling**: Tool results are added as "tool" messages
5. **Continuation**: Generation continues with `continue_generation`
6. **Completion**: The process repeats until no more tools are requested

This creates a natural conversation flow where the agent can gather information through tools before providing a final answer.
21 changes: 17 additions & 4 deletions lib/active_agent/action_prompt/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ def embed
# Add embedding capability to Message class
ActiveAgent::ActionPrompt::Message.class_eval do
def embed
agent_class = ActiveAgent::Base.descendants.first
agent_class = ApplicationAgent
agent = agent_class.new
agent.context = ActiveAgent::ActionPrompt::Prompt.new(message: self)
agent.embed
Expand All @@ -217,8 +217,18 @@ def perform_generation

def handle_response(response)
return response unless response.message.requested_actions.present?

# Perform the requested actions
perform_actions(requested_actions: response.message.requested_actions)
update_context(response)

# Continue generation with updated context
continue_generation
end

def continue_generation
# Continue generating with the updated context that includes tool results
generation_provider.generate(context) if context && generation_provider
handle_response(generation_provider.response)
end

def update_context(response)
Expand All @@ -233,9 +243,12 @@ def perform_actions(requested_actions:)

def perform_action(action)
current_context = context.clone
# Set params from the action for controller access
# Merge action params with original params to preserve context
original_params = current_context.params || {}
if action.params.is_a?(Hash)
self.params = action.params
self.params = original_params.merge(action.params)
else
self.params = original_params
end
process(action.name)
context.message.role = :tool
Expand Down
75 changes: 59 additions & 16 deletions lib/active_agent/generation_provider/open_ai_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,33 +76,76 @@ def provider_stream
end

def prompt_parameters(model: @prompt.options[:model] || @model_name, messages: @prompt.messages, temperature: @prompt.options[:temperature] || @config["temperature"] || 0.7, tools: @prompt.actions)
{
params = {
model: model,
messages: provider_messages(messages),
temperature: temperature,
max_tokens: @prompt.options[:max_tokens] || @config["max_tokens"],
tools: tools.presence
tools: format_tools(tools)
}.compact
params
end

def format_tools(tools)
return nil if tools.blank?

tools.map do |tool|
if tool["function"] || tool[:function]
# Tool already has the correct structure
tool
else
# Legacy format - wrap in function structure
{
type: "function",
function: {
name: tool["name"] || tool[:name],
description: tool["description"] || tool[:description],
parameters: tool["parameters"] || tool[:parameters]
}
}
end
end
end

def provider_messages(messages)
messages.map do |message|
# Start with basic message structure
provider_message = {
role: message.role,
tool_call_id: message.action_id.presence,
name: message.action_name.presence,
tool_calls: message.raw_actions.present? ? message.raw_actions[:tool_calls] : (message.requested_actions.map { |action| { type: "function", name: action.name, arguments: action.params.to_json } } if message.action_requested),
generation_id: message.generation_id,
content: message.content,
type: message.content_type,
charset: message.charset
}.compact

if message.content_type == "image_url" || message.content[0..4] == "data:"
provider_message[:type] = "image_url"
provider_message[:image_url] = { url: message.content }
role: message.role.to_s,
content: message.content
}

# Add tool-specific fields based on role
case message.role.to_s
when "assistant"
if message.action_requested && message.requested_actions.any?
provider_message[:tool_calls] = message.requested_actions.map do |action|
{
type: "function",
function: {
name: action.name,
arguments: action.params.to_json
},
id: action.id
}
end
elsif message.raw_actions.present? && message.raw_actions.is_a?(Array)
provider_message[:tool_calls] = message.raw_actions
end
when "tool"
provider_message[:tool_call_id] = message.action_id
provider_message[:name] = message.action_name if message.action_name
end
provider_message

# Handle image content
if message.content_type == "image_url"
provider_message[:content] = [ {
type: "image_url",
image_url: { url: message.content }
} ]
end

provider_message.compact
end
end

Expand Down
4 changes: 2 additions & 2 deletions test/agents/application_agent_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class ApplicationAgentTest < ActiveSupport::TestCase

test "it renders a prompt with an plain text message and generates a response" do
VCR.use_cassette("application_agent_prompt_context_message_generation") do
test_response_message_content = "It seems like you're looking for information or assistance regarding a \"Test Application Agent.\" Could you please provide more context or specify what exactly you need help with? Are you referring to a software testing agent, a specific tool, or something else? Your clarification will help me assist you better!"
test_response_message_content = "It seems like you're asking about a \"Test Application Agent.\" Could you please provide more context or specify what you need help with? Are you looking for information on a specific software application, a test automation framework, or something else entirely?"
# region application_agent_prompt_context_message_generation
message = "Test Application Agent"
prompt = ApplicationAgent.with(message: message).prompt_context
Expand All @@ -27,7 +27,7 @@ class ApplicationAgentTest < ActiveSupport::TestCase

test "it renders a prompt with an plain text message with previous messages and generates a response" do
VCR.use_cassette("application_agent_loaded_context_message_generation") do
test_response_message_content = "Sure! I can help you with that. Could you please provide more details about the issue you're experiencing with your account?"
test_response_message_content = "Sure, I can help you with that! Could you please provide more details about the issue you're facing with your account?"
# region application_agent_loaded_context_message_generation
message = "I need help with my account"
previous_context = ActiveAgent::ActionPrompt::Prompt.new(
Expand Down
4 changes: 2 additions & 2 deletions test/agents/data_extraction_agent_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class DataExtractionAgentTest < ActiveSupport::TestCase
# endregion data_extraction_agent_describe_cat_image_response
doc_example_output(response)

assert_equal response.message.content, "The cat in the image has a sleek, short coat that appears to be a grayish-brown color. Its eyes are large and striking, with a vivid green hue. The cat is sitting comfortably, being gently petted by a hand that is adorned with a bracelet. Overall, it has a calm and curious expression. The background features a dark, soft surface, adding to the cozy atmosphere of the scene."
assert_equal "The cat in the image is lying on its back on a brown leather surface. It has a primarily white coat with some black patches. Its paws are stretched out, and the cat appears to be comfortably relaxed, with its eyes closed and a peaceful expression. The light from the sun creates a warm glow around it, highlighting its features.", response.message.content
end
end

Expand Down Expand Up @@ -106,7 +106,7 @@ class DataExtractionAgentTest < ActiveSupport::TestCase
response = prompt.generate_now
doc_example_output(response)

assert_equal response.message.content, "The graph titled \"Quarterly Sales Report\" displays sales revenue for four quarters in 2024. Key points include:\n\n- **Q1**: Blue bar represents the lowest sales revenue.\n- **Q2**: Green bar shows an increase in sales compared to Q1.\n- **Q3**: Yellow bar continues the upward trend with higher sales than Q2.\n- **Q4**: Red bar indicates the highest sales revenue of the year.\n\nOverall, there is a clear upward trend in sales revenue over the quarters, reaching a peak in Q4."
assert_equal "The image presents a bar chart titled \"Quarterly Sales Report\" for the year 2024. It depicts sales revenue by quarter, with data represented for four quarters (Q1, Q2, Q3, and Q4) using differently colored bars:\n\n- **Q1**: Blue bar\n- **Q2**: Green bar\n- **Q3**: Yellow bar\n- **Q4**: Red bar\n\nThe sales revenue ranges from $0 to $100,000, with each quarter showing varying levels of sales revenue, with Q4 having the highest bar.", response.message.content
end
end

Expand Down
64 changes: 64 additions & 0 deletions test/agents/multi_turn_tool_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
require "test_helper"

class MultiTurnToolTest < ActiveSupport::TestCase
test "agent performs tool call and continues generation with result" do
VCR.use_cassette("multi_turn_tool_basic") do
# region multi_turn_basic
message = "Add 2 and 3"
prompt = CalculatorAgent.with(message: message).prompt_context
response = prompt.generate_now
# endregion multi_turn_basic

doc_example_output(response)

# Verify the conversation flow
assert_equal 5, response.prompt.messages.size

# System message
assert_equal :system, response.prompt.messages[0].role
assert_includes response.prompt.messages[0].content, "calculator"

# User message
assert_equal :user, response.prompt.messages[1].role
assert_equal "Add 2 and 3", response.prompt.messages[1].content

# Assistant makes tool call
assert_equal :assistant, response.prompt.messages[2].role
assert response.prompt.messages[2].action_requested
assert_equal "add", response.prompt.messages[2].requested_actions.first.name

# Tool response
assert_equal :tool, response.prompt.messages[3].role
assert_equal "5.0", response.prompt.messages[3].content

# Assistant provides final answer
assert_equal :assistant, response.prompt.messages[4].role
assert_includes response.prompt.messages[4].content, "5"
end
end

test "agent chains multiple tool calls for complex task" do
VCR.use_cassette("multi_turn_tool_chain") do
# region multi_turn_chain
message = "Calculate the area of a 5x10 rectangle, then multiply by 2"
prompt = CalculatorAgent.with(message: message).prompt_context
response = prompt.generate_now
# endregion multi_turn_chain

doc_example_output(response)

# Should have at least 2 tool calls
tool_messages = response.prompt.messages.select { |m| m.role == :tool }
assert tool_messages.size >= 2

# First tool call calculates area (50)
assert_equal "50.0", tool_messages[0].content

# Second tool call multiplies by 2 (100)
assert_equal "100.0", tool_messages[1].content

# Final message should mention the result
assert_includes response.message.content, "100"
end
end
end
1 change: 0 additions & 1 deletion test/agents/open_ai_agent_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ def setup
# Configure OpenAI before tests
OpenAI.configure do |config|
config.access_token = "test-api-key"
config.organization_id = "test-organization-id"
config.log_errors = Rails.env.development?
config.request_timeout = 600
end
Expand Down
2 changes: 1 addition & 1 deletion test/agents/streaming_agent_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class StreamingAgentTest < ActiveSupport::TestCase
# endregion streaming_agent_stream_response
end

assert_equal broadcast_calls.size, 30
assert_equal 84, broadcast_calls.size
ensure
# Restore original broadcast method
ActionCable.server.singleton_class.class_eval do
Expand Down
Loading