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