clacky-ai--openclacky
469 行
18 KiB
Ruby
469 行
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Clacky
|
|
class Agent
|
|
# Tool execution and permission management
|
|
# Handles tool confirmation, preview, and result building
|
|
module ToolExecutor
|
|
# Check if a tool should be auto-executed based on permission mode
|
|
# @param tool_name [String] Name of the tool
|
|
# @param tool_params [Hash, String] Tool parameters
|
|
# @return [Boolean] true if should auto-execute
|
|
def should_auto_execute?(tool_name, tool_params = {})
|
|
case @config.permission_mode
|
|
when :auto_approve, :confirm_all
|
|
# Both modes auto-execute all file/shell tools without confirmation.
|
|
# The difference is only in request_user_feedback handling:
|
|
# auto_approve → no human present, inject auto_reply
|
|
# confirm_all → human present, truly wait for user input
|
|
true
|
|
when :confirm_safes
|
|
# Use Security module to check auto-execution safety
|
|
is_safe_operation?(tool_name, tool_params)
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
# Check if an operation is considered safe for auto-execution
|
|
# @param tool_name [String] Name of the tool
|
|
# @param tool_params [Hash, String] Tool parameters
|
|
# @return [Boolean] true if safe operation
|
|
def is_safe_operation?(tool_name, tool_params = {})
|
|
# For terminal commands, defer to Security layer for the verdict.
|
|
if tool_name.to_s.downcase == 'terminal'
|
|
params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
|
|
command = params[:command] || params['command']
|
|
# No command = session_id continuation / kill / action → safe by default.
|
|
return true unless command
|
|
|
|
return Clacky::Tools::Security.command_safe_for_auto_execution?(command)
|
|
end
|
|
|
|
if tool_name.to_s.downcase == 'edit' || tool_name.to_s.downcase == 'write'
|
|
return false
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
# Request user confirmation for tool execution
|
|
# Shows preview and returns approval status
|
|
# @param call [Hash] Tool call with :name and :arguments
|
|
# @return [Hash] { approved: Boolean, feedback: String, system_injected: Boolean }
|
|
def confirm_tool_use?(call)
|
|
# Show preview first and check for errors
|
|
preview_error = show_tool_preview(call)
|
|
|
|
# If preview detected an error, auto-deny and provide feedback
|
|
if preview_error && preview_error[:error]
|
|
feedback = build_preview_error_feedback(call[:name], preview_error)
|
|
return { approved: false, feedback: feedback, system_injected: true }
|
|
end
|
|
|
|
# Request confirmation via UI
|
|
if @ui
|
|
prompt_text = format_tool_prompt(call)
|
|
result = @ui.request_confirmation(prompt_text, default: true)
|
|
|
|
case result
|
|
when true
|
|
{ approved: true, feedback: nil }
|
|
when false, nil
|
|
# User denied - add visual marker based on tool type
|
|
tool_name_capitalized = call[:name].capitalize
|
|
@ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
|
|
{ approved: false, feedback: nil }
|
|
else
|
|
# String feedback - also add visual marker
|
|
tool_name_capitalized = call[:name].capitalize
|
|
@ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
|
|
{ approved: false, feedback: result.to_s }
|
|
end
|
|
else
|
|
# Fallback: auto-approve if no UI
|
|
{ approved: true, feedback: nil }
|
|
end
|
|
end
|
|
|
|
# Show preview for tool execution
|
|
# @param call [Hash] Tool call with :name and :arguments
|
|
# @return [Hash, nil] Error information if preview detected issues
|
|
def show_tool_preview(call)
|
|
return nil unless @ui
|
|
|
|
begin
|
|
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
|
|
preview_error = nil
|
|
case call[:name]
|
|
when "write"
|
|
preview_error = show_write_preview(args)
|
|
when "edit"
|
|
preview_error = show_edit_preview(args)
|
|
# Shell and other tools don't need special preview
|
|
# They will be shown via show_tool_call in the main flow
|
|
end
|
|
|
|
preview_error
|
|
rescue JSON::ParserError
|
|
nil
|
|
rescue StandardError => e
|
|
@debug_logs << {
|
|
timestamp: Time.now.iso8601,
|
|
event: "tool_preview_error",
|
|
tool_name: call[:name],
|
|
error_class: e.class.name,
|
|
error_message: e.message
|
|
}
|
|
nil
|
|
end
|
|
end
|
|
|
|
# Format tool call for user confirmation prompt
|
|
# @param call [Hash] Tool call with :name and :arguments
|
|
# @return [String] Formatted prompt text
|
|
def format_tool_prompt(call)
|
|
begin
|
|
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
|
|
# Try to use tool's format_call method for better formatting
|
|
tool = @tool_registry.get(call[:name]) rescue nil
|
|
if tool
|
|
formatted = tool.format_call(args) rescue nil
|
|
return formatted if formatted
|
|
end
|
|
|
|
# Fallback to manual formatting for common tools
|
|
case call[:name]
|
|
when "edit"
|
|
path = args[:path] || args[:file_path]
|
|
filename = Utils::PathHelper.safe_basename(path)
|
|
"Edit(#{filename})"
|
|
when "write"
|
|
filename = Utils::PathHelper.safe_basename(args[:path])
|
|
if args[:path] && File.exist?(args[:path])
|
|
"Write(#{filename}) - overwrite existing"
|
|
else
|
|
"Write(#{filename}) - create new"
|
|
end
|
|
when "terminal"
|
|
cmd = args[:command] || ''
|
|
display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
|
|
"terminal(\"#{display_cmd}\")"
|
|
else
|
|
"Allow #{call[:name]}"
|
|
end
|
|
rescue JSON::ParserError
|
|
"Allow #{call[:name]}"
|
|
end
|
|
end
|
|
|
|
# Build success result for tool execution
|
|
# @param call [Hash] Tool call
|
|
# @param result [Object] Tool execution result
|
|
# @return [Hash] Formatted result for LLM
|
|
def build_success_result(call, result)
|
|
# Try to get tool instance to use its format_result_for_llm method
|
|
tool = @tool_registry.get(call[:name]) rescue nil
|
|
|
|
formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
|
|
# Tool provides a custom LLM-friendly format
|
|
tool.format_result_for_llm(result)
|
|
else
|
|
# Fallback: use the original result
|
|
result
|
|
end
|
|
|
|
# Inject TODO reminder for non-todo_manager tools
|
|
formatted_result = inject_todo_reminder(call[:name], formatted_result)
|
|
|
|
# Extract image_inject sidecar before building the tool content string.
|
|
# image_inject carries the base64 payload that must be delivered as a
|
|
# follow-up `role:"user"` message (OpenAI/OpenRouter/Gemini only accept
|
|
# image_url blocks in user messages, not in tool messages).
|
|
# Strip it from the content sent to the API so it isn't tokenised as text.
|
|
image_inject = nil
|
|
if formatted_result.is_a?(Hash) && formatted_result[:image_inject]
|
|
image_inject = formatted_result[:image_inject]
|
|
formatted_result = formatted_result.reject { |k, _| k == :image_inject }
|
|
if formatted_result[:content_string]
|
|
formatted_result = formatted_result[:content_string]
|
|
end
|
|
end
|
|
|
|
# If the tool returned a plain string, use it directly (avoids double-escaping).
|
|
# If it returned an Array (e.g. multipart vision blocks with image + text),
|
|
# pass it through as-is so format_tool_results can send it to the API.
|
|
# Otherwise JSON-encode Hash/other values.
|
|
content = if formatted_result.is_a?(String)
|
|
formatted_result
|
|
elsif formatted_result.is_a?(Array)
|
|
formatted_result
|
|
else
|
|
JSON.generate(formatted_result)
|
|
end
|
|
|
|
result = { id: call[:id], content: content }
|
|
result[:image_inject] = image_inject if image_inject
|
|
result
|
|
end
|
|
|
|
# Build error result for tool execution
|
|
# @param call [Hash] Tool call
|
|
# @param error_message [String] Error message
|
|
# @return [Hash] Formatted error result
|
|
def build_error_result(call, error_message)
|
|
{
|
|
id: call[:id],
|
|
content: JSON.generate({ error: error_message })
|
|
}
|
|
end
|
|
|
|
# Build denied result when user denies tool execution
|
|
# @param call [Hash] Tool call
|
|
# @param user_feedback [String, nil] User's feedback message
|
|
# @param system_injected [Boolean] Whether this is a system-generated denial
|
|
# @return [Hash] Formatted denial result
|
|
def build_denied_result(call, user_feedback = nil, system_injected = false)
|
|
if system_injected
|
|
# System-generated feedback (e.g., from preview errors)
|
|
tool_content = {
|
|
error: "Tool #{call[:name]} denied: #{user_feedback}",
|
|
system_injected: true
|
|
}
|
|
else
|
|
# User manually denied or provided feedback
|
|
# Clearly state the action was NOT performed so the LLM knows the change did not happen
|
|
message = if user_feedback && !user_feedback.empty?
|
|
"Tool use denied by user. This action was NOT performed. User feedback: #{user_feedback}"
|
|
else
|
|
"Tool use denied by user. This action was NOT performed."
|
|
end
|
|
|
|
tool_content = {
|
|
error: message,
|
|
action_performed: false,
|
|
user_feedback: user_feedback
|
|
}
|
|
end
|
|
|
|
{
|
|
id: call[:id],
|
|
content: JSON.generate(tool_content)
|
|
}
|
|
end
|
|
|
|
# Check if a tool is potentially slow and should show progress
|
|
# @param tool_name [String] Name of the tool
|
|
# @param args [Hash] Tool arguments
|
|
# @return [Boolean] true if tool is potentially slow
|
|
private def potentially_slow_tool?(tool_name, args)
|
|
case tool_name.to_s.downcase
|
|
when 'terminal'
|
|
# Check if the command is a slow command
|
|
command = args[:command] || args['command']
|
|
return false unless command
|
|
|
|
# List of slow command patterns
|
|
slow_patterns = [
|
|
/bundle\s+(install|exec\s+rspec|exec\s+rake)/,
|
|
/npm\s+(install|run\s+test|run\s+build)/,
|
|
/yarn\s+(install|test|build)/,
|
|
/pnpm\s+install/,
|
|
/cargo\s+(build|test)/,
|
|
/go\s+(build|test)/,
|
|
/make\s+(test|build)/,
|
|
/pytest/,
|
|
/jest/,
|
|
/sleep\s+\d+/
|
|
]
|
|
|
|
slow_patterns.any? { |pattern| command.match?(pattern) }
|
|
when 'web_fetch', 'web_search'
|
|
true
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
private def build_tool_progress_message(tool_name, args)
|
|
case tool_name.to_s.downcase
|
|
when 'terminal'
|
|
"Running command"
|
|
when 'web_fetch'
|
|
"Fetching web page"
|
|
when 'web_search'
|
|
"Searching web"
|
|
else
|
|
"Executing #{tool_name}"
|
|
end
|
|
end
|
|
|
|
# Inject TODO reminder into tool results for non-todo_manager tools
|
|
# This helps AI remember to mark TODOs as complete after executing tasks
|
|
# @param tool_name [String] Name of the tool
|
|
# @param result [Object] Tool execution result
|
|
# @return [Object] Result with optional TODO reminder
|
|
private def inject_todo_reminder(tool_name, result)
|
|
# Skip injection for todo_manager tool itself to avoid redundancy
|
|
return result if tool_name == "todo_manager"
|
|
|
|
# Get pending TODOs
|
|
todo_tool = @tool_registry.get("todo_manager")
|
|
return result unless todo_tool
|
|
|
|
pending_todos = begin
|
|
todo_result = todo_tool.execute(action: "list", todos_storage: @todos)
|
|
if todo_result.is_a?(Hash) && todo_result[:todos]
|
|
todo_result[:todos].select { |t| t[:status] == "pending" }
|
|
else
|
|
[]
|
|
end
|
|
rescue
|
|
[]
|
|
end
|
|
|
|
# Only inject reminder if there are pending TODOs
|
|
return result unless pending_todos && !pending_todos.empty?
|
|
|
|
# Create a friendly reminder message
|
|
reminder = "\n\n📋 REMINDER: You have #{pending_todos.length} pending TODO(s). " \
|
|
"After completing each task, remember to mark it as complete using " \
|
|
"todo_manager with action 'complete' and the task id."
|
|
|
|
# Inject reminder based on result type
|
|
case result
|
|
when String
|
|
result + reminder
|
|
when Hash
|
|
result.merge({ _todo_reminder: reminder.strip })
|
|
when Array
|
|
result + [{ _todo_reminder: reminder.strip }]
|
|
else
|
|
result
|
|
end
|
|
end
|
|
|
|
# Build feedback message from preview error
|
|
# @param tool_name [String] Name of the tool
|
|
# @param error_info [Hash] Error information from preview
|
|
# @return [String] Feedback message
|
|
private def build_preview_error_feedback(tool_name, error_info)
|
|
case tool_name
|
|
when "edit"
|
|
"Tool edit denied: The edit operation will fail because the old_string was not found in the file. " \
|
|
"Please use file_reader to read '#{error_info[:path]}' first, " \
|
|
"find the correct string to replace, and try again with the exact string (including whitespace)."
|
|
else
|
|
"Tool preview error: #{error_info[:error]}"
|
|
end
|
|
end
|
|
|
|
# Show preview for write tool
|
|
# @param args [Hash] Write tool arguments
|
|
# @return [nil] Always returns nil (no errors for write)
|
|
private def show_write_preview(args)
|
|
path = args[:path] || args['path']
|
|
# Expand ~ to home directory so File.exist? works correctly
|
|
expanded_path = path&.start_with?("~") ? File.expand_path(path) : path
|
|
new_content = args[:content] || args['content'] || ""
|
|
|
|
is_new_file = !(expanded_path && File.exist?(expanded_path))
|
|
@ui&.show_file_write_preview(path, is_new_file: is_new_file)
|
|
|
|
if is_new_file
|
|
@ui&.show_diff("", new_content, max_lines: 50)
|
|
else
|
|
old_content = File.read(expanded_path)
|
|
old_content = old_content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}") unless old_content.encoding == Encoding::UTF_8 && old_content.valid_encoding?
|
|
@ui&.show_diff(old_content, new_content, max_lines: 50)
|
|
end
|
|
nil
|
|
end
|
|
|
|
# Show preview for edit tool
|
|
# @param args [Hash] Edit tool arguments
|
|
# @return [Hash, nil] Error information if preview detected issues
|
|
private def show_edit_preview(args)
|
|
path = args[:path] || args[:file_path] || args['path'] || args['file_path']
|
|
old_string = args[:old_string] || args['old_string'] || ""
|
|
new_string = args[:new_string] || args['new_string'] || ""
|
|
replace_all = args[:replace_all] || args['replace_all'] || false
|
|
|
|
# Expand ~ to home directory so File.exist? and File.read work correctly
|
|
expanded_path = path&.start_with?("~") ? File.expand_path(path) : path
|
|
|
|
@ui&.show_file_edit_preview(path)
|
|
|
|
if !expanded_path || expanded_path.empty?
|
|
@ui&.show_file_error("No file path provided")
|
|
return { error: "No file path provided for edit operation" }
|
|
end
|
|
|
|
unless File.exist?(expanded_path)
|
|
@ui&.show_file_error("File not found: #{path}")
|
|
return { error: "File not found: #{path}", path: path }
|
|
end
|
|
|
|
if File.directory?(expanded_path)
|
|
@ui&.show_file_error("Path is a directory, not a file: #{path}")
|
|
return { error: "Path is a directory, not a file: #{path}", path: path }
|
|
end
|
|
|
|
if old_string.empty?
|
|
@ui&.show_file_error("No old_string provided (nothing to replace)")
|
|
return { error: "No old_string provided (nothing to replace)" }
|
|
end
|
|
|
|
file_content = File.read(expanded_path)
|
|
|
|
# Use the same find_match logic as Edit tool to handle fuzzy matching
|
|
# (trim, unescape, smart line matching) — prevents diff from being blank
|
|
# when simple include? fails but Edit#execute's fuzzy match would succeed
|
|
match_result = Utils::StringMatcher.find_match(file_content, old_string)
|
|
|
|
unless match_result
|
|
# Log debug info for troubleshooting
|
|
@debug_logs << {
|
|
timestamp: Time.now.iso8601,
|
|
event: "edit_preview_failed",
|
|
path: path,
|
|
looking_for: old_string[0..500],
|
|
file_content_preview: file_content[0..1000],
|
|
file_size: file_content.length
|
|
}
|
|
|
|
@ui&.show_file_error("Edit file error")
|
|
return {
|
|
error: "String to replace not found in file",
|
|
path: path,
|
|
looking_for: old_string[0..200]
|
|
}
|
|
end
|
|
|
|
# Use the actual matched string (may differ via trim/unescape) for replacement
|
|
actual_old_string = match_result[:matched_string]
|
|
|
|
# Use the same replace logic as the actual tool execution
|
|
new_content = if replace_all
|
|
file_content.gsub(actual_old_string, new_string)
|
|
else
|
|
file_content.sub(actual_old_string, new_string)
|
|
end
|
|
@ui&.show_diff(file_content, new_content, max_lines: 50)
|
|
nil # No error
|
|
end
|
|
|
|
# Show preview for shell tool
|
|
# @param args [Hash] Shell tool arguments
|
|
# @return [nil] Always returns nil
|
|
private def show_shell_preview(args)
|
|
command = args[:command] || ""
|
|
@ui&.show_shell_preview(command)
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
end
|