feat: Instrument captain (#12949)
Co-authored-by: aakashb95 <aakash@chatwoot.com>
This commit is contained in:
@@ -1,94 +1,78 @@
|
||||
module Captain::ChatHelper
|
||||
include Integrations::LlmInstrumentation
|
||||
include Captain::ToolExecutionHelper
|
||||
|
||||
def request_chat_completion
|
||||
log_chat_completion_request
|
||||
|
||||
response = @client.chat(
|
||||
parameters: {
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
tools: @tool_registry&.registered_tools || [],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: @assistant&.config&.[]('temperature').to_f || 1
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
with_agent_session do
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@client.chat(
|
||||
parameters: chat_parameters
|
||||
)
|
||||
end
|
||||
handle_response(response)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
|
||||
raise e
|
||||
end
|
||||
|
||||
def instrumentation_params
|
||||
{
|
||||
span_name: "llm.captain.#{feature_name}",
|
||||
account_id: resolved_account_id,
|
||||
conversation_id: @conversation_id,
|
||||
feature_name: feature_name,
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
temperature: temperature,
|
||||
metadata: {
|
||||
assistant_id: @assistant&.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def chat_parameters
|
||||
{
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
tools: @tool_registry&.registered_tools || [],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: temperature
|
||||
}
|
||||
end
|
||||
|
||||
def temperature
|
||||
@assistant&.config&.[]('temperature').to_f || 1
|
||||
end
|
||||
|
||||
def resolved_account_id
|
||||
@account&.id || @assistant&.account_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||
message = response.dig('choices', 0, 'message')
|
||||
if message['tool_calls']
|
||||
process_tool_calls(message['tool_calls'])
|
||||
else
|
||||
message = JSON.parse(message['content'].strip)
|
||||
persist_message(message, 'assistant')
|
||||
message
|
||||
end
|
||||
# Ensures all LLM calls and tool executions within an agentic loop
|
||||
# are grouped under a single trace/session in Langfuse.
|
||||
#
|
||||
# Without this guard, each recursive call to request_chat_completion
|
||||
# (triggered by tool calls) would create a separate trace instead of
|
||||
# nesting within the existing session span.
|
||||
def with_agent_session(&)
|
||||
already_active = @agent_session_active
|
||||
return yield if already_active
|
||||
|
||||
@agent_session_active = true
|
||||
instrument_agent_session(instrumentation_params, &)
|
||||
ensure
|
||||
@agent_session_active = false unless already_active
|
||||
end
|
||||
|
||||
def process_tool_calls(tool_calls)
|
||||
append_tool_calls(tool_calls)
|
||||
tool_calls.each do |tool_call|
|
||||
process_tool_call(tool_call)
|
||||
end
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
function_name = tool_call['function']['name']
|
||||
tool_call_id = tool_call['id']
|
||||
|
||||
if @tool_registry.respond_to?(function_name)
|
||||
execute_tool(function_name, arguments, tool_call_id)
|
||||
else
|
||||
process_invalid_tool_call(function_name, tool_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_tool(function_name, arguments, tool_call_id)
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t('captain.copilot.using_tool', function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
result = @tool_registry.send(function_name, arguments)
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t('captain.copilot.completed_tool_call', function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
append_tool_response(result, tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_calls(tool_calls)
|
||||
@messages << {
|
||||
role: 'assistant',
|
||||
tool_calls: tool_calls
|
||||
}
|
||||
end
|
||||
|
||||
def process_invalid_tool_call(function_name, tool_call_id)
|
||||
persist_message({ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, 'assistant_thinking')
|
||||
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_response(content, tool_call_id)
|
||||
@messages << {
|
||||
role: 'tool',
|
||||
tool_call_id: tool_call_id,
|
||||
content: content
|
||||
}
|
||||
# Must be implemented by including class to identify the feature for instrumentation.
|
||||
# Used for Langfuse tagging and span naming.
|
||||
def feature_name
|
||||
raise NotImplementedError, "#{self.class.name} must implement #feature_name"
|
||||
end
|
||||
|
||||
def log_chat_completion_request
|
||||
|
||||
83
enterprise/app/helpers/captain/tool_execution_helper.rb
Normal file
83
enterprise/app/helpers/captain/tool_execution_helper.rb
Normal file
@@ -0,0 +1,83 @@
|
||||
module Captain::ToolExecutionHelper
|
||||
private
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||
message = response.dig('choices', 0, 'message')
|
||||
|
||||
if message['tool_calls']
|
||||
process_tool_calls(message['tool_calls'])
|
||||
else
|
||||
message = JSON.parse(message['content'].strip)
|
||||
persist_message(message, 'assistant')
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
def process_tool_calls(tool_calls)
|
||||
append_tool_calls(tool_calls)
|
||||
tool_calls.each { |tool_call| process_tool_call(tool_call) }
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
function_name = tool_call['function']['name']
|
||||
tool_call_id = tool_call['id']
|
||||
|
||||
if @tool_registry.respond_to?(function_name)
|
||||
execute_tool(function_name, arguments, tool_call_id)
|
||||
else
|
||||
process_invalid_tool_call(function_name, tool_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_tool(function_name, arguments, tool_call_id)
|
||||
persist_tool_status(function_name, 'captain.copilot.using_tool')
|
||||
result = perform_tool_call(function_name, arguments)
|
||||
persist_tool_status(function_name, 'captain.copilot.completed_tool_call')
|
||||
append_tool_response(result, tool_call_id)
|
||||
end
|
||||
|
||||
def perform_tool_call(function_name, arguments)
|
||||
instrument_tool_call(function_name, arguments) do
|
||||
@tool_registry.send(function_name, arguments)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Tool #{function_name} failed: #{e.message}"
|
||||
"Error executing #{function_name}: #{e.message}"
|
||||
end
|
||||
|
||||
def persist_tool_status(function_name, translation_key)
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t(translation_key, function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
end
|
||||
|
||||
def append_tool_calls(tool_calls)
|
||||
@messages << {
|
||||
role: 'assistant',
|
||||
tool_calls: tool_calls
|
||||
}
|
||||
end
|
||||
|
||||
def process_invalid_tool_call(function_name, tool_call_id)
|
||||
persist_message(
|
||||
{ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name },
|
||||
'assistant_thinking'
|
||||
)
|
||||
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_response(content, tool_call_id)
|
||||
@messages << {
|
||||
role: 'tool',
|
||||
tool_call_id: tool_call_id,
|
||||
content: content
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -30,7 +30,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
delegate :account, :inbox, to: :@conversation
|
||||
|
||||
def generate_and_process_response
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.id).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
process_response
|
||||
|
||||
@@ -15,11 +15,14 @@ class Captain::Copilot::ResponseJob < ApplicationJob
|
||||
private
|
||||
|
||||
def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
|
||||
Captain::Copilot::ChatService.new(
|
||||
service = Captain::Copilot::ChatService.new(
|
||||
assistant,
|
||||
user_id: user_id,
|
||||
copilot_thread_id: copilot_thread_id,
|
||||
conversation_id: conversation_id
|
||||
).generate_response(message)
|
||||
)
|
||||
# When using copilot_thread, message is already in previous_history
|
||||
# Pass nil to avoid duplicate
|
||||
service.generate_response(copilot_thread_id.present? ? nil : message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
@user = nil
|
||||
@copilot_thread = nil
|
||||
@previous_history = []
|
||||
@conversation_id = config[:conversation_id]
|
||||
setup_user(config)
|
||||
setup_message_history(config)
|
||||
register_tools
|
||||
@@ -113,4 +114,8 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
message_type: message_type
|
||||
)
|
||||
end
|
||||
|
||||
def feature_name
|
||||
'copilot'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,10 +3,11 @@ require 'openai'
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil)
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@conversation_id = conversation_id
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
register_tools
|
||||
@@ -42,4 +43,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
def persist_message(message, message_type = 'assistant')
|
||||
# No need to implement
|
||||
end
|
||||
|
||||
def feature_name
|
||||
'assistant'
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user