Class: Clacky::Tools::AskUser

Inherits:
Base
  • Object
show all
Defined in:
lib/clacky/tools/ask_user.rb

Constant Summary collapse

TOOL_NAMES =

Both names route here: request_user_feedback is the retired predecessor, still present in stored sessions and in skill instructions.

%w[ask_user request_user_feedback].freeze

Instance Attribute Summary

Attributes inherited from Base

#agent

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#category, #description, #name, #parameters, #to_function_definition

Class Method Details

.feedback_tool?(name) ⇒ Boolean

Returns:

  • (Boolean)


80
81
82
# File 'lib/clacky/tools/ask_user.rb', line 80

def self.feedback_tool?(name)
  TOOL_NAMES.include?(name.to_s)
end

.fetch_key(hash, key) ⇒ Object



133
134
135
136
137
# File 'lib/clacky/tools/ask_user.rb', line 133

def self.fetch_key(hash, key)
  return nil unless hash.is_a?(Hash)

  hash[key].nil? ? hash[key.to_s] : hash[key]
end

.normalize_question(item) ⇒ Object



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/clacky/tools/ask_user.rb', line 108

def self.normalize_question(item)
  return nil unless item.is_a?(Hash)

  text = fetch_key(item, :question).to_s.strip
  return nil if text.empty?

  options = fetch_key(item, :options)
  options = [] unless options.is_a?(Array)
  options = options.map { |opt| opt.to_s.strip }.reject(&:empty?)

  recommended = fetch_key(item, :recommended)
  recommended = recommended.to_i if recommended.is_a?(Numeric) || recommended.to_s =~ /\A-?\d+\z/
  recommended = nil unless recommended.is_a?(Integer) && recommended >= 0 && recommended < options.size

  {
    question: text,
    description: fetch_key(item, :description).to_s.strip,
    options: options,
    multi: truthy?(fetch_key(item, :multi)),
    # A question with no options is free-form by definition, whatever the model said.
    allow_free_text: options.empty? || truthy?(fetch_key(item, :allow_free_text)),
    recommended: recommended
  }
end

.normalize_questions(args) ⇒ Object

Coerce whatever the model produced into a stable [{ question:, description:, options: [String], multi:, allow_free_text:, recommended: }] shape. Never raises: malformed input degrades to a plain free-form question so the agent surfaces something answerable instead of stalling on a schema error.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/clacky/tools/ask_user.rb', line 88

def self.normalize_questions(args)
  args = {} unless args.is_a?(Hash)
  raw = fetch_key(args, :questions)
  raw = [] unless raw.is_a?(Array)

  list = raw.map { |item| normalize_question(item) }.compact

  if list.empty?
    question = fetch_key(args, :question).to_s.strip
    return [] if question.empty?

    list = [normalize_question(
      question: question,
      options: fetch_key(args, :options)
    )].compact
  end

  list
end

.render_text(questions, context = nil) ⇒ Object

Render questions as markdown for UIs without a native card (terminal, IM channels).



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/clacky/tools/ask_user.rb', line 147

def self.render_text(questions, context = nil)
  parts = []
  parts << "**Context:** #{context.strip}" if context && !context.to_s.strip.empty?

  multiple = questions.size > 1
  questions.each_with_index do |q, q_index|
    parts << "" unless parts.empty?
    heading = multiple ? "**Question #{q_index + 1}:** #{q[:question]}" : "**Question:** #{q[:question]}"
    parts << heading
    parts << q[:description] unless q[:description].empty?

    next if q[:options].empty?

    parts << ""
    parts << (q[:multi] ? "**Options (choose any):**" : "**Options:**")
    q[:options].each_with_index do |opt, index|
      marker = q[:recommended] == index ? " _(recommended)_" : ""
      parts << "  #{index + 1}. #{opt}#{marker}"
    end
    parts << "  #{q[:options].size + 1}. Other — type your own answer" if q[:allow_free_text]
  end

  parts.join("\n")
end

.truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


139
140
141
142
143
144
# File 'lib/clacky/tools/ask_user.rb', line 139

def self.truthy?(value)
  return true if value == true
  return false if value.nil? || value == false

  %w[true yes 1].include?(value.to_s.strip.downcase)
end

Instance Method Details

#execute(questions: nil, question: nil, context: nil, options: nil, working_dir: nil) ⇒ Object



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/clacky/tools/ask_user.rb', line 172

def execute(questions: nil, question: nil, context: nil, options: nil, working_dir: nil)
  normalized = self.class.normalize_questions(
    questions: questions, question: question, options: options
  )

  if normalized.empty?
    return {
      success: false,
      error: "ask_user requires at least one question with non-empty text."
    }
  end

  {
    success: true,
    message: self.class.render_text(normalized, context),
    awaiting_feedback: true
  }
end

#format_call(args) ⇒ Object



191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/clacky/tools/ask_user.rb', line 191

def format_call(args)
  questions = self.class.normalize_questions(args)
  return "ask_user(...)" if questions.empty?

  if questions.size > 1
    "ask_user(#{questions.size} questions)"
  else
    text = questions.first[:question]
    preview = text.length > 60 ? "#{text[0..60]}..." : text
    "ask_user(\"#{preview}\")"
  end
end

#format_result(result) ⇒ Object



204
205
206
207
208
209
210
# File 'lib/clacky/tools/ask_user.rb', line 204

def format_result(result)
  if result.is_a?(Hash) && result[:message]
    result[:message]
  else
    "Waiting for user feedback..."
  end
end