Class: Clacky::Agent

Inherits:
Object
  • Object
show all
Includes:
CostTracker, FakeToolCallDetector, LlmCaller, MemoryUpdater, MessageCompressorHelper, SessionSerializer, SkillAutoCreator, SkillEvolution, SkillManager, SkillReflector, 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/skill_evolution.rb,
lib/clacky/agent/skill_reflector.rb,
lib/clacky/agent/session_serializer.rb,
lib/clacky/agent/skill_auto_creator.rb,
lib/clacky/agent/system_prompt_builder.rb,
lib/clacky/agent/fake_tool_call_detector.rb,
lib/clacky/agent/message_compressor_helper.rb

Defined Under Namespace

Modules: CostTracker, FakeToolCallDetector, LlmCaller, MemoryUpdater, MessageCompressorHelper, SessionSerializer, SkillAutoCreator, SkillEvolution, SkillManager, SkillReflector, SystemPromptBuilder, TimeMachine, ToolExecutor

Constant Summary collapse

REASONING_EFFORTS =
%w[low medium high xhigh max].freeze
MAX_TOOL_RESULT_CHARS =

Cap oversized tool result content to keep a single tool message from blowing up the prompt budget (issue #218: a 7350-path glob produced a ~890k-char result that pushed history past the model context window and poisoned the session). Only string content is truncated — Array content (multipart/image blocks) is left alone since image payloads are handled by the image_inject path above.

80_000

Constants included from FakeToolCallDetector

FakeToolCallDetector::FAKE_TOOL_CALL_PATTERNS, FakeToolCallDetector::MAX_FAKE_TOOL_CALL_RETRIES

Constants included from SkillAutoCreator

SkillAutoCreator::DEFAULT_AUTO_CREATE_THRESHOLD

Constants included from SkillReflector

SkillReflector::MIN_SKILL_ITERATIONS

Constants included from MemoryUpdater

MemoryUpdater::MEMORIES_DIR, MemoryUpdater::MEMORY_UPDATE_MIN_ITERATIONS

Constants included from TimeMachine

TimeMachine::ABSENT_MARKER

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_MCP_SERVERS, SkillManager::MAX_CONTEXT_SKILLS

Constants included from MessageCompressorHelper

MessageCompressorHelper::IDLE_COMPRESSION_THRESHOLD, MessageCompressorHelper::MAX_RECENT_MESSAGES, MessageCompressorHelper::TARGET_COMPRESSED_TOKENS

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from SkillAutoCreator

#maybe_create_skill_from_task

Methods included from SkillReflector

#maybe_reflect_on_skill

Methods included from SkillEvolution

#run_skill_evolution_hooks

Methods included from MemoryUpdater

#run_memory_update_subagent, #should_update_memory?

Methods included from TimeMachine

#active_messages, delete_session_snapshots, #get_child_tasks, #get_task_history, #preview_restore_to_task, #record_file_before_change, #restore_to_task_state, session_dir, #set_current_task_title, snapshots_root, #start_new_task, #switch_to_task, #task_change_count, #task_diff_files, #task_file_diff, #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_all_skills_meta, #load_memories_meta, #load_skills, #memories_base_dir, #parse_memory_frontmatter, #parse_skill_command, #shred_directory, #touch_skill_for_lru, warn_skill_limit_once

Methods included from SessionSerializer

#_replay_single_message, #attach_ext_events_to_last, #extract_ext_events_from_text, #extract_image_files_from_content, #extract_images_from_content, #extract_text_from_content, #get_recent_user_messages, #inject_chunk_index_if_needed, #parse_piped_entries, #parse_tool_calls_line, #refresh_system_prompt, #replay_ext_events, #replay_history, #replay_subagent_transcript, #restore_session, #scan_balanced_json, #to_session_data

Methods included from CostTracker

#absorb_subagent_cost, #billing_store, #collect_iteration_tokens, #persist_billing_record, #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

#append_ext_events_line, #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, #merge_into_previous_chunk, #parse_shell_result, #parse_todo_result, #parse_write_result, #pull_assistant_before, #pull_tool_results_after, #render_message_sections, #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.



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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/clacky/agent.rb', line 76

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
  @channel_info = nil  # { platform:, user_id:, user_name:, chat_id: } set by ChannelManager
  @tool_registry = ToolRegistry.new
  @hooks = HookManager.new(agent: self)
  @session_id = session_id
  @name = ""
  @pinned = false
  @history = MessageHistory.new
  @todos = []  # Store todos in memory
  @iterations = 0
  @total_cost = 0.0
  @cost_mutex = Mutex.new
  @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
  @latest_latency = nil  # Most recent LLM call's latency metrics (see Client#send_messages_with_tools)
  @reasoning_effort = nil  # Per-session reasoning effort override; nil = provider default
  @ui = ui  # UIController for direct UI interaction
  @debug_logs = []  # Debug logs for troubleshooting
  @pending_injections = []     # Pending inline skill injections to flush after observe()
  @pending_subagent_transcripts = {} # tool_call_id => subagent trail, attached by observe()
  @pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
  @pending_error_rollback = false  # Deferred rollback flag set by restore_session on error
  @last_run_interrupted = false    # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)

  # 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)

  # MCP virtual skills: load mcp.json and expose one VirtualSkill per
  # configured server in the AVAILABLE MCP SERVERS section. The agent does
  # NOT spawn or talk to MCP server processes itself — all calls go through
  # the local Clacky HTTP API (/api/mcp/:server/tools and /call). Subagents
  # invoke those endpoints via curl, so MCP behaves like any other skill.
  @skill_loader.attach_virtual_skill_provider(Mcp::SkillProvider.new(working_dir: @working_dir))

  # 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!
  # Free-mode counterpart: branded but not activated → fetch unencrypted skills
  # via the public endpoint so users get a working install with no serial number.
  @brand_config.sync_free_skills_async!
  # Brand extensions bundled into the activated license's distribution.
  @brand_config.sync_brand_extensions_async!

  # Initialize Time Machine
  init_time_machine

  # Register built-in tools
  register_builtin_tools

  # Load declarative shell hooks from ~/.clacky/hooks.yml. Entries with
  # `type: rewrite` use the rich JSON protocol (updatedInput rewrite);
  # entries without `type` use the simple exit-code protocol.
  ShellHookLoader.load_into(
    @hooks,
    session_id_fn:      -> { @session_id },
    cwd_fn:             -> { @working_dir },
    permission_mode_fn: -> { @config.permission_mode.to_s }
  )

  # Copy ext.yml-contributed hook callbacks (contributes.hooks) onto this
  # agent's hook manager. The callbacks were registered process-wide at
  # boot via ExtensionHookLoader.
  ExtensionHookRegistry.apply_to(@hooks)

  # Ensure user-space parsers are in place (~/.clacky/parsers/)
  Utils::ParserManager.setup!

  # Ensure bundled shell scripts are in place (~/.clacky/scripts/)
  Utils::ScriptsManager.setup!

  # Ensure bundled search providers are in place (~/.clacky/searchers/)
  Utils::SearcherManager.setup!
end

Instance Attribute Details

#agent_profileObject (readonly)

Returns the value of attribute agent_profile.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def agent_profile
  @agent_profile
end

#cache_statsObject (readonly)

Returns the value of attribute cache_stats.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def cache_stats
  @cache_stats
end

#channel_infoObject

Returns the value of attribute channel_info.



53
54
55
# File 'lib/clacky/agent.rb', line 53

def channel_info
  @channel_info
end

#configObject (readonly)

Returns the value of attribute config.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def config
  @config
end

#cost_sourceObject (readonly)

Returns the value of attribute cost_source.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def cost_source
  @cost_source
end

#created_atObject (readonly)

Returns the value of attribute created_at.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def created_at
  @created_at
end

#errorObject (readonly)

Returns the value of attribute error.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def error
  @error
end

#historyObject (readonly)

Returns the value of attribute history.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def history
  @history
end

#iterationsObject (readonly)

Returns the value of attribute iterations.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def iterations
  @iterations
end

#latest_latencyObject (readonly)

Returns the value of attribute latest_latency.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def latest_latency
  @latest_latency
end

#nameObject (readonly)

Returns the value of attribute name.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def name
  @name
end

#pinnedObject

Returns the value of attribute pinned.



52
53
54
# File 'lib/clacky/agent.rb', line 52

def pinned
  @pinned
end

#project_idObject

Returns the value of attribute project_id.



54
55
56
# File 'lib/clacky/agent.rb', line 54

def project_id
  @project_id
end

#reasoning_effortObject

Returns the value of attribute reasoning_effort.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def reasoning_effort
  @reasoning_effort
end

#session_idObject (readonly)

Returns the value of attribute session_id.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def session_id
  @session_id
end

#skill_loaderObject (readonly)

Returns the value of attribute skill_loader.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def skill_loader
  @skill_loader
end

#sourceObject (readonly)

Returns the value of attribute source.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def source
  @source
end

#statusObject (readonly)

Returns the value of attribute status.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def status
  @status
end

#todosObject (readonly)

Returns the value of attribute todos.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def todos
  @todos
end

#total_costObject (readonly)

Returns the value of attribute total_cost.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def total_cost
  @total_cost
end

#total_tasksObject (readonly)

Returns the value of attribute total_tasks.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def total_tasks
  @total_tasks
end

#uiObject (readonly)

Returns the value of attribute ui.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def ui
  @ui
end

#updated_atObject (readonly)

Returns the value of attribute updated_at.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def updated_at
  @updated_at
end

#working_dirObject (readonly)

Returns the value of attribute working_dir.



47
48
49
# File 'lib/clacky/agent.rb', line 47

def working_dir
  @working_dir
end

Class Method Details

.from_session(client, config, session_data, ui: nil, profile:) ⇒ Object

Restore from a saved session



178
179
180
181
182
183
184
185
186
187
# File 'lib/clacky/agent.rb', line 178

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



189
190
191
# File 'lib/clacky/agent.rb', line 189

def add_hook(event, &block)
  @hooks.add(event, &block)
end

#available_modelsObject

Get list of available model names



242
243
244
# File 'lib/clacky/agent.rb', line 242

def available_models
  @config.model_names
end

#change_working_dir(new_dir) ⇒ Object

Change the working directory for this session Injects a new session context to notify the AI of the directory change



235
236
237
238
239
# File 'lib/clacky/agent.rb', line 235

def change_working_dir(new_dir)
  @working_dir = new_dir
  inject_session_context
  true
end

#current_model_infoObject

Get current model configuration info



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/clacky/agent.rb', line 247

def current_model_info
  model = @config.current_model
  return nil unless model

  card_id = @config.current_model_id
  base_entry = card_id ? @config.models.find { |m| m["id"] == card_id } : nil
  sub_model = @config.session_model_overlay_name

  {
    id: model["id"],
    model: model["model"],
    base_url: model["base_url"],
    provider_id: model["provider_id"],
    card_model: base_entry&.dig("model"),
    sub_model: sub_model
  }
end

#emit_event(type, persist: false, **data) ⇒ Object

Emit a custom extension event to the UI.

type must be namespaced "ext.." so custom events can never collide with the built-in protocol.

Transient by default: progress ticks and other high-frequency chatter are pushed live and forgotten, so extensions cannot bloat session.json without opting in. Pass persist: true for milestone events that must reappear in the chat stream after a reload; those are anchored to the current message, survive compression via the chunk MD, and are replayed in place.



298
299
300
301
302
303
304
305
306
307
# File 'lib/clacky/agent.rb', line 298

def emit_event(type, persist: false, **data)
  name = type.to_s
  unless name.start_with?("ext.")
    raise ArgumentError, "custom event type must be namespaced as 'ext.<extension>.<event>', got #{name.inspect}"
  end

  @ui&.emit(name, **data)
  @history.append_ext_event({ type: name, data: data }) if persist
  self
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



1473
1474
1475
# File 'lib/clacky/agent.rb', line 1473

def enqueue_injection(skill, task)
  @pending_injections << { skill: skill, task: task }
end

#extract_subagent_transcript(subagent, skill_identifier) ⇒ Object

Extract the subagent's own message trail for persistence/replay. Returns a trimmed, LLM-free array of content, tool_calls hashes capturing only what the subagent did after the fork — system-injected scaffolding (fork instructions, ack) is dropped. Stored on the parent's invoke_skill tool result under :subagent_transcript so the WebUI can render a collapsible sub-process without polluting the main thread.



1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
# File 'lib/clacky/agent.rb', line 1873

def extract_subagent_transcript(subagent, skill_identifier)
  parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
  new_messages = subagent.history.to_a[parent_count..] || []

  events = new_messages.filter_map do |m|
    next if m[:system_injected]
    role = m[:role].to_s
    next unless %w[assistant tool user].include?(role)

    entry = { role: role }
    entry[:content] = m[:content] if m[:content] && !m[:content].to_s.empty?
    if m[:tool_calls].is_a?(Array) && !m[:tool_calls].empty?
      entry[:tool_calls] = m[:tool_calls].map do |tc|
        func = tc[:function] || tc
        { name: func[:name] || tc[:name], arguments: func[:arguments] || tc[:arguments] || {} }
      end
    end
    entry[:tool_call_id] = m[:tool_call_id] if m[:tool_call_id]
    entry.key?(:content) || entry.key?(:tool_calls) ? entry : nil
  end

  {
    skill: skill_identifier,
    iterations: subagent.iterations,
    cost_usd: subagent.total_cost.round(4),
    events: events
  }
end

#fan_out_subagents(tasks, model: nil, forbidden_tools: [], max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY, timeout: nil) ⇒ Array<Fanout::Result>

Run several independent tasks on forked subagents at the same time.

Each subagent is forked on the calling thread (forking deep-copies parent config + history, which must not race) and only the blocking run is moved onto the pool. Results keep the order of tasks; a task that raises or overruns the budget yields a failed slot instead of aborting its siblings.

Subagents run silently by default: they share the parent's session, so a live UI would interleave several raw transcripts into one chat.

Parameters:

  • tasks (Array<String>)

    one prompt per subagent

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

    model for every subagent (nil = current)

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

    tool names blocked at runtime

  • max_concurrency (Integer) (defaults to: Fanout::DEFAULT_MAX_CONCURRENCY)

    subagents allowed to run at once

  • timeout (Numeric, nil) (defaults to: nil)

    wall-clock budget for the whole batch

Returns:

  • (Array<Fanout::Result>)

    value is the subagent's final reply text



1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
# File 'lib/clacky/agent.rb', line 1644

def fan_out_subagents(tasks, model: nil, forbidden_tools: [], max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY,
                      timeout: nil)
  return [] if tasks.empty?

  jobs = tasks.each_with_index.map do |task, index|
    subagent = fork_subagent(
      model: model,
      forbidden_tools: forbidden_tools,
      system_prompt_suffix: "You are one of several subagents running in parallel on independent tasks. " \
                            "Do your task and return only the requested output. Do not ask follow-up questions."
    )
    parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
    label = "Subagent #{index + 1}/#{tasks.size}"
    # Fanout workers are fresh threads, so the epoch that lets the web
    # broadcaster drop events from superseded tasks has to be carried over
    # by hand — otherwise interrupted subagents keep writing to the new task.
    epoch = Thread.current[:task_epoch]

    lambda do
      Thread.current[:task_epoch] = epoch
      within_phase(label, kind: "fanout_subagent", concurrent: true) do
        result = subagent.run(task)
        absorb_subagent_cost(result, notify_ui: false)
        final_assistant_text(subagent, parent_count)
      end
    end
  end

  Fanout.new(max_concurrency: max_concurrency, timeout: timeout).run(jobs)
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



1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
# File 'lib/clacky/agent.rb', line 1697

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 is now a *virtual* role — we don't require it to exist as a
      # concrete entry in @models. Instead we derive it from whatever
      # model the user is currently on (current_model), so switching
      # primary models automatically re-pairs with the right lite
      # companion (Claude → Haiku, DeepSeek V4-pro → V4-flash, ...).
      lite_cfg = subagent_config.lite_model_config_for_current
      if lite_cfg
        if lite_cfg["virtual"]
          # Provider-preset derived: apply the lite fields as a *session
          # overlay* on the subagent's config — this intentionally avoids
          # mutating the shared @models array / hashes which would pollute
          # the parent agent's own current model (e.g. turning the parent's
          # Opus entry into Haiku for the rest of the session).
          subagent_config.apply_virtual_model_overlay!(
            "api_key"          => lite_cfg["api_key"],
            "base_url"         => lite_cfg["base_url"],
            "model"            => lite_cfg["model"],
            "anthropic_format" => lite_cfg["anthropic_format"]
          )
        elsif lite_cfg["id"]
          # Explicit user-configured lite (from CLACKY_LITE_* env): a
          # real @models entry with a stable id. Switch to it normally.
          subagent_config.switch_model_by_id(lite_cfg["id"])
        end
      end
      # If no lite is resolvable, just use current (primary) model.
    else
      # Regular model name lookup — find the first model with a matching
      # name and switch by its stable id.
      target = subagent_config.models.find { |m| m["model"] == model }
      if target && target["id"]
        subagent_config.switch_model_by_id(target["id"])
      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))

  # The cloned history carries per-message task_id tags. Without the parent's
  # Time Machine task state the subagent's @active_task_id stays 0, so
  # active_task_chain collapses to {0} and active_messages filters out every
  # message tagged task_id > 0 — silently shrinking the context and busting
  # prompt caching. Carry the task state alongside @history so the subagent
  # sees the same chain (and cache prefix) as the parent.
  subagent.instance_variable_set(:@task_parents, deep_clone(@task_parents))
  subagent.instance_variable_set(:@current_task_id, @current_task_id)
  subagent.instance_variable_set(:@active_task_id, @active_task_id)
  subagent.instance_variable_set(:@task_meta, deep_clone(@task_meta))

  # 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



1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
# File 'lib/clacky/agent.rb', line 1839

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

#goal_active?Boolean

True if a standing goal loop is active for this session.

Returns:

  • (Boolean)


284
285
286
# File 'lib/clacky/agent.rb', line 284

def goal_active?
  @goal_manager&.active? || false
end

#goal_managerObject

Lazily-built GoalManager bound to this session. The judge routes through this agent's Client on a lightweight model (the provider's lite model when available, else the primary model) — a cheap side call that never touches conversation history.



276
277
278
279
280
281
# File 'lib/clacky/agent.rb', line 276

def goal_manager
  @goal_manager ||= GoalManager.new(
    judge_client: @client,
    judge_model:  judge_model_name
  )
end

#permission_modeObject



58
59
60
# File 'lib/clacky/agent.rb', line 58

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)



1490
1491
1492
1493
1494
# File 'lib/clacky/agent.rb', line 1490

def redact_tool_args(args)
  return args if @pending_script_tmpdirs.empty?

  redact_value(args)
end

#redact_value(obj) ⇒ Object



1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
# File 'lib/clacky/agent.rb', line 1496

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. The tmpdir lives for the agent's lifetime (a session), not just a single agent.run.

Parameters:

  • dir (String)

    Absolute path to the tmpdir



1481
1482
1483
# File 'lib/clacky/agent.rb', line 1481

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.



461
462
463
# File 'lib/clacky/agent.rb', line 461

def rename(new_name)
  @name = new_name.to_s.strip
end

#run(user_input, files: [], display_text: nil, created_at: nil) ⇒ Object



465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
# File 'lib/clacky/agent.rb', line 465

def run(user_input, files: [], display_text: nil, created_at: nil)
  # Intercept /goal ... commands before any task/LLM work. Control-plane
  # commands (status/pause/resume/clear) return immediately without a turn;
  # `/goal <text>` sets the goal, then falls through to run the first turn.
  goal_intercept = handle_goal_command(user_input)
  return goal_intercept[:result] if goal_intercept[:handled]
  user_input = goal_intercept[:user_input] if goal_intercept[:user_input]

  # Auto-clear a finished/paused goal when the user starts a new non-goal
  # task. /goal <text> already replaced the goal above; control commands
  # returned early. The "✓ Goal achieved" line stays in the thread.
  if !goal_intercept[:user_input] && @goal_manager&.state && !@goal_manager.active?
    @goal_manager.clear
    broadcast_goal_status
  end

  # Show the "thinking" indicator as early as possible so the user gets
  # immediate feedback after sending a message. Without this the UI stays
  # silent during synchronous setup work (system prompt assembly, file
  # parsing, history compression checks) before the first LLM call. The
  # subsequent `think` call will re-emit show_progress, which is an
  # idempotent update on the same progress UI element.
  @ui&.show_progress

  # Start new task for Time Machine
  task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)

  # Continuation of a previously-interrupted task (e.g. user sent a
  # supplementary message without stopping the running task) keeps the
  # existing task-start snapshot so the completion summary accumulates
  # iterations/cost/duration across the relay, instead of resetting and
  # only counting the post-interrupt portion.
  if @last_run_interrupted
    @last_run_interrupted = false
  else
    @start_time = Time.now
    @task_truncation_count = 0  # Reset truncation counter for each task
    @task_fake_tool_call_count = 0  # Reset fake tool-call counter for each task
    @task_timeout_hint_injected = false  # Reset read-timeout hint injection (see LlmCaller)
    @task_upstream_truncation_hint_injected = false  # Reset upstream-truncation hint injection (see LlmCaller)
    @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,
      prompt_tokens: 0,
      completion_tokens: 0,
      total_requests: 0,
      cache_hit_requests: 0
    }
  end

  # 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

  # Inject chunk index card if archived chunks exist and index is stale
  inject_chunk_index_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)
    # Preserve the downgrade_reason tag across the remap (process_path
    # returns a fresh FileRef that doesn't know about it). Without this,
    # the file_prompt builder can't emit the "not supported by model" /
    # "too large" note for downgraded images.
    downgrade_reason = f[:downgrade_reason] || f["downgrade_reason"]
    ocr_text         = f[:ocr_text]         || f["ocr_text"]
    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,
      downgrade_reason: downgrade_reason, ocr_text: ocr_text }
  end

  # Build display_files for replay: lightweight metadata so the UI can reconstruct
  # file badges (PDF, doc, etc.) on page refresh. Vision-inlined images are NOT
  # stored here — they recover from image_url blocks in user_content. Downgraded
  # images (provider has no vision / too large / OCR'd) DO need path here so the
  # UI can re-render them from the on-disk copy across session switches.
  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",
      path: f[:path] || f["path"],
      preview_path: f[:preview_path] || f["preview_path"] }
  end

  created_at ||= Time.now.to_f
  @history.append({ role: "user", content: user_content, task_id: task_id, created_at: created_at,
                    display_text: display_text,
                    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"]
      downgrade_reason = f[:downgrade_reason] || f["downgrade_reason"]
      ocr_text         = f[:ocr_text]         || f["ocr_text"]

      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

      # Inline note explaining why an image was *not* sent as vision
      # content. Colocated with the file info (not in system prompt) so
      # it reflects the exact reason for *this* upload under *this*
      # model — switching models later won't leave stale warnings.
      note = downgrade_note_for(downgrade_reason)
      lines << "Note: #{note}" if note

      # OCR transcription (when an OCR sidecar successfully described
      # an image the primary model couldn't see). Embedded inline so
      # the LLM has the description colocated with the file entry.
      if ocr_text && !ocr_text.strip.empty?
        lines << "OCR description:"
        lines << ocr_text.strip
      end

      # 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"
          interp = Utils::ParserManager.interpreter_for(File.basename(parser_path))
          lines << "Action required: fix the parser at #{parser_path}, then run:"
          lines << "  #{interp} #{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)

  result = nil
  begin
    # Track if request_user_feedback was called
    awaiting_user_feedback = false
    # Track if task was interrupted by user (denied tool execution)
    task_interrupted = false

    loop do
      @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?

      # [DIAG] Only log when finish_reason=="stop" AND tool_calls non-empty —
      # the suspicious combo that indicates an upstream-truncated tool_use
      # response. Normal responses produce no log line here to avoid noise.
      begin
        tool_calls = response[:tool_calls] || []
        if response[:finish_reason] == "stop" && !tool_calls.empty?
          tc_summary = tool_calls.map do |c|
            args_str = c[:arguments].is_a?(String) ? c[:arguments] : c[:arguments].to_s
            {
              name: c[:name].to_s,
              args_len: args_str.length,
              args_head: args_str[0, 120]
            }
          end
          Clacky::Logger.warn("agent.think_response",
            session_id: @session_id,
            iteration: @iterations,
            finish_reason: response[:finish_reason].to_s,
            tool_calls_count: tool_calls.size,
            tool_calls: tc_summary,
            content_len: response[:content].to_s.length,
            completion_tokens: response.dig(:token_usage, :completion_tokens),
            ttft_ms: response.dig(:latency, :ttft_ms),
            suspicious_truncation: true
          )
        end
      rescue StandardError => e
        Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
      end

      # Detect fake tool-calls written as XML/text in content (model bug
      # where it emits `<invoke name="...">` instead of using the
      # structured tool_calls field). Only triggers when tool_calls is
      # absent — a real call alongside stray XML is not our problem here.
      if (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
         fake_tool_call_in_content?(response[:content])
        case handle_fake_tool_call(response)
        when :retry then next
        when :stop then break
        end
      end

      # Check if done (no more tool calls needed).
      #
      # Defensive rule: we ONLY exit on empty/missing tool_calls.
      # We used to also short-circuit on finish_reason=="stop", but
      # upstream routers (OpenRouter → Anthropic/Bedrock) can return the
      # contradictory combo `finish_reason=="stop" + non-empty tool_calls
      # with truncated args`, which caused the agent to silently treat a
      # truncated response as "task complete". Truncation is now caught
      # earlier by LlmCaller#detect_upstream_truncation! (which raises
      # UpstreamTruncatedError → RetryableError); this branch stays as
      # a belt-and-braces guard: if that detector ever misses a new
      # truncation pattern, we still won't silently exit while the model
      # is mid-tool_call.
      if response[:tool_calls].nil? || response[:tool_calls].empty?
        content_str = response[:content].to_s
        stripped = content_str.strip
        ends_with_question = stripped.end_with?("?", "")
        finish_reason_str = response[:finish_reason].to_s
        completion_tokens = response.dig(:token_usage, :completion_tokens)

        Clacky::Logger.info("agent.loop_break_normal",
          session_id: @session_id,
          iteration: @iterations,
          branch: (response[:tool_calls].nil? ? "tool_calls_nil" : "tool_calls_empty"),
          finish_reason: finish_reason_str,
          tool_calls_count: (response[:tool_calls] || []).size,
          completion_tokens: completion_tokens,
          max_tokens: @config.max_tokens,
          content_len: content_str.length,
          content_ends_with_question: ends_with_question
        )

        if finish_reason_str == "length"
          Clacky::Logger.warn("agent.loop_break_on_length",
            session_id: @session_id,
            iteration: @iterations,
            completion_tokens: completion_tokens,
            max_tokens: @config.max_tokens,
            content_len: content_str.length,
            content_tail: content_str[-200, 200]
          )
        end
        if response[:content] && !response[:content].empty?
          emit_assistant_message(response[:content], reasoning_content: response[:reasoning_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

        # If the assistant ended its turn with a question, treat this as
        # an in-flight conversation (agent is awaiting the user's reply)
        # and skip skill evolution — the task isn't truly complete yet.
        awaiting_user_feedback = true if ends_with_question

        break
      end

      # Show assistant message if there's content before tool calls
      if response[:content] && !response[:content].empty?
        emit_assistant_message(response[:content], reasoning_content: response[:reasoning_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]
        task_interrupted = true
        # 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: "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

    # Run skill evolution hooks after main loop completes
    # Skip if task was interrupted by user (denied tool) or awaiting user feedback
    # Only for main agent (not subagents) to avoid recursive evolution
    unless @is_subagent || task_interrupted || awaiting_user_feedback
      run_skill_evolution_hooks
    end

    # Run long-term memory update as a forked subagent BEFORE we print
    # show_complete. Running it as a subagent (rather than inline in
    # the main loop) gives us correct visual ordering structurally:
    # the subagent blocks until done, its progress spinner finishes,
    # and only then [OK] Task Complete is printed. No cleanup dance,
    # no cross-method progress handle holding.
    # Skip on interrupt / feedback / subagent (self-guarded inside too).
    unless @is_subagent || task_interrupted || awaiting_user_feedback
      run_memory_update_subagent
    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],
        cost_source: result[:cost_source],
        duration: result[:duration_seconds],
        cache_stats: result[:cache_stats],
        awaiting_user_feedback: awaiting_user_feedback
      )
    end
    @hooks.trigger(:on_complete, result)

    # Standing-goal loop: after a completed turn, ask the judge whether the
    # goal is met. If not (and budget/health allow), auto-run the next turn
    # in this same thread. Skipped for subagents and interrupts.
    # awaiting_user_feedback (agent ended with '?') is intentionally not
    # checked here - maybe_continue_goal is a no-op when no goal is active,
    # and when one is active the judge decides done/continue, not punctuation.
    unless @is_subagent || task_interrupted
      continuation = maybe_continue_goal(result)
      return continuation if continuation
    end

    result
  rescue Clacky::AgentInterrupted
    # Mark this run as interrupted so the next run() (e.g. user's
    # supplementary message during a running task) keeps the existing
    # task-start snapshot — the completion summary should reflect the
    # entire task across the relay, not just the post-interrupt portion.
    @last_run_interrupted = true
    # 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)
    raise
  ensure
    # Safety net: ensure any lingering progress spinner is stopped.
    @ui&.show_progress(phase: "done")

    # Fire-and-forget telemetry after every agent run.
    # Tracks daily active users (distinct devices per day) and task volume.
    Clacky::Telemetry.task!(result: result)
  end
end

#run_detached(task, model: nil, forbidden_tools: []) ⇒ String

Run a one-off task on a forked subagent and return its final reply text, WITHOUT mutating this (parent) agent's history. Used by extensions that need a side analysis (e.g. meeting annotate) which must reuse the parent's cached context + unified billing, but must NOT pollute the main conversation.

The subagent deep-clones the parent history (cache prefix + task state), runs to completion, and is discarded. Only the cost is merged back into the parent.

Parameters:

  • task (String)

    The task/prompt for the subagent

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

    Model name ("lite" for the lite companion, nil = current)

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

    Tool names to block at runtime

Returns:

  • (String)

    Subagent's final assistant reply (empty string if none)



1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
# File 'lib/clacky/agent.rb', line 1608

def run_detached(task, model: nil, forbidden_tools: [])
  subagent = fork_subagent(
    model: model,
    forbidden_tools: forbidden_tools,
    system_prompt_suffix: "You are running a one-off background analysis. Do the task and return only the requested output. Do not ask follow-up questions."
  )
  # Detached runs must stay invisible: a real UI (e.g. WebUIController bound
  # to the parent's session_id) would broadcast the subagent's raw output
  # into the parent chat transcript. Swap in a no-op UI so nothing leaks.
  subagent.instance_variable_set(:@ui, NullUIController.new)
  parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
  result = subagent.run(task)

  # A detached run stays invisible, so its cost is merged silently — the
  # sessionbar refresh would be the one thing that gives it away.
  absorb_subagent_cost(result, notify_ui: false)

  final_assistant_text(subagent, parent_count)
end

#running?Boolean

Check if agent is currently running

Returns:

  • (Boolean)


1533
1534
1535
# File 'lib/clacky/agent.rb', line 1533

def running?
  !@start_time.nil?
end

#set_session_sub_model(model_name) ⇒ Boolean

Pin this session to a sub-model name without changing its underlying card (credentials / base_url stay put). Pass nil or "" to clear and fall back to the card's default model. Validation that the name is listed under the current provider is the caller's job.

Parameters:

  • model_name (String, nil)

Returns:

  • (Boolean)


211
212
213
214
215
# File 'lib/clacky/agent.rb', line 211

def set_session_sub_model(model_name)
  @config.session_model_overlay = model_name
  rebuild_client_for_current_model!
  true
end

#switch_model_by_id(id) ⇒ Boolean

Switch this session to a different model, identified by its stable runtime id. Ids survive list reorders, additions, and field edits, which is why we no longer expose an index-based API.

Parameters:

  • id (String)

    Model id (see AgentConfig#parse_models)

Returns:

  • (Boolean)

    true if switched successfully, false otherwise



198
199
200
201
202
203
# File 'lib/clacky/agent.rb', line 198

def switch_model_by_id(id)
  return false unless @config.switch_model_by_id(id)

  rebuild_client_for_current_model!
  true
end