Class: Clacky::Agent
- Inherits:
-
Object
- Object
- Clacky::Agent
- Includes:
- CostTracker, LlmCaller, MemoryUpdater, MessageCompressorHelper, SessionSerializer, SkillManager, SystemPromptBuilder, TimeMachine, ToolExecutor
- Defined in:
- lib/clacky/agent.rb,
lib/clacky/agent/llm_caller.rb,
lib/clacky/agent/cost_tracker.rb,
lib/clacky/agent/time_machine.rb,
lib/clacky/agent/skill_manager.rb,
lib/clacky/agent/tool_executor.rb,
lib/clacky/agent/memory_updater.rb,
lib/clacky/agent/session_serializer.rb,
lib/clacky/agent/system_prompt_builder.rb,
lib/clacky/agent/message_compressor_helper.rb
Defined Under Namespace
Modules: CostTracker, LlmCaller, MemoryUpdater, MessageCompressorHelper, SessionSerializer, SkillManager, SystemPromptBuilder, TimeMachine, ToolExecutor
Constant Summary
Constants included from MemoryUpdater
MemoryUpdater::MEMORIES_DIR, MemoryUpdater::MEMORY_UPDATE_MIN_ITERATIONS
Constants included from LlmCaller
LlmCaller::MAX_RETRIES_ON_FALLBACK, LlmCaller::RETRIES_BEFORE_FALLBACK
Constants included from SystemPromptBuilder
SystemPromptBuilder::MAX_MEMORY_FILE_CHARS
Constants included from SkillManager
SkillManager::MAX_CONTEXT_SKILLS
Constants included from MessageCompressorHelper
MessageCompressorHelper::COMPRESSION_THRESHOLD, MessageCompressorHelper::IDLE_COMPRESSION_THRESHOLD, MessageCompressorHelper::MAX_RECENT_MESSAGES, MessageCompressorHelper::MESSAGE_COUNT_THRESHOLD, MessageCompressorHelper::TARGET_COMPRESSED_TOKENS
Instance Attribute Summary collapse
-
#agent_profile ⇒ Object
readonly
Returns the value of attribute agent_profile.
-
#cache_stats ⇒ Object
readonly
Returns the value of attribute cache_stats.
-
#cost_source ⇒ Object
readonly
Returns the value of attribute cost_source.
-
#created_at ⇒ Object
readonly
Returns the value of attribute created_at.
-
#error ⇒ Object
readonly
Returns the value of attribute error.
-
#history ⇒ Object
readonly
Returns the value of attribute history.
-
#iterations ⇒ Object
readonly
Returns the value of attribute iterations.
-
#name ⇒ Object
readonly
Returns the value of attribute name.
-
#session_id ⇒ Object
readonly
Returns the value of attribute session_id.
-
#skill_loader ⇒ Object
readonly
Returns the value of attribute skill_loader.
-
#source ⇒ Object
readonly
Returns the value of attribute source.
-
#status ⇒ Object
readonly
Returns the value of attribute status.
-
#todos ⇒ Object
readonly
Returns the value of attribute todos.
-
#total_cost ⇒ Object
readonly
Returns the value of attribute total_cost.
-
#total_tasks ⇒ Object
readonly
Returns the value of attribute total_tasks.
-
#ui ⇒ Object
readonly
Returns the value of attribute ui.
-
#updated_at ⇒ Object
readonly
Returns the value of attribute updated_at.
-
#working_dir ⇒ Object
readonly
Returns the value of attribute working_dir.
Class Method Summary collapse
-
.from_session(client, config, session_data, ui: nil, profile:) ⇒ Object
Restore from a saved session.
Instance Method Summary collapse
- #add_hook(event, &block) ⇒ Object
-
#available_models ⇒ Object
Get list of available model names.
-
#current_model_info ⇒ Object
Get current model configuration info.
-
#enqueue_injection(skill, task) ⇒ Object
Enqueue an inline skill injection to be flushed after observe().
-
#fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil) ⇒ Agent
Fork a subagent with specified configuration The subagent inherits all messages and tools from parent agent Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks.
-
#generate_subagent_summary(subagent) ⇒ String
Generate summary from subagent execution Extracts new messages added by subagent and creates a concise summary This summary will replace the subagent instructions message in parent agent.
-
#initialize(client, config, working_dir:, ui:, profile:, session_id:, source:) ⇒ Agent
constructor
A new instance of Agent.
-
#interrupt! ⇒ Object
Interrupt the agent’s current run Called when user presses Ctrl+C during agent execution.
- #permission_mode ⇒ Object
-
#redact_tool_args(args) ⇒ String, ...
Redact volatile tmpdir paths from tool call arguments before showing in UI.
- #redact_value(obj) ⇒ Object
-
#register_script_tmpdir(dir) ⇒ Object
Register a tmpdir that contains decrypted brand skill scripts.
-
#rename(new_name) ⇒ Object
Rename this session.
- #run(user_input, files: []) ⇒ Object
-
#running? ⇒ Boolean
Check if agent is currently running.
-
#switch_model(model_name) ⇒ Object
Switch to a different model by name Returns true if switched, false if model not found.
-
#track_modified_files(tool_name, args) ⇒ Object
Track modified files for Time Machine snapshots.
Methods included from MemoryUpdater
#cleanup_memory_messages, #inject_memory_prompt!, #should_update_memory?
Methods included from TimeMachine
#active_messages, #get_child_tasks, #get_task_history, #restore_to_task_state, #save_modified_files_snapshot, #start_new_task, #switch_to_task, #undo_last_task
Methods included from SystemPromptBuilder
Methods included from SkillManager
#build_skill_context, #build_template_context, #execute_skill_with_subagent, #filter_skills_by_profile, #inject_skill_as_assistant_message, #inject_skill_command_as_assistant_message, #load_memories_meta, #load_skills, #memories_base_dir, #parse_memory_frontmatter, #parse_skill_command, #shred_directory
Methods included from SessionSerializer
#_replay_single_message, #extract_image_files_from_content, #extract_images_from_content, #extract_text_from_content, #get_recent_user_messages, #refresh_system_prompt, #replay_history, #restore_session, #to_session_data
Methods included from CostTracker
#collect_iteration_tokens, #track_cost
Methods included from ToolExecutor
#build_denied_result, #build_error_result, #build_success_result, #confirm_tool_use?, #format_tool_prompt, #is_safe_operation?, #should_auto_execute?, #show_tool_preview
Methods included from MessageCompressorHelper
#build_chunk_md, #calculate_target_recent_count, #compress_messages_if_needed, #empty_extraction_data, #extract_decision_text, #extract_from_messages, #extract_key_information, #extract_tool_names, #filter_todo_results, #filter_write_results, #find_in_progress, #format_message_content, #generate_hierarchical_summary, #generate_level1_summary, #generate_level2_summary, #generate_level3_summary, #generate_level4_summary, #get_recent_messages_with_tool_pairs, #handle_compression_response, #parse_shell_result, #parse_todo_result, #parse_write_result, #pull_assistant_before, #pull_tool_results_after, #save_compressed_chunk, #tool_result_for?, #tool_result_ids, #tool_result_message?, #trigger_idle_compression, #truncate_content, #truncate_tool_result
Constructor Details
#initialize(client, config, working_dir:, ui:, profile:, session_id:, source:) ⇒ Agent
Returns a new instance of Agent.
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/clacky/agent.rb', line 45 def initialize(client, config, working_dir:, ui:, profile:, session_id:, source:) @client = client # Client for current model @config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config) @agent_profile = AgentProfile.load(profile) @source = source.to_sym # :manual | :cron | :channel @tool_registry = ToolRegistry.new @hooks = HookManager.new @session_id = session_id @name = "" @history = MessageHistory.new @todos = [] # Store todos in memory @iterations = 0 @total_cost = 0.0 @cache_stats = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_requests: 0, cache_hit_requests: 0, raw_api_usage_samples: [] # Store raw API usage for debugging } @start_time = nil @working_dir = working_dir || Dir.pwd @created_at = Time.now.iso8601 @total_tasks = 0 @cost_source = :estimated # Track whether cost is from API or estimated @task_cost_source = :estimated # Track cost source for current task @previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation @interrupted = false # Flag for user interrupt @ui = ui # UIController for direct UI interaction @debug_logs = [] # Debug logs for troubleshooting @pending_injections = [] # Pending inline skill injections to flush after observe() @pending_script_tmpdirs = [] # Decrypted-script tmpdirs to shred when agent.run completes @pending_error_rollback = false # Deferred rollback flag set by restore_session on error # Compression tracking @compression_level = 0 # Tracks how many times we've compressed (for progressive summarization) @compressed_summaries = [] # Store summaries from previous compressions for reference # Message compressor for LLM-based intelligent compression # Uses LLM to preserve key decisions, errors, and context while reducing token count @message_compressor = MessageCompressor.new(@client, model: current_model) # Load brand config — used for brand skill decryption and background sync @brand_config = Clacky::BrandConfig.load # Skill loader for skill management (brand_config enables encrypted skill loading) @skill_loader = SkillLoader.new(working_dir: @working_dir, brand_config: @brand_config) # Background sync: compare remote skill versions and download updates quietly. # Runs in a daemon thread so Agent startup is never blocked. @brand_config.sync_brand_skills_async! # Initialize Time Machine init_time_machine # Register built-in tools register_builtin_tools # Ensure user-space parsers are in place (~/.clacky/parsers/) Utils::ParserManager.setup! # Ensure bundled shell scripts are in place (~/.clacky/scripts/) Utils::ScriptsManager.setup! end |
Instance Attribute Details
#agent_profile ⇒ Object (readonly)
Returns the value of attribute agent_profile.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def agent_profile @agent_profile end |
#cache_stats ⇒ Object (readonly)
Returns the value of attribute cache_stats.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def cache_stats @cache_stats end |
#cost_source ⇒ Object (readonly)
Returns the value of attribute cost_source.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def cost_source @cost_source end |
#created_at ⇒ Object (readonly)
Returns the value of attribute created_at.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def created_at @created_at end |
#error ⇒ Object (readonly)
Returns the value of attribute error.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def error @error end |
#history ⇒ Object (readonly)
Returns the value of attribute history.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def history @history end |
#iterations ⇒ Object (readonly)
Returns the value of attribute iterations.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def iterations @iterations end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def name @name end |
#session_id ⇒ Object (readonly)
Returns the value of attribute session_id.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def session_id @session_id end |
#skill_loader ⇒ Object (readonly)
Returns the value of attribute skill_loader.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def skill_loader @skill_loader end |
#source ⇒ Object (readonly)
Returns the value of attribute source.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def source @source end |
#status ⇒ Object (readonly)
Returns the value of attribute status.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def status @status end |
#todos ⇒ Object (readonly)
Returns the value of attribute todos.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def todos @todos end |
#total_cost ⇒ Object (readonly)
Returns the value of attribute total_cost.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def total_cost @total_cost end |
#total_tasks ⇒ Object (readonly)
Returns the value of attribute total_tasks.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def total_tasks @total_tasks end |
#ui ⇒ Object (readonly)
Returns the value of attribute ui.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def ui @ui end |
#updated_at ⇒ Object (readonly)
Returns the value of attribute updated_at.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def updated_at @updated_at end |
#working_dir ⇒ Object (readonly)
Returns the value of attribute working_dir.
37 38 39 |
# File 'lib/clacky/agent.rb', line 37 def working_dir @working_dir end |
Class Method Details
.from_session(client, config, session_data, ui: nil, profile:) ⇒ Object
Restore from a saved session
111 112 113 114 115 116 117 118 119 120 |
# File 'lib/clacky/agent.rb', line 111 def self.from_session(client, config, session_data, ui: nil, profile:) working_dir = session_data[:working_dir] || session_data["working_dir"] || Dir.pwd original_id = session_data[:session_id] || session_data["session_id"] || Clacky::SessionManager.generate_id # Restore source from persisted data; fall back to :manual for legacy sessions source = (session_data[:source] || session_data["source"] || "manual").to_sym agent = new(client, config, working_dir: working_dir, ui: ui, profile: profile, session_id: original_id, source: source) agent.restore_session(session_data) agent end |
Instance Method Details
#add_hook(event, &block) ⇒ Object
122 123 124 |
# File 'lib/clacky/agent.rb', line 122 def add_hook(event, &block) @hooks.add(event, &block) end |
#available_models ⇒ Object
Get list of available model names
146 147 148 |
# File 'lib/clacky/agent.rb', line 146 def available_models @config.model_names end |
#current_model_info ⇒ Object
Get current model configuration info
151 152 153 154 155 156 157 158 159 160 |
# File 'lib/clacky/agent.rb', line 151 def current_model_info model = @config.current_model return nil unless model { name: model["name"], model: model["model"], base_url: model["base_url"] } end |
#enqueue_injection(skill, task) ⇒ Object
Enqueue an inline skill injection to be flushed after observe(). Called by InvokeSkill#execute to avoid injecting during tool execution, which would break Bedrock’s toolUse/toolResult pairing requirement.
769 770 771 |
# File 'lib/clacky/agent.rb', line 769 def enqueue_injection(skill, task) @pending_injections << { skill: skill, task: task } end |
#fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil) ⇒ Agent
Fork a subagent with specified configuration The subagent inherits all messages and tools from parent agent Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks
908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 |
# File 'lib/clacky/agent.rb', line 908 def fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil) # Clone config to avoid affecting parent subagent_config = @config.deep_copy # Switch to specified model if provided if model if model == "lite" # Special keyword: use lite model if available, otherwise fall back to default lite_model = subagent_config.lite_model if lite_model model_index = subagent_config.models.index(lite_model) subagent_config.switch_model(model_index) if model_index end # If no lite model, just use current (default) model else # Regular model name lookup model_index = subagent_config.model_names.index(model) if model_index subagent_config.switch_model(model_index) else raise AgentError, "Model '#{model}' not found in config. Available models: #{subagent_config.model_names.join(', ')}" end end end # Create new client for subagent subagent_client = Clacky::Client.new( subagent_config.api_key, base_url: subagent_config.base_url, model: subagent_config.model_name, anthropic_format: subagent_config.anthropic_format? ) # Create subagent (reuses all tools from parent, inherits agent profile from parent) # Subagent gets its own unique session_id. subagent = self.class.new( subagent_client, subagent_config, working_dir: @working_dir, ui: @ui, profile: @agent_profile.name, session_id: Clacky::SessionManager.generate_id, source: @source ) subagent.instance_variable_set(:@is_subagent, true) # Inherit previous_total_tokens so the first iteration delta is calculated correctly subagent.instance_variable_set(:@previous_total_tokens, @previous_total_tokens) # Deep clone history to avoid cross-contamination. # Dangling tool_calls (no tool_result yet) are cleaned up automatically by # MessageHistory#append when the subagent appends its first user message. = deep_clone(@history.to_a) subagent.instance_variable_set(:@history, MessageHistory.new()) # Append system prompt suffix as user message (for cache reuse) if system_prompt_suffix subagent_history = subagent.history # Build forbidden tools notice if any tools are forbidden forbidden_notice = if forbidden_tools.any? tool_list = forbidden_tools.map { |t| "`#{t}`" }.join(", ") "\n\n[System Notice] The following tools are disabled in this subagent and will be rejected if called: #{tool_list}" else "" end subagent_history.append({ role: "user", 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}", system_injected: true, subagent_instructions: true }) # Insert an assistant acknowledgement so the conversation structure is complete: # [user] role/constraints → [assistant] ack → [user] actual task (from run()) subagent_history.append({ role: "assistant", content: "Understood. I am now operating as a subagent with the constraints above. Please provide the task.", system_injected: true }) end # Register hook to forbid certain tools at runtime (doesn't affect tool registry for cache) if forbidden_tools.any? subagent.add_hook(:before_tool_use) do |call| if forbidden_tools.include?(call[:name]) { action: :deny, reason: "Tool '#{call[:name]}' is forbidden in this subagent context" } else { action: :allow } end end end # Mark subagent metadata for summary generation subagent.instance_variable_set(:@is_subagent, true) subagent.instance_variable_set(:@parent_message_count, @history.size) subagent end |
#generate_subagent_summary(subagent) ⇒ String
Generate summary from subagent execution Extracts new messages added by subagent and creates a concise summary This summary will replace the subagent instructions message in parent agent
1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 |
# File 'lib/clacky/agent.rb', line 1017 def generate_subagent_summary(subagent) parent_count = subagent.instance_variable_get(:@parent_message_count) || 0 = subagent.history.to_a[parent_count..] || [] # Extract tool calls tool_calls = .select { |m| m[:role] == "assistant" && m[:tool_calls] } .flat_map { |m| m[:tool_calls].map { |tc| tc[:name] } } .uniq # Extract final assistant response last_response = .reverse .find { |m| m[:role] == "assistant" && m[:content] && !m[:content].empty? } &.dig(:content) # Build summary (this will replace the subagent instructions message) parts = [] parts << "[SUBAGENT SUMMARY]" parts << "Completed in #{subagent.iterations} iterations, cost: $#{subagent.total_cost.round(4)}" parts << "Tools used: #{tool_calls.join(', ')}" if tool_calls.any? parts << "" parts << "Results:" parts << (last_response || "(No response)") parts.join("\n") end |
#interrupt! ⇒ Object
Interrupt the agent’s current run Called when user presses Ctrl+C during agent execution
760 761 762 |
# File 'lib/clacky/agent.rb', line 760 def interrupt! @interrupted = true end |
#permission_mode ⇒ Object
41 42 43 |
# File 'lib/clacky/agent.rb', line 41 def @config&.&.to_s || "" end |
#redact_tool_args(args) ⇒ String, ...
Redact volatile tmpdir paths from tool call arguments before showing in UI. Replaces each registered path with <SKILL_DIR> so encrypted skill locations are never exposed to the user.
786 787 788 789 790 |
# File 'lib/clacky/agent.rb', line 786 def redact_tool_args(args) return args if @pending_script_tmpdirs.empty? redact_value(args) end |
#redact_value(obj) ⇒ Object
792 793 794 795 796 797 798 799 800 801 802 803 |
# File 'lib/clacky/agent.rb', line 792 def redact_value(obj) case obj when String @pending_script_tmpdirs.map(&:to_s).sort_by { |p| -p.length }.reduce(obj) { |s, path| s.gsub(path, "<SKILL_DIR>") } when Hash obj.transform_values { |v| redact_value(v) } when Array obj.map { |v| redact_value(v) } else obj end end |
#register_script_tmpdir(dir) ⇒ Object
Register a tmpdir that contains decrypted brand skill scripts. SkillManager calls this after decrypt_all_scripts so agent.run’s ensure block can shred it when the run completes.
777 778 779 |
# File 'lib/clacky/agent.rb', line 777 def register_script_tmpdir(dir) @pending_script_tmpdirs << dir end |
#rename(new_name) ⇒ Object
Rename this session. Called by auto-naming (first message) or user explicit rename.
168 169 170 |
# File 'lib/clacky/agent.rb', line 168 def rename(new_name) @name = new_name.to_s.strip end |
#run(user_input, files: []) ⇒ Object
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 |
# File 'lib/clacky/agent.rb', line 172 def run(user_input, files: []) # Start new task for Time Machine task_id = start_new_task @start_time = Time.now @task_cost_source = :estimated # Reset for new task # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration # across tasks to correctly calculate delta tokens in each iteration @task_start_iterations = @iterations # Track starting iterations for this task @task_start_cost = @total_cost # Track starting cost for this task # Track cache stats for current task @task_cache_stats = { cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_requests: 0, cache_hit_requests: 0 } # Deferred error rollback: if the previous session ended with an error, # trim history back to just before that failed user message now — at the # point the user actually sends a new message, not at restore time. # (Trimming at restore time caused replay_history to return empty results.) if @pending_error_rollback @pending_error_rollback = false last_user_index = @history.last_real_user_index if last_user_index @history.truncate_from(last_user_index) @hooks.trigger(:session_rollback, { reason: "Previous session ended with error — rolling back before new message", rolled_back_message_index: last_user_index }) end end # Add system prompt as the first message if this is the first run if @history.empty? system_prompt = build_system_prompt @history.append({ role: "system", content: system_prompt }) end # Inject session context (date + model) if not yet present or date has changed inject_session_context_if_needed # Split files into vision images and disk files; downgrade oversized images to disk image_files, disk_files = partition_files(Array(files)) vision_images, downgraded = resolve_vision_images(image_files) all_disk_files = disk_files + downgraded # Format user message — text + inline vision images # Store the tmp path alongside the data_url so the history replay can # reconstruct the image if the base64 was stripped (e.g. after compression). user_content = format_user_content(user_input, vision_images.map { |v| { url: v[:url], path: v[:path] } }) # Parse disk files — agent's responsibility, not the upload layer. # process_path runs the parser script and returns a FileRef with preview_path or parse_error. all_disk_files = all_disk_files.map do |f| path = f[:path] || f["path"] name = f[:name] || f["name"] next f unless path && File.exist?(path.to_s) ref = Utils::FileProcessor.process_path(path, name: name) { name: ref.name, type: ref.type.to_s, path: ref.original_path, preview_path: ref.preview_path, parse_error: ref.parse_error, parser_path: ref.parser_path } end # Build display_files for replay: lightweight metadata so the UI can reconstruct # file badges (PDF, doc, etc.) on page refresh. Images are NOT stored here — they # are recovered from the image_url blocks in user_content by extract_image_files_from_content. display_files = all_disk_files.filter_map do |f| name = f[:name] || f["name"] next unless name { name: name, type: f[:type] || f["type"] || "file", preview_path: f[:preview_path] || f["preview_path"] } end @history.append({ role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f, display_files: display_files.empty? ? nil : display_files }) @total_tasks += 1 # Inject disk file references as a system_injected message so: # - LLM sees the file info (system_injected is NOT stripped from to_api) # - replay_history skips it (next if ev[:system_injected]), keeping the user bubble clean # # Images: also injected here (alongside vision inline) so LLM knows filename + size. = vision_images.map { |v| { name: v[:name], type: "image", size_bytes: v[:size_bytes], path: v[:path] } } + all_disk_files unless .empty? file_prompt = .filter_map do |f| name = f[:name] || f["name"] type = f[:type] || f["type"] path = f[:path] || f["path"] preview_path = f[:preview_path] || f["preview_path"] size_bytes = f[:size_bytes] || f["size_bytes"] parse_error = f[:parse_error] || f["parse_error"] parser_path = f[:parser_path] || f["parser_path"] next unless name lines = ["[File: #{name}]", "Type: #{type || "file"}"] lines << "Size: #{format_size(size_bytes)}" if size_bytes lines << "Original: #{path}" if path lines << "Preview (Markdown): #{preview_path}" if preview_path # Parser failed — instruct LLM to fix and re-run if preview_path.nil? && parse_error lines << "Parse failed: #{parse_error}" if parser_path expected_preview = "#{path}.preview.md" lines << "Action required: fix the parser at #{parser_path}, then run:" lines << " ruby #{parser_path} #{path} > #{expected_preview}" lines << "Once done, read #{expected_preview} to continue helping the user." end end lines.join("\n") end.join("\n\n") unless file_prompt.empty? @history.append({ role: "user", content: file_prompt, system_injected: true, task_id: task_id }) end end # If the user typed a slash command targeting a skill with disable-model-invocation: true, # inject the skill content as a synthetic assistant message so the LLM can act on it. # Skills already in the system prompt (model_invocation_allowed?) are skipped. (user_input, task_id) @hooks.trigger(:on_start, user_input) begin # Track if request_user_feedback was called awaiting_user_feedback = false loop do break if should_stop? @iterations += 1 @hooks.trigger(:on_iteration, @iterations) # Think: LLM reasoning with tool support response = think # Debug: check for potential infinite loops if @config.verbose @ui&.log("Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}", level: :debug) end # Skip if compression happened (response is nil) next if response.nil? # Check if done (no more tool calls needed) if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty? # During memory update phase, show LLM response as info (not a chat bubble) if @memory_updating && response[:content] && !response[:content].empty? @ui&.show_info(response[:content].strip) elsif response[:content] && !response[:content].empty? (response[:content]) end # Show token usage after the assistant message so WebUI renders it below the bubble @ui&.show_token_usage(response[:token_usage]) if response[:token_usage] # Debug: log why we're stopping if @config.verbose && (response[:tool_calls].nil? || response[:tool_calls].empty?) reason = response[:finish_reason] == "stop" ? "API returned finish_reason=stop" : "No tool calls in response" @ui&.log("Stopping: #{reason}", level: :debug) if response[:content] && response[:content].is_a?(String) preview = response[:content].length > 200 ? response[:content][0...200] + "..." : response[:content] @ui&.log("Response content: #{preview}", level: :debug) end end # Inject memory update prompt and let the loop handle it naturally next if inject_memory_prompt! break end # Show assistant message if there's content before tool calls # During memory update phase, suppress text output (only tool calls matter) if response[:content] && !response[:content].empty? && !@memory_updating (response[:content]) end # Show token usage after assistant message (or immediately if no message). # This ensures WebUI renders the token line below the assistant bubble. @ui&.show_token_usage(response[:token_usage]) if response[:token_usage] # Act: Execute tool calls action_result = act(response[:tool_calls]) # Check if request_user_feedback was called if action_result[:awaiting_feedback] awaiting_user_feedback = true observe(response, action_result[:tool_results]) flush_pending_injections break end # Observe: Add tool results to conversation context observe(response, action_result[:tool_results]) # Flush any inline skill injections enqueued by invoke_skill during act(). # Must happen AFTER observe() so toolResult is appended before skill instructions, # producing a legal message sequence for all API providers (especially Bedrock). flush_pending_injections # Check if user denied any tool if action_result[:denied] # If user provided feedback, treat it as a user question/instruction if action_result[:feedback] && !action_result[:feedback].empty? # Add user feedback as a new user message with system_injected marker @history.append({ role: "user", content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions.", system_injected: true }) # Continue loop to let agent respond to feedback next else # User just said "no" without feedback - stop and wait @ui&.("Tool execution was denied. Please give more instructions...", files: []) break end end end result = build_result # Save snapshots of modified files for Time Machine if @modified_files_in_task && !@modified_files_in_task.empty? save_modified_files_snapshot(@modified_files_in_task) @modified_files_in_task = [] # Reset for next task end if @is_subagent # Parent agent (skill_manager) prints the completion summary; skip here. else @ui&.show_complete( iterations: result[:iterations], cost: result[:total_cost_usd], duration: result[:duration_seconds], cache_stats: result[:cache_stats], awaiting_user_feedback: awaiting_user_feedback ) end @hooks.trigger(:on_complete, result) result rescue Clacky::AgentInterrupted # Let CLI handle the interrupt message raise rescue StandardError => e # Log complete error information to debug_logs for troubleshooting @debug_logs << { timestamp: Time.now.iso8601, event: "agent_run_error", error_class: e.class.name, error_message: e., backtrace: e.backtrace&.first(30) # Keep first 30 lines of backtrace } Clacky::Logger.error("agent_run_error", error: e) # 400 errors mean our request was malformed — roll back history so the bad # message is not replayed on the next user turn. # Other errors (auth, network, etc.) leave history intact for retry. @pending_error_rollback = true if e.is_a?(Clacky::BadRequestError) # Build error result for session data, but let CLI handle error display result = build_result(:error, error: e.) # rubocop:disable Lint/UselessAssignment raise ensure # Always clean up memory update messages, even if interrupted or error occurred # Shred any decrypted-script tmpdirs created during this run for encrypted brand skills. # This covers the inline-injection path; the subagent path shreds immediately after # subagent.run returns (see execute_skill_with_subagent). shred_script_tmpdirs end end |
#running? ⇒ Boolean
Check if agent is currently running
829 830 831 |
# File 'lib/clacky/agent.rb', line 829 def running? @start_time != nil && !should_stop? end |
#switch_model(model_name) ⇒ Object
Switch to a different model by name Returns true if switched, false if model not found
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/clacky/agent.rb', line 128 def switch_model(model_name) if @config.switch_model(model_name) # Re-create client for new model @client = Clacky::Client.new( @config.api_key, base_url: @config.base_url, model: @config.model_name, anthropic_format: @config.anthropic_format? ) # Update message compressor with new client and model @message_compressor = MessageCompressor.new(@client, model: current_model) true else false end end |
#track_modified_files(tool_name, args) ⇒ Object
Track modified files for Time Machine snapshots
1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 |
# File 'lib/clacky/agent.rb', line 1228 def track_modified_files(tool_name, args) @modified_files_in_task ||= [] case tool_name when "write", "edit" file_path = args[:path] full_path = File.(file_path, @working_dir) @modified_files_in_task << full_path unless @modified_files_in_task.include?(full_path) end end |