Module: Clacky::Agent::ToolExecutor

Included in:
Clacky::Agent
Defined in:
lib/clacky/agent/tool_executor.rb

Overview

Tool execution and permission management Handles tool confirmation, preview, and result building

Instance Method Summary collapse

Instance Method Details

#build_denied_result(call, user_feedback = nil, system_injected = false) ⇒ Hash

Build denied result when user denies tool execution

Parameters:

  • call (Hash)

    Tool call

  • user_feedback (String, nil) (defaults to: nil)

    User’s feedback message

  • system_injected (Boolean) (defaults to: false)

    Whether this is a system-generated denial

Returns:

  • (Hash)

    Formatted denial result



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/clacky/agent/tool_executor.rb', line 209

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

#build_error_result(call, error_message) ⇒ Hash

Build error result for tool execution

Parameters:

  • call (Hash)

    Tool call

  • error_message (String)

    Error message

Returns:

  • (Hash)

    Formatted error result



197
198
199
200
201
202
# File 'lib/clacky/agent/tool_executor.rb', line 197

def build_error_result(call, error_message)
  {
    id: call[:id],
    content: JSON.generate({ error: error_message })
  }
end

#build_success_result(call, result) ⇒ Hash

Build success result for tool execution

Parameters:

  • call (Hash)

    Tool call

  • result (Object)

    Tool execution result

Returns:

  • (Hash)

    Formatted result for LLM



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/clacky/agent/tool_executor.rb', line 159

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)

  # 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)
              # Multipart content (e.g. screenshot image blocks) — keep as Array
              formatted_result
            else
              JSON.generate(formatted_result)
            end

  {
    id: call[:id],
    content: content
  }
end

#confirm_tool_use?(call) ⇒ Hash

Request user confirmation for tool execution Shows preview and returns approval status

Parameters:

  • call (Hash)

    Tool call with :name and :arguments

Returns:

  • (Hash)

    { approved: Boolean, feedback: String, system_injected: Boolean }



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/clacky/agent/tool_executor.rb', line 56

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

#format_tool_prompt(call) ⇒ String

Format tool call for user confirmation prompt

Parameters:

  • call (Hash)

    Tool call with :name and :arguments

Returns:

  • (String)

    Formatted prompt text



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/clacky/agent/tool_executor.rb', line 119

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 "shell", "safe_shell"
      cmd = args[:command] || ''
      display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
      "#{call[:name]}(\"#{display_cmd}\")"
    else
      "Allow #{call[:name]}"
    end
  rescue JSON::ParserError
    "Allow #{call[:name]}"
  end
end

#is_safe_operation?(tool_name, tool_params = {}) ⇒ Boolean

Check if an operation is considered safe for auto-execution

Parameters:

  • tool_name (String)

    Name of the tool

  • tool_params (Hash, String) (defaults to: {})

    Tool parameters

Returns:

  • (Boolean)

    true if safe operation



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/clacky/agent/tool_executor.rb', line 35

def is_safe_operation?(tool_name, tool_params = {})
  # For shell commands, use SafeShell to check safety
  if tool_name.to_s.downcase == 'shell' || tool_name.to_s.downcase == 'safe_shell'
    params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
    command = params[:command] || params['command']
    return false unless command

    return Tools::SafeShell.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

#should_auto_execute?(tool_name, tool_params = {}) ⇒ Boolean

Check if a tool should be auto-executed based on permission mode

Parameters:

  • tool_name (String)

    Name of the tool

  • tool_params (Hash, String) (defaults to: {})

    Tool parameters

Returns:

  • (Boolean)

    true if should auto-execute



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/clacky/agent/tool_executor.rb', line 12

def should_auto_execute?(tool_name, tool_params = {})
  # During memory update phase, always auto-execute (no user confirmation needed)
  return true if @memory_updating

  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 SafeShell integration for safety check
    is_safe_operation?(tool_name, tool_params)
  else
    false
  end
end

#show_tool_preview(call) ⇒ Hash?

Show preview for tool execution

Parameters:

  • call (Hash)

    Tool call with :name and :arguments

Returns:

  • (Hash, nil)

    Error information if preview detected issues



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/clacky/agent/tool_executor.rb', line 94

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
  end
end