Class: Clacky::Agent

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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

#build_system_prompt

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_profileObject (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_statsObject (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_sourceObject (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_atObject (readonly)

Returns the value of attribute created_at.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def created_at
  @created_at
end

#errorObject (readonly)

Returns the value of attribute error.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def error
  @error
end

#historyObject (readonly)

Returns the value of attribute history.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def history
  @history
end

#iterationsObject (readonly)

Returns the value of attribute iterations.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def iterations
  @iterations
end

#nameObject (readonly)

Returns the value of attribute name.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def name
  @name
end

#session_idObject (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_loaderObject (readonly)

Returns the value of attribute skill_loader.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def skill_loader
  @skill_loader
end

#sourceObject (readonly)

Returns the value of attribute source.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def source
  @source
end

#statusObject (readonly)

Returns the value of attribute status.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def status
  @status
end

#todosObject (readonly)

Returns the value of attribute todos.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def todos
  @todos
end

#total_costObject (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_tasksObject (readonly)

Returns the value of attribute total_tasks.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def total_tasks
  @total_tasks
end

#uiObject (readonly)

Returns the value of attribute ui.



37
38
39
# File 'lib/clacky/agent.rb', line 37

def ui
  @ui
end

#updated_atObject (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_dirObject (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_modelsObject

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_infoObject

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.

Parameters:

  • skill (Clacky::Skill)

    The skill whose instructions should be injected

  • task (String)

    The task description passed to the skill



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

Parameters:

  • model (String, nil) (defaults to: nil)

    Model name to use (nil = use current model)

  • forbidden_tools (Array<String>) (defaults to: [])

    List of tool names to forbid

  • system_prompt_suffix (String, nil) (defaults to: nil)

    Additional instructions (inserted as user message for cache reuse)

Returns:

  • (Agent)

    New subagent instance



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.
  cloned_messages = deep_clone(@history.to_a)
  subagent.instance_variable_set(:@history, MessageHistory.new(cloned_messages))

  # 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

Parameters:

  • subagent (Agent)

    The subagent that completed execution

Returns:

  • (String)

    Summary text to insert into 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
  new_messages = subagent.history.to_a[parent_count..] || []

  # Extract tool calls
  tool_calls = new_messages
    .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 = new_messages
    .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_modeObject



41
42
43
# File 'lib/clacky/agent.rb', line 41

def permission_mode
  @config&.permission_mode&.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.

Parameters:

  • args (String, Hash, nil)

    Raw tool arguments

Returns:

  • (String, Hash, nil)

    Redacted arguments (same type as input)



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.

Parameters:

  • dir (String)

    Absolute path to the tmpdir



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.
  all_meta_files = vision_images.map { |v|
    { name: v[:name], type: "image", size_bytes: v[:size_bytes], path: v[:path] }
  } + all_disk_files

  unless all_meta_files.empty?
    file_prompt = all_meta_files.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.
  inject_skill_command_as_assistant_message(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?
          emit_assistant_message(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
        emit_assistant_message(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&.show_assistant_message("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.message,
      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.message)  # rubocop:disable Lint/UselessAssignment
    raise
  ensure
    # Always clean up memory update messages, even if interrupted or error occurred
    cleanup_memory_messages

    # 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

Returns:

  • (Boolean)


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

Parameters:

  • tool_name (String)

    Name of the tool that was executed

  • args (Hash)

    Arguments passed to the tool



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.expand_path(file_path, @working_dir)
    @modified_files_in_task << full_path unless @modified_files_in_task.include?(full_path)
  end
end