openclacky 0.7.0 → 0.7.2
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 +4 -4
- data/.clacky/skills/commit/SKILL.md +29 -4
- data/.clackyrules +3 -1
- data/CHANGELOG.md +103 -2
- data/README.md +70 -161
- data/bin/clarky +11 -0
- data/docs/HOW-TO-USE-CN.md +96 -0
- data/docs/HOW-TO-USE.md +94 -0
- data/docs/config.example.yml +27 -0
- data/docs/deploy_subagent_design.md +540 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/why-openclacky.md +0 -1
- data/lib/clacky/agent/cost_tracker.rb +180 -0
- data/lib/clacky/agent/llm_caller.rb +54 -0
- data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
- data/lib/clacky/agent/message_compressor_helper.rb +534 -0
- data/lib/clacky/agent/session_serializer.rb +152 -0
- data/lib/clacky/agent/skill_manager.rb +138 -0
- data/lib/clacky/agent/system_prompt_builder.rb +96 -0
- data/lib/clacky/agent/time_machine.rb +199 -0
- data/lib/clacky/agent/tool_executor.rb +434 -0
- data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
- data/lib/clacky/agent.rb +260 -1370
- data/lib/clacky/agent_config.rb +447 -10
- data/lib/clacky/cli.rb +275 -98
- data/lib/clacky/client.rb +12 -2
- data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
- data/lib/clacky/default_skills/new/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +195 -0
- data/lib/clacky/providers.rb +107 -0
- data/lib/clacky/skill.rb +48 -7
- data/lib/clacky/skill_loader.rb +7 -0
- data/lib/clacky/tools/edit.rb +105 -48
- data/lib/clacky/tools/file_reader.rb +44 -73
- data/lib/clacky/tools/invoke_skill.rb +89 -0
- data/lib/clacky/tools/list_tasks.rb +54 -0
- data/lib/clacky/tools/redo_task.rb +41 -0
- data/lib/clacky/tools/safe_shell.rb +1 -1
- data/lib/clacky/tools/shell.rb +74 -62
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/undo_task.rb +32 -0
- data/lib/clacky/tools/web_fetch.rb +2 -1
- data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
- data/lib/clacky/ui2/components/inline_input.rb +23 -2
- data/lib/clacky/ui2/components/input_area.rb +65 -21
- data/lib/clacky/ui2/components/modal_component.rb +199 -62
- data/lib/clacky/ui2/layout_manager.rb +75 -25
- data/lib/clacky/ui2/line_editor.rb +23 -2
- data/lib/clacky/ui2/markdown_renderer.rb +31 -10
- data/lib/clacky/ui2/screen_buffer.rb +2 -0
- data/lib/clacky/ui2/ui_controller.rb +316 -37
- data/lib/clacky/ui2.rb +2 -0
- data/lib/clacky/ui_interface.rb +50 -0
- data/lib/clacky/utils/arguments_parser.rb +31 -3
- data/lib/clacky/utils/file_processor.rb +13 -18
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +19 -9
- data/scripts/install.sh +274 -97
- data/scripts/uninstall.sh +12 -12
- metadata +40 -13
- data/.clacky/skills/test-skill/SKILL.md +0 -15
- data/lib/clacky/compression/base.rb +0 -231
- data/lib/clacky/compression/standard.rb +0 -339
- data/lib/clacky/config.rb +0 -117
- /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
- /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
- /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
- /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
- /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
- /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
data/lib/clacky/agent.rb
CHANGED
|
@@ -6,57 +6,35 @@ require "tty-prompt"
|
|
|
6
6
|
require "set"
|
|
7
7
|
require_relative "utils/arguments_parser"
|
|
8
8
|
require_relative "utils/file_processor"
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
# Load all agent modules
|
|
11
|
+
require_relative "agent/message_compressor"
|
|
12
|
+
require_relative "agent/message_compressor_helper"
|
|
13
|
+
require_relative "agent/tool_executor"
|
|
14
|
+
require_relative "agent/cost_tracker"
|
|
15
|
+
require_relative "agent/session_serializer"
|
|
16
|
+
require_relative "agent/skill_manager"
|
|
17
|
+
require_relative "agent/system_prompt_builder"
|
|
18
|
+
require_relative "agent/llm_caller"
|
|
19
|
+
require_relative "agent/time_machine"
|
|
10
20
|
|
|
11
21
|
module Clacky
|
|
12
22
|
class Agent
|
|
23
|
+
# Include all functionality modules
|
|
24
|
+
include MessageCompressorHelper
|
|
25
|
+
include ToolExecutor
|
|
26
|
+
include CostTracker
|
|
27
|
+
include SessionSerializer
|
|
28
|
+
include SkillManager
|
|
29
|
+
include SystemPromptBuilder
|
|
30
|
+
include LlmCaller
|
|
31
|
+
include TimeMachine
|
|
32
|
+
|
|
13
33
|
attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
|
|
14
34
|
:cache_stats, :cost_source, :ui, :skill_loader
|
|
15
35
|
|
|
16
|
-
# System prompt for the coding agent
|
|
17
|
-
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
18
|
-
You are OpenClacky, an AI coding assistant and technical co-founder, designed to help non-technical
|
|
19
|
-
users complete software development projects. You are responsible for development in the current project.
|
|
20
|
-
|
|
21
|
-
Your role is to:
|
|
22
|
-
- Understand project requirements and translate them into technical solutions
|
|
23
|
-
- Write clean, maintainable, and well-documented code
|
|
24
|
-
- Follow best practices and industry standards
|
|
25
|
-
- Explain technical concepts in simple terms when needed
|
|
26
|
-
- Proactively identify potential issues and suggest improvements
|
|
27
|
-
- Help with debugging, testing, and deployment
|
|
28
|
-
|
|
29
|
-
Working process:
|
|
30
|
-
1. **For complex tasks with multiple steps**:
|
|
31
|
-
- Use todo_manager to create a complete TODO list FIRST
|
|
32
|
-
- After creating the TODO list, START EXECUTING each task immediately
|
|
33
|
-
- Don't stop after planning - continue to work on the tasks!
|
|
34
|
-
2. Always read existing code before making changes (use file_reader/glob/grep)
|
|
35
|
-
3. Ask clarifying questions if requirements are unclear
|
|
36
|
-
4. Break down complex tasks into manageable steps
|
|
37
|
-
5. **USE TOOLS to create/modify files** - don't just return code
|
|
38
|
-
6. Write code that is secure, efficient, and easy to understand
|
|
39
|
-
7. Test your changes using the shell tool when appropriate
|
|
40
|
-
8. **IMPORTANT**: After completing each step, mark the TODO as completed and continue to the next one
|
|
41
|
-
9. Keep working until ALL TODOs are completed or you need user input
|
|
42
|
-
10. Provide brief explanations after completing actions
|
|
43
|
-
|
|
44
|
-
IMPORTANT: You should frequently refer to the existing codebase. For unclear instructions,
|
|
45
|
-
prioritize understanding the codebase first before answering or taking action.
|
|
46
|
-
Always read relevant code files to understand the project structure, patterns, and conventions.
|
|
47
|
-
|
|
48
|
-
CRITICAL RULE FOR TODO MANAGER:
|
|
49
|
-
When using todo_manager to add tasks, you MUST continue working immediately after adding ALL todos.
|
|
50
|
-
Adding todos is NOT completion - it's just the planning phase!
|
|
51
|
-
Workflow: add todo 1 → add todo 2 → add todo 3 → START WORKING on todo 1 → complete(1) → work on todo 2 → complete(2) → etc.
|
|
52
|
-
NEVER stop after just adding todos without executing them!
|
|
53
|
-
|
|
54
|
-
NOTE: Available skills are listed below in the AVAILABLE SKILLS section.
|
|
55
|
-
When a user's request matches a skill, you MUST use the skill tool instead of implementing it yourself.
|
|
56
|
-
PROMPT
|
|
57
|
-
|
|
58
36
|
def initialize(client, config = {}, working_dir: nil, ui: nil)
|
|
59
|
-
@client = client
|
|
37
|
+
@client = client # Client for current model
|
|
60
38
|
@config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
|
|
61
39
|
@tool_registry = ToolRegistry.new
|
|
62
40
|
@hooks = HookManager.new
|
|
@@ -89,11 +67,14 @@ module Clacky
|
|
|
89
67
|
|
|
90
68
|
# Message compressor for LLM-based intelligent compression
|
|
91
69
|
# Uses LLM to preserve key decisions, errors, and context while reducing token count
|
|
92
|
-
@message_compressor = MessageCompressor.new(@client, model:
|
|
70
|
+
@message_compressor = MessageCompressor.new(@client, model: current_model)
|
|
93
71
|
|
|
94
72
|
# Skill loader for skill management
|
|
95
73
|
@skill_loader = SkillLoader.new(@working_dir)
|
|
96
74
|
|
|
75
|
+
# Initialize Time Machine
|
|
76
|
+
init_time_machine
|
|
77
|
+
|
|
97
78
|
# Register built-in tools
|
|
98
79
|
register_builtin_tools
|
|
99
80
|
end
|
|
@@ -105,80 +86,54 @@ module Clacky
|
|
|
105
86
|
agent
|
|
106
87
|
end
|
|
107
88
|
|
|
108
|
-
def
|
|
109
|
-
@
|
|
110
|
-
@messages = session_data[:messages]
|
|
111
|
-
@todos = session_data[:todos] || [] # Restore todos from session
|
|
112
|
-
@iterations = session_data.dig(:stats, :total_iterations) || 0
|
|
113
|
-
@total_cost = session_data.dig(:stats, :total_cost_usd) || 0.0
|
|
114
|
-
@working_dir = session_data[:working_dir]
|
|
115
|
-
@created_at = session_data[:created_at]
|
|
116
|
-
@total_tasks = session_data.dig(:stats, :total_tasks) || 0
|
|
117
|
-
|
|
118
|
-
# Restore cache statistics if available
|
|
119
|
-
@cache_stats = session_data.dig(:stats, :cache_stats) || {
|
|
120
|
-
cache_creation_input_tokens: 0,
|
|
121
|
-
cache_read_input_tokens: 0,
|
|
122
|
-
total_requests: 0,
|
|
123
|
-
cache_hit_requests: 0
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
# Restore previous_total_tokens for accurate delta calculation across sessions
|
|
127
|
-
@previous_total_tokens = session_data.dig(:stats, :previous_total_tokens) || 0
|
|
128
|
-
|
|
129
|
-
# Check if the session ended with an error
|
|
130
|
-
last_status = session_data.dig(:stats, :last_status)
|
|
131
|
-
last_error = session_data.dig(:stats, :last_error)
|
|
132
|
-
|
|
133
|
-
if last_status == "error" && last_error
|
|
134
|
-
# Find and remove the last user message that caused the error
|
|
135
|
-
# This allows the user to retry with a different prompt
|
|
136
|
-
last_user_index = @messages.rindex { |m| m[:role] == "user" }
|
|
137
|
-
if last_user_index
|
|
138
|
-
@messages = @messages[0...last_user_index]
|
|
139
|
-
|
|
140
|
-
# Trigger a hook to notify about the rollback
|
|
141
|
-
@hooks.trigger(:session_rollback, {
|
|
142
|
-
reason: "Previous session ended with error",
|
|
143
|
-
error_message: last_error,
|
|
144
|
-
rolled_back_message_index: last_user_index
|
|
145
|
-
})
|
|
146
|
-
end
|
|
147
|
-
end
|
|
89
|
+
def add_hook(event, &block)
|
|
90
|
+
@hooks.add(event, &block)
|
|
148
91
|
end
|
|
149
92
|
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
93
|
+
# Switch to a different model by name
|
|
94
|
+
# Returns true if switched, false if model not found
|
|
95
|
+
def switch_model(model_name)
|
|
96
|
+
if @config.switch_model(model_name)
|
|
97
|
+
# Re-create client for new model
|
|
98
|
+
@client = Clacky::Client.new(
|
|
99
|
+
@config.api_key,
|
|
100
|
+
base_url: @config.base_url,
|
|
101
|
+
anthropic_format: @config.anthropic_format?
|
|
102
|
+
)
|
|
103
|
+
# Update message compressor with new client and model
|
|
104
|
+
@message_compressor = MessageCompressor.new(@client, model: current_model)
|
|
105
|
+
true
|
|
106
|
+
else
|
|
107
|
+
false
|
|
157
108
|
end
|
|
109
|
+
end
|
|
158
110
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
end
|
|
111
|
+
# Get list of available model names
|
|
112
|
+
def available_models
|
|
113
|
+
@config.model_names
|
|
163
114
|
end
|
|
164
115
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
116
|
+
# Get current model configuration info
|
|
117
|
+
def current_model_info
|
|
118
|
+
model = @config.current_model
|
|
119
|
+
return nil unless model
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
name: model["name"],
|
|
123
|
+
model: model["model"],
|
|
124
|
+
base_url: model["base_url"]
|
|
125
|
+
}
|
|
175
126
|
end
|
|
176
127
|
|
|
177
|
-
|
|
178
|
-
|
|
128
|
+
# Get current model name
|
|
129
|
+
private def current_model
|
|
130
|
+
@config.model_name
|
|
179
131
|
end
|
|
180
132
|
|
|
181
133
|
def run(user_input, images: [])
|
|
134
|
+
# Start new task for Time Machine
|
|
135
|
+
task_id = start_new_task
|
|
136
|
+
|
|
182
137
|
@start_time = Time.now
|
|
183
138
|
@task_cost_source = :estimated # Reset for new task
|
|
184
139
|
# Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
|
|
@@ -208,7 +163,7 @@ module Clacky
|
|
|
208
163
|
|
|
209
164
|
# Format user message with images if provided
|
|
210
165
|
user_content = format_user_content(user_input, images)
|
|
211
|
-
@messages << { role: "user", content: user_content }
|
|
166
|
+
@messages << { role: "user", content: user_content, task_id: task_id }
|
|
212
167
|
@total_tasks += 1
|
|
213
168
|
|
|
214
169
|
@hooks.trigger(:on_start, user_input)
|
|
@@ -288,6 +243,13 @@ module Clacky
|
|
|
288
243
|
end
|
|
289
244
|
|
|
290
245
|
result = build_result(:success)
|
|
246
|
+
|
|
247
|
+
# Save snapshots of modified files for Time Machine
|
|
248
|
+
if @modified_files_in_task && !@modified_files_in_task.empty?
|
|
249
|
+
save_modified_files_snapshot(@modified_files_in_task)
|
|
250
|
+
@modified_files_in_task = [] # Reset for next task
|
|
251
|
+
end
|
|
252
|
+
|
|
291
253
|
@ui&.show_complete(
|
|
292
254
|
iterations: result[:iterations],
|
|
293
255
|
cost: result[:total_cost_usd],
|
|
@@ -316,306 +278,30 @@ module Clacky
|
|
|
316
278
|
end
|
|
317
279
|
end
|
|
318
280
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
# Get the skill loader instance
|
|
322
|
-
# @return [SkillLoader]
|
|
323
|
-
def skill_loader
|
|
324
|
-
@skill_loader
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
# Load all skills from configured locations
|
|
328
|
-
# @return [Array<Skill>]
|
|
329
|
-
def load_skills
|
|
330
|
-
@skill_loader.load_all
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
# Check if input is a skill command and process it
|
|
334
|
-
# @param input [String] User input
|
|
335
|
-
# @return [Hash, nil] Returns { skill: Skill, arguments: String } if skill command, nil otherwise
|
|
336
|
-
def parse_skill_command(input)
|
|
337
|
-
# Check for slash command pattern
|
|
338
|
-
if input.start_with?("/")
|
|
339
|
-
# Extract command and arguments
|
|
340
|
-
match = input.match(%r{^/(\S+)(?:\s+(.*))?$})
|
|
341
|
-
return nil unless match
|
|
342
|
-
|
|
343
|
-
skill_name = match[1]
|
|
344
|
-
arguments = match[2] || ""
|
|
345
|
-
|
|
346
|
-
# Find skill by command
|
|
347
|
-
skill = @skill_loader.find_by_command("/#{skill_name}")
|
|
348
|
-
return nil unless skill
|
|
349
|
-
|
|
350
|
-
# Check if user can invoke this skill
|
|
351
|
-
unless skill.user_invocable?
|
|
352
|
-
return nil
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
{ skill: skill, arguments: arguments }
|
|
356
|
-
else
|
|
357
|
-
nil
|
|
358
|
-
end
|
|
359
|
-
end
|
|
360
|
-
|
|
361
|
-
# Execute a skill command
|
|
362
|
-
# @param input [String] User input (should be a skill command)
|
|
363
|
-
# @return [String] The expanded prompt with skill content
|
|
364
|
-
def execute_skill_command(input)
|
|
365
|
-
parsed = parse_skill_command(input)
|
|
366
|
-
return input unless parsed
|
|
367
|
-
|
|
368
|
-
skill = parsed[:skill]
|
|
369
|
-
arguments = parsed[:arguments]
|
|
370
|
-
|
|
371
|
-
# Process skill content with arguments
|
|
372
|
-
expanded_content = skill.process_content(arguments)
|
|
373
|
-
|
|
374
|
-
# Log skill usage
|
|
375
|
-
@ui&.log("Executing skill: #{skill.identifier}", level: :info)
|
|
376
|
-
|
|
377
|
-
expanded_content
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
# Generate skill context - loads all auto-invocable skills
|
|
381
|
-
# @return [String] Skill context to add to system prompt
|
|
382
|
-
def build_skill_context
|
|
383
|
-
# Load all auto-invocable skills
|
|
384
|
-
all_skills = @skill_loader.load_all
|
|
385
|
-
auto_invocable = all_skills.select(&:model_invocation_allowed?)
|
|
386
|
-
|
|
387
|
-
return "" if auto_invocable.empty?
|
|
388
|
-
|
|
389
|
-
context = "\n\n" + "=" * 80 + "\n"
|
|
390
|
-
context += "AVAILABLE SKILLS:\n"
|
|
391
|
-
context += "=" * 80 + "\n\n"
|
|
392
|
-
context += "CRITICAL SKILL USAGE RULES:\n"
|
|
393
|
-
context += "- When a user's request matches any available skill, this is a BLOCKING REQUIREMENT:\n"
|
|
394
|
-
context += " invoke the relevant skill tool BEFORE generating any other response about the task\n"
|
|
395
|
-
context += "- NEVER mention a skill without actually calling the skill tool\n"
|
|
396
|
-
context += "- NEVER implement the skill's functionality yourself - always delegate to the skill\n"
|
|
397
|
-
context += "- Skills provide specialized capabilities - use them instead of manual implementation\n"
|
|
398
|
-
context += "- When users reference '/<skill-name>' (e.g., '/pptx'), they are requesting a skill\n\n"
|
|
399
|
-
context += "Workflow: Use file_reader to read the SKILL.md file, then follow its instructions.\n\n"
|
|
400
|
-
context += "Available skills:\n\n"
|
|
401
|
-
|
|
402
|
-
auto_invocable.each do |skill|
|
|
403
|
-
skill_md_path = skill.directory.join("SKILL.md")
|
|
404
|
-
context += "- name: #{skill.identifier}\n"
|
|
405
|
-
context += " description: #{skill.context_description}\n"
|
|
406
|
-
context += " SKILL.md: #{skill_md_path}\n\n"
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
context += "\n"
|
|
410
|
-
context
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
# Generate session data for saving
|
|
414
|
-
# @param status [Symbol] Status of the last task: :success, :error, or :interrupted
|
|
415
|
-
# @param error_message [String] Error message if status is :error
|
|
416
|
-
def to_session_data(status: :success, error_message: nil)
|
|
417
|
-
# Get last real user message for preview (skip compressed system messages)
|
|
418
|
-
last_user_msg = @messages.reverse.find do |m|
|
|
419
|
-
m[:role] == "user" && !m[:content].to_s.start_with?("[SYSTEM]")
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
# Extract preview text from last user message
|
|
423
|
-
last_message_preview = if last_user_msg
|
|
424
|
-
content = last_user_msg[:content]
|
|
425
|
-
if content.is_a?(String)
|
|
426
|
-
# Truncate to 100 characters for preview
|
|
427
|
-
content.length > 100 ? "#{content[0..100]}..." : content
|
|
428
|
-
else
|
|
429
|
-
"User message (non-string content)"
|
|
430
|
-
end
|
|
431
|
-
else
|
|
432
|
-
"No messages"
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
stats_data = {
|
|
436
|
-
total_tasks: @total_tasks,
|
|
437
|
-
total_iterations: @iterations,
|
|
438
|
-
total_cost_usd: @total_cost.round(4),
|
|
439
|
-
duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
|
|
440
|
-
last_status: status.to_s,
|
|
441
|
-
cache_stats: @cache_stats,
|
|
442
|
-
debug_logs: @debug_logs,
|
|
443
|
-
previous_total_tokens: @previous_total_tokens
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
# Add error message if status is error
|
|
447
|
-
stats_data[:last_error] = error_message if status == :error && error_message
|
|
448
|
-
|
|
449
|
-
{
|
|
450
|
-
session_id: @session_id,
|
|
451
|
-
created_at: @created_at,
|
|
452
|
-
updated_at: Time.now.iso8601,
|
|
453
|
-
working_dir: @working_dir,
|
|
454
|
-
todos: @todos, # Include todos in session data
|
|
455
|
-
config: {
|
|
456
|
-
model: @config.model,
|
|
457
|
-
permission_mode: @config.permission_mode.to_s,
|
|
458
|
-
enable_compression: @config.enable_compression,
|
|
459
|
-
enable_prompt_caching: @config.enable_prompt_caching,
|
|
460
|
-
keep_recent_messages: @config.keep_recent_messages,
|
|
461
|
-
max_tokens: @config.max_tokens,
|
|
462
|
-
verbose: @config.verbose
|
|
463
|
-
},
|
|
464
|
-
stats: stats_data,
|
|
465
|
-
messages: @messages,
|
|
466
|
-
first_user_message: last_message_preview
|
|
467
|
-
}
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
private
|
|
471
|
-
|
|
472
|
-
def should_auto_execute?(tool_name, tool_params = {})
|
|
473
|
-
case @config.permission_mode
|
|
474
|
-
when :auto_approve
|
|
475
|
-
true
|
|
476
|
-
when :confirm_safes
|
|
477
|
-
# Use SafeShell integration for safety check
|
|
478
|
-
is_safe_operation?(tool_name, tool_params)
|
|
479
|
-
when :confirm_edits
|
|
480
|
-
!editing_tool?(tool_name)
|
|
481
|
-
when :plan_only
|
|
482
|
-
false
|
|
483
|
-
else
|
|
484
|
-
false
|
|
485
|
-
end
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
def editing_tool?(tool_name)
|
|
489
|
-
AgentConfig::EDITING_TOOLS.include?(tool_name.to_s.downcase)
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
def is_safe_operation?(tool_name, tool_params = {})
|
|
493
|
-
# For shell commands, use SafeShell to check safety
|
|
494
|
-
if tool_name.to_s.downcase == 'shell' || tool_name.to_s.downcase == 'safe_shell'
|
|
495
|
-
begin
|
|
496
|
-
require_relative 'tools/safe_shell'
|
|
497
|
-
|
|
498
|
-
# Parse tool_params if it's a JSON string
|
|
499
|
-
params = tool_params.is_a?(String) ? JSON.parse(tool_params) : tool_params
|
|
500
|
-
command = params[:command] || params['command']
|
|
501
|
-
return false unless command
|
|
502
|
-
|
|
503
|
-
# Use SafeShell to analyze the command
|
|
504
|
-
return Tools::SafeShell.command_safe_for_auto_execution?(command)
|
|
505
|
-
rescue LoadError
|
|
506
|
-
# If SafeShell not available, be conservative
|
|
507
|
-
return false
|
|
508
|
-
rescue => e
|
|
509
|
-
# In case of any error, be conservative
|
|
510
|
-
return false
|
|
511
|
-
end
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
# For non-shell tools, consider them safe for now
|
|
515
|
-
# You can extend this logic for other tools
|
|
516
|
-
!editing_tool?(tool_name)
|
|
517
|
-
end
|
|
518
|
-
|
|
519
|
-
def build_system_prompt
|
|
520
|
-
prompt = SYSTEM_PROMPT.dup
|
|
521
|
-
|
|
522
|
-
# Try to load project rules from multiple sources (in order of priority)
|
|
523
|
-
rules_files = [
|
|
524
|
-
{ path: ".clackyrules", name: ".clackyrules" },
|
|
525
|
-
{ path: ".cursorrules", name: ".cursorrules" },
|
|
526
|
-
{ path: "CLAUDE.md", name: "CLAUDE.md" }
|
|
527
|
-
]
|
|
528
|
-
|
|
529
|
-
rules_content = nil
|
|
530
|
-
rules_source = nil
|
|
531
|
-
|
|
532
|
-
rules_files.each do |file_info|
|
|
533
|
-
full_path = File.join(@working_dir, file_info[:path])
|
|
534
|
-
if File.exist?(full_path)
|
|
535
|
-
content = File.read(full_path).strip
|
|
536
|
-
unless content.empty?
|
|
537
|
-
rules_content = content
|
|
538
|
-
rules_source = file_info[:name]
|
|
539
|
-
break
|
|
540
|
-
end
|
|
541
|
-
end
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
# Add rules to prompt if found
|
|
545
|
-
if rules_content && rules_source
|
|
546
|
-
prompt += "\n\n" + "=" * 80 + "\n"
|
|
547
|
-
prompt += "PROJECT-SPECIFIC RULES (from #{rules_source}):\n"
|
|
548
|
-
prompt += "=" * 80 + "\n"
|
|
549
|
-
prompt += rules_content
|
|
550
|
-
prompt += "\n" + "=" * 80 + "\n"
|
|
551
|
-
prompt += "⚠️ IMPORTANT: Follow these project-specific rules at all times!\n"
|
|
552
|
-
prompt += "=" * 80
|
|
553
|
-
end
|
|
554
|
-
|
|
555
|
-
# Add all loaded skills to system prompt
|
|
556
|
-
skill_context = build_skill_context
|
|
557
|
-
prompt += skill_context if skill_context && !skill_context.empty?
|
|
558
|
-
|
|
559
|
-
prompt
|
|
560
|
-
end
|
|
561
|
-
|
|
562
|
-
def think
|
|
281
|
+
private def think
|
|
563
282
|
# Check API key before starting progress indicator
|
|
564
283
|
if @client.instance_variable_get(:@api_key).nil? || @client.instance_variable_get(:@api_key).empty?
|
|
565
284
|
@ui&.show_error("API key is not configured! Please run /config to set up your API key.")
|
|
566
285
|
raise AgentError, "API key is not configured"
|
|
567
286
|
end
|
|
568
287
|
|
|
569
|
-
@ui&.show_progress
|
|
570
|
-
|
|
571
288
|
# Check if compression is needed
|
|
572
|
-
compression_context = compress_messages_if_needed
|
|
289
|
+
compression_context = compress_messages_if_needed(force: false)
|
|
573
290
|
|
|
574
291
|
# If compression is triggered, insert compression message and handle it
|
|
575
292
|
if compression_context
|
|
576
|
-
#
|
|
577
|
-
@
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
# Always send tools definitions to allow multi-step tool calling
|
|
581
|
-
tools_to_send = @tool_registry.all_definitions
|
|
582
|
-
|
|
583
|
-
# Retry logic for network failures
|
|
584
|
-
max_retries = 10
|
|
585
|
-
retry_delay = 5
|
|
586
|
-
retries = 0
|
|
587
|
-
|
|
588
|
-
begin
|
|
589
|
-
response = @client.send_messages_with_tools(
|
|
590
|
-
@messages,
|
|
591
|
-
model: @config.model,
|
|
592
|
-
tools: tools_to_send,
|
|
593
|
-
max_tokens: @config.max_tokens,
|
|
594
|
-
enable_caching: @config.enable_prompt_caching
|
|
293
|
+
# Show compression start notification
|
|
294
|
+
@ui&.show_info(
|
|
295
|
+
"Message history compression starting (~#{compression_context[:original_token_count]} tokens, #{compression_context[:original_message_count]} messages) - Level #{compression_context[:compression_level]}"
|
|
595
296
|
)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
if retries <= max_retries
|
|
599
|
-
@ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
|
|
600
|
-
sleep retry_delay
|
|
601
|
-
retry
|
|
602
|
-
else
|
|
603
|
-
@ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
|
|
604
|
-
raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
605
|
-
end
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
# Clear progress indicator (change to gray and show final time)
|
|
609
|
-
@ui&.clear_progress
|
|
610
|
-
|
|
611
|
-
# If this was a compression call, rebuild message list with compressed content
|
|
612
|
-
if compression_context
|
|
297
|
+
@messages << compression_context[:compression_message]
|
|
298
|
+
response = call_llm
|
|
613
299
|
handle_compression_response(response, compression_context)
|
|
614
|
-
# Return early - don't process as normal response
|
|
615
300
|
return nil
|
|
616
301
|
end
|
|
617
302
|
|
|
618
|
-
|
|
303
|
+
# Normal LLM call
|
|
304
|
+
response = call_llm
|
|
619
305
|
|
|
620
306
|
# Handle truncated responses (when max_tokens limit is reached)
|
|
621
307
|
if response[:finish_reason] == "length"
|
|
@@ -665,7 +351,7 @@ module Clacky
|
|
|
665
351
|
end
|
|
666
352
|
|
|
667
353
|
# Add assistant response to messages
|
|
668
|
-
msg = { role: "assistant" }
|
|
354
|
+
msg = { role: "assistant", task_id: @current_task_id }
|
|
669
355
|
# Always include content field (some APIs require it even with tool_calls)
|
|
670
356
|
# Use empty string instead of null for better compatibility
|
|
671
357
|
msg[:content] = response[:content] || ""
|
|
@@ -678,7 +364,7 @@ module Clacky
|
|
|
678
364
|
response
|
|
679
365
|
end
|
|
680
366
|
|
|
681
|
-
def act(tool_calls)
|
|
367
|
+
private def act(tool_calls)
|
|
682
368
|
return { denied: false, feedback: nil, tool_results: [], awaiting_feedback: false } unless tool_calls
|
|
683
369
|
|
|
684
370
|
denied = false
|
|
@@ -695,8 +381,14 @@ module Clacky
|
|
|
695
381
|
next
|
|
696
382
|
end
|
|
697
383
|
|
|
698
|
-
#
|
|
699
|
-
|
|
384
|
+
# Show preview for edit and write tools even in auto-approve mode
|
|
385
|
+
if should_auto_execute?(call[:name], call[:arguments])
|
|
386
|
+
# In auto-approve mode, show preview for edit and write tools
|
|
387
|
+
if call[:name] == "edit" || call[:name] == "write"
|
|
388
|
+
show_tool_preview(call)
|
|
389
|
+
end
|
|
390
|
+
else
|
|
391
|
+
# Permission check (if not in auto-approve mode)
|
|
700
392
|
if @config.is_plan_only?
|
|
701
393
|
@ui&.show_info("Planned: #{call[:name]}")
|
|
702
394
|
results << build_planned_result(call)
|
|
@@ -750,6 +442,17 @@ module Clacky
|
|
|
750
442
|
args[:todos_storage] = @todos
|
|
751
443
|
end
|
|
752
444
|
|
|
445
|
+
# Special handling for InvokeSkill: inject agent and skill_loader
|
|
446
|
+
if call[:name] == "invoke_skill"
|
|
447
|
+
args[:agent] = self
|
|
448
|
+
args[:skill_loader] = @skill_loader
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Special handling for Time Machine tools: inject agent
|
|
452
|
+
if ["undo_task", "redo_task", "list_tasks"].include?(call[:name])
|
|
453
|
+
args[:agent] = self
|
|
454
|
+
end
|
|
455
|
+
|
|
753
456
|
# For safe_shell, skip safety check if user has already confirmed
|
|
754
457
|
if call[:name] == "safe_shell" || call[:name] == "shell"
|
|
755
458
|
args[:skip_safety_check] = true
|
|
@@ -766,6 +469,9 @@ module Clacky
|
|
|
766
469
|
# Clear progress if shown
|
|
767
470
|
@ui&.clear_progress if potentially_slow_tool?(call[:name], args)
|
|
768
471
|
|
|
472
|
+
# Track modified files for Time Machine snapshots
|
|
473
|
+
track_modified_files(call[:name], args)
|
|
474
|
+
|
|
769
475
|
# Hook: after_tool_use
|
|
770
476
|
@hooks.trigger(:after_tool_use, call, result)
|
|
771
477
|
|
|
@@ -814,13 +520,13 @@ module Clacky
|
|
|
814
520
|
}
|
|
815
521
|
end
|
|
816
522
|
|
|
817
|
-
def observe(response, tool_results)
|
|
523
|
+
private def observe(response, tool_results)
|
|
818
524
|
# Add tool results as messages
|
|
819
525
|
# Use Client to format results based on API type (Anthropic vs OpenAI)
|
|
820
526
|
return if tool_results.empty?
|
|
821
527
|
|
|
822
|
-
formatted_messages = @client.format_tool_results(response, tool_results, model:
|
|
823
|
-
formatted_messages.each { |msg| @messages << msg }
|
|
528
|
+
formatted_messages = @client.format_tool_results(response, tool_results, model: current_model)
|
|
529
|
+
formatted_messages.each { |msg| @messages << msg.merge(task_id: @current_task_id) }
|
|
824
530
|
end
|
|
825
531
|
|
|
826
532
|
# Interrupt the agent's current run
|
|
@@ -834,978 +540,16 @@ module Clacky
|
|
|
834
540
|
@start_time != nil && !should_stop?
|
|
835
541
|
end
|
|
836
542
|
|
|
837
|
-
def should_stop?
|
|
543
|
+
private def should_stop?
|
|
838
544
|
if @interrupted
|
|
839
545
|
@interrupted = false # Reset for next run
|
|
840
546
|
return true
|
|
841
547
|
end
|
|
842
548
|
|
|
843
|
-
|
|
844
549
|
false
|
|
845
550
|
end
|
|
846
551
|
|
|
847
|
-
|
|
848
|
-
private def potentially_slow_tool?(tool_name, args)
|
|
849
|
-
case tool_name.to_s.downcase
|
|
850
|
-
when 'shell', 'safe_shell'
|
|
851
|
-
# Check if the command is a slow command
|
|
852
|
-
command = args[:command] || args['command']
|
|
853
|
-
return false unless command
|
|
854
|
-
|
|
855
|
-
# List of slow command patterns
|
|
856
|
-
slow_patterns = [
|
|
857
|
-
/bundle\s+(install|exec\s+rspec|exec\s+rake)/,
|
|
858
|
-
/npm\s+(install|run\s+test|run\s+build)/,
|
|
859
|
-
/yarn\s+(install|test|build)/,
|
|
860
|
-
/pnpm\s+install/,
|
|
861
|
-
/cargo\s+(build|test)/,
|
|
862
|
-
/go\s+(build|test)/,
|
|
863
|
-
/make\s+(test|build)/,
|
|
864
|
-
/pytest/,
|
|
865
|
-
/jest/
|
|
866
|
-
]
|
|
867
|
-
|
|
868
|
-
slow_patterns.any? { |pattern| command.match?(pattern) }
|
|
869
|
-
when 'web_fetch', 'web_search'
|
|
870
|
-
true # Network operations can be slow
|
|
871
|
-
else
|
|
872
|
-
false # Most file operations are fast
|
|
873
|
-
end
|
|
874
|
-
end
|
|
875
|
-
|
|
876
|
-
# Build progress message for tool execution
|
|
877
|
-
private def build_tool_progress_message(tool_name, args)
|
|
878
|
-
case tool_name.to_s.downcase
|
|
879
|
-
when 'shell', 'safe_shell'
|
|
880
|
-
"Running command"
|
|
881
|
-
when 'web_fetch'
|
|
882
|
-
"Fetching web page"
|
|
883
|
-
when 'web_search'
|
|
884
|
-
"Searching web"
|
|
885
|
-
else
|
|
886
|
-
"Executing #{tool_name}"
|
|
887
|
-
end
|
|
888
|
-
end
|
|
889
|
-
|
|
890
|
-
def track_cost(usage, raw_api_usage: nil)
|
|
891
|
-
# Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
|
|
892
|
-
iteration_cost = nil
|
|
893
|
-
if usage[:api_cost]
|
|
894
|
-
@total_cost += usage[:api_cost]
|
|
895
|
-
@cost_source = :api
|
|
896
|
-
@task_cost_source = :api
|
|
897
|
-
iteration_cost = usage[:api_cost]
|
|
898
|
-
@ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
|
|
899
|
-
else
|
|
900
|
-
# Priority 2: Calculate from tokens using ModelPricing
|
|
901
|
-
result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
|
|
902
|
-
cost = result[:cost]
|
|
903
|
-
pricing_source = result[:source]
|
|
904
|
-
|
|
905
|
-
@total_cost += cost
|
|
906
|
-
iteration_cost = cost
|
|
907
|
-
# Map pricing source to cost source: :price or :default
|
|
908
|
-
@cost_source = pricing_source
|
|
909
|
-
@task_cost_source = pricing_source
|
|
910
|
-
|
|
911
|
-
if @config.verbose
|
|
912
|
-
source_label = pricing_source == :price ? "model pricing" : "default pricing"
|
|
913
|
-
@ui&.log("Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}", level: :debug)
|
|
914
|
-
@ui&.log("Usage breakdown: prompt=#{usage[:prompt_tokens]}, completion=#{usage[:completion_tokens]}, cache_write=#{usage[:cache_creation_input_tokens] || 0}, cache_read=#{usage[:cache_read_input_tokens] || 0}", level: :debug)
|
|
915
|
-
end
|
|
916
|
-
end
|
|
917
|
-
|
|
918
|
-
# Display token usage statistics for this iteration
|
|
919
|
-
display_iteration_tokens(usage, iteration_cost)
|
|
920
|
-
|
|
921
|
-
# Track cache usage statistics (global)
|
|
922
|
-
@cache_stats[:total_requests] += 1
|
|
923
|
-
|
|
924
|
-
if usage[:cache_creation_input_tokens]
|
|
925
|
-
@cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
|
|
926
|
-
end
|
|
927
|
-
|
|
928
|
-
if usage[:cache_read_input_tokens]
|
|
929
|
-
@cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
|
|
930
|
-
@cache_stats[:cache_hit_requests] += 1
|
|
931
|
-
end
|
|
932
|
-
|
|
933
|
-
# Store raw API usage samples (keep last 3 for debugging)
|
|
934
|
-
if raw_api_usage
|
|
935
|
-
@cache_stats[:raw_api_usage_samples] ||= []
|
|
936
|
-
@cache_stats[:raw_api_usage_samples] << raw_api_usage
|
|
937
|
-
@cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
|
|
938
|
-
end
|
|
939
|
-
|
|
940
|
-
# Track cache usage for current task
|
|
941
|
-
if @task_cache_stats
|
|
942
|
-
@task_cache_stats[:total_requests] += 1
|
|
943
|
-
|
|
944
|
-
if usage[:cache_creation_input_tokens]
|
|
945
|
-
@task_cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
|
|
946
|
-
end
|
|
947
|
-
|
|
948
|
-
if usage[:cache_read_input_tokens]
|
|
949
|
-
@task_cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
|
|
950
|
-
@task_cache_stats[:cache_hit_requests] += 1
|
|
951
|
-
end
|
|
952
|
-
end
|
|
953
|
-
end
|
|
954
|
-
|
|
955
|
-
# Display token usage for current iteration
|
|
956
|
-
private def display_iteration_tokens(usage, cost)
|
|
957
|
-
prompt_tokens = usage[:prompt_tokens] || 0
|
|
958
|
-
completion_tokens = usage[:completion_tokens] || 0
|
|
959
|
-
total_tokens = usage[:total_tokens] || (prompt_tokens + completion_tokens)
|
|
960
|
-
cache_write = usage[:cache_creation_input_tokens] || 0
|
|
961
|
-
cache_read = usage[:cache_read_input_tokens] || 0
|
|
962
|
-
|
|
963
|
-
# Calculate token delta from previous iteration
|
|
964
|
-
delta_tokens = total_tokens - @previous_total_tokens
|
|
965
|
-
@previous_total_tokens = total_tokens # Update for next iteration
|
|
966
|
-
|
|
967
|
-
# Prepare data for UI to format and display
|
|
968
|
-
token_data = {
|
|
969
|
-
delta_tokens: delta_tokens,
|
|
970
|
-
prompt_tokens: prompt_tokens,
|
|
971
|
-
completion_tokens: completion_tokens,
|
|
972
|
-
total_tokens: total_tokens,
|
|
973
|
-
cache_write: cache_write,
|
|
974
|
-
cache_read: cache_read,
|
|
975
|
-
cost: cost
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
# Let UI handle formatting and display
|
|
979
|
-
@ui&.show_token_usage(token_data)
|
|
980
|
-
end
|
|
981
|
-
|
|
982
|
-
# Estimate token count for a message content
|
|
983
|
-
# Simple approximation: characters / 4 (English text)
|
|
984
|
-
# For Chinese/other languages, characters / 2 is more accurate
|
|
985
|
-
# This is a rough estimate for compression triggering purposes
|
|
986
|
-
private def estimate_tokens(content)
|
|
987
|
-
return 0 if content.nil?
|
|
988
|
-
|
|
989
|
-
text = if content.is_a?(String)
|
|
990
|
-
content
|
|
991
|
-
elsif content.is_a?(Array)
|
|
992
|
-
# Handle content arrays (e.g., with images)
|
|
993
|
-
# Add safety check to prevent nil.compact error
|
|
994
|
-
mapped = content.map { |c| c[:text] if c.is_a?(Hash) }
|
|
995
|
-
(mapped || []).compact.join
|
|
996
|
-
else
|
|
997
|
-
content.to_s
|
|
998
|
-
end
|
|
999
|
-
|
|
1000
|
-
return 0 if text.empty?
|
|
1001
|
-
|
|
1002
|
-
# Detect language mix - count non-ASCII characters
|
|
1003
|
-
ascii_count = text.bytes.count { |b| b < 128 }
|
|
1004
|
-
total_bytes = text.bytes.length
|
|
1005
|
-
|
|
1006
|
-
# Mix ratio (1.0 = all English, 0.5 = all Chinese)
|
|
1007
|
-
mix_ratio = total_bytes > 0 ? ascii_count.to_f / total_bytes : 1.0
|
|
1008
|
-
|
|
1009
|
-
# English: ~4 chars/token, Chinese: ~2 chars/token
|
|
1010
|
-
base_chars_per_token = mix_ratio * 4 + (1 - mix_ratio) * 2
|
|
1011
|
-
|
|
1012
|
-
(text.length / base_chars_per_token).to_i + 50 # Add overhead for message structure
|
|
1013
|
-
end
|
|
1014
|
-
|
|
1015
|
-
# Calculate total token count for all messages
|
|
1016
|
-
# Returns estimated tokens and breakdown by category
|
|
1017
|
-
private def total_message_tokens
|
|
1018
|
-
system_tokens = 0
|
|
1019
|
-
user_tokens = 0
|
|
1020
|
-
assistant_tokens = 0
|
|
1021
|
-
tool_tokens = 0
|
|
1022
|
-
summary_tokens = 0
|
|
1023
|
-
|
|
1024
|
-
@messages.each do |msg|
|
|
1025
|
-
tokens = estimate_tokens(msg[:content])
|
|
1026
|
-
case msg[:role]
|
|
1027
|
-
when "system"
|
|
1028
|
-
system_tokens += tokens
|
|
1029
|
-
when "user"
|
|
1030
|
-
user_tokens += tokens
|
|
1031
|
-
when "assistant"
|
|
1032
|
-
assistant_tokens += tokens
|
|
1033
|
-
when "tool"
|
|
1034
|
-
tool_tokens += tokens
|
|
1035
|
-
end
|
|
1036
|
-
end
|
|
1037
|
-
|
|
1038
|
-
{
|
|
1039
|
-
total: system_tokens + user_tokens + assistant_tokens + tool_tokens,
|
|
1040
|
-
system: system_tokens,
|
|
1041
|
-
user: user_tokens,
|
|
1042
|
-
assistant: assistant_tokens,
|
|
1043
|
-
tool: tool_tokens
|
|
1044
|
-
}
|
|
1045
|
-
end
|
|
1046
|
-
|
|
1047
|
-
# Compression thresholds
|
|
1048
|
-
COMPRESSION_THRESHOLD = 150_000 # Trigger compression when exceeding this (in tokens)
|
|
1049
|
-
MESSAGE_COUNT_THRESHOLD = 150 # Trigger compression when exceeding this (in message count)
|
|
1050
|
-
MAX_RECENT_MESSAGES = 20 # Keep this many recent message pairs intact
|
|
1051
|
-
TARGET_COMPRESSED_TOKENS = 10_000 # Target size after compression
|
|
1052
|
-
|
|
1053
|
-
def compress_messages_if_needed
|
|
1054
|
-
# Check if compression is enabled
|
|
1055
|
-
return nil unless @config.enable_compression
|
|
1056
|
-
|
|
1057
|
-
# Calculate total tokens and message count
|
|
1058
|
-
token_counts = total_message_tokens
|
|
1059
|
-
total_tokens = token_counts[:total]
|
|
1060
|
-
message_count = @messages.length
|
|
1061
|
-
|
|
1062
|
-
# Check if we should trigger compression
|
|
1063
|
-
# Either: token count exceeds threshold OR message count exceeds threshold
|
|
1064
|
-
token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
|
|
1065
|
-
message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD
|
|
1066
|
-
|
|
1067
|
-
# Only compress if we exceed at least one threshold
|
|
1068
|
-
return nil unless token_threshold_exceeded || message_count_exceeded
|
|
1069
|
-
|
|
1070
|
-
# Calculate how much we need to reduce
|
|
1071
|
-
reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS
|
|
1072
|
-
|
|
1073
|
-
# Don't compress if reduction is minimal (< 10% of current size)
|
|
1074
|
-
# Only apply this check when triggered by token threshold
|
|
1075
|
-
if token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
|
|
1076
|
-
return nil
|
|
1077
|
-
end
|
|
1078
|
-
|
|
1079
|
-
# If only message count threshold is exceeded, force compression
|
|
1080
|
-
# to keep conversation history manageable
|
|
1081
|
-
|
|
1082
|
-
# Calculate target size for recent messages based on compression level
|
|
1083
|
-
target_recent_count = calculate_target_recent_count(reduction_needed)
|
|
1084
|
-
|
|
1085
|
-
# Increment compression level for progressive summarization
|
|
1086
|
-
@compression_level += 1
|
|
1087
|
-
|
|
1088
|
-
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
1089
|
-
recent_messages = get_recent_messages_with_tool_pairs(@messages, target_recent_count)
|
|
1090
|
-
recent_messages = [] if recent_messages.nil?
|
|
1091
|
-
|
|
1092
|
-
# Build compression instruction message (to be inserted into conversation)
|
|
1093
|
-
compression_message = @message_compressor.build_compression_message(@messages, recent_messages: recent_messages)
|
|
1094
|
-
|
|
1095
|
-
return nil if compression_message.nil?
|
|
1096
|
-
|
|
1097
|
-
# Show compression start notification
|
|
1098
|
-
@ui&.show_info(
|
|
1099
|
-
"Message history compression starting (~#{total_tokens} tokens, #{@messages.length} messages) - Level #{@compression_level}"
|
|
1100
|
-
)
|
|
1101
|
-
|
|
1102
|
-
# Return compression context for agent to handle
|
|
1103
|
-
{
|
|
1104
|
-
compression_message: compression_message,
|
|
1105
|
-
recent_messages: recent_messages,
|
|
1106
|
-
original_token_count: total_tokens,
|
|
1107
|
-
original_message_count: @messages.length,
|
|
1108
|
-
compression_level: @compression_level
|
|
1109
|
-
}
|
|
1110
|
-
end
|
|
1111
|
-
|
|
1112
|
-
# Handle compression response and rebuild message list
|
|
1113
|
-
def handle_compression_response(response, compression_context)
|
|
1114
|
-
# Extract compressed content from response
|
|
1115
|
-
compressed_content = response[:content]
|
|
1116
|
-
|
|
1117
|
-
# Track cost for compression call
|
|
1118
|
-
track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
|
|
1119
|
-
|
|
1120
|
-
# Rebuild message list with compression
|
|
1121
|
-
# Note: we need to remove the compression instruction message we just added
|
|
1122
|
-
original_messages = @messages[0..-2] # All except the last (compression instruction)
|
|
1123
|
-
|
|
1124
|
-
@messages = @message_compressor.rebuild_with_compression(
|
|
1125
|
-
compressed_content,
|
|
1126
|
-
original_messages: original_messages,
|
|
1127
|
-
recent_messages: compression_context[:recent_messages]
|
|
1128
|
-
)
|
|
1129
|
-
|
|
1130
|
-
# Track this compression
|
|
1131
|
-
@compressed_summaries << {
|
|
1132
|
-
level: compression_context[:compression_level],
|
|
1133
|
-
message_count: compression_context[:original_message_count],
|
|
1134
|
-
timestamp: Time.now.iso8601,
|
|
1135
|
-
strategy: :insert_then_compress
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
final_tokens = total_message_tokens[:total]
|
|
1139
|
-
|
|
1140
|
-
# Show compression info
|
|
1141
|
-
@ui&.show_info(
|
|
1142
|
-
"History compressed (~#{compression_context[:original_token_count]} -> ~#{final_tokens} tokens, " \
|
|
1143
|
-
"level #{compression_context[:compression_level]})"
|
|
1144
|
-
)
|
|
1145
|
-
end
|
|
1146
|
-
|
|
1147
|
-
# Calculate how many recent messages to keep based on how much we need to compress
|
|
1148
|
-
private def calculate_target_recent_count(reduction_needed)
|
|
1149
|
-
# We want recent messages to be around 20-30% of the total target
|
|
1150
|
-
# This keeps the context window useful without being too large
|
|
1151
|
-
tokens_per_message = 500 # Average estimate for a message with content
|
|
1152
|
-
|
|
1153
|
-
# Target recent messages budget (~20% of target compressed size)
|
|
1154
|
-
recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
|
|
1155
|
-
target_messages = (recent_budget / tokens_per_message).to_i
|
|
1156
|
-
|
|
1157
|
-
# Clamp to reasonable bounds
|
|
1158
|
-
[[target_messages, 20].max, MAX_RECENT_MESSAGES].min
|
|
1159
|
-
end
|
|
1160
|
-
|
|
1161
|
-
# Generate hierarchical summary based on compression level
|
|
1162
|
-
# Level 1: Detailed summary with files, decisions, features
|
|
1163
|
-
# Level 2: Concise summary with key items
|
|
1164
|
-
# Level 3: Minimal summary (just project type)
|
|
1165
|
-
# Level 4+: Ultra-minimal (single line)
|
|
1166
|
-
private def generate_hierarchical_summary(messages)
|
|
1167
|
-
level = @compression_level
|
|
1168
|
-
|
|
1169
|
-
# Extract key information from messages
|
|
1170
|
-
extracted = extract_key_information(messages)
|
|
1171
|
-
|
|
1172
|
-
summary_text = case level
|
|
1173
|
-
when 1
|
|
1174
|
-
generate_level1_summary(extracted)
|
|
1175
|
-
when 2
|
|
1176
|
-
generate_level2_summary(extracted)
|
|
1177
|
-
when 3
|
|
1178
|
-
generate_level3_summary(extracted)
|
|
1179
|
-
else
|
|
1180
|
-
generate_level4_summary(extracted)
|
|
1181
|
-
end
|
|
1182
|
-
|
|
1183
|
-
{
|
|
1184
|
-
role: "user",
|
|
1185
|
-
content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
|
|
1186
|
-
system_injected: true,
|
|
1187
|
-
compression_level: level
|
|
1188
|
-
}
|
|
1189
|
-
end
|
|
1190
|
-
|
|
1191
|
-
# Extract key information from messages for summarization
|
|
1192
|
-
private def extract_key_information(messages)
|
|
1193
|
-
return empty_extraction_data if messages.nil?
|
|
1194
|
-
|
|
1195
|
-
{
|
|
1196
|
-
# Message counts
|
|
1197
|
-
user_msgs: messages.count { |m| m[:role] == "user" },
|
|
1198
|
-
assistant_msgs: messages.count { |m| m[:role] == "assistant" },
|
|
1199
|
-
tool_msgs: messages.count { |m| m[:role] == "tool" },
|
|
1200
|
-
|
|
1201
|
-
# Tools used
|
|
1202
|
-
tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },
|
|
1203
|
-
|
|
1204
|
-
# Files created/modified
|
|
1205
|
-
files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
|
|
1206
|
-
files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },
|
|
1207
|
-
|
|
1208
|
-
# Key decisions (limit to first 5)
|
|
1209
|
-
decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),
|
|
1210
|
-
|
|
1211
|
-
# Completed tasks (from TODO results)
|
|
1212
|
-
completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },
|
|
1213
|
-
|
|
1214
|
-
# Current in-progress work
|
|
1215
|
-
in_progress: find_in_progress(messages),
|
|
1216
|
-
|
|
1217
|
-
# Key results from shell commands
|
|
1218
|
-
shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
|
|
1219
|
-
}
|
|
1220
|
-
end
|
|
1221
|
-
|
|
1222
|
-
# Helper: safely extract from messages with proper nil handling
|
|
1223
|
-
private def extract_from_messages(messages, role_filter = nil, &block)
|
|
1224
|
-
return [] if messages.nil?
|
|
1225
|
-
|
|
1226
|
-
results = messages
|
|
1227
|
-
.select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
|
|
1228
|
-
.map(&block)
|
|
1229
|
-
.compact
|
|
1230
|
-
|
|
1231
|
-
# Flatten if we have nested arrays (from methods returning arrays of items)
|
|
1232
|
-
results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
|
|
1233
|
-
end
|
|
1234
|
-
|
|
1235
|
-
# Helper: extract tool names from tool_calls
|
|
1236
|
-
private def extract_tool_names(tool_calls)
|
|
1237
|
-
return [] unless tool_calls.is_a?(Array)
|
|
1238
|
-
tool_calls.map { |tc| tc.dig(:function, :name) }
|
|
1239
|
-
end
|
|
1240
|
-
|
|
1241
|
-
# Helper: filter write results by action
|
|
1242
|
-
private def filter_write_results(result, action)
|
|
1243
|
-
result && result[:action] == action ? result[:file] : nil
|
|
1244
|
-
end
|
|
1245
|
-
|
|
1246
|
-
# Helper: filter todo results by status
|
|
1247
|
-
private def filter_todo_results(result, status)
|
|
1248
|
-
result && result[:status] == status ? result[:task] : nil
|
|
1249
|
-
end
|
|
1250
|
-
|
|
1251
|
-
# Helper: extract decision text from content (returns array of decisions or empty array)
|
|
1252
|
-
private def extract_decision_text(content)
|
|
1253
|
-
return [] unless content.is_a?(String)
|
|
1254
|
-
return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")
|
|
1255
|
-
|
|
1256
|
-
sentences = content.split(/[.!?]/).select do |s|
|
|
1257
|
-
s.include?("decision") || s.include?("chose") || s.include?("using") ||
|
|
1258
|
-
s.include?("decided") || s.include?("will use") || s.include?("selected")
|
|
1259
|
-
end
|
|
1260
|
-
sentences.map(&:strip).map { |s| s[0..100] }
|
|
1261
|
-
end
|
|
1262
|
-
|
|
1263
|
-
# Helper: find in-progress task
|
|
1264
|
-
private def find_in_progress(messages)
|
|
1265
|
-
return nil if messages.nil?
|
|
1266
|
-
|
|
1267
|
-
messages.reverse_each do |m|
|
|
1268
|
-
if m[:role] == "tool"
|
|
1269
|
-
content = m[:content].to_s
|
|
1270
|
-
if content.include?("in progress") || content.include?("working on")
|
|
1271
|
-
return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
|
|
1272
|
-
end
|
|
1273
|
-
end
|
|
1274
|
-
end
|
|
1275
|
-
nil
|
|
1276
|
-
end
|
|
1277
|
-
|
|
1278
|
-
# Helper: empty extraction data
|
|
1279
|
-
private def empty_extraction_data
|
|
1280
|
-
{
|
|
1281
|
-
user_msgs: 0,
|
|
1282
|
-
assistant_msgs: 0,
|
|
1283
|
-
tool_msgs: 0,
|
|
1284
|
-
tools_used: [],
|
|
1285
|
-
files_created: [],
|
|
1286
|
-
files_modified: [],
|
|
1287
|
-
decisions: [],
|
|
1288
|
-
completed_tasks: [],
|
|
1289
|
-
in_progress: nil,
|
|
1290
|
-
shell_results: []
|
|
1291
|
-
}
|
|
1292
|
-
end
|
|
1293
|
-
|
|
1294
|
-
private def parse_write_result(content)
|
|
1295
|
-
return nil unless content.is_a?(String)
|
|
1296
|
-
|
|
1297
|
-
# Check for "Created: path" or "Updated: path" patterns
|
|
1298
|
-
if content.include?("Created:")
|
|
1299
|
-
{ action: "created", file: content[/Created:\s*(.+)/, 1]&.strip }
|
|
1300
|
-
elsif content.include?("Updated:") || content.include?("modified")
|
|
1301
|
-
{ action: "modified", file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
|
|
1302
|
-
else
|
|
1303
|
-
nil
|
|
1304
|
-
end
|
|
1305
|
-
end
|
|
1306
|
-
|
|
1307
|
-
private def parse_todo_result(content)
|
|
1308
|
-
return nil unless content.is_a?(String)
|
|
1309
|
-
|
|
1310
|
-
if content.include?("completed")
|
|
1311
|
-
{ status: "completed", task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
|
|
1312
|
-
elsif content.include?("added")
|
|
1313
|
-
{ status: "added", task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
|
|
1314
|
-
else
|
|
1315
|
-
nil
|
|
1316
|
-
end
|
|
1317
|
-
end
|
|
1318
|
-
|
|
1319
|
-
private def parse_shell_result(content)
|
|
1320
|
-
return nil unless content.is_a?(String)
|
|
1321
|
-
|
|
1322
|
-
if content.include?("passed") || content.include?("success")
|
|
1323
|
-
"tests passed"
|
|
1324
|
-
elsif content.include?("failed") || content.include?("error")
|
|
1325
|
-
"command failed"
|
|
1326
|
-
elsif content =~ /bundle install|npm install|go mod download/
|
|
1327
|
-
"dependencies installed"
|
|
1328
|
-
elsif content.include?("Installed")
|
|
1329
|
-
content[/Installed:\s*(.+)/, 1]&.strip
|
|
1330
|
-
else
|
|
1331
|
-
nil
|
|
1332
|
-
end
|
|
1333
|
-
end
|
|
1334
|
-
|
|
1335
|
-
# Level 1: Detailed summary (for first compression)
|
|
1336
|
-
private def generate_level1_summary(data)
|
|
1337
|
-
parts = []
|
|
1338
|
-
|
|
1339
|
-
parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"
|
|
1340
|
-
|
|
1341
|
-
# Files created
|
|
1342
|
-
if data[:files_created].any?
|
|
1343
|
-
files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
|
|
1344
|
-
parts << "Created: #{files_list}"
|
|
1345
|
-
end
|
|
1346
|
-
|
|
1347
|
-
# Files modified
|
|
1348
|
-
if data[:files_modified].any?
|
|
1349
|
-
files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
|
|
1350
|
-
parts << "Modified: #{files_list}"
|
|
1351
|
-
end
|
|
1352
|
-
|
|
1353
|
-
# Completed tasks
|
|
1354
|
-
if data[:completed_tasks].any?
|
|
1355
|
-
tasks_list = data[:completed_tasks].first(3).join(", ")
|
|
1356
|
-
parts << "Completed: #{tasks_list}"
|
|
1357
|
-
end
|
|
1358
|
-
|
|
1359
|
-
# In progress
|
|
1360
|
-
if data[:in_progress]
|
|
1361
|
-
parts << "In Progress: #{data[:in_progress]}"
|
|
1362
|
-
end
|
|
1363
|
-
|
|
1364
|
-
# Key decisions
|
|
1365
|
-
if data[:decisions].any?
|
|
1366
|
-
decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
|
|
1367
|
-
parts << "Decisions: #{decisions_text}"
|
|
1368
|
-
end
|
|
1369
|
-
|
|
1370
|
-
# Tools used
|
|
1371
|
-
if data[:tools_used].any?
|
|
1372
|
-
parts << "Tools: #{data[:tools_used].join(', ')}"
|
|
1373
|
-
end
|
|
1374
|
-
|
|
1375
|
-
parts << "Continuing with recent conversation..."
|
|
1376
|
-
parts.join("\n")
|
|
1377
|
-
end
|
|
1378
|
-
|
|
1379
|
-
# Level 2: Concise summary (for second compression)
|
|
1380
|
-
private def generate_level2_summary(data)
|
|
1381
|
-
parts = []
|
|
1382
|
-
|
|
1383
|
-
parts << "Conversation summary:"
|
|
1384
|
-
|
|
1385
|
-
# Key files (limit to most important)
|
|
1386
|
-
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
1387
|
-
if all_files.any?
|
|
1388
|
-
key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
|
|
1389
|
-
parts << "Files: #{key_files}"
|
|
1390
|
-
end
|
|
1391
|
-
|
|
1392
|
-
# Key accomplishments
|
|
1393
|
-
accomplishments = []
|
|
1394
|
-
accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
|
|
1395
|
-
accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
|
|
1396
|
-
accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]
|
|
1397
|
-
|
|
1398
|
-
parts << accomplishments.join(", ") if accomplishments.any?
|
|
1399
|
-
|
|
1400
|
-
parts << "Recent context follows..."
|
|
1401
|
-
parts.join("\n")
|
|
1402
|
-
end
|
|
1403
|
-
|
|
1404
|
-
# Level 3: Minimal summary (for third compression)
|
|
1405
|
-
private def generate_level3_summary(data)
|
|
1406
|
-
parts = []
|
|
1407
|
-
|
|
1408
|
-
parts << "Project progress:"
|
|
1409
|
-
|
|
1410
|
-
# Just counts and key items
|
|
1411
|
-
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
1412
|
-
parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"
|
|
1413
|
-
|
|
1414
|
-
if data[:in_progress]
|
|
1415
|
-
parts << "Currently: #{data[:in_progress]}"
|
|
1416
|
-
end
|
|
1417
|
-
|
|
1418
|
-
parts << "See recent messages for details."
|
|
1419
|
-
parts.join("\n")
|
|
1420
|
-
end
|
|
1421
|
-
|
|
1422
|
-
# Level 4: Ultra-minimal summary (for fourth+ compression)
|
|
1423
|
-
private def generate_level4_summary(data)
|
|
1424
|
-
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
1425
|
-
"Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
|
|
1426
|
-
end
|
|
1427
|
-
|
|
1428
|
-
def get_recent_messages_with_tool_pairs(messages, count)
|
|
1429
|
-
# This method ensures that assistant messages with tool_calls are always kept together
|
|
1430
|
-
# with ALL their corresponding tool_results, maintaining the correct order.
|
|
1431
|
-
# This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
|
|
1432
|
-
|
|
1433
|
-
return [] if messages.nil? || messages.empty?
|
|
1434
|
-
|
|
1435
|
-
# Track which messages to include
|
|
1436
|
-
messages_to_include = Set.new
|
|
1437
|
-
|
|
1438
|
-
# Start from the end and work backwards
|
|
1439
|
-
i = messages.size - 1
|
|
1440
|
-
messages_collected = 0
|
|
1441
|
-
|
|
1442
|
-
while i >= 0 && messages_collected < count
|
|
1443
|
-
msg = messages[i]
|
|
1444
|
-
|
|
1445
|
-
# Skip if already marked for inclusion
|
|
1446
|
-
if messages_to_include.include?(i)
|
|
1447
|
-
i -= 1
|
|
1448
|
-
next
|
|
1449
|
-
end
|
|
1450
|
-
|
|
1451
|
-
# Mark this message for inclusion
|
|
1452
|
-
messages_to_include.add(i)
|
|
1453
|
-
messages_collected += 1
|
|
1454
|
-
|
|
1455
|
-
# If this is an assistant message with tool_calls, we MUST include ALL corresponding tool results
|
|
1456
|
-
if msg[:role] == "assistant" && msg[:tool_calls]
|
|
1457
|
-
tool_call_ids = msg[:tool_calls].map { |tc| tc[:id] }
|
|
1458
|
-
|
|
1459
|
-
# Find all tool results that belong to this assistant message
|
|
1460
|
-
# They should be in the messages immediately following this assistant message
|
|
1461
|
-
j = i + 1
|
|
1462
|
-
while j < messages.size
|
|
1463
|
-
next_msg = messages[j]
|
|
1464
|
-
|
|
1465
|
-
# If we find a tool result for one of our tool_calls, include it
|
|
1466
|
-
if next_msg[:role] == "tool" && tool_call_ids.include?(next_msg[:tool_call_id])
|
|
1467
|
-
messages_to_include.add(j)
|
|
1468
|
-
elsif next_msg[:role] != "tool"
|
|
1469
|
-
# Stop when we hit a non-tool message (start of next turn)
|
|
1470
|
-
break
|
|
1471
|
-
end
|
|
1472
|
-
|
|
1473
|
-
j += 1
|
|
1474
|
-
end
|
|
1475
|
-
end
|
|
1476
|
-
|
|
1477
|
-
# If this is a tool result, make sure its assistant message is also included
|
|
1478
|
-
if msg[:role] == "tool"
|
|
1479
|
-
# Find the corresponding assistant message
|
|
1480
|
-
j = i - 1
|
|
1481
|
-
while j >= 0
|
|
1482
|
-
prev_msg = messages[j]
|
|
1483
|
-
if prev_msg[:role] == "assistant" && prev_msg[:tool_calls]
|
|
1484
|
-
# Check if this assistant has the matching tool_call
|
|
1485
|
-
has_matching_call = prev_msg[:tool_calls].any? { |tc| tc[:id] == msg[:tool_call_id] }
|
|
1486
|
-
if has_matching_call
|
|
1487
|
-
unless messages_to_include.include?(j)
|
|
1488
|
-
messages_to_include.add(j)
|
|
1489
|
-
messages_collected += 1
|
|
1490
|
-
end
|
|
1491
|
-
|
|
1492
|
-
# Also include all other tool results for this assistant message
|
|
1493
|
-
tool_call_ids = prev_msg[:tool_calls].map { |tc| tc[:id] }
|
|
1494
|
-
k = j + 1
|
|
1495
|
-
while k < messages.size
|
|
1496
|
-
result_msg = messages[k]
|
|
1497
|
-
if result_msg[:role] == "tool" && tool_call_ids.include?(result_msg[:tool_call_id])
|
|
1498
|
-
messages_to_include.add(k)
|
|
1499
|
-
elsif result_msg[:role] != "tool"
|
|
1500
|
-
break
|
|
1501
|
-
end
|
|
1502
|
-
k += 1
|
|
1503
|
-
end
|
|
1504
|
-
|
|
1505
|
-
break
|
|
1506
|
-
end
|
|
1507
|
-
end
|
|
1508
|
-
j -= 1
|
|
1509
|
-
end
|
|
1510
|
-
end
|
|
1511
|
-
|
|
1512
|
-
i -= 1
|
|
1513
|
-
end
|
|
1514
|
-
|
|
1515
|
-
# Extract the messages in their original order
|
|
1516
|
-
messages_to_include.to_a.sort.map { |idx| messages[idx] }
|
|
1517
|
-
end
|
|
1518
|
-
|
|
1519
|
-
def confirm_tool_use?(call)
|
|
1520
|
-
# Show preview first and check for errors
|
|
1521
|
-
preview_error = show_tool_preview(call)
|
|
1522
|
-
|
|
1523
|
-
# If preview detected an error, auto-deny and provide feedback
|
|
1524
|
-
if preview_error && preview_error[:error]
|
|
1525
|
-
feedback = build_preview_error_feedback(call[:name], preview_error)
|
|
1526
|
-
return { approved: false, feedback: feedback, system_injected: true }
|
|
1527
|
-
end
|
|
1528
|
-
|
|
1529
|
-
# Request confirmation via UI
|
|
1530
|
-
if @ui
|
|
1531
|
-
prompt_text = format_tool_prompt(call)
|
|
1532
|
-
result = @ui.request_confirmation(prompt_text, default: true)
|
|
1533
|
-
|
|
1534
|
-
case result
|
|
1535
|
-
when true
|
|
1536
|
-
{ approved: true, feedback: nil }
|
|
1537
|
-
when false, nil
|
|
1538
|
-
# User denied - add visual marker based on tool type
|
|
1539
|
-
tool_name_capitalized = call[:name].capitalize
|
|
1540
|
-
@ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
|
|
1541
|
-
{ approved: false, feedback: nil }
|
|
1542
|
-
else
|
|
1543
|
-
# String feedback - also add visual marker
|
|
1544
|
-
tool_name_capitalized = call[:name].capitalize
|
|
1545
|
-
@ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
|
|
1546
|
-
{ approved: false, feedback: result.to_s }
|
|
1547
|
-
end
|
|
1548
|
-
else
|
|
1549
|
-
# Fallback: auto-approve if no UI
|
|
1550
|
-
{ approved: true, feedback: nil }
|
|
1551
|
-
end
|
|
1552
|
-
end
|
|
1553
|
-
|
|
1554
|
-
private def build_preview_error_feedback(tool_name, error_info)
|
|
1555
|
-
case tool_name
|
|
1556
|
-
when "edit"
|
|
1557
|
-
"Tool edit denied: The edit operation will fail because the old_string was not found in the file. " \
|
|
1558
|
-
"Please use file_reader to read '#{error_info[:path]}' first, " \
|
|
1559
|
-
"find the correct string to replace, and try again with the exact string (including whitespace)."
|
|
1560
|
-
else
|
|
1561
|
-
"Tool preview error: #{error_info[:error]}"
|
|
1562
|
-
end
|
|
1563
|
-
end
|
|
1564
|
-
|
|
1565
|
-
def format_tool_prompt(call)
|
|
1566
|
-
begin
|
|
1567
|
-
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
1568
|
-
|
|
1569
|
-
# Try to use tool's format_call method for better formatting
|
|
1570
|
-
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1571
|
-
if tool
|
|
1572
|
-
formatted = tool.format_call(args) rescue nil
|
|
1573
|
-
return formatted if formatted
|
|
1574
|
-
end
|
|
1575
|
-
|
|
1576
|
-
# Fallback to manual formatting for common tools
|
|
1577
|
-
case call[:name]
|
|
1578
|
-
when "edit"
|
|
1579
|
-
path = args[:path] || args[:file_path]
|
|
1580
|
-
filename = Utils::PathHelper.safe_basename(path)
|
|
1581
|
-
"Edit(#{filename})"
|
|
1582
|
-
when "write"
|
|
1583
|
-
filename = Utils::PathHelper.safe_basename(args[:path])
|
|
1584
|
-
if args[:path] && File.exist?(args[:path])
|
|
1585
|
-
"Write(#{filename}) - overwrite existing"
|
|
1586
|
-
else
|
|
1587
|
-
"Write(#{filename}) - create new"
|
|
1588
|
-
end
|
|
1589
|
-
when "shell", "safe_shell"
|
|
1590
|
-
cmd = args[:command] || ''
|
|
1591
|
-
display_cmd = cmd.length > 30 ? "#{cmd[0..27]}..." : cmd
|
|
1592
|
-
"#{call[:name]}(\"#{display_cmd}\")"
|
|
1593
|
-
else
|
|
1594
|
-
"Allow #{call[:name]}"
|
|
1595
|
-
end
|
|
1596
|
-
rescue JSON::ParserError
|
|
1597
|
-
"Allow #{call[:name]}"
|
|
1598
|
-
end
|
|
1599
|
-
end
|
|
1600
|
-
|
|
1601
|
-
def show_tool_preview(call)
|
|
1602
|
-
return nil unless @ui
|
|
1603
|
-
|
|
1604
|
-
begin
|
|
1605
|
-
args = JSON.parse(call[:arguments], symbolize_names: true)
|
|
1606
|
-
|
|
1607
|
-
preview_error = nil
|
|
1608
|
-
case call[:name]
|
|
1609
|
-
when "write"
|
|
1610
|
-
preview_error = show_write_preview(args)
|
|
1611
|
-
when "edit"
|
|
1612
|
-
preview_error = show_edit_preview(args)
|
|
1613
|
-
when "shell", "safe_shell"
|
|
1614
|
-
show_shell_preview(args)
|
|
1615
|
-
else
|
|
1616
|
-
# For other tools, show formatted arguments
|
|
1617
|
-
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1618
|
-
if tool
|
|
1619
|
-
formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
|
|
1620
|
-
@ui&.show_tool_args(formatted)
|
|
1621
|
-
else
|
|
1622
|
-
@ui&.show_tool_args(call[:arguments])
|
|
1623
|
-
end
|
|
1624
|
-
end
|
|
1625
|
-
|
|
1626
|
-
preview_error
|
|
1627
|
-
rescue JSON::ParserError
|
|
1628
|
-
@ui&.show_tool_args(call[:arguments])
|
|
1629
|
-
nil
|
|
1630
|
-
end
|
|
1631
|
-
end
|
|
1632
|
-
|
|
1633
|
-
def show_write_preview(args)
|
|
1634
|
-
path = args[:path] || args['path']
|
|
1635
|
-
new_content = args[:content] || args['content'] || ""
|
|
1636
|
-
|
|
1637
|
-
is_new_file = !(path && File.exist?(path))
|
|
1638
|
-
@ui&.show_file_write_preview(path, is_new_file: is_new_file)
|
|
1639
|
-
|
|
1640
|
-
if is_new_file
|
|
1641
|
-
@ui&.show_diff("", new_content, max_lines: 50)
|
|
1642
|
-
else
|
|
1643
|
-
old_content = File.read(path)
|
|
1644
|
-
@ui&.show_diff(old_content, new_content, max_lines: 50)
|
|
1645
|
-
end
|
|
1646
|
-
nil
|
|
1647
|
-
end
|
|
1648
|
-
|
|
1649
|
-
def show_edit_preview(args)
|
|
1650
|
-
path = args[:path] || args[:file_path] || args['path'] || args['file_path']
|
|
1651
|
-
old_string = args[:old_string] || args['old_string'] || ""
|
|
1652
|
-
new_string = args[:new_string] || args['new_string'] || ""
|
|
1653
|
-
|
|
1654
|
-
@ui&.show_file_edit_preview(path)
|
|
1655
|
-
|
|
1656
|
-
if !path || path.empty?
|
|
1657
|
-
@ui&.show_file_error("No file path provided")
|
|
1658
|
-
return { error: "No file path provided for edit operation" }
|
|
1659
|
-
end
|
|
1660
|
-
|
|
1661
|
-
unless File.exist?(path)
|
|
1662
|
-
@ui&.show_file_error("File not found: #{path}")
|
|
1663
|
-
return { error: "File not found: #{path}", path: path }
|
|
1664
|
-
end
|
|
1665
|
-
|
|
1666
|
-
if old_string.empty?
|
|
1667
|
-
@ui&.show_file_error("No old_string provided (nothing to replace)")
|
|
1668
|
-
return { error: "No old_string provided (nothing to replace)" }
|
|
1669
|
-
end
|
|
1670
|
-
|
|
1671
|
-
file_content = File.read(path)
|
|
1672
|
-
|
|
1673
|
-
# Check if old_string exists in file
|
|
1674
|
-
unless file_content.include?(old_string)
|
|
1675
|
-
# Log debug info for troubleshooting
|
|
1676
|
-
@debug_logs << {
|
|
1677
|
-
timestamp: Time.now.iso8601,
|
|
1678
|
-
event: "edit_preview_failed",
|
|
1679
|
-
path: path,
|
|
1680
|
-
looking_for: old_string[0..500],
|
|
1681
|
-
file_content_preview: file_content[0..1000],
|
|
1682
|
-
file_size: file_content.length
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
@ui&.show_file_error("Edit file error")
|
|
1686
|
-
return {
|
|
1687
|
-
error: "String to replace not found in file",
|
|
1688
|
-
path: path,
|
|
1689
|
-
looking_for: old_string[0..200]
|
|
1690
|
-
}
|
|
1691
|
-
end
|
|
1692
|
-
|
|
1693
|
-
new_content = file_content.sub(old_string, new_string)
|
|
1694
|
-
@ui&.show_diff(file_content, new_content, max_lines: 50)
|
|
1695
|
-
nil # No error
|
|
1696
|
-
end
|
|
1697
|
-
|
|
1698
|
-
def show_shell_preview(args)
|
|
1699
|
-
command = args[:command] || ""
|
|
1700
|
-
@ui&.show_shell_preview(command)
|
|
1701
|
-
nil
|
|
1702
|
-
end
|
|
1703
|
-
|
|
1704
|
-
def build_success_result(call, result)
|
|
1705
|
-
# Try to get tool instance to use its format_result_for_llm method
|
|
1706
|
-
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1707
|
-
|
|
1708
|
-
formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
|
|
1709
|
-
# Tool provides a custom LLM-friendly format
|
|
1710
|
-
tool.format_result_for_llm(result)
|
|
1711
|
-
else
|
|
1712
|
-
# Fallback: use the original result
|
|
1713
|
-
result
|
|
1714
|
-
end
|
|
1715
|
-
|
|
1716
|
-
# Inject TODO reminder for non-todo_manager tools
|
|
1717
|
-
formatted_result = inject_todo_reminder(call[:name], formatted_result)
|
|
1718
|
-
|
|
1719
|
-
{
|
|
1720
|
-
id: call[:id],
|
|
1721
|
-
content: JSON.generate(formatted_result)
|
|
1722
|
-
}
|
|
1723
|
-
end
|
|
1724
|
-
|
|
1725
|
-
# Inject TODO reminder into tool results for non-todo_manager tools
|
|
1726
|
-
# This helps AI remember to mark TODOs as complete after executing tasks
|
|
1727
|
-
private def inject_todo_reminder(tool_name, result)
|
|
1728
|
-
# Skip injection for todo_manager tool itself to avoid redundancy
|
|
1729
|
-
return result if tool_name == "todo_manager"
|
|
1730
|
-
|
|
1731
|
-
# Get pending TODOs
|
|
1732
|
-
todo_tool = @tool_registry.get("todo_manager")
|
|
1733
|
-
return result unless todo_tool
|
|
1734
|
-
|
|
1735
|
-
pending_todos = begin
|
|
1736
|
-
todo_result = todo_tool.execute(action: "list", todos_storage: @todos)
|
|
1737
|
-
if todo_result.is_a?(Hash) && todo_result[:todos]
|
|
1738
|
-
todo_result[:todos].select { |t| t[:status] == "pending" }
|
|
1739
|
-
else
|
|
1740
|
-
[]
|
|
1741
|
-
end
|
|
1742
|
-
rescue
|
|
1743
|
-
[]
|
|
1744
|
-
end
|
|
1745
|
-
|
|
1746
|
-
# Only inject reminder if there are pending TODOs
|
|
1747
|
-
return result unless pending_todos && !pending_todos.empty?
|
|
1748
|
-
|
|
1749
|
-
# Create a friendly reminder message
|
|
1750
|
-
reminder = "\n\n📋 REMINDER: You have #{pending_todos.length} pending TODO(s). " \
|
|
1751
|
-
"After completing each task, remember to mark it as complete using " \
|
|
1752
|
-
"todo_manager with action 'complete' and the task id."
|
|
1753
|
-
|
|
1754
|
-
# Inject reminder based on result type
|
|
1755
|
-
case result
|
|
1756
|
-
when String
|
|
1757
|
-
result + reminder
|
|
1758
|
-
when Hash
|
|
1759
|
-
result.merge({ _todo_reminder: reminder.strip })
|
|
1760
|
-
when Array
|
|
1761
|
-
result + [{ _todo_reminder: reminder.strip }]
|
|
1762
|
-
else
|
|
1763
|
-
result
|
|
1764
|
-
end
|
|
1765
|
-
end
|
|
1766
|
-
|
|
1767
|
-
def build_error_result(call, error_message)
|
|
1768
|
-
{
|
|
1769
|
-
id: call[:id],
|
|
1770
|
-
content: JSON.generate({ error: error_message })
|
|
1771
|
-
}
|
|
1772
|
-
end
|
|
1773
|
-
|
|
1774
|
-
def build_denied_result(call, user_feedback = nil, system_injected = false)
|
|
1775
|
-
if system_injected
|
|
1776
|
-
# System-generated feedback (e.g., from preview errors)
|
|
1777
|
-
tool_content = {
|
|
1778
|
-
error: "Tool #{call[:name]} denied: #{user_feedback}",
|
|
1779
|
-
system_injected: true
|
|
1780
|
-
}
|
|
1781
|
-
else
|
|
1782
|
-
# User manually denied or provided feedback
|
|
1783
|
-
message = if user_feedback && !user_feedback.empty?
|
|
1784
|
-
"Tool use denied by user. User feedback: #{user_feedback}"
|
|
1785
|
-
else
|
|
1786
|
-
"Tool use denied by user"
|
|
1787
|
-
end
|
|
1788
|
-
|
|
1789
|
-
tool_content = {
|
|
1790
|
-
error: message,
|
|
1791
|
-
user_feedback: user_feedback
|
|
1792
|
-
}
|
|
1793
|
-
end
|
|
1794
|
-
|
|
1795
|
-
{
|
|
1796
|
-
id: call[:id],
|
|
1797
|
-
content: JSON.generate(tool_content)
|
|
1798
|
-
}
|
|
1799
|
-
end
|
|
1800
|
-
|
|
1801
|
-
def build_planned_result(call)
|
|
1802
|
-
{
|
|
1803
|
-
id: call[:id],
|
|
1804
|
-
content: JSON.generate({ planned: true, message: "Tool execution skipped (plan mode)" })
|
|
1805
|
-
}
|
|
1806
|
-
end
|
|
1807
|
-
|
|
1808
|
-
def build_result(status, error: nil)
|
|
552
|
+
private def build_result(status, error: nil)
|
|
1809
553
|
# Calculate iterations for current task only
|
|
1810
554
|
task_iterations = @iterations - (@task_start_iterations || 0)
|
|
1811
555
|
|
|
@@ -1825,7 +569,7 @@ module Clacky
|
|
|
1825
569
|
}
|
|
1826
570
|
end
|
|
1827
571
|
|
|
1828
|
-
def format_tool_calls_for_api(tool_calls)
|
|
572
|
+
private def format_tool_calls_for_api(tool_calls)
|
|
1829
573
|
return nil unless tool_calls
|
|
1830
574
|
|
|
1831
575
|
tool_calls.map do |call|
|
|
@@ -1840,8 +584,7 @@ module Clacky
|
|
|
1840
584
|
end
|
|
1841
585
|
end
|
|
1842
586
|
|
|
1843
|
-
def register_builtin_tools
|
|
1844
|
-
|
|
587
|
+
private def register_builtin_tools
|
|
1845
588
|
@tool_registry.register(Tools::SafeShell.new)
|
|
1846
589
|
@tool_registry.register(Tools::FileReader.new)
|
|
1847
590
|
@tool_registry.register(Tools::Write.new)
|
|
@@ -1853,13 +596,148 @@ module Clacky
|
|
|
1853
596
|
@tool_registry.register(Tools::TodoManager.new)
|
|
1854
597
|
@tool_registry.register(Tools::RunProject.new)
|
|
1855
598
|
@tool_registry.register(Tools::RequestUserFeedback.new)
|
|
599
|
+
@tool_registry.register(Tools::InvokeSkill.new)
|
|
600
|
+
@tool_registry.register(Tools::UndoTask.new)
|
|
601
|
+
@tool_registry.register(Tools::RedoTask.new)
|
|
602
|
+
@tool_registry.register(Tools::ListTasks.new)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Fork a subagent with specified configuration
|
|
606
|
+
# The subagent inherits all messages and tools from parent agent
|
|
607
|
+
# Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks
|
|
608
|
+
# @param model [String, nil] Model name to use (nil = use current model)
|
|
609
|
+
# @param forbidden_tools [Array<String>] List of tool names to forbid
|
|
610
|
+
# @param system_prompt_suffix [String, nil] Additional instructions (inserted as user message for cache reuse)
|
|
611
|
+
# @return [Agent] New subagent instance
|
|
612
|
+
def fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil)
|
|
613
|
+
# Clone config to avoid affecting parent
|
|
614
|
+
subagent_config = @config.dup
|
|
615
|
+
|
|
616
|
+
# Switch to specified model if provided
|
|
617
|
+
if model
|
|
618
|
+
if model == "lite"
|
|
619
|
+
# Special keyword: use lite model if available, otherwise fall back to default
|
|
620
|
+
lite_model = subagent_config.lite_model
|
|
621
|
+
if lite_model
|
|
622
|
+
model_index = subagent_config.models.index(lite_model)
|
|
623
|
+
subagent_config.switch_model(model_index) if model_index
|
|
624
|
+
end
|
|
625
|
+
# If no lite model, just use current (default) model
|
|
626
|
+
else
|
|
627
|
+
# Regular model name lookup
|
|
628
|
+
model_index = subagent_config.model_names.index(model)
|
|
629
|
+
if model_index
|
|
630
|
+
subagent_config.switch_model(model_index)
|
|
631
|
+
else
|
|
632
|
+
raise AgentError, "Model '#{model}' not found in config. Available models: #{subagent_config.model_names.join(', ')}"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Create new client for subagent
|
|
638
|
+
subagent_client = Clacky::Client.new(
|
|
639
|
+
subagent_config.api_key,
|
|
640
|
+
base_url: subagent_config.base_url,
|
|
641
|
+
anthropic_format: subagent_config.anthropic_format?
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# Create subagent (reuses all tools from parent)
|
|
645
|
+
subagent = self.class.new(
|
|
646
|
+
subagent_client,
|
|
647
|
+
subagent_config,
|
|
648
|
+
working_dir: @working_dir,
|
|
649
|
+
ui: @ui
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Deep clone messages to avoid cross-contamination
|
|
653
|
+
subagent.instance_variable_set(:@messages, deep_clone(@messages))
|
|
654
|
+
|
|
655
|
+
# Append system prompt suffix as user message (for cache reuse)
|
|
656
|
+
if system_prompt_suffix
|
|
657
|
+
messages = subagent.instance_variable_get(:@messages)
|
|
658
|
+
|
|
659
|
+
# Build forbidden tools notice if any tools are forbidden
|
|
660
|
+
forbidden_notice = if forbidden_tools.any?
|
|
661
|
+
tool_list = forbidden_tools.map { |t| "`#{t}`" }.join(", ")
|
|
662
|
+
"\n\n[System Notice] The following tools are disabled in this subagent and will be rejected if called: #{tool_list}"
|
|
663
|
+
else
|
|
664
|
+
""
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
messages << {
|
|
668
|
+
role: "user",
|
|
669
|
+
content: "CRITICAL: TASK CONTEXT SWITCH - FORKED SUBAGENT MODE\n\nYou are now running as a forked subagent — a temporary, isolated agent spawned by the parent agent to handle a specific task. You run independently and cannot communicate back to the parent mid-task. When you finish (i.e., you stop calling tools and return a final response), your output will be automatically summarized and returned to the parent agent as a result so it can continue.\n\n#{system_prompt_suffix}#{forbidden_notice}",
|
|
670
|
+
system_injected: true,
|
|
671
|
+
subagent_instructions: true
|
|
672
|
+
}
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Register hook to forbid certain tools at runtime (doesn't affect tool registry for cache)
|
|
676
|
+
if forbidden_tools.any?
|
|
677
|
+
subagent.add_hook(:before_tool_use) do |call|
|
|
678
|
+
if forbidden_tools.include?(call[:name])
|
|
679
|
+
{
|
|
680
|
+
action: :deny,
|
|
681
|
+
reason: "Tool '#{call[:name]}' is forbidden in this subagent context"
|
|
682
|
+
}
|
|
683
|
+
else
|
|
684
|
+
{ action: :allow }
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Mark subagent metadata for summary generation
|
|
690
|
+
subagent.instance_variable_set(:@is_subagent, true)
|
|
691
|
+
subagent.instance_variable_set(:@parent_message_count, @messages.length)
|
|
692
|
+
|
|
693
|
+
subagent
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Generate summary from subagent execution
|
|
697
|
+
# Extracts new messages added by subagent and creates a concise summary
|
|
698
|
+
# This summary will replace the subagent instructions message in parent agent
|
|
699
|
+
# @param subagent [Agent] The subagent that completed execution
|
|
700
|
+
# @return [String] Summary text to insert into parent agent
|
|
701
|
+
def generate_subagent_summary(subagent)
|
|
702
|
+
parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
|
|
703
|
+
new_messages = subagent.messages[parent_count..-1] || []
|
|
704
|
+
|
|
705
|
+
# Extract tool calls
|
|
706
|
+
tool_calls = new_messages
|
|
707
|
+
.select { |m| m[:role] == "assistant" && m[:tool_calls] }
|
|
708
|
+
.flat_map { |m| m[:tool_calls].map { |tc| tc[:name] } }
|
|
709
|
+
.uniq
|
|
710
|
+
|
|
711
|
+
# Extract final assistant response
|
|
712
|
+
last_response = new_messages
|
|
713
|
+
.reverse
|
|
714
|
+
.find { |m| m[:role] == "assistant" && m[:content] && !m[:content].empty? }
|
|
715
|
+
&.dig(:content)
|
|
716
|
+
|
|
717
|
+
# Build summary (this will replace the subagent instructions message)
|
|
718
|
+
parts = []
|
|
719
|
+
parts << "[SUBAGENT SUMMARY]"
|
|
720
|
+
parts << "Completed in #{subagent.iterations} iterations, cost: $#{subagent.total_cost.round(4)}"
|
|
721
|
+
parts << "Tools used: #{tool_calls.join(', ')}" if tool_calls.any?
|
|
722
|
+
parts << ""
|
|
723
|
+
parts << "Results:"
|
|
724
|
+
parts << (last_response || "(No response)")
|
|
725
|
+
|
|
726
|
+
parts.join("\n")
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# Deep clone helper for messages using Marshal
|
|
730
|
+
# @param obj [Object] Object to clone
|
|
731
|
+
# @return [Object] Deep cloned object
|
|
732
|
+
private def deep_clone(obj)
|
|
733
|
+
Marshal.load(Marshal.dump(obj))
|
|
1856
734
|
end
|
|
1857
735
|
|
|
1858
736
|
# Format user content with optional images
|
|
1859
737
|
# @param text [String] User's text input
|
|
1860
738
|
# @param images [Array<String>] Array of image file paths
|
|
1861
739
|
# @return [String|Array] String if no images, Array with text and image_url objects if images present
|
|
1862
|
-
def format_user_content(text, images)
|
|
740
|
+
private def format_user_content(text, images)
|
|
1863
741
|
return text if images.nil? || images.empty?
|
|
1864
742
|
|
|
1865
743
|
content = []
|
|
@@ -1873,6 +751,18 @@ module Clacky
|
|
|
1873
751
|
content
|
|
1874
752
|
end
|
|
1875
753
|
|
|
1876
|
-
|
|
754
|
+
# Track modified files for Time Machine snapshots
|
|
755
|
+
# @param tool_name [String] Name of the tool that was executed
|
|
756
|
+
# @param args [Hash] Arguments passed to the tool
|
|
757
|
+
def track_modified_files(tool_name, args)
|
|
758
|
+
@modified_files_in_task ||= []
|
|
759
|
+
|
|
760
|
+
case tool_name
|
|
761
|
+
when "write", "edit"
|
|
762
|
+
file_path = args[:path]
|
|
763
|
+
full_path = File.expand_path(file_path, @working_dir)
|
|
764
|
+
@modified_files_in_task << full_path unless @modified_files_in_task.include?(full_path)
|
|
765
|
+
end
|
|
766
|
+
end
|
|
1877
767
|
end
|
|
1878
768
|
end
|