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
TERMINAL_HEAD_CHARS =

For terminal output, keep both head and tail because build/test logs put the most actionable information (error summaries, exit codes) at the end. Splitting the budget evenly preserves both the command echo and the final error summary.

40_000
TERMINAL_TAIL_CHARS =
40_000
MAX_TRANSCRIPT_EVENTS =

Per-subagent transcript budget. Mirrors the intent of MessageHistory::MAX_EXT_EVENTS_PER_MESSAGE: milestones are worth keeping, runaway trails are not. A fan-out stores one of these per job.

200
MAX_TRANSCRIPT_BYTES =
64 * 1024

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_one_subagent_transcript, #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
176
177
178
179
180
181
182
# 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 trails], attached by observe()
  @subagent_transcripts_mutex = Mutex.new # fan-out collects from worker threads
  @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)
  @cancel_flag = CancelFlag.new # Cooperative cancel: set by fan_out_labeled when the parent is interrupted; subagents on worker threads observe it via check_stale!

  # 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

  # Register tools contributed by ext.yml containers (contributes.tools).
  # Each tool file must define at least one Clacky::Tools::Base subclass —
  # every subclass defined in that file is instantiated and registered.
  register_extension_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



185
186
187
188
189
190
191
192
193
194
# File 'lib/clacky/agent.rb', line 185

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



196
197
198
# File 'lib/clacky/agent.rb', line 196

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

#available_modelsObject

Get list of available model names



253
254
255
# File 'lib/clacky/agent.rb', line 253

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



246
247
248
249
250
# File 'lib/clacky/agent.rb', line 246

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

#current_model_infoObject

Get current model configuration info



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/clacky/agent.rb', line 258

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"],
    remark: model["remark"],
    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.



310
311
312
313
314
315
316
317
318
319
# File 'lib/clacky/agent.rb', line 310

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



1614
1615
1616
# File 'lib/clacky/agent.rb', line 1614

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.



2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
# File 'lib/clacky/agent.rb', line 2087

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: cap_transcript_events(events)
  }
end

#fan_out_labeled(jobs, max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY, timeout: nil, tool_call_id: nil) ⇒ Array<Fanout::Result>

Run labeled jobs in parallel, each inside its own concurrent UI phase.

Exposed for extension tools that build their own subagents (e.g. one per skill) but still need the UI wiring to be correct: the web UI folds each phase into its own live card, and the CLI collapses concurrent phases into a single progress line. Getting that right by hand is easy to botch, so the orchestration lives here while job construction stays with the caller.

Callers must build their subagents on the calling thread before handing the jobs over — forking deep-copies parent config + history, which must not race. Only the blocking run belongs in the lambda.

Pass :subagent alongside :run (and a tool_call_id) to have each job's message trail persisted onto the tool result, so the WebUI can replay the whole batch after a reload instead of just the collapsed return values.

Parameters:

  • jobs (Array<Hash>)

    each { label: String, run: #call, subagent: Agent (optional) }

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

    jobs allowed to run at once

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

    wall-clock budget for the whole batch

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

    anchors persisted transcripts to this tool call

Returns:



1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
# File 'lib/clacky/agent.rb', line 1822

def fan_out_labeled(jobs, max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY, timeout: nil, tool_call_id: nil)
  return [] if jobs.empty?

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

  wrapped = jobs.each_with_index.map do |job, index|
    label = job[:label] || job["label"] || "Subagent #{index + 1}/#{jobs.size}"
    run = job[:run] || job["run"]
    subagent = job[:subagent] || job["subagent"]
    raise ArgumentError, "job #{index} must provide a callable :run" unless run.respond_to?(:call)

    lambda do
      Thread.current[:task_epoch] = epoch
      begin
        within_phase(label, kind: "fanout_subagent", concurrent: true) { run.call }
      ensure
        # Runs in ensure so a job that raised still leaves a trail — a failed
        # subagent is exactly the one worth inspecting afterwards.
        record_subagent_transcript(tool_call_id, subagent, label, index: index) if subagent
      end
    end
  end

  Fanout.new(max_concurrency: max_concurrency, timeout: timeout)
        .run(wrapped, on_cancel: -> { @cancel_flag&.cancel! })
end

#final_reply(subagent) ⇒ String

The subagent's last non-empty assistant message — its actual answer.

A subagent's run result carries cost and iteration counts but no reply text, and its trailing history entries are usually tool results, so the answer has to be found by scanning backwards from the end. Only messages appended after the fork are considered; earlier ones are the inherited parent conversation.

Use this when the caller wants the raw answer to pass on programmatically. For a human-facing digest use #generate_subagent_summary instead.

Parameters:

Returns:

  • (String)

    the reply, or "" when the subagent never answered



1886
1887
1888
1889
1890
1891
1892
1893
1894
# File 'lib/clacky/agent.rb', line 1886

def final_reply(subagent)
  parent_count = subagent.instance_variable_get(:@parent_message_count) || 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

#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



1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
# File 'lib/clacky/agent.rb', line 1903

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_entry = subagent_config.current_model
  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?,
    api_format: subagent_config.api_format,
    provider_id: subagent_config.provider_id_for(subagent_entry),
    capabilities: subagent_entry && subagent_entry["capabilities"]
  )

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

  # Share the parent's cancel flag so a fan-out interrupt reaches this
  # subagent on its worker thread — its own check_stale! polls the same flag.
  subagent.instance_variable_set(:@cancel_flag, @cancel_flag)

  # 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



2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
# File 'lib/clacky/agent.rb', line 2053

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)


296
297
298
# File 'lib/clacky/agent.rb', line 296

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.



288
289
290
291
292
293
# File 'lib/clacky/agent.rb', line 288

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

#record_subagent_transcript(tool_call_id, subagent, label, index: 0) ⇒ Object

Called from fan-out worker threads, hence the mutex. Slots are keyed by job index so the persisted order matches the caller's job order rather than completion order.



1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
# File 'lib/clacky/agent.rb', line 1861

def record_subagent_transcript(tool_call_id, subagent, label, index: 0)
  return unless tool_call_id

  transcript = extract_subagent_transcript(subagent, label)
  transcript[:index] = index
  @subagent_transcripts_mutex.synchronize do
    (@pending_subagent_transcripts[tool_call_id] ||= []) << transcript
  end
rescue StandardError => e
  Clacky::Logger.warn("agent.subagent_transcript_failed", error: e.message, label: label)
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)



1631
1632
1633
1634
1635
# File 'lib/clacky/agent.rb', line 1631

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

  redact_value(args)
end

#redact_value(obj) ⇒ Object



1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
# File 'lib/clacky/agent.rb', line 1637

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



1622
1623
1624
# File 'lib/clacky/agent.rb', line 1622

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.



473
474
475
# File 'lib/clacky/agent.rb', line 473

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

#run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil) ⇒ Object



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
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
# File 'lib/clacky/agent.rb', line 477

def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: 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

  # A reused agent may carry a cancel flag set by a previously-interrupted
  # fan-out batch; a fresh task must start uncancelled.
  @cancel_flag = CancelFlag.new

  # 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"]
    reference        = f[:reference]        || f["reference"]

    # Directory references: capture only the path so the LLM can explore
    # on demand with the read/shell tools.
    if File.directory?(path.to_s)
      next { name: name || File.basename(path.to_s), type: "directory", path: path.to_s,
             reference: reference }
    end

    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, reference: reference }
  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|
    # @mention file/directory references are replayed from display_references
    # (with mention badges), so skip them here to avoid double-rendering as
    # plain attachment badges.
    next if f[:reference] || f["reference"]
    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

  # Resolved once here (not after append) so the user message can carry the
  # confirmed skill name: only a skill that actually dispatches gets marked,
  # so the UI never highlights a typo'd or unavailable command. The display
  # name is resolved against the client's language (Thread.current[:lang],
  # seeded from the WS message / X-Lang header) so the Web UI and third-party
  # clients can render a localized label without re-resolving the skill.
  skill_command = parse_skill_command(user_input)
  skill_command_display = if skill_command[:found] && skill_command[:skill]
                            skill_command[:skill].display_name(Thread.current[:lang])
                          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,
                    skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
                    skill_command_display: skill_command_display,
                    display_files: display_files.empty? ? nil : display_files,
                    display_references: Array(references_display).empty? ? nil : references_display })
  @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

      # Directory reference: emit only the path so the LLM can explore on
      # demand with the read/shell tools.
      if type == "directory"
        next ["[Directory: #{name}]", "Path: #{path}"].join("\n")
      end

      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

  # Inject referenced past chats (the @mention "send as reference" behavior)
  # as a system_injected message — same mechanism as file references: the LLM
  # sees the context, but replay_history skips it and no user bubble renders.
  Array(reference_contexts).each do |ctx|
    next if ctx.to_s.strip.empty?
    @history.append({ role: "user", content: ctx, system_injected: true, task_id: task_id })
  end

  result = nil
  begin
    # 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.
    # Inside the begin block so a fork_subagent failure (e.g. skill-declared model
    # not found) still reaches the ensure block that stops the progress spinner.
    inject_skill_command_as_assistant_message(skill_command, task_id)

    @hooks.trigger(:on_start, user_input)

    # Track if ask_user was called
    awaiting_user_feedback = false
    # Heuristic sibling of the above: the reply merely ended with a question
    # mark. Kept separate because it must never reach build_result — the
    # session status it feeds shows a "waiting" badge to the user, and a
    # rhetorical closing question is not a request for input.
    turn_unfinished = false
    # Track if task was interrupted by user (denied tool execution)
    task_interrupted = false

    loop do
      Clacky::Shutdown.checkpoint!
      @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], created_at: response[:created_at])
        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.
        turn_unfinished = 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], interim: true, created_at: response[:created_at])
      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 ask_user 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(awaiting_user_feedback: awaiting_user_feedback)

    # 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 || turn_unfinished
      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 || turn_unfinished
      run_memory_update_subagent
    end

    if @is_subagent
      # Parent agent (skill_manager) prints the completion summary; skip here.
    else
      @ui&.show_complete(
        task_id: result[:task_id],
        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
    # A cancelled fan-out captured its subagents' progress but never reached
    # observe() to persist it — anchor those trails now so a page reload
    # after the interrupt still shows what the subagents did.
    flush_pending_subagent_transcripts_on_interrupt
    # 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)



1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
# File 'lib/clacky/agent.rb', line 1782

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)
  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_reply(subagent)
end

#running?Boolean

Check if agent is currently running

Returns:

  • (Boolean)


1674
1675
1676
# File 'lib/clacky/agent.rb', line 1674

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)


218
219
220
221
222
# File 'lib/clacky/agent.rb', line 218

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



205
206
207
208
209
210
# File 'lib/clacky/agent.rb', line 205

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

  rebuild_client_for_current_model!
  true
end