openclacky 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.clackyrules +80 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +74 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +38 -0
- data/bin/clacky +7 -0
- data/bin/openclacky +6 -0
- data/clacky-legacy/clacky.gemspec +24 -0
- data/clacky-legacy/clarky.gemspec +24 -0
- data/lib/clacky/agent.rb +1015 -0
- data/lib/clacky/agent_config.rb +47 -0
- data/lib/clacky/cli.rb +713 -0
- data/lib/clacky/client.rb +159 -0
- data/lib/clacky/config.rb +43 -0
- data/lib/clacky/conversation.rb +41 -0
- data/lib/clacky/hook_manager.rb +61 -0
- data/lib/clacky/progress_indicator.rb +53 -0
- data/lib/clacky/session_manager.rb +124 -0
- data/lib/clacky/thinking_verbs.rb +26 -0
- data/lib/clacky/tool_registry.rb +44 -0
- data/lib/clacky/tools/base.rb +64 -0
- data/lib/clacky/tools/edit.rb +100 -0
- data/lib/clacky/tools/file_reader.rb +79 -0
- data/lib/clacky/tools/glob.rb +93 -0
- data/lib/clacky/tools/grep.rb +169 -0
- data/lib/clacky/tools/run_project.rb +287 -0
- data/lib/clacky/tools/safe_shell.rb +396 -0
- data/lib/clacky/tools/shell.rb +305 -0
- data/lib/clacky/tools/todo_manager.rb +228 -0
- data/lib/clacky/tools/trash_manager.rb +371 -0
- data/lib/clacky/tools/web_fetch.rb +161 -0
- data/lib/clacky/tools/web_search.rb +138 -0
- data/lib/clacky/tools/write.rb +65 -0
- data/lib/clacky/trash_directory.rb +112 -0
- data/lib/clacky/utils/arguments_parser.rb +139 -0
- data/lib/clacky/utils/limit_stack.rb +80 -0
- data/lib/clacky/utils/path_helper.rb +15 -0
- data/lib/clacky/version.rb +5 -0
- data/lib/clacky.rb +38 -0
- data/sig/clacky.rbs +4 -0
- metadata +160 -0
data/lib/clacky/agent.rb
ADDED
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
require "readline"
|
|
6
|
+
require "set"
|
|
7
|
+
require_relative "utils/arguments_parser"
|
|
8
|
+
|
|
9
|
+
module Clacky
|
|
10
|
+
class Agent
|
|
11
|
+
attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos
|
|
12
|
+
|
|
13
|
+
# Pricing per 1M tokens (approximate - adjust based on actual model)
|
|
14
|
+
PRICING = {
|
|
15
|
+
input: 0.50, # $0.50 per 1M input tokens
|
|
16
|
+
output: 1.50 # $1.50 per 1M output tokens
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# System prompt for the coding agent
|
|
20
|
+
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
21
|
+
You are OpenClacky, an AI coding assistant and technical co-founder, designed to help non-technical
|
|
22
|
+
users complete software development projects. You are responsible for development in the current project.
|
|
23
|
+
|
|
24
|
+
Your role is to:
|
|
25
|
+
- Understand project requirements and translate them into technical solutions
|
|
26
|
+
- Write clean, maintainable, and well-documented code
|
|
27
|
+
- Follow best practices and industry standards
|
|
28
|
+
- Explain technical concepts in simple terms when needed
|
|
29
|
+
- Proactively identify potential issues and suggest improvements
|
|
30
|
+
- Help with debugging, testing, and deployment
|
|
31
|
+
|
|
32
|
+
Working process:
|
|
33
|
+
1. **For complex tasks with multiple steps**:
|
|
34
|
+
- Use todo_manager to create a complete TODO list FIRST
|
|
35
|
+
- After creating the TODO list, START EXECUTING each task immediately
|
|
36
|
+
- Don't stop after planning - continue to work on the tasks!
|
|
37
|
+
2. Always read existing code before making changes (use file_reader/glob/grep)
|
|
38
|
+
3. Ask clarifying questions if requirements are unclear
|
|
39
|
+
4. Break down complex tasks into manageable steps
|
|
40
|
+
5. **USE TOOLS to create/modify files** - don't just return code
|
|
41
|
+
6. Write code that is secure, efficient, and easy to understand
|
|
42
|
+
7. Test your changes using the shell tool when appropriate
|
|
43
|
+
8. **IMPORTANT**: After completing each step, mark the TODO as completed and continue to the next one
|
|
44
|
+
9. Keep working until ALL TODOs are completed or you need user input
|
|
45
|
+
10. Provide brief explanations after completing actions
|
|
46
|
+
|
|
47
|
+
IMPORTANT: You should frequently refer to the existing codebase. For unclear instructions,
|
|
48
|
+
prioritize understanding the codebase first before answering or taking action.
|
|
49
|
+
Always read relevant code files to understand the project structure, patterns, and conventions.
|
|
50
|
+
|
|
51
|
+
CRITICAL RULE FOR TODO MANAGER:
|
|
52
|
+
When using todo_manager to add tasks, you MUST continue working immediately after adding ALL todos.
|
|
53
|
+
Adding todos is NOT completion - it's just the planning phase!
|
|
54
|
+
Workflow: add todo 1 → add todo 2 → add todo 3 → START WORKING on todo 1 → complete(1) → work on todo 2 → complete(2) → etc.
|
|
55
|
+
NEVER stop after just adding todos without executing them!
|
|
56
|
+
PROMPT
|
|
57
|
+
|
|
58
|
+
def initialize(client, config = {}, working_dir: nil)
|
|
59
|
+
@client = client
|
|
60
|
+
@config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
|
|
61
|
+
@tool_registry = ToolRegistry.new
|
|
62
|
+
@hooks = HookManager.new
|
|
63
|
+
@session_id = SecureRandom.uuid
|
|
64
|
+
@messages = []
|
|
65
|
+
@todos = [] # Store todos in memory
|
|
66
|
+
@iterations = 0
|
|
67
|
+
@total_cost = 0.0
|
|
68
|
+
@start_time = nil
|
|
69
|
+
@working_dir = working_dir || Dir.pwd
|
|
70
|
+
@created_at = Time.now.iso8601
|
|
71
|
+
@total_tasks = 0
|
|
72
|
+
|
|
73
|
+
# Register built-in tools
|
|
74
|
+
register_builtin_tools
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Restore from a saved session
|
|
78
|
+
def self.from_session(client, config, session_data)
|
|
79
|
+
agent = new(client, config)
|
|
80
|
+
agent.restore_session(session_data)
|
|
81
|
+
agent
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def restore_session(session_data)
|
|
85
|
+
@session_id = session_data[:session_id]
|
|
86
|
+
@messages = session_data[:messages]
|
|
87
|
+
@todos = session_data[:todos] || [] # Restore todos from session
|
|
88
|
+
@iterations = session_data.dig(:stats, :total_iterations) || 0
|
|
89
|
+
@total_cost = session_data.dig(:stats, :total_cost_usd) || 0.0
|
|
90
|
+
@working_dir = session_data[:working_dir]
|
|
91
|
+
@created_at = session_data[:created_at]
|
|
92
|
+
@total_tasks = session_data.dig(:stats, :total_tasks) || 0
|
|
93
|
+
|
|
94
|
+
# Check if the session ended with an error
|
|
95
|
+
last_status = session_data.dig(:stats, :last_status)
|
|
96
|
+
last_error = session_data.dig(:stats, :last_error)
|
|
97
|
+
|
|
98
|
+
if last_status == "error" && last_error
|
|
99
|
+
# Find and remove the last user message that caused the error
|
|
100
|
+
# This allows the user to retry with a different prompt
|
|
101
|
+
last_user_index = @messages.rindex { |m| m[:role] == "user" }
|
|
102
|
+
if last_user_index
|
|
103
|
+
@messages = @messages[0...last_user_index]
|
|
104
|
+
|
|
105
|
+
# Trigger a hook to notify about the rollback
|
|
106
|
+
trigger_hook(:session_rollback, {
|
|
107
|
+
reason: "Previous session ended with error",
|
|
108
|
+
error_message: last_error,
|
|
109
|
+
rolled_back_message_index: last_user_index
|
|
110
|
+
})
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def add_hook(event, &block)
|
|
116
|
+
@hooks.add(event, &block)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run(user_input, &block)
|
|
120
|
+
@start_time = Time.now
|
|
121
|
+
|
|
122
|
+
# Add system prompt as the first message if this is the first run
|
|
123
|
+
if @messages.empty?
|
|
124
|
+
system_prompt = build_system_prompt
|
|
125
|
+
@messages << { role: "system", content: system_prompt }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@messages << { role: "user", content: user_input }
|
|
129
|
+
@total_tasks += 1
|
|
130
|
+
|
|
131
|
+
emit_event(:on_start, { input: user_input }, &block)
|
|
132
|
+
@hooks.trigger(:on_start, user_input)
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
loop do
|
|
136
|
+
break if should_stop?
|
|
137
|
+
|
|
138
|
+
@iterations += 1
|
|
139
|
+
emit_event(:on_iteration, { iteration: @iterations }, &block)
|
|
140
|
+
@hooks.trigger(:on_iteration, @iterations)
|
|
141
|
+
|
|
142
|
+
# Think: LLM reasoning with tool support
|
|
143
|
+
response = think(&block)
|
|
144
|
+
|
|
145
|
+
# Debug: check for potential infinite loops
|
|
146
|
+
if @config.verbose
|
|
147
|
+
puts "[DEBUG] Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Check if done (no more tool calls needed)
|
|
151
|
+
if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
152
|
+
emit_event(:answer, { content: response[:content] }, &block)
|
|
153
|
+
break
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Emit assistant_message event if there's content before tool calls
|
|
157
|
+
if response[:content] && !response[:content].empty?
|
|
158
|
+
emit_event(:assistant_message, { content: response[:content] }, &block)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Act: Execute tool calls
|
|
162
|
+
action_result = act(response[:tool_calls], &block)
|
|
163
|
+
|
|
164
|
+
# Observe: Add tool results to conversation context
|
|
165
|
+
observe(response, action_result[:tool_results])
|
|
166
|
+
|
|
167
|
+
# Check if user denied any tool
|
|
168
|
+
if action_result[:denied]
|
|
169
|
+
# If user provided feedback, treat it as a user question/instruction
|
|
170
|
+
if action_result[:feedback] && !action_result[:feedback].empty?
|
|
171
|
+
# Add user feedback as a new user message
|
|
172
|
+
# Use a clear format that signals this is important user input
|
|
173
|
+
@messages << {
|
|
174
|
+
role: "user",
|
|
175
|
+
content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions."
|
|
176
|
+
}
|
|
177
|
+
# Continue loop to let agent respond to feedback
|
|
178
|
+
next
|
|
179
|
+
else
|
|
180
|
+
# User just said "no" without feedback - stop and wait
|
|
181
|
+
emit_event(:answer, { content: "Tool execution was denied. Please provide further instructions." }, &block)
|
|
182
|
+
break
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
result = build_result(:success)
|
|
188
|
+
emit_event(:on_complete, result, &block)
|
|
189
|
+
@hooks.trigger(:on_complete, result)
|
|
190
|
+
result
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
result = build_result(:error, error: e.message)
|
|
193
|
+
emit_event(:on_complete, result, &block)
|
|
194
|
+
raise
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Generate session data for saving
|
|
199
|
+
# @param status [Symbol] Status of the last task: :success, :error, or :interrupted
|
|
200
|
+
# @param error_message [String] Error message if status is :error
|
|
201
|
+
def to_session_data(status: :success, error_message: nil)
|
|
202
|
+
# Get first real user message for preview (skip compressed system messages)
|
|
203
|
+
first_user_msg = @messages.find do |m|
|
|
204
|
+
m[:role] == "user" && !m[:content].to_s.start_with?("[SYSTEM]")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Extract preview text from first user message
|
|
208
|
+
first_message_preview = if first_user_msg
|
|
209
|
+
content = first_user_msg[:content]
|
|
210
|
+
if content.is_a?(String)
|
|
211
|
+
# Truncate to 100 characters for preview
|
|
212
|
+
content.length > 100 ? "#{content[0..100]}..." : content
|
|
213
|
+
else
|
|
214
|
+
"User message (non-string content)"
|
|
215
|
+
end
|
|
216
|
+
else
|
|
217
|
+
"No messages"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
stats_data = {
|
|
221
|
+
total_tasks: @total_tasks,
|
|
222
|
+
total_iterations: @iterations,
|
|
223
|
+
total_cost_usd: @total_cost.round(4),
|
|
224
|
+
duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
|
|
225
|
+
last_status: status.to_s
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# Add error message if status is error
|
|
229
|
+
stats_data[:last_error] = error_message if status == :error && error_message
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
session_id: @session_id,
|
|
233
|
+
created_at: @created_at,
|
|
234
|
+
updated_at: Time.now.iso8601,
|
|
235
|
+
working_dir: @working_dir,
|
|
236
|
+
todos: @todos, # Include todos in session data
|
|
237
|
+
config: {
|
|
238
|
+
model: @config.model,
|
|
239
|
+
permission_mode: @config.permission_mode.to_s,
|
|
240
|
+
max_iterations: @config.max_iterations,
|
|
241
|
+
max_cost_usd: @config.max_cost_usd,
|
|
242
|
+
enable_compression: @config.enable_compression,
|
|
243
|
+
keep_recent_messages: @config.keep_recent_messages,
|
|
244
|
+
max_tokens: @config.max_tokens,
|
|
245
|
+
verbose: @config.verbose
|
|
246
|
+
},
|
|
247
|
+
stats: stats_data,
|
|
248
|
+
messages: @messages,
|
|
249
|
+
first_user_message: first_message_preview
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
def should_auto_execute?(tool_name, tool_params = {})
|
|
256
|
+
# Check if tool is disallowed
|
|
257
|
+
return false if @config.disallowed_tools.include?(tool_name)
|
|
258
|
+
|
|
259
|
+
case @config.permission_mode
|
|
260
|
+
when :auto_approve
|
|
261
|
+
true
|
|
262
|
+
when :confirm_safes
|
|
263
|
+
# Use SafeShell integration for safety check
|
|
264
|
+
is_safe_operation?(tool_name, tool_params)
|
|
265
|
+
when :confirm_edits
|
|
266
|
+
!editing_tool?(tool_name)
|
|
267
|
+
when :plan_only
|
|
268
|
+
false
|
|
269
|
+
else
|
|
270
|
+
false
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def editing_tool?(tool_name)
|
|
275
|
+
AgentConfig::EDITING_TOOLS.include?(tool_name.to_s.downcase)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def is_safe_operation?(tool_name, tool_params = {})
|
|
279
|
+
# For shell commands, use SafeShell to check safety
|
|
280
|
+
if tool_name.to_s.downcase == 'shell' || tool_name.to_s.downcase == 'safe_shell'
|
|
281
|
+
begin
|
|
282
|
+
require_relative 'tools/safe_shell'
|
|
283
|
+
|
|
284
|
+
# Parse tool_params if it's a JSON string
|
|
285
|
+
params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
|
|
286
|
+
command = params[:command] || params['command']
|
|
287
|
+
return false unless command
|
|
288
|
+
|
|
289
|
+
# Use SafeShell to analyze the command
|
|
290
|
+
return Tools::SafeShell.command_safe_for_auto_execution?(command)
|
|
291
|
+
rescue LoadError
|
|
292
|
+
# If SafeShell not available, be conservative
|
|
293
|
+
return false
|
|
294
|
+
rescue => e
|
|
295
|
+
# In case of any error, be conservative
|
|
296
|
+
return false
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# For non-shell tools, consider them safe for now
|
|
301
|
+
# You can extend this logic for other tools
|
|
302
|
+
!editing_tool?(tool_name)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def build_system_prompt
|
|
306
|
+
prompt = SYSTEM_PROMPT.dup
|
|
307
|
+
|
|
308
|
+
# Try to load project rules from multiple sources (in order of priority)
|
|
309
|
+
rules_files = [
|
|
310
|
+
{ path: ".clackyrules", name: ".clackyrules" },
|
|
311
|
+
{ path: ".cursorrules", name: ".cursorrules" },
|
|
312
|
+
{ path: "CLAUDE.md", name: "CLAUDE.md" }
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
rules_content = nil
|
|
316
|
+
rules_source = nil
|
|
317
|
+
|
|
318
|
+
rules_files.each do |file_info|
|
|
319
|
+
full_path = File.join(@working_dir, file_info[:path])
|
|
320
|
+
if File.exist?(full_path)
|
|
321
|
+
content = File.read(full_path).strip
|
|
322
|
+
unless content.empty?
|
|
323
|
+
rules_content = content
|
|
324
|
+
rules_source = file_info[:name]
|
|
325
|
+
break
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Add rules to prompt if found
|
|
331
|
+
if rules_content && rules_source
|
|
332
|
+
prompt += "\n\n" + "=" * 80 + "\n"
|
|
333
|
+
prompt += "PROJECT-SPECIFIC RULES (from #{rules_source}):\n"
|
|
334
|
+
prompt += "=" * 80 + "\n"
|
|
335
|
+
prompt += rules_content
|
|
336
|
+
prompt += "\n" + "=" * 80 + "\n"
|
|
337
|
+
prompt += "⚠️ IMPORTANT: Follow these project-specific rules at all times!\n"
|
|
338
|
+
prompt += "=" * 80
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
prompt
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def think(&block)
|
|
345
|
+
emit_event(:thinking, { iteration: @iterations }, &block)
|
|
346
|
+
|
|
347
|
+
# Compress messages if needed to reduce cost
|
|
348
|
+
compress_messages_if_needed if @config.enable_compression
|
|
349
|
+
|
|
350
|
+
# Always send tools definitions to allow multi-step tool calling
|
|
351
|
+
tools_to_send = @tool_registry.allowed_definitions(@config.allowed_tools)
|
|
352
|
+
|
|
353
|
+
# Show progress indicator while waiting for LLM response
|
|
354
|
+
progress = ProgressIndicator.new(verbose: @config.verbose)
|
|
355
|
+
progress.start
|
|
356
|
+
|
|
357
|
+
begin
|
|
358
|
+
# Retry logic for network failures
|
|
359
|
+
max_retries = 10
|
|
360
|
+
retry_delay = 5
|
|
361
|
+
retries = 0
|
|
362
|
+
|
|
363
|
+
begin
|
|
364
|
+
response = @client.send_messages_with_tools(
|
|
365
|
+
@messages,
|
|
366
|
+
model: @config.model,
|
|
367
|
+
tools: tools_to_send,
|
|
368
|
+
max_tokens: @config.max_tokens,
|
|
369
|
+
verbose: @config.verbose
|
|
370
|
+
)
|
|
371
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
372
|
+
retries += 1
|
|
373
|
+
if retries <= max_retries
|
|
374
|
+
progress.finish
|
|
375
|
+
puts "\n⚠️ Network request failed: #{e.class.name} - #{e.message}"
|
|
376
|
+
puts "🔄 Retry #{retries}/#{max_retries}, waiting #{retry_delay} seconds..."
|
|
377
|
+
sleep retry_delay
|
|
378
|
+
progress.start
|
|
379
|
+
retry
|
|
380
|
+
else
|
|
381
|
+
progress.finish
|
|
382
|
+
puts "\n❌ Network request failed after #{max_retries} retries, giving up"
|
|
383
|
+
raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
track_cost(response[:usage])
|
|
388
|
+
|
|
389
|
+
# Add assistant response to messages
|
|
390
|
+
msg = { role: "assistant" }
|
|
391
|
+
# Always include content field (some APIs require it even with tool_calls)
|
|
392
|
+
# Use empty string instead of null for better compatibility
|
|
393
|
+
msg[:content] = response[:content] || ""
|
|
394
|
+
msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
|
|
395
|
+
@messages << msg
|
|
396
|
+
|
|
397
|
+
if @config.verbose
|
|
398
|
+
puts "\n[DEBUG] Assistant response added to messages:"
|
|
399
|
+
puts JSON.pretty_generate(msg)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
response
|
|
403
|
+
ensure
|
|
404
|
+
progress.finish
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def act(tool_calls, &block)
|
|
409
|
+
return { denied: false, feedback: nil, tool_results: [] } unless tool_calls
|
|
410
|
+
|
|
411
|
+
denied = false
|
|
412
|
+
feedback = nil
|
|
413
|
+
results = []
|
|
414
|
+
|
|
415
|
+
tool_calls.each_with_index do |call, index|
|
|
416
|
+
# Hook: before_tool_use
|
|
417
|
+
hook_result = @hooks.trigger(:before_tool_use, call)
|
|
418
|
+
if hook_result[:action] == :deny
|
|
419
|
+
emit_event(:tool_denied, call, &block)
|
|
420
|
+
results << build_error_result(call, hook_result[:reason] || "Tool use denied by hook")
|
|
421
|
+
next
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Permission check (if not in auto-approve mode)
|
|
425
|
+
unless should_auto_execute?(call[:name], call[:arguments])
|
|
426
|
+
if @config.is_plan_only?
|
|
427
|
+
emit_event(:tool_planned, call, &block)
|
|
428
|
+
results << build_planned_result(call)
|
|
429
|
+
next
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
confirmation = confirm_tool_use?(call, &block)
|
|
433
|
+
unless confirmation[:approved]
|
|
434
|
+
emit_event(:tool_denied, call, &block)
|
|
435
|
+
denied = true
|
|
436
|
+
user_feedback = confirmation[:feedback]
|
|
437
|
+
feedback = user_feedback if user_feedback
|
|
438
|
+
results << build_denied_result(call, user_feedback)
|
|
439
|
+
|
|
440
|
+
# If user provided feedback, stop processing remaining tools immediately
|
|
441
|
+
# Let the agent respond to the feedback in the next iteration
|
|
442
|
+
if user_feedback && !user_feedback.empty?
|
|
443
|
+
# Fill in denied results for all remaining tool calls to avoid mismatch
|
|
444
|
+
remaining_calls = tool_calls[(index + 1)..-1] || []
|
|
445
|
+
remaining_calls.each do |remaining_call|
|
|
446
|
+
results << build_denied_result(remaining_call, "Auto-denied due to user feedback on previous tool")
|
|
447
|
+
end
|
|
448
|
+
break
|
|
449
|
+
end
|
|
450
|
+
next
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
emit_event(:tool_call, call, &block)
|
|
455
|
+
|
|
456
|
+
# Execute tool
|
|
457
|
+
begin
|
|
458
|
+
tool = @tool_registry.get(call[:name])
|
|
459
|
+
|
|
460
|
+
# Parse and validate arguments with JSON repair capability
|
|
461
|
+
args = Utils::ArgumentsParser.parse_and_validate(call, @tool_registry)
|
|
462
|
+
|
|
463
|
+
# Special handling for TodoManager: inject todos array
|
|
464
|
+
if call[:name] == "todo_manager"
|
|
465
|
+
args[:todos_storage] = @todos
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
result = tool.execute(**args)
|
|
469
|
+
|
|
470
|
+
# Hook: after_tool_use
|
|
471
|
+
@hooks.trigger(:after_tool_use, call, result)
|
|
472
|
+
|
|
473
|
+
emit_event(:observation, { tool: call[:name], result: result }, &block)
|
|
474
|
+
results << build_success_result(call, result)
|
|
475
|
+
rescue StandardError => e
|
|
476
|
+
@hooks.trigger(:on_tool_error, call, e)
|
|
477
|
+
emit_event(:tool_error, { call: call, error: e }, &block)
|
|
478
|
+
results << build_error_result(call, e.message)
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
{
|
|
483
|
+
denied: denied,
|
|
484
|
+
feedback: feedback,
|
|
485
|
+
tool_results: results
|
|
486
|
+
}
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def observe(response, tool_results)
|
|
490
|
+
# Add tool results as messages
|
|
491
|
+
# Using OpenAI format which is compatible with most APIs through LiteLLM
|
|
492
|
+
|
|
493
|
+
# CRITICAL: Tool results must be in the same order as tool_calls in the response
|
|
494
|
+
# Claude/Bedrock API requires this strict ordering
|
|
495
|
+
return if tool_results.empty?
|
|
496
|
+
|
|
497
|
+
# Create a map of tool_call_id -> result for quick lookup
|
|
498
|
+
results_map = tool_results.each_with_object({}) do |result, hash|
|
|
499
|
+
hash[result[:id]] = result
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Add results in the same order as the original tool_calls
|
|
503
|
+
response[:tool_calls].each do |tool_call|
|
|
504
|
+
result = results_map[tool_call[:id]]
|
|
505
|
+
if result
|
|
506
|
+
@messages << {
|
|
507
|
+
role: "tool",
|
|
508
|
+
tool_call_id: result[:id],
|
|
509
|
+
content: result[:content]
|
|
510
|
+
}
|
|
511
|
+
else
|
|
512
|
+
# This shouldn't happen, but add a fallback error result
|
|
513
|
+
@messages << {
|
|
514
|
+
role: "tool",
|
|
515
|
+
tool_call_id: tool_call[:id],
|
|
516
|
+
content: JSON.generate({ error: "Tool result missing" })
|
|
517
|
+
}
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def should_stop?
|
|
523
|
+
if @iterations >= @config.max_iterations
|
|
524
|
+
puts "\n⚠️ Reached maximum iterations (#{@config.max_iterations})" if @config.verbose
|
|
525
|
+
return true
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
if @total_cost >= @config.max_cost_usd
|
|
529
|
+
puts "\n⚠️ Reached maximum cost ($#{@config.max_cost_usd})" if @config.verbose
|
|
530
|
+
return true
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Check timeout only if configured (nil means no timeout)
|
|
534
|
+
if @config.timeout_seconds && Time.now - @start_time > @config.timeout_seconds
|
|
535
|
+
puts "\n⚠️ Reached timeout (#{@config.timeout_seconds}s)" if @config.verbose
|
|
536
|
+
return true
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
false
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def track_cost(usage)
|
|
543
|
+
input_cost = (usage[:prompt_tokens] / 1_000_000.0) * PRICING[:input]
|
|
544
|
+
output_cost = (usage[:completion_tokens] / 1_000_000.0) * PRICING[:output]
|
|
545
|
+
@total_cost += input_cost + output_cost
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def compress_messages_if_needed
|
|
549
|
+
# Check if compression is enabled
|
|
550
|
+
return unless @config.enable_compression
|
|
551
|
+
|
|
552
|
+
# Only compress if we have more messages than threshold
|
|
553
|
+
threshold = @config.keep_recent_messages + 80 # +80 to trigger at ~100 messages
|
|
554
|
+
return if @messages.size <= threshold
|
|
555
|
+
|
|
556
|
+
original_size = @messages.size
|
|
557
|
+
target_size = @config.keep_recent_messages + 2
|
|
558
|
+
|
|
559
|
+
# Show compression progress using ProgressIndicator
|
|
560
|
+
progress = ProgressIndicator.new(
|
|
561
|
+
verbose: @config.verbose,
|
|
562
|
+
message: "🗜️ Compressing conversation history (#{original_size} → ~#{target_size} messages)"
|
|
563
|
+
)
|
|
564
|
+
progress.start
|
|
565
|
+
|
|
566
|
+
begin
|
|
567
|
+
# Find the system message (should be first)
|
|
568
|
+
system_msg = @messages.find { |m| m[:role] == "system" }
|
|
569
|
+
|
|
570
|
+
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
571
|
+
recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
|
|
572
|
+
|
|
573
|
+
# Get messages to compress (everything except system and recent)
|
|
574
|
+
messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
|
|
575
|
+
|
|
576
|
+
if messages_to_compress.empty?
|
|
577
|
+
progress.finish
|
|
578
|
+
return
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Create summary of compressed messages
|
|
582
|
+
summary = summarize_messages(messages_to_compress)
|
|
583
|
+
|
|
584
|
+
# Rebuild messages array: [system, summary, recent_messages]
|
|
585
|
+
@messages = [system_msg, summary, *recent_messages].compact
|
|
586
|
+
|
|
587
|
+
final_size = @messages.size
|
|
588
|
+
|
|
589
|
+
# Finish progress and show completion message
|
|
590
|
+
progress.finish
|
|
591
|
+
puts "✅ Compressed conversation history (#{original_size} → #{final_size} messages)"
|
|
592
|
+
|
|
593
|
+
# Show detailed summary in verbose mode
|
|
594
|
+
if @config.verbose
|
|
595
|
+
puts "\n[COMPRESSION SUMMARY]"
|
|
596
|
+
puts summary[:content]
|
|
597
|
+
puts ""
|
|
598
|
+
end
|
|
599
|
+
ensure
|
|
600
|
+
progress.finish
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def get_recent_messages_with_tool_pairs(messages, count)
|
|
605
|
+
# This method ensures that assistant messages with tool_calls are always kept together
|
|
606
|
+
# with ALL their corresponding tool_results, maintaining the correct order.
|
|
607
|
+
# This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
|
|
608
|
+
|
|
609
|
+
return [] if messages.empty?
|
|
610
|
+
|
|
611
|
+
# Track which messages to include
|
|
612
|
+
messages_to_include = Set.new
|
|
613
|
+
|
|
614
|
+
# Start from the end and work backwards
|
|
615
|
+
i = messages.size - 1
|
|
616
|
+
messages_collected = 0
|
|
617
|
+
|
|
618
|
+
while i >= 0 && messages_collected < count
|
|
619
|
+
msg = messages[i]
|
|
620
|
+
|
|
621
|
+
# Skip if already marked for inclusion
|
|
622
|
+
if messages_to_include.include?(i)
|
|
623
|
+
i -= 1
|
|
624
|
+
next
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Mark this message for inclusion
|
|
628
|
+
messages_to_include.add(i)
|
|
629
|
+
messages_collected += 1
|
|
630
|
+
|
|
631
|
+
# If this is an assistant message with tool_calls, we MUST include ALL corresponding tool results
|
|
632
|
+
if msg[:role] == "assistant" && msg[:tool_calls]
|
|
633
|
+
tool_call_ids = msg[:tool_calls].map { |tc| tc[:id] }
|
|
634
|
+
|
|
635
|
+
# Find all tool results that belong to this assistant message
|
|
636
|
+
# They should be in the messages immediately following this assistant message
|
|
637
|
+
j = i + 1
|
|
638
|
+
while j < messages.size
|
|
639
|
+
next_msg = messages[j]
|
|
640
|
+
|
|
641
|
+
# If we find a tool result for one of our tool_calls, include it
|
|
642
|
+
if next_msg[:role] == "tool" && tool_call_ids.include?(next_msg[:tool_call_id])
|
|
643
|
+
messages_to_include.add(j)
|
|
644
|
+
elsif next_msg[:role] != "tool"
|
|
645
|
+
# Stop when we hit a non-tool message (start of next turn)
|
|
646
|
+
break
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
j += 1
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# If this is a tool result, make sure its assistant message is also included
|
|
654
|
+
if msg[:role] == "tool"
|
|
655
|
+
# Find the corresponding assistant message
|
|
656
|
+
j = i - 1
|
|
657
|
+
while j >= 0
|
|
658
|
+
prev_msg = messages[j]
|
|
659
|
+
if prev_msg[:role] == "assistant" && prev_msg[:tool_calls]
|
|
660
|
+
# Check if this assistant has the matching tool_call
|
|
661
|
+
has_matching_call = prev_msg[:tool_calls].any? { |tc| tc[:id] == msg[:tool_call_id] }
|
|
662
|
+
if has_matching_call
|
|
663
|
+
unless messages_to_include.include?(j)
|
|
664
|
+
messages_to_include.add(j)
|
|
665
|
+
messages_collected += 1
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Also include all other tool results for this assistant message
|
|
669
|
+
tool_call_ids = prev_msg[:tool_calls].map { |tc| tc[:id] }
|
|
670
|
+
k = j + 1
|
|
671
|
+
while k < messages.size
|
|
672
|
+
result_msg = messages[k]
|
|
673
|
+
if result_msg[:role] == "tool" && tool_call_ids.include?(result_msg[:tool_call_id])
|
|
674
|
+
messages_to_include.add(k)
|
|
675
|
+
elsif result_msg[:role] != "tool"
|
|
676
|
+
break
|
|
677
|
+
end
|
|
678
|
+
k += 1
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
break
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
j -= 1
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
i -= 1
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Extract the messages in their original order
|
|
692
|
+
messages_to_include.to_a.sort.map { |idx| messages[idx] }
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def summarize_messages(messages)
|
|
696
|
+
# Count different message types
|
|
697
|
+
user_msgs = messages.count { |m| m[:role] == "user" }
|
|
698
|
+
assistant_msgs = messages.count { |m| m[:role] == "assistant" }
|
|
699
|
+
tool_msgs = messages.count { |m| m[:role] == "tool" }
|
|
700
|
+
|
|
701
|
+
# Extract key information
|
|
702
|
+
tools_used = messages
|
|
703
|
+
.select { |m| m[:role] == "assistant" && m[:tool_calls] }
|
|
704
|
+
.flat_map { |m| m[:tool_calls].map { |tc| tc.dig(:function, :name) } }
|
|
705
|
+
.compact
|
|
706
|
+
.uniq
|
|
707
|
+
|
|
708
|
+
# Count completed tasks from tool results
|
|
709
|
+
completed_todos = messages
|
|
710
|
+
.select { |m| m[:role] == "tool" }
|
|
711
|
+
.map { |m| JSON.parse(m[:content]) rescue nil }
|
|
712
|
+
.compact
|
|
713
|
+
.select { |data| data.is_a?(Hash) && data["message"]&.include?("completed") }
|
|
714
|
+
.size
|
|
715
|
+
|
|
716
|
+
summary_text = "Previous conversation summary (#{messages.size} messages compressed):\n"
|
|
717
|
+
summary_text += "- User requests: #{user_msgs}\n"
|
|
718
|
+
summary_text += "- Assistant responses: #{assistant_msgs}\n"
|
|
719
|
+
summary_text += "- Tool executions: #{tool_msgs}\n"
|
|
720
|
+
summary_text += "- Tools used: #{tools_used.join(', ')}\n" if tools_used.any?
|
|
721
|
+
summary_text += "- Completed tasks: #{completed_todos}\n" if completed_todos > 0
|
|
722
|
+
summary_text += "\nContinuing with recent conversation context..."
|
|
723
|
+
|
|
724
|
+
{
|
|
725
|
+
role: "user",
|
|
726
|
+
content: "[SYSTEM] " + summary_text
|
|
727
|
+
}
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def emit_event(type, data, &block)
|
|
731
|
+
return unless block
|
|
732
|
+
|
|
733
|
+
block.call({
|
|
734
|
+
type: type,
|
|
735
|
+
data: data,
|
|
736
|
+
iteration: @iterations,
|
|
737
|
+
cost: @total_cost
|
|
738
|
+
})
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def confirm_tool_use?(call, &block)
|
|
742
|
+
emit_event(:tool_confirmation_required, call, &block)
|
|
743
|
+
|
|
744
|
+
# Show preview first and check for errors
|
|
745
|
+
preview_error = show_tool_preview(call)
|
|
746
|
+
|
|
747
|
+
# If preview detected an error (e.g., edit with non-existent string),
|
|
748
|
+
# auto-deny and provide detailed feedback
|
|
749
|
+
if preview_error && preview_error[:error]
|
|
750
|
+
puts "\nTool call auto-denied due to preview error"
|
|
751
|
+
|
|
752
|
+
# Build helpful feedback message
|
|
753
|
+
feedback = case call[:name]
|
|
754
|
+
when "edit"
|
|
755
|
+
"The edit operation will fail because the old_string was not found in the file. " \
|
|
756
|
+
"Please use file_reader to read '#{preview_error[:path]}' first, " \
|
|
757
|
+
"find the correct string to replace, and try again with the exact string (including whitespace)."
|
|
758
|
+
else
|
|
759
|
+
"Tool preview error: #{preview_error[:error]}"
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
return { approved: false, feedback: feedback }
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Then show the confirmation prompt with better formatting
|
|
766
|
+
prompt_text = format_tool_prompt(call)
|
|
767
|
+
puts "\n❓ #{prompt_text}"
|
|
768
|
+
|
|
769
|
+
# Use Readline for better input handling (backspace, arrow keys, etc.)
|
|
770
|
+
response = Readline.readline(" (Enter/y to approve, n to deny, or provide feedback): ", true)
|
|
771
|
+
|
|
772
|
+
if response.nil? # Handle EOF/pipe input
|
|
773
|
+
return { approved: false, feedback: nil }
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
response = response.chomp
|
|
777
|
+
response_lower = response.downcase
|
|
778
|
+
|
|
779
|
+
# Empty response (just Enter) or "y"/"yes" = approved
|
|
780
|
+
if response.empty? || response_lower == "y" || response_lower == "yes"
|
|
781
|
+
return { approved: true, feedback: nil }
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# "n"/"no" = denied without feedback
|
|
785
|
+
if response_lower == "n" || response_lower == "no"
|
|
786
|
+
return { approved: false, feedback: nil }
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# Any other input = denied with feedback
|
|
790
|
+
{ approved: false, feedback: response }
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def format_tool_prompt(call)
|
|
794
|
+
begin
|
|
795
|
+
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
796
|
+
|
|
797
|
+
# Try to use tool's format_call method for better formatting
|
|
798
|
+
tool = @tool_registry.get(call[:name]) rescue nil
|
|
799
|
+
if tool
|
|
800
|
+
formatted = tool.format_call(args) rescue nil
|
|
801
|
+
return formatted if formatted
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Fallback to manual formatting for common tools
|
|
805
|
+
case call[:name]
|
|
806
|
+
when "edit"
|
|
807
|
+
path = args[:path] || args[:file_path]
|
|
808
|
+
filename = Utils::PathHelper.safe_basename(path)
|
|
809
|
+
"Edit(#{filename})"
|
|
810
|
+
when "write"
|
|
811
|
+
filename = Utils::PathHelper.safe_basename(args[:path])
|
|
812
|
+
if args[:path] && File.exist?(args[:path])
|
|
813
|
+
"Write(#{filename}) - overwrite existing"
|
|
814
|
+
else
|
|
815
|
+
"Write(#{filename}) - create new"
|
|
816
|
+
end
|
|
817
|
+
when "shell", "safe_shell"
|
|
818
|
+
cmd = args[:command] || ''
|
|
819
|
+
display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
|
|
820
|
+
"#{call[:name]}(\"#{display_cmd}\")"
|
|
821
|
+
else
|
|
822
|
+
"Allow #{call[:name]}"
|
|
823
|
+
end
|
|
824
|
+
rescue JSON::ParserError
|
|
825
|
+
"Allow #{call[:name]}"
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def show_tool_preview(call)
|
|
830
|
+
begin
|
|
831
|
+
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
832
|
+
|
|
833
|
+
preview_error = nil
|
|
834
|
+
case call[:name]
|
|
835
|
+
when "write"
|
|
836
|
+
preview_error = show_write_preview(args)
|
|
837
|
+
when "edit"
|
|
838
|
+
preview_error = show_edit_preview(args)
|
|
839
|
+
when "shell", "safe_shell"
|
|
840
|
+
preview_error = show_shell_preview(args)
|
|
841
|
+
else
|
|
842
|
+
# For other tools, show formatted arguments
|
|
843
|
+
tool = @tool_registry.get(call[:name]) rescue nil
|
|
844
|
+
if tool
|
|
845
|
+
formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
|
|
846
|
+
puts "\nArgs: #{formatted}"
|
|
847
|
+
else
|
|
848
|
+
puts "\nArgs: #{call[:arguments]}"
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
return preview_error
|
|
853
|
+
rescue JSON::ParserError
|
|
854
|
+
puts " Args: #{call[:arguments]}"
|
|
855
|
+
return nil
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def show_write_preview(args)
|
|
860
|
+
path = args[:path] || args['path']
|
|
861
|
+
new_content = args[:content] || args['content'] || ""
|
|
862
|
+
|
|
863
|
+
puts "\n📝 File: #{path || '(unknown)'}"
|
|
864
|
+
|
|
865
|
+
if path && File.exist?(path)
|
|
866
|
+
old_content = File.read(path)
|
|
867
|
+
puts "Modifying existing file\n"
|
|
868
|
+
show_diff(old_content, new_content, max_lines: 50)
|
|
869
|
+
else
|
|
870
|
+
puts "Creating new file\n"
|
|
871
|
+
# Show diff from empty content to new content (all additions)
|
|
872
|
+
show_diff("", new_content, max_lines: 50)
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
def show_edit_preview(args)
|
|
877
|
+
path = args[:path] || args[:file_path] || args['path'] || args['file_path']
|
|
878
|
+
old_string = args[:old_string] || args['old_string'] || ""
|
|
879
|
+
new_string = args[:new_string] || args['new_string'] || ""
|
|
880
|
+
|
|
881
|
+
puts "\n📝 File: #{path || '(unknown)'}"
|
|
882
|
+
|
|
883
|
+
if !path || path.empty?
|
|
884
|
+
puts " ⚠️ No file path provided"
|
|
885
|
+
return { error: "No file path provided for edit operation" }
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
unless File.exist?(path)
|
|
889
|
+
puts " ⚠️ File not found: #{path}"
|
|
890
|
+
return { error: "File not found: #{path}" }
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
if old_string.empty?
|
|
894
|
+
puts " ⚠️ No old_string provided (nothing to replace)"
|
|
895
|
+
return { error: "No old_string provided (nothing to replace)" }
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
file_content = File.read(path)
|
|
899
|
+
|
|
900
|
+
# Check if old_string exists in file
|
|
901
|
+
unless file_content.include?(old_string)
|
|
902
|
+
puts " ⚠️ String to replace not found in file"
|
|
903
|
+
puts " Looking for (first 100 chars):"
|
|
904
|
+
puts " #{old_string[0..100].inspect}"
|
|
905
|
+
return {
|
|
906
|
+
error: "String to replace not found in file",
|
|
907
|
+
path: path,
|
|
908
|
+
looking_for: old_string[0..200]
|
|
909
|
+
}
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
new_content = file_content.sub(old_string, new_string)
|
|
913
|
+
show_diff(file_content, new_content, max_lines: 50)
|
|
914
|
+
nil # No error
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
def show_shell_preview(args)
|
|
918
|
+
command = args[:command] || ""
|
|
919
|
+
puts "\n💻 Command: #{command}"
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def show_diff(old_content, new_content, max_lines: 50)
|
|
923
|
+
require 'diffy'
|
|
924
|
+
|
|
925
|
+
diff = Diffy::Diff.new(old_content, new_content, context: 3)
|
|
926
|
+
all_lines = diff.to_s(:color).lines
|
|
927
|
+
display_lines = all_lines.first(max_lines)
|
|
928
|
+
|
|
929
|
+
display_lines.each { |line| puts line.chomp }
|
|
930
|
+
puts "\n... (#{all_lines.size - max_lines} more lines, diff truncated)" if all_lines.size > max_lines
|
|
931
|
+
rescue LoadError
|
|
932
|
+
# Fallback if diffy is not available
|
|
933
|
+
puts " Old size: #{old_content.bytesize} bytes"
|
|
934
|
+
puts " New size: #{new_content.bytesize} bytes"
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
def build_success_result(call, result)
|
|
938
|
+
{
|
|
939
|
+
id: call[:id],
|
|
940
|
+
content: JSON.generate(result)
|
|
941
|
+
}
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def build_error_result(call, error_message)
|
|
945
|
+
{
|
|
946
|
+
id: call[:id],
|
|
947
|
+
content: JSON.generate({ error: error_message })
|
|
948
|
+
}
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def build_denied_result(call, user_feedback = nil)
|
|
952
|
+
message = if user_feedback && !user_feedback.empty?
|
|
953
|
+
"Tool use denied by user. User feedback: #{user_feedback}"
|
|
954
|
+
else
|
|
955
|
+
"Tool use denied by user"
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
{
|
|
959
|
+
id: call[:id],
|
|
960
|
+
content: JSON.generate({
|
|
961
|
+
error: message,
|
|
962
|
+
user_feedback: user_feedback
|
|
963
|
+
})
|
|
964
|
+
}
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
def build_planned_result(call)
|
|
968
|
+
{
|
|
969
|
+
id: call[:id],
|
|
970
|
+
content: JSON.generate({ planned: true, message: "Tool execution skipped (plan mode)" })
|
|
971
|
+
}
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def build_result(status, error: nil)
|
|
975
|
+
{
|
|
976
|
+
status: status,
|
|
977
|
+
session_id: @session_id,
|
|
978
|
+
iterations: @iterations,
|
|
979
|
+
duration_seconds: Time.now - @start_time,
|
|
980
|
+
total_cost_usd: @total_cost.round(4),
|
|
981
|
+
messages: @messages,
|
|
982
|
+
error: error
|
|
983
|
+
}
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
def format_tool_calls_for_api(tool_calls)
|
|
987
|
+
return nil unless tool_calls
|
|
988
|
+
|
|
989
|
+
tool_calls.map do |call|
|
|
990
|
+
{
|
|
991
|
+
id: call[:id],
|
|
992
|
+
type: call[:type] || "function",
|
|
993
|
+
function: {
|
|
994
|
+
name: call[:name],
|
|
995
|
+
arguments: call[:arguments]
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
def register_builtin_tools
|
|
1002
|
+
|
|
1003
|
+
@tool_registry.register(Tools::SafeShell.new)
|
|
1004
|
+
@tool_registry.register(Tools::FileReader.new)
|
|
1005
|
+
@tool_registry.register(Tools::Write.new)
|
|
1006
|
+
@tool_registry.register(Tools::Edit.new)
|
|
1007
|
+
@tool_registry.register(Tools::Glob.new)
|
|
1008
|
+
@tool_registry.register(Tools::Grep.new)
|
|
1009
|
+
@tool_registry.register(Tools::WebSearch.new)
|
|
1010
|
+
@tool_registry.register(Tools::WebFetch.new)
|
|
1011
|
+
@tool_registry.register(Tools::TodoManager.new)
|
|
1012
|
+
@tool_registry.register(Tools::RunProject.new)
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
end
|