Captain now handles a burst of customer messages with one reply. If more messages arrive before Captain replies, the latest job uses the full conversation history. The change applies only to Captain V2 and works across every channel that Captain supports. Blocked on: https://github.com/chatwoot/chatwoot/pull/15212 ## Closes Closes https://github.com/chatwoot/chatwoot/issues/14545 ## How to reproduce 1. Start a pending conversation with Captain V2. 2. Send several messages while Captain is preparing a reply. 3. Captain can generate and send a separate reply for each message. ## What changed * Each Captain V2 job records the incoming message that started it. * A job stops before generation if a newer message already exists. * Captain discards a generated reply if a newer message arrived during generation. * The latest job replies using the full conversation history. * Langfuse records whether a generation was discarded and whether a customer credit was used. * Captain V1 keeps its existing behavior. ## Tradeoffs Discarded generations still cost money. Message bursts can also increase background job work and model provider load. A continuous stream of incoming messages can delay the reply until one generation finishes without a newer message. A small timing window remains if a message arrives after the final check and before Captain saves the reply. A handoff also cannot be undone if a newer message arrives after Captain has already changed the conversation status. ## How to test 1. Enable Captain V2 and start a pending Captain conversation. 2. Send several messages while Captain is preparing a reply. 3. Confirm that Captain sends one reply based on the full message history. 4. Confirm that Langfuse marks discarded runs with `discarded=true` and `credit_used=false`. 5. Disable Captain V2 and confirm that Captain V1 behavior is unchanged. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com>
61 lines
2.0 KiB
Ruby
61 lines
2.0 KiB
Ruby
require 'agents'
|
|
|
|
class Captain::Tools::HttpTool < Captain::Tools::BasePublicTool
|
|
def initialize(assistant, custom_tool)
|
|
@custom_tool = custom_tool
|
|
super(assistant)
|
|
end
|
|
|
|
def active?
|
|
@custom_tool.enabled?
|
|
end
|
|
|
|
def perform(tool_context, **params)
|
|
url = @custom_tool.build_request_url(params)
|
|
body = @custom_tool.build_request_body(params)
|
|
|
|
response_body = execute_http_request(url, body, tool_context)
|
|
@custom_tool.format_response(response_body)
|
|
rescue StandardError => e
|
|
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
|
'An error occurred while executing the request'
|
|
end
|
|
|
|
private
|
|
|
|
def safe_to_run_after_new_customer_message?
|
|
@custom_tool.http_method == 'GET'
|
|
end
|
|
|
|
# Limit response size to prevent memory exhaustion and match LLM token limits
|
|
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
|
MAX_RESPONSE_SIZE = 1.megabyte
|
|
|
|
# Route through SafeFetch so custom tool requests share the app's centralized HTTP
|
|
# fetching (resolution, timeouts, response size limits, and redirect handling).
|
|
def execute_http_request(url, body, tool_context)
|
|
json_body = body if @custom_tool.http_method == 'POST'
|
|
auth_headers = @custom_tool.build_auth_headers
|
|
|
|
response_body = +''
|
|
SafeFetch.fetch(
|
|
url,
|
|
method: @custom_tool.http_method == 'POST' ? :post : :get,
|
|
body: json_body,
|
|
headers: request_headers(tool_context, json_body, auth_headers),
|
|
sensitive_headers: auth_headers.keys,
|
|
http_basic_authentication: @custom_tool.build_basic_auth_credentials,
|
|
max_bytes: MAX_RESPONSE_SIZE,
|
|
validate_content_type: false
|
|
) { |result| response_body = result.tempfile.read }
|
|
response_body
|
|
end
|
|
|
|
def request_headers(tool_context, json_body, auth_headers)
|
|
headers = auth_headers.dup
|
|
headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {}))
|
|
headers['Content-Type'] = 'application/json' if json_body.present?
|
|
headers
|
|
end
|
|
end
|