openclacky 0.6.2 → 0.6.4
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/CHANGELOG.md +30 -0
- data/docs/why-openclacky.md +267 -0
- data/lib/clacky/agent.rb +579 -99
- data/lib/clacky/cli.rb +350 -9
- data/lib/clacky/client.rb +519 -58
- data/lib/clacky/config.rb +71 -4
- data/lib/clacky/default_skills/skill-add/SKILL.md +66 -0
- data/lib/clacky/skill.rb +236 -0
- data/lib/clacky/skill_loader.rb +320 -0
- data/lib/clacky/tools/edit.rb +111 -8
- data/lib/clacky/tools/file_reader.rb +112 -9
- data/lib/clacky/tools/glob.rb +9 -2
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +14 -8
- data/lib/clacky/tools/shell.rb +89 -52
- data/lib/clacky/tools/web_fetch.rb +81 -18
- data/lib/clacky/ui2/components/command_suggestions.rb +273 -0
- data/lib/clacky/ui2/components/inline_input.rb +34 -15
- data/lib/clacky/ui2/components/input_area.rb +105 -83
- data/lib/clacky/ui2/layout_manager.rb +89 -33
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +38 -47
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +4 -1
- metadata +6 -1
data/lib/clacky/agent.rb
CHANGED
|
@@ -10,7 +10,7 @@ require_relative "utils/file_processor"
|
|
|
10
10
|
module Clacky
|
|
11
11
|
class Agent
|
|
12
12
|
attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
|
|
13
|
-
:cache_stats, :cost_source, :ui
|
|
13
|
+
:cache_stats, :cost_source, :ui, :skill_loader
|
|
14
14
|
|
|
15
15
|
# System prompt for the coding agent
|
|
16
16
|
SYSTEM_PROMPT = <<~PROMPT.freeze
|
|
@@ -49,6 +49,9 @@ module Clacky
|
|
|
49
49
|
Adding todos is NOT completion - it's just the planning phase!
|
|
50
50
|
Workflow: add todo 1 → add todo 2 → add todo 3 → START WORKING on todo 1 → complete(1) → work on todo 2 → complete(2) → etc.
|
|
51
51
|
NEVER stop after just adding todos without executing them!
|
|
52
|
+
|
|
53
|
+
NOTE: Available skills are listed below in the AVAILABLE SKILLS section.
|
|
54
|
+
When a user's request matches a skill, you MUST use the skill tool instead of implementing it yourself.
|
|
52
55
|
PROMPT
|
|
53
56
|
|
|
54
57
|
def initialize(client, config = {}, working_dir: nil, ui: nil)
|
|
@@ -79,6 +82,13 @@ module Clacky
|
|
|
79
82
|
@ui = ui # UIController for direct UI interaction
|
|
80
83
|
@debug_logs = [] # Debug logs for troubleshooting
|
|
81
84
|
|
|
85
|
+
# Compression tracking
|
|
86
|
+
@compression_level = 0 # Tracks how many times we've compressed (for progressive summarization)
|
|
87
|
+
@compressed_summaries = [] # Store summaries from previous compressions for reference
|
|
88
|
+
|
|
89
|
+
# Skill loader for skill management
|
|
90
|
+
@skill_loader = SkillLoader.new(@working_dir)
|
|
91
|
+
|
|
82
92
|
# Register built-in tools
|
|
83
93
|
register_builtin_tools
|
|
84
94
|
end
|
|
@@ -108,6 +118,9 @@ module Clacky
|
|
|
108
118
|
cache_hit_requests: 0
|
|
109
119
|
}
|
|
110
120
|
|
|
121
|
+
# Restore previous_total_tokens for accurate delta calculation across sessions
|
|
122
|
+
@previous_total_tokens = session_data.dig(:stats, :previous_total_tokens) || 0
|
|
123
|
+
|
|
111
124
|
# Check if the session ended with an error
|
|
112
125
|
last_status = session_data.dig(:stats, :last_status)
|
|
113
126
|
last_error = session_data.dig(:stats, :last_error)
|
|
@@ -213,6 +226,17 @@ module Clacky
|
|
|
213
226
|
# Check if done (no more tool calls needed)
|
|
214
227
|
if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
215
228
|
@ui&.show_assistant_message(response[:content]) if response[:content] && !response[:content].empty?
|
|
229
|
+
|
|
230
|
+
# Debug: log why we're stopping
|
|
231
|
+
if @config.verbose && (response[:tool_calls].nil? || response[:tool_calls].empty?)
|
|
232
|
+
reason = response[:finish_reason] == "stop" ? "API returned finish_reason=stop" : "No tool calls in response"
|
|
233
|
+
@ui&.log("Stopping: #{reason}", level: :debug)
|
|
234
|
+
if response[:content] && response[:content].is_a?(String)
|
|
235
|
+
preview = response[:content].length > 200 ? response[:content][0...200] + "..." : response[:content]
|
|
236
|
+
@ui&.log("Response content: #{preview}", level: :debug)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
216
240
|
break
|
|
217
241
|
end
|
|
218
242
|
|
|
@@ -266,6 +290,100 @@ module Clacky
|
|
|
266
290
|
end
|
|
267
291
|
end
|
|
268
292
|
|
|
293
|
+
# ===== Skill-related methods =====
|
|
294
|
+
|
|
295
|
+
# Get the skill loader instance
|
|
296
|
+
# @return [SkillLoader]
|
|
297
|
+
def skill_loader
|
|
298
|
+
@skill_loader
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Load all skills from configured locations
|
|
302
|
+
# @return [Array<Skill>]
|
|
303
|
+
def load_skills
|
|
304
|
+
@skill_loader.load_all
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Check if input is a skill command and process it
|
|
308
|
+
# @param input [String] User input
|
|
309
|
+
# @return [Hash, nil] Returns { skill: Skill, arguments: String } if skill command, nil otherwise
|
|
310
|
+
def parse_skill_command(input)
|
|
311
|
+
# Check for slash command pattern
|
|
312
|
+
if input.start_with?("/")
|
|
313
|
+
# Extract command and arguments
|
|
314
|
+
match = input.match(%r{^/(\S+)(?:\s+(.*))?$})
|
|
315
|
+
return nil unless match
|
|
316
|
+
|
|
317
|
+
skill_name = match[1]
|
|
318
|
+
arguments = match[2] || ""
|
|
319
|
+
|
|
320
|
+
# Find skill by command
|
|
321
|
+
skill = @skill_loader.find_by_command("/#{skill_name}")
|
|
322
|
+
return nil unless skill
|
|
323
|
+
|
|
324
|
+
# Check if user can invoke this skill
|
|
325
|
+
unless skill.user_invocable?
|
|
326
|
+
return nil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
{ skill: skill, arguments: arguments }
|
|
330
|
+
else
|
|
331
|
+
nil
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Execute a skill command
|
|
336
|
+
# @param input [String] User input (should be a skill command)
|
|
337
|
+
# @return [String] The expanded prompt with skill content
|
|
338
|
+
def execute_skill_command(input)
|
|
339
|
+
parsed = parse_skill_command(input)
|
|
340
|
+
return input unless parsed
|
|
341
|
+
|
|
342
|
+
skill = parsed[:skill]
|
|
343
|
+
arguments = parsed[:arguments]
|
|
344
|
+
|
|
345
|
+
# Process skill content with arguments
|
|
346
|
+
expanded_content = skill.process_content(arguments)
|
|
347
|
+
|
|
348
|
+
# Log skill usage
|
|
349
|
+
@ui&.log("Executing skill: #{skill.identifier}", level: :info)
|
|
350
|
+
|
|
351
|
+
expanded_content
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Generate skill context - loads all auto-invocable skills
|
|
355
|
+
# @return [String] Skill context to add to system prompt
|
|
356
|
+
def build_skill_context
|
|
357
|
+
# Load all auto-invocable skills
|
|
358
|
+
all_skills = @skill_loader.load_all
|
|
359
|
+
auto_invocable = all_skills.select(&:model_invocation_allowed?)
|
|
360
|
+
|
|
361
|
+
return "" if auto_invocable.empty?
|
|
362
|
+
|
|
363
|
+
context = "\n\n" + "=" * 80 + "\n"
|
|
364
|
+
context += "AVAILABLE SKILLS:\n"
|
|
365
|
+
context += "=" * 80 + "\n\n"
|
|
366
|
+
context += "CRITICAL SKILL USAGE RULES:\n"
|
|
367
|
+
context += "- When a user's request matches any available skill, this is a BLOCKING REQUIREMENT:\n"
|
|
368
|
+
context += " invoke the relevant skill tool BEFORE generating any other response about the task\n"
|
|
369
|
+
context += "- NEVER mention a skill without actually calling the skill tool\n"
|
|
370
|
+
context += "- NEVER implement the skill's functionality yourself - always delegate to the skill\n"
|
|
371
|
+
context += "- Skills provide specialized capabilities - use them instead of manual implementation\n"
|
|
372
|
+
context += "- When users reference '/<skill-name>' (e.g., '/pptx'), they are requesting a skill\n\n"
|
|
373
|
+
context += "Workflow: Use file_reader to read the SKILL.md file, then follow its instructions.\n\n"
|
|
374
|
+
context += "Available skills:\n\n"
|
|
375
|
+
|
|
376
|
+
auto_invocable.each do |skill|
|
|
377
|
+
skill_md_path = skill.directory.join("SKILL.md")
|
|
378
|
+
context += "- name: #{skill.identifier}\n"
|
|
379
|
+
context += " description: #{skill.context_description}\n"
|
|
380
|
+
context += " SKILL.md: #{skill_md_path}\n\n"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
context += "\n"
|
|
384
|
+
context
|
|
385
|
+
end
|
|
386
|
+
|
|
269
387
|
# Generate session data for saving
|
|
270
388
|
# @param status [Symbol] Status of the last task: :success, :error, or :interrupted
|
|
271
389
|
# @param error_message [String] Error message if status is :error
|
|
@@ -295,7 +413,8 @@ module Clacky
|
|
|
295
413
|
duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
|
|
296
414
|
last_status: status.to_s,
|
|
297
415
|
cache_stats: @cache_stats,
|
|
298
|
-
debug_logs: @debug_logs
|
|
416
|
+
debug_logs: @debug_logs,
|
|
417
|
+
previous_total_tokens: @previous_total_tokens
|
|
299
418
|
}
|
|
300
419
|
|
|
301
420
|
# Add error message if status is error
|
|
@@ -407,6 +526,10 @@ module Clacky
|
|
|
407
526
|
prompt += "=" * 80
|
|
408
527
|
end
|
|
409
528
|
|
|
529
|
+
# Add all loaded skills to system prompt
|
|
530
|
+
skill_context = build_skill_context
|
|
531
|
+
prompt += skill_context if skill_context && !skill_context.empty?
|
|
532
|
+
|
|
410
533
|
prompt
|
|
411
534
|
end
|
|
412
535
|
|
|
@@ -414,7 +537,7 @@ module Clacky
|
|
|
414
537
|
@ui&.show_progress
|
|
415
538
|
|
|
416
539
|
# Compress messages if needed to reduce cost
|
|
417
|
-
compress_messages_if_needed
|
|
540
|
+
compression_message = compress_messages_if_needed
|
|
418
541
|
|
|
419
542
|
# Always send tools definitions to allow multi-step tool calling
|
|
420
543
|
tools_to_send = @tool_registry.all_definitions
|
|
@@ -440,13 +563,16 @@ module Clacky
|
|
|
440
563
|
retry
|
|
441
564
|
else
|
|
442
565
|
@ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
|
|
443
|
-
raise
|
|
566
|
+
raise AgentError, "Network connection failed after #{max_retries} retries: #{e.message}"
|
|
444
567
|
end
|
|
445
568
|
end
|
|
446
569
|
|
|
447
570
|
# Clear progress indicator (change to gray and show final time)
|
|
448
571
|
@ui&.clear_progress
|
|
449
572
|
|
|
573
|
+
# Show compression message after clearing progress (so it doesn't get deleted)
|
|
574
|
+
@ui&.show_info(compression_message) if compression_message
|
|
575
|
+
|
|
450
576
|
track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
|
|
451
577
|
|
|
452
578
|
# Handle truncated responses (when max_tokens limit is reached)
|
|
@@ -501,7 +627,10 @@ module Clacky
|
|
|
501
627
|
# Always include content field (some APIs require it even with tool_calls)
|
|
502
628
|
# Use empty string instead of null for better compatibility
|
|
503
629
|
msg[:content] = response[:content] || ""
|
|
504
|
-
|
|
630
|
+
# Only add tool_calls if they actually exist (don't add empty arrays)
|
|
631
|
+
if response[:tool_calls]&.any?
|
|
632
|
+
msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls])
|
|
633
|
+
end
|
|
505
634
|
@messages << msg
|
|
506
635
|
|
|
507
636
|
response
|
|
@@ -543,7 +672,8 @@ module Clacky
|
|
|
543
672
|
denied = true
|
|
544
673
|
user_feedback = confirmation[:feedback]
|
|
545
674
|
feedback = user_feedback if user_feedback
|
|
546
|
-
|
|
675
|
+
system_injected = confirmation[:system_injected]
|
|
676
|
+
results << build_denied_result(call, user_feedback, system_injected)
|
|
547
677
|
|
|
548
678
|
# Auto-deny all remaining tools
|
|
549
679
|
remaining_calls = tool_calls[(index + 1)..-1] || []
|
|
@@ -551,7 +681,7 @@ module Clacky
|
|
|
551
681
|
reason = user_feedback && !user_feedback.empty? ?
|
|
552
682
|
user_feedback :
|
|
553
683
|
"Auto-denied due to user rejection of previous tool"
|
|
554
|
-
results << build_denied_result(remaining_call, reason)
|
|
684
|
+
results << build_denied_result(remaining_call, reason, system_injected)
|
|
555
685
|
end
|
|
556
686
|
break
|
|
557
687
|
end
|
|
@@ -571,6 +701,11 @@ module Clacky
|
|
|
571
701
|
args[:todos_storage] = @todos
|
|
572
702
|
end
|
|
573
703
|
|
|
704
|
+
# For safe_shell, skip safety check if user has already confirmed
|
|
705
|
+
if call[:name] == "safe_shell" || call[:name] == "shell"
|
|
706
|
+
args[:skip_safety_check] = true
|
|
707
|
+
end
|
|
708
|
+
|
|
574
709
|
# Show progress for potentially slow tools (no prefix newline)
|
|
575
710
|
if potentially_slow_tool?(call[:name], args)
|
|
576
711
|
progress_message = build_tool_progress_message(call[:name], args)
|
|
@@ -593,9 +728,21 @@ module Clacky
|
|
|
593
728
|
@ui&.show_tool_result(tool.format_result(result))
|
|
594
729
|
results << build_success_result(call, result)
|
|
595
730
|
rescue StandardError => e
|
|
731
|
+
# Log complete error information to debug_logs for troubleshooting
|
|
732
|
+
@debug_logs << {
|
|
733
|
+
timestamp: Time.now.iso8601,
|
|
734
|
+
event: "tool_execution_error",
|
|
735
|
+
tool_name: call[:name],
|
|
736
|
+
tool_args: call[:arguments],
|
|
737
|
+
error_class: e.class.name,
|
|
738
|
+
error_message: e.message,
|
|
739
|
+
backtrace: e.backtrace&.first(20) # Keep first 20 lines of backtrace
|
|
740
|
+
}
|
|
741
|
+
|
|
596
742
|
@hooks.trigger(:on_tool_error, call, e)
|
|
597
743
|
@ui&.show_tool_error(e)
|
|
598
|
-
|
|
744
|
+
# Use build_denied_result with system_injected=true so LLM knows it can retry
|
|
745
|
+
results << build_denied_result(call, e.message, true)
|
|
599
746
|
end
|
|
600
747
|
end
|
|
601
748
|
|
|
@@ -608,35 +755,11 @@ module Clacky
|
|
|
608
755
|
|
|
609
756
|
def observe(response, tool_results)
|
|
610
757
|
# Add tool results as messages
|
|
611
|
-
#
|
|
612
|
-
|
|
613
|
-
# CRITICAL: Tool results must be in the same order as tool_calls in the response
|
|
614
|
-
# Claude/Bedrock API requires this strict ordering
|
|
758
|
+
# Use Client to format results based on API type (Anthropic vs OpenAI)
|
|
615
759
|
return if tool_results.empty?
|
|
616
760
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
hash[result[:id]] = result
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
# Add results in the same order as the original tool_calls
|
|
623
|
-
response[:tool_calls].each do |tool_call|
|
|
624
|
-
result = results_map[tool_call[:id]]
|
|
625
|
-
if result
|
|
626
|
-
@messages << {
|
|
627
|
-
role: "tool",
|
|
628
|
-
tool_call_id: result[:id],
|
|
629
|
-
content: result[:content]
|
|
630
|
-
}
|
|
631
|
-
else
|
|
632
|
-
# This shouldn't happen, but add a fallback error result
|
|
633
|
-
@messages << {
|
|
634
|
-
role: "tool",
|
|
635
|
-
tool_call_id: tool_call[:id],
|
|
636
|
-
content: JSON.generate({ error: "Tool result missing" })
|
|
637
|
-
}
|
|
638
|
-
end
|
|
639
|
-
end
|
|
761
|
+
formatted_messages = @client.format_tool_results(response, tool_results, model: @config.model)
|
|
762
|
+
formatted_messages.each { |msg| @messages << msg }
|
|
640
763
|
end
|
|
641
764
|
|
|
642
765
|
# Interrupt the agent's current run
|
|
@@ -795,41 +918,426 @@ module Clacky
|
|
|
795
918
|
@ui&.show_token_usage(token_data)
|
|
796
919
|
end
|
|
797
920
|
|
|
921
|
+
# Estimate token count for a message content
|
|
922
|
+
# Simple approximation: characters / 4 (English text)
|
|
923
|
+
# For Chinese/other languages, characters / 2 is more accurate
|
|
924
|
+
# This is a rough estimate for compression triggering purposes
|
|
925
|
+
private def estimate_tokens(content)
|
|
926
|
+
return 0 if content.nil?
|
|
927
|
+
|
|
928
|
+
text = if content.is_a?(String)
|
|
929
|
+
content
|
|
930
|
+
elsif content.is_a?(Array)
|
|
931
|
+
# Handle content arrays (e.g., with images)
|
|
932
|
+
# Add safety check to prevent nil.compact error
|
|
933
|
+
mapped = content.map { |c| c[:text] if c.is_a?(Hash) }
|
|
934
|
+
(mapped || []).compact.join
|
|
935
|
+
else
|
|
936
|
+
content.to_s
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
return 0 if text.empty?
|
|
940
|
+
|
|
941
|
+
# Detect language mix - count non-ASCII characters
|
|
942
|
+
ascii_count = text.bytes.count { |b| b < 128 }
|
|
943
|
+
total_bytes = text.bytes.length
|
|
944
|
+
|
|
945
|
+
# Mix ratio (1.0 = all English, 0.5 = all Chinese)
|
|
946
|
+
mix_ratio = total_bytes > 0 ? ascii_count.to_f / total_bytes : 1.0
|
|
947
|
+
|
|
948
|
+
# English: ~4 chars/token, Chinese: ~2 chars/token
|
|
949
|
+
base_chars_per_token = mix_ratio * 4 + (1 - mix_ratio) * 2
|
|
950
|
+
|
|
951
|
+
(text.length / base_chars_per_token).to_i + 50 # Add overhead for message structure
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# Calculate total token count for all messages
|
|
955
|
+
# Returns estimated tokens and breakdown by category
|
|
956
|
+
private def total_message_tokens
|
|
957
|
+
system_tokens = 0
|
|
958
|
+
user_tokens = 0
|
|
959
|
+
assistant_tokens = 0
|
|
960
|
+
tool_tokens = 0
|
|
961
|
+
summary_tokens = 0
|
|
962
|
+
|
|
963
|
+
@messages.each do |msg|
|
|
964
|
+
tokens = estimate_tokens(msg[:content])
|
|
965
|
+
case msg[:role]
|
|
966
|
+
when "system"
|
|
967
|
+
system_tokens += tokens
|
|
968
|
+
when "user"
|
|
969
|
+
user_tokens += tokens
|
|
970
|
+
when "assistant"
|
|
971
|
+
assistant_tokens += tokens
|
|
972
|
+
when "tool"
|
|
973
|
+
tool_tokens += tokens
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
{
|
|
978
|
+
total: system_tokens + user_tokens + assistant_tokens + tool_tokens,
|
|
979
|
+
system: system_tokens,
|
|
980
|
+
user: user_tokens,
|
|
981
|
+
assistant: assistant_tokens,
|
|
982
|
+
tool: tool_tokens
|
|
983
|
+
}
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
# Compression thresholds
|
|
987
|
+
COMPRESSION_THRESHOLD = 80_000 # Trigger compression when exceeding this (in tokens)
|
|
988
|
+
MESSAGE_COUNT_THRESHOLD = 100 # Trigger compression when exceeding this (in message count)
|
|
989
|
+
TARGET_COMPRESSED_TOKENS = 70_000 # Target size after compression
|
|
990
|
+
MAX_RECENT_MESSAGES = 30 # Keep this many recent message pairs intact
|
|
991
|
+
|
|
798
992
|
def compress_messages_if_needed
|
|
799
993
|
# Check if compression is enabled
|
|
800
994
|
return unless @config.enable_compression
|
|
801
995
|
|
|
802
|
-
#
|
|
803
|
-
|
|
804
|
-
|
|
996
|
+
# Calculate total tokens and message count
|
|
997
|
+
token_counts = total_message_tokens
|
|
998
|
+
total_tokens = token_counts[:total]
|
|
999
|
+
message_count = @messages.length
|
|
1000
|
+
|
|
1001
|
+
# Check if we should trigger compression
|
|
1002
|
+
# Either: token count exceeds threshold OR message count exceeds threshold
|
|
1003
|
+
token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
|
|
1004
|
+
message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD
|
|
1005
|
+
|
|
1006
|
+
# Only compress if we exceed at least one threshold
|
|
1007
|
+
return unless token_threshold_exceeded || message_count_exceeded
|
|
805
1008
|
|
|
806
|
-
|
|
807
|
-
|
|
1009
|
+
# Calculate how much we need to reduce
|
|
1010
|
+
reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS
|
|
808
1011
|
|
|
809
|
-
|
|
1012
|
+
# Don't compress if reduction is minimal (< 10% of current size)
|
|
1013
|
+
# Only apply this check when triggered by token threshold
|
|
1014
|
+
if token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
|
|
1015
|
+
return
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# If only message count threshold is exceeded, force compression
|
|
1019
|
+
# to keep conversation history manageable
|
|
1020
|
+
|
|
1021
|
+
# Calculate target size for recent messages based on compression level
|
|
1022
|
+
target_recent_count = calculate_target_recent_count(reduction_needed)
|
|
1023
|
+
|
|
1024
|
+
# Increment compression level for progressive summarization
|
|
1025
|
+
@compression_level += 1
|
|
1026
|
+
|
|
1027
|
+
original_tokens = total_tokens
|
|
810
1028
|
|
|
811
1029
|
# Find the system message (should be first)
|
|
812
1030
|
system_msg = @messages.find { |m| m[:role] == "system" }
|
|
813
1031
|
|
|
814
1032
|
# Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
|
|
815
|
-
recent_messages = get_recent_messages_with_tool_pairs(@messages,
|
|
1033
|
+
recent_messages = get_recent_messages_with_tool_pairs(@messages, target_recent_count)
|
|
1034
|
+
recent_messages = [] if recent_messages.nil?
|
|
816
1035
|
|
|
817
1036
|
# Get messages to compress (everything except system and recent)
|
|
818
1037
|
messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
|
|
819
1038
|
|
|
820
1039
|
return if messages_to_compress.empty?
|
|
821
1040
|
|
|
822
|
-
# Create summary
|
|
823
|
-
summary =
|
|
1041
|
+
# Create hierarchical summary based on compression level
|
|
1042
|
+
summary = generate_hierarchical_summary(messages_to_compress)
|
|
824
1043
|
|
|
825
1044
|
# Rebuild messages array: [system, summary, recent_messages]
|
|
826
1045
|
rebuilt_messages = [system_msg, summary, *recent_messages].compact
|
|
827
1046
|
|
|
828
1047
|
@messages = rebuilt_messages
|
|
829
1048
|
|
|
830
|
-
|
|
1049
|
+
# Track this compression for progressive summarization
|
|
1050
|
+
@compressed_summaries << {
|
|
1051
|
+
level: @compression_level,
|
|
1052
|
+
message_count: messages_to_compress.size,
|
|
1053
|
+
timestamp: Time.now.iso8601
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
final_tokens = total_message_tokens[:total]
|
|
1057
|
+
|
|
1058
|
+
# Return compression message (to be shown after clearing progress)
|
|
1059
|
+
"History compressed (~#{original_tokens} -> ~#{final_tokens} tokens, level #{@compression_level})"
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
# Calculate how many recent messages to keep based on how much we need to compress
|
|
1063
|
+
private def calculate_target_recent_count(reduction_needed)
|
|
1064
|
+
# We want recent messages to be around 20-30% of the total target
|
|
1065
|
+
# This keeps the context window useful without being too large
|
|
1066
|
+
tokens_per_message = 500 # Average estimate for a message with content
|
|
1067
|
+
|
|
1068
|
+
# Target recent messages budget (~20% of target compressed size)
|
|
1069
|
+
recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
|
|
1070
|
+
target_messages = (recent_budget / tokens_per_message).to_i
|
|
1071
|
+
|
|
1072
|
+
# Clamp to reasonable bounds
|
|
1073
|
+
[[target_messages, 20].max, MAX_RECENT_MESSAGES].min
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
# Generate hierarchical summary based on compression level
|
|
1077
|
+
# Level 1: Detailed summary with files, decisions, features
|
|
1078
|
+
# Level 2: Concise summary with key items
|
|
1079
|
+
# Level 3: Minimal summary (just project type)
|
|
1080
|
+
# Level 4+: Ultra-minimal (single line)
|
|
1081
|
+
private def generate_hierarchical_summary(messages)
|
|
1082
|
+
level = @compression_level
|
|
1083
|
+
|
|
1084
|
+
# Extract key information from messages
|
|
1085
|
+
extracted = extract_key_information(messages)
|
|
1086
|
+
|
|
1087
|
+
summary_text = case level
|
|
1088
|
+
when 1
|
|
1089
|
+
generate_level1_summary(extracted)
|
|
1090
|
+
when 2
|
|
1091
|
+
generate_level2_summary(extracted)
|
|
1092
|
+
when 3
|
|
1093
|
+
generate_level3_summary(extracted)
|
|
1094
|
+
else
|
|
1095
|
+
generate_level4_summary(extracted)
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
{
|
|
1099
|
+
role: "user",
|
|
1100
|
+
content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
|
|
1101
|
+
system_injected: true,
|
|
1102
|
+
compression_level: level
|
|
1103
|
+
}
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
# Extract key information from messages for summarization
|
|
1107
|
+
private def extract_key_information(messages)
|
|
1108
|
+
return empty_extraction_data if messages.nil?
|
|
1109
|
+
|
|
1110
|
+
{
|
|
1111
|
+
# Message counts
|
|
1112
|
+
user_msgs: messages.count { |m| m[:role] == "user" },
|
|
1113
|
+
assistant_msgs: messages.count { |m| m[:role] == "assistant" },
|
|
1114
|
+
tool_msgs: messages.count { |m| m[:role] == "tool" },
|
|
1115
|
+
|
|
1116
|
+
# Tools used
|
|
1117
|
+
tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },
|
|
1118
|
+
|
|
1119
|
+
# Files created/modified
|
|
1120
|
+
files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
|
|
1121
|
+
files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },
|
|
1122
|
+
|
|
1123
|
+
# Key decisions (limit to first 5)
|
|
1124
|
+
decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),
|
|
1125
|
+
|
|
1126
|
+
# Completed tasks (from TODO results)
|
|
1127
|
+
completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },
|
|
1128
|
+
|
|
1129
|
+
# Current in-progress work
|
|
1130
|
+
in_progress: find_in_progress(messages),
|
|
1131
|
+
|
|
1132
|
+
# Key results from shell commands
|
|
1133
|
+
shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
|
|
1134
|
+
}
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
# Helper: safely extract from messages with proper nil handling
|
|
1138
|
+
private def extract_from_messages(messages, role_filter = nil, &block)
|
|
1139
|
+
return [] if messages.nil?
|
|
1140
|
+
|
|
1141
|
+
results = messages
|
|
1142
|
+
.select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
|
|
1143
|
+
.map(&block)
|
|
1144
|
+
.compact
|
|
831
1145
|
|
|
832
|
-
|
|
1146
|
+
# Flatten if we have nested arrays (from methods returning arrays of items)
|
|
1147
|
+
results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
# Helper: extract tool names from tool_calls
|
|
1151
|
+
private def extract_tool_names(tool_calls)
|
|
1152
|
+
return [] unless tool_calls.is_a?(Array)
|
|
1153
|
+
tool_calls.map { |tc| tc.dig(:function, :name) }
|
|
1154
|
+
end
|
|
1155
|
+
|
|
1156
|
+
# Helper: filter write results by action
|
|
1157
|
+
private def filter_write_results(result, action)
|
|
1158
|
+
result && result[:action] == action ? result[:file] : nil
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# Helper: filter todo results by status
|
|
1162
|
+
private def filter_todo_results(result, status)
|
|
1163
|
+
result && result[:status] == status ? result[:task] : nil
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
# Helper: extract decision text from content (returns array of decisions or empty array)
|
|
1167
|
+
private def extract_decision_text(content)
|
|
1168
|
+
return [] unless content.is_a?(String)
|
|
1169
|
+
return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")
|
|
1170
|
+
|
|
1171
|
+
sentences = content.split(/[.!?]/).select do |s|
|
|
1172
|
+
s.include?("decision") || s.include?("chose") || s.include?("using") ||
|
|
1173
|
+
s.include?("decided") || s.include?("will use") || s.include?("selected")
|
|
1174
|
+
end
|
|
1175
|
+
sentences.map(&:strip).map { |s| s[0..100] }
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
# Helper: find in-progress task
|
|
1179
|
+
private def find_in_progress(messages)
|
|
1180
|
+
return nil if messages.nil?
|
|
1181
|
+
|
|
1182
|
+
messages.reverse_each do |m|
|
|
1183
|
+
if m[:role] == "tool"
|
|
1184
|
+
content = m[:content].to_s
|
|
1185
|
+
if content.include?("in progress") || content.include?("working on")
|
|
1186
|
+
return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
|
|
1187
|
+
end
|
|
1188
|
+
end
|
|
1189
|
+
end
|
|
1190
|
+
nil
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# Helper: empty extraction data
|
|
1194
|
+
private def empty_extraction_data
|
|
1195
|
+
{
|
|
1196
|
+
user_msgs: 0,
|
|
1197
|
+
assistant_msgs: 0,
|
|
1198
|
+
tool_msgs: 0,
|
|
1199
|
+
tools_used: [],
|
|
1200
|
+
files_created: [],
|
|
1201
|
+
files_modified: [],
|
|
1202
|
+
decisions: [],
|
|
1203
|
+
completed_tasks: [],
|
|
1204
|
+
in_progress: nil,
|
|
1205
|
+
shell_results: []
|
|
1206
|
+
}
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
private def parse_write_result(content)
|
|
1210
|
+
return nil unless content.is_a?(String)
|
|
1211
|
+
|
|
1212
|
+
# Check for "Created: path" or "Updated: path" patterns
|
|
1213
|
+
if content.include?("Created:")
|
|
1214
|
+
{ action: "created", file: content[/Created:\s*(.+)/, 1]&.strip }
|
|
1215
|
+
elsif content.include?("Updated:") || content.include?("modified")
|
|
1216
|
+
{ action: "modified", file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
|
|
1217
|
+
else
|
|
1218
|
+
nil
|
|
1219
|
+
end
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
private def parse_todo_result(content)
|
|
1223
|
+
return nil unless content.is_a?(String)
|
|
1224
|
+
|
|
1225
|
+
if content.include?("completed")
|
|
1226
|
+
{ status: "completed", task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
|
|
1227
|
+
elsif content.include?("added")
|
|
1228
|
+
{ status: "added", task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
|
|
1229
|
+
else
|
|
1230
|
+
nil
|
|
1231
|
+
end
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
private def parse_shell_result(content)
|
|
1235
|
+
return nil unless content.is_a?(String)
|
|
1236
|
+
|
|
1237
|
+
if content.include?("passed") || content.include?("success")
|
|
1238
|
+
"tests passed"
|
|
1239
|
+
elsif content.include?("failed") || content.include?("error")
|
|
1240
|
+
"command failed"
|
|
1241
|
+
elsif content =~ /bundle install|npm install|go mod download/
|
|
1242
|
+
"dependencies installed"
|
|
1243
|
+
elsif content.include?("Installed")
|
|
1244
|
+
content[/Installed:\s*(.+)/, 1]&.strip
|
|
1245
|
+
else
|
|
1246
|
+
nil
|
|
1247
|
+
end
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
# Level 1: Detailed summary (for first compression)
|
|
1251
|
+
private def generate_level1_summary(data)
|
|
1252
|
+
parts = []
|
|
1253
|
+
|
|
1254
|
+
parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"
|
|
1255
|
+
|
|
1256
|
+
# Files created
|
|
1257
|
+
if data[:files_created].any?
|
|
1258
|
+
files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
|
|
1259
|
+
parts << "Created: #{files_list}"
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
# Files modified
|
|
1263
|
+
if data[:files_modified].any?
|
|
1264
|
+
files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
|
|
1265
|
+
parts << "Modified: #{files_list}"
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
# Completed tasks
|
|
1269
|
+
if data[:completed_tasks].any?
|
|
1270
|
+
tasks_list = data[:completed_tasks].first(3).join(", ")
|
|
1271
|
+
parts << "Completed: #{tasks_list}"
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
# In progress
|
|
1275
|
+
if data[:in_progress]
|
|
1276
|
+
parts << "In Progress: #{data[:in_progress]}"
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
# Key decisions
|
|
1280
|
+
if data[:decisions].any?
|
|
1281
|
+
decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
|
|
1282
|
+
parts << "Decisions: #{decisions_text}"
|
|
1283
|
+
end
|
|
1284
|
+
|
|
1285
|
+
# Tools used
|
|
1286
|
+
if data[:tools_used].any?
|
|
1287
|
+
parts << "Tools: #{data[:tools_used].join(', ')}"
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
parts << "Continuing with recent conversation..."
|
|
1291
|
+
parts.join("\n")
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1294
|
+
# Level 2: Concise summary (for second compression)
|
|
1295
|
+
private def generate_level2_summary(data)
|
|
1296
|
+
parts = []
|
|
1297
|
+
|
|
1298
|
+
parts << "Conversation summary:"
|
|
1299
|
+
|
|
1300
|
+
# Key files (limit to most important)
|
|
1301
|
+
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
1302
|
+
if all_files.any?
|
|
1303
|
+
key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
|
|
1304
|
+
parts << "Files: #{key_files}"
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
# Key accomplishments
|
|
1308
|
+
accomplishments = []
|
|
1309
|
+
accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
|
|
1310
|
+
accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
|
|
1311
|
+
accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]
|
|
1312
|
+
|
|
1313
|
+
parts << accomplishments.join(", ") if accomplishments.any?
|
|
1314
|
+
|
|
1315
|
+
parts << "Recent context follows..."
|
|
1316
|
+
parts.join("\n")
|
|
1317
|
+
end
|
|
1318
|
+
|
|
1319
|
+
# Level 3: Minimal summary (for third compression)
|
|
1320
|
+
private def generate_level3_summary(data)
|
|
1321
|
+
parts = []
|
|
1322
|
+
|
|
1323
|
+
parts << "Project progress:"
|
|
1324
|
+
|
|
1325
|
+
# Just counts and key items
|
|
1326
|
+
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
1327
|
+
parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"
|
|
1328
|
+
|
|
1329
|
+
if data[:in_progress]
|
|
1330
|
+
parts << "Currently: #{data[:in_progress]}"
|
|
1331
|
+
end
|
|
1332
|
+
|
|
1333
|
+
parts << "See recent messages for details."
|
|
1334
|
+
parts.join("\n")
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
# Level 4: Ultra-minimal summary (for fourth+ compression)
|
|
1338
|
+
private def generate_level4_summary(data)
|
|
1339
|
+
all_files = (data[:files_created] + data[:files_modified]).uniq
|
|
1340
|
+
"Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
|
|
833
1341
|
end
|
|
834
1342
|
|
|
835
1343
|
def get_recent_messages_with_tool_pairs(messages, count)
|
|
@@ -837,7 +1345,7 @@ module Clacky
|
|
|
837
1345
|
# with ALL their corresponding tool_results, maintaining the correct order.
|
|
838
1346
|
# This is critical for Bedrock Claude API which validates the tool_calls/tool_results pairing.
|
|
839
1347
|
|
|
840
|
-
return [] if messages.empty?
|
|
1348
|
+
return [] if messages.nil? || messages.empty?
|
|
841
1349
|
|
|
842
1350
|
# Track which messages to include
|
|
843
1351
|
messages_to_include = Set.new
|
|
@@ -923,51 +1431,14 @@ module Clacky
|
|
|
923
1431
|
messages_to_include.to_a.sort.map { |idx| messages[idx] }
|
|
924
1432
|
end
|
|
925
1433
|
|
|
926
|
-
def summarize_messages(messages)
|
|
927
|
-
# Count different message types
|
|
928
|
-
user_msgs = messages.count { |m| m[:role] == "user" }
|
|
929
|
-
assistant_msgs = messages.count { |m| m[:role] == "assistant" }
|
|
930
|
-
tool_msgs = messages.count { |m| m[:role] == "tool" }
|
|
931
|
-
|
|
932
|
-
# Extract key information
|
|
933
|
-
tools_used = messages
|
|
934
|
-
.select { |m| m[:role] == "assistant" && m[:tool_calls] }
|
|
935
|
-
.flat_map { |m| m[:tool_calls].map { |tc| tc.dig(:function, :name) } }
|
|
936
|
-
.compact
|
|
937
|
-
.uniq
|
|
938
|
-
|
|
939
|
-
# Count completed tasks from tool results
|
|
940
|
-
completed_todos = messages
|
|
941
|
-
.select { |m| m[:role] == "tool" }
|
|
942
|
-
.map { |m| JSON.parse(m[:content]) rescue nil }
|
|
943
|
-
.compact
|
|
944
|
-
.select { |data| data.is_a?(Hash) && data["message"]&.include?("completed") }
|
|
945
|
-
.size
|
|
946
|
-
|
|
947
|
-
summary_text = "Previous conversation summary (#{messages.size} messages compressed):\n"
|
|
948
|
-
summary_text += "- User requests: #{user_msgs}\n"
|
|
949
|
-
summary_text += "- Assistant responses: #{assistant_msgs}\n"
|
|
950
|
-
summary_text += "- Tool executions: #{tool_msgs}\n"
|
|
951
|
-
summary_text += "- Tools used: #{tools_used.join(', ')}\n" if tools_used.any?
|
|
952
|
-
summary_text += "- Completed tasks: #{completed_todos}\n" if completed_todos > 0
|
|
953
|
-
summary_text += "\nContinuing with recent conversation context..."
|
|
954
|
-
|
|
955
|
-
{
|
|
956
|
-
role: "user",
|
|
957
|
-
content: "[SYSTEM] " + summary_text,
|
|
958
|
-
system_injected: true
|
|
959
|
-
}
|
|
960
|
-
end
|
|
961
|
-
|
|
962
1434
|
def confirm_tool_use?(call)
|
|
963
1435
|
# Show preview first and check for errors
|
|
964
1436
|
preview_error = show_tool_preview(call)
|
|
965
1437
|
|
|
966
1438
|
# If preview detected an error, auto-deny and provide feedback
|
|
967
1439
|
if preview_error && preview_error[:error]
|
|
968
|
-
@ui&.show_warning("Tool call auto-denied due to preview error")
|
|
969
1440
|
feedback = build_preview_error_feedback(call[:name], preview_error)
|
|
970
|
-
return { approved: false, feedback: feedback }
|
|
1441
|
+
return { approved: false, feedback: feedback, system_injected: true }
|
|
971
1442
|
end
|
|
972
1443
|
|
|
973
1444
|
# Request confirmation via UI
|
|
@@ -998,7 +1469,7 @@ module Clacky
|
|
|
998
1469
|
private def build_preview_error_feedback(tool_name, error_info)
|
|
999
1470
|
case tool_name
|
|
1000
1471
|
when "edit"
|
|
1001
|
-
"The edit operation will fail because the old_string was not found in the file. " \
|
|
1472
|
+
"Tool edit denied: The edit operation will fail because the old_string was not found in the file. " \
|
|
1002
1473
|
"Please use file_reader to read '#{error_info[:path]}' first, " \
|
|
1003
1474
|
"find the correct string to replace, and try again with the exact string (including whitespace)."
|
|
1004
1475
|
else
|
|
@@ -1126,9 +1597,7 @@ module Clacky
|
|
|
1126
1597
|
file_size: file_content.length
|
|
1127
1598
|
}
|
|
1128
1599
|
|
|
1129
|
-
@ui&.show_file_error("
|
|
1130
|
-
@ui&.show_file_error("Looking for (first 100 chars):")
|
|
1131
|
-
@ui&.show_file_error(old_string[0..100].inspect)
|
|
1600
|
+
@ui&.show_file_error("Edit file error")
|
|
1132
1601
|
return {
|
|
1133
1602
|
error: "String to replace not found in file",
|
|
1134
1603
|
path: path,
|
|
@@ -1172,19 +1641,30 @@ module Clacky
|
|
|
1172
1641
|
}
|
|
1173
1642
|
end
|
|
1174
1643
|
|
|
1175
|
-
def build_denied_result(call, user_feedback = nil)
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1644
|
+
def build_denied_result(call, user_feedback = nil, system_injected = false)
|
|
1645
|
+
if system_injected
|
|
1646
|
+
# System-generated feedback (e.g., from preview errors)
|
|
1647
|
+
tool_content = {
|
|
1648
|
+
error: "Tool #{call[:name]} denied: #{user_feedback}",
|
|
1649
|
+
system_injected: true
|
|
1650
|
+
}
|
|
1651
|
+
else
|
|
1652
|
+
# User manually denied or provided feedback
|
|
1653
|
+
message = if user_feedback && !user_feedback.empty?
|
|
1654
|
+
"Tool use denied by user. User feedback: #{user_feedback}"
|
|
1655
|
+
else
|
|
1656
|
+
"Tool use denied by user"
|
|
1657
|
+
end
|
|
1181
1658
|
|
|
1182
|
-
|
|
1183
|
-
id: call[:id],
|
|
1184
|
-
content: JSON.generate({
|
|
1659
|
+
tool_content = {
|
|
1185
1660
|
error: message,
|
|
1186
1661
|
user_feedback: user_feedback
|
|
1187
|
-
}
|
|
1662
|
+
}
|
|
1663
|
+
end
|
|
1664
|
+
|
|
1665
|
+
{
|
|
1666
|
+
id: call[:id],
|
|
1667
|
+
content: JSON.generate(tool_content)
|
|
1188
1668
|
}
|
|
1189
1669
|
end
|
|
1190
1670
|
|