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, #extract_image_files_from_content, #extract_images_from_content, #extract_text_from_content, #get_recent_user_messages, #inject_chunk_index_if_needed, #refresh_system_prompt, #replay_history, #replay_subagent_transcript, #restore_session, #to_session_data

Methods included from CostTracker

#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

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



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
176
177
# File 'lib/clacky/agent.rb', line 78

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
  @session_id = session_id
  @name = ""
  @pinned = false
  @hidden = false
  @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
  @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_transcript = nil # Subagent trail to attach to the next tool result (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.



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

def agent_profile
  @agent_profile
end

#cache_statsObject (readonly)

Returns the value of attribute cache_stats.



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

def cache_stats
  @cache_stats
end

#channel_infoObject

Returns the value of attribute channel_info.



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

def channel_info
  @channel_info
end

#configObject (readonly)

Returns the value of attribute config.



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

def config
  @config
end

#cost_sourceObject (readonly)

Returns the value of attribute cost_source.



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

def cost_source
  @cost_source
end

#created_atObject (readonly)

Returns the value of attribute created_at.



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

def created_at
  @created_at
end

#errorObject (readonly)

Returns the value of attribute error.



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

def error
  @error
end

#hiddenObject

Returns the value of attribute hidden.



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

def hidden
  @hidden
end

#historyObject (readonly)

Returns the value of attribute history.



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

def history
  @history
end

#iterationsObject (readonly)

Returns the value of attribute iterations.



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

def iterations
  @iterations
end

#latest_latencyObject (readonly)

Returns the value of attribute latest_latency.



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

def latest_latency
  @latest_latency
end

#nameObject (readonly)

Returns the value of attribute name.



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

def name
  @name
end

#pinnedObject

Returns the value of attribute pinned.



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

def pinned
  @pinned
end

#project_idObject

Returns the value of attribute project_id.



56
57
58
# File 'lib/clacky/agent.rb', line 56

def project_id
  @project_id
end

#reasoning_effortObject

Returns the value of attribute reasoning_effort.



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

def reasoning_effort
  @reasoning_effort
end

#session_idObject (readonly)

Returns the value of attribute session_id.



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

def session_id
  @session_id
end

#skill_loaderObject (readonly)

Returns the value of attribute skill_loader.



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

def skill_loader
  @skill_loader
end

#sourceObject (readonly)

Returns the value of attribute source.



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

def source
  @source
end

#statusObject (readonly)

Returns the value of attribute status.



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

def status
  @status
end

#todosObject (readonly)

Returns the value of attribute todos.



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

def todos
  @todos
end

#total_costObject (readonly)

Returns the value of attribute total_cost.



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

def total_cost
  @total_cost
end

#total_tasksObject (readonly)

Returns the value of attribute total_tasks.



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

def total_tasks
  @total_tasks
end

#uiObject (readonly)

Returns the value of attribute ui.



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

def ui
  @ui
end

#updated_atObject (readonly)

Returns the value of attribute updated_at.



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

def updated_at
  @updated_at
end

#working_dirObject (readonly)

Returns the value of attribute working_dir.



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

def working_dir
  @working_dir
end

Class Method Details

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

Restore from a saved session



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

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



191
192
193
# File 'lib/clacky/agent.rb', line 191

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

#available_modelsObject

Get list of available model names



244
245
246
# File 'lib/clacky/agent.rb', line 244

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



237
238
239
240
241
# File 'lib/clacky/agent.rb', line 237

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

#current_model_infoObject

Get current model configuration info



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

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

#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



1442
1443
1444
# File 'lib/clacky/agent.rb', line 1442

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.



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
# File 'lib/clacky/agent.rb', line 1783

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

#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



1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
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
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
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
# File 'lib/clacky/agent.rb', line 1607

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



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
# File 'lib/clacky/agent.rb', line 1749

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)


286
287
288
# File 'lib/clacky/agent.rb', line 286

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.



278
279
280
281
282
283
# File 'lib/clacky/agent.rb', line 278

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

#permission_modeObject



60
61
62
# File 'lib/clacky/agent.rb', line 60

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)



1459
1460
1461
1462
1463
# File 'lib/clacky/agent.rb', line 1459

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

  redact_value(args)
end

#redact_value(obj) ⇒ Object



1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
# File 'lib/clacky/agent.rb', line 1465

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



1450
1451
1452
# File 'lib/clacky/agent.rb', line 1450

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.



442
443
444
# File 'lib/clacky/agent.rb', line 442

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

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



446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
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
# File 'lib/clacky/agent.rb', line 446

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)



1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
# File 'lib/clacky/agent.rb', line 1577

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)

  @total_cost += result[:total_cost_usd] || 0.0

  new_messages = subagent.history.to_a[parent_count..] || []
  new_messages
    .reverse
    .find { |m| m[:role] == "assistant" && m[:content] && !m[:content].to_s.empty? }
    &.dig(:content)
    .to_s
end

#running?Boolean

Check if agent is currently running

Returns:

  • (Boolean)


1502
1503
1504
# File 'lib/clacky/agent.rb', line 1502

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)


213
214
215
216
217
# File 'lib/clacky/agent.rb', line 213

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



200
201
202
203
204
205
# File 'lib/clacky/agent.rb', line 200

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

  rebuild_client_for_current_model!
  true
end