Class: Clacky::Agent
- Inherits:
-
Object
- Object
- Clacky::Agent
- 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
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
-
#agent_profile ⇒ Object
readonly
Returns the value of attribute agent_profile.
-
#cache_stats ⇒ Object
readonly
Returns the value of attribute cache_stats.
-
#channel_info ⇒ Object
Returns the value of attribute channel_info.
-
#config ⇒ Object
readonly
Returns the value of attribute config.
-
#cost_source ⇒ Object
readonly
Returns the value of attribute cost_source.
-
#created_at ⇒ Object
readonly
Returns the value of attribute created_at.
-
#error ⇒ Object
readonly
Returns the value of attribute error.
-
#hidden ⇒ Object
Returns the value of attribute hidden.
-
#history ⇒ Object
readonly
Returns the value of attribute history.
-
#iterations ⇒ Object
readonly
Returns the value of attribute iterations.
-
#latest_latency ⇒ Object
readonly
Returns the value of attribute latest_latency.
-
#name ⇒ Object
readonly
Returns the value of attribute name.
-
#pinned ⇒ Object
Returns the value of attribute pinned.
-
#project_id ⇒ Object
Returns the value of attribute project_id.
-
#reasoning_effort ⇒ Object
Returns the value of attribute reasoning_effort.
-
#session_id ⇒ Object
readonly
Returns the value of attribute session_id.
-
#skill_loader ⇒ Object
readonly
Returns the value of attribute skill_loader.
-
#source ⇒ Object
readonly
Returns the value of attribute source.
-
#status ⇒ Object
readonly
Returns the value of attribute status.
-
#todos ⇒ Object
readonly
Returns the value of attribute todos.
-
#total_cost ⇒ Object
readonly
Returns the value of attribute total_cost.
-
#total_tasks ⇒ Object
readonly
Returns the value of attribute total_tasks.
-
#ui ⇒ Object
readonly
Returns the value of attribute ui.
-
#updated_at ⇒ Object
readonly
Returns the value of attribute updated_at.
-
#working_dir ⇒ Object
readonly
Returns the value of attribute working_dir.
Class Method Summary collapse
-
.from_session(client, config, session_data, ui: nil, profile:) ⇒ Object
Restore from a saved session.
Instance Method Summary collapse
- #add_hook(event, &block) ⇒ Object
-
#available_models ⇒ Object
Get list of available model names.
-
#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.
-
#current_model_info ⇒ Object
Get current model configuration info.
-
#enqueue_injection(skill, task) ⇒ Object
Enqueue an inline skill injection to be flushed after observe().
-
#extract_subagent_transcript(subagent, skill_identifier) ⇒ Object
Extract the subagent's own message trail for persistence/replay.
-
#fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil) ⇒ Agent
Fork a subagent with specified configuration The subagent inherits all messages and tools from parent agent Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks.
-
#generate_subagent_summary(subagent) ⇒ String
Generate summary from subagent execution Extracts new messages added by subagent and creates a concise summary This summary will replace the subagent instructions message in parent agent.
-
#goal_active? ⇒ Boolean
True if a standing goal loop is active for this session.
-
#goal_manager ⇒ Object
Lazily-built GoalManager bound to this session.
-
#initialize(client, config, working_dir:, ui:, profile:, session_id:, source:) ⇒ Agent
constructor
A new instance of Agent.
- #permission_mode ⇒ Object
-
#redact_tool_args(args) ⇒ String, ...
Redact volatile tmpdir paths from tool call arguments before showing in UI.
- #redact_value(obj) ⇒ Object
-
#register_script_tmpdir(dir) ⇒ Object
Register a tmpdir that contains decrypted brand skill scripts.
-
#rename(new_name) ⇒ Object
Rename this session.
- #run(user_input, files: [], display_text: nil, created_at: nil) ⇒ Object
-
#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.
-
#running? ⇒ Boolean
Check if agent is currently running.
-
#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).
-
#switch_model_by_id(id) ⇒ Boolean
Switch this session to a different model, identified by its stable runtime id.
Methods included from SkillAutoCreator
Methods included from SkillReflector
Methods included from SkillEvolution
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
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..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_profile ⇒ Object (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_stats ⇒ Object (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_info ⇒ Object
Returns the value of attribute channel_info.
55 56 57 |
# File 'lib/clacky/agent.rb', line 55 def channel_info @channel_info end |
#config ⇒ Object (readonly)
Returns the value of attribute config.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def config @config end |
#cost_source ⇒ Object (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_at ⇒ Object (readonly)
Returns the value of attribute created_at.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def created_at @created_at end |
#error ⇒ Object (readonly)
Returns the value of attribute error.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def error @error end |
#hidden ⇒ Object
Returns the value of attribute hidden.
54 55 56 |
# File 'lib/clacky/agent.rb', line 54 def hidden @hidden end |
#history ⇒ Object (readonly)
Returns the value of attribute history.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def history @history end |
#iterations ⇒ Object (readonly)
Returns the value of attribute iterations.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def iterations @iterations end |
#latest_latency ⇒ Object (readonly)
Returns the value of attribute latest_latency.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def latest_latency @latest_latency end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def name @name end |
#pinned ⇒ Object
Returns the value of attribute pinned.
53 54 55 |
# File 'lib/clacky/agent.rb', line 53 def pinned @pinned end |
#project_id ⇒ Object
Returns the value of attribute project_id.
56 57 58 |
# File 'lib/clacky/agent.rb', line 56 def project_id @project_id end |
#reasoning_effort ⇒ Object
Returns the value of attribute reasoning_effort.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def reasoning_effort @reasoning_effort end |
#session_id ⇒ Object (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_loader ⇒ Object (readonly)
Returns the value of attribute skill_loader.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def skill_loader @skill_loader end |
#source ⇒ Object (readonly)
Returns the value of attribute source.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def source @source end |
#status ⇒ Object (readonly)
Returns the value of attribute status.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def status @status end |
#todos ⇒ Object (readonly)
Returns the value of attribute todos.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def todos @todos end |
#total_cost ⇒ Object (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_tasks ⇒ Object (readonly)
Returns the value of attribute total_tasks.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def total_tasks @total_tasks end |
#ui ⇒ Object (readonly)
Returns the value of attribute ui.
48 49 50 |
# File 'lib/clacky/agent.rb', line 48 def ui @ui end |
#updated_at ⇒ Object (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_dir ⇒ Object (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_models ⇒ Object
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_info ⇒ Object
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. { 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.
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 = subagent.history.to_a[parent_count..] || [] events = .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
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.( "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. = deep_clone(@history.to_a) subagent.instance_variable_set(:@history, MessageHistory.new()) # 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
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 = subagent.history.to_a[parent_count..] || [] # Extract tool calls tool_calls = .select { |m| m[:role] == "assistant" && m[:tool_calls] } .flat_map { |m| m[:tool_calls].map { |tc| tc[:name] } } .uniq # Extract final assistant response last_response = .reverse .find { |m| m[:role] == "assistant" && m[:content] && !m[:content].empty? } &.dig(:content) # Build summary (this will replace the subagent instructions message) parts = [] parts << "[SUBAGENT SUMMARY]" parts << "Completed in #{subagent.iterations} iterations, cost: $#{subagent.total_cost.round(4)}" parts << "Tools used: #{tool_calls.join(', ')}" if tool_calls.any? parts << "" parts << "Results:" parts << (last_response || "(No response)") parts.join("\n") end |
#goal_active? ⇒ Boolean
True if a standing goal loop is active for this session.
286 287 288 |
# File 'lib/clacky/agent.rb', line 286 def goal_active? @goal_manager&.active? || false end |
#goal_manager ⇒ Object
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_mode ⇒ Object
60 61 62 |
# File 'lib/clacky/agent.rb', line 60 def @config&.&.to_s || "" end |
#redact_tool_args(args) ⇒ String, ...
Redact volatile tmpdir paths from tool call arguments before showing in UI. Replaces each registered path with <SKILL_DIR> so encrypted skill locations are never exposed to the user.
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.
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. = vision_images.map { |v| { name: v[:name], type: "image", size_bytes: v[:size_bytes], path: v[:path] } } + all_disk_files unless .empty? file_prompt = .filter_map do |f| name = f[:name] || f["name"] type = f[:type] || f["type"] path = f[:path] || f["path"] preview_path = f[:preview_path] || f["preview_path"] size_bytes = f[:size_bytes] || f["size_bytes"] parse_error = f[:parse_error] || f["parse_error"] parser_path = f[:parser_path] || f["parser_path"] 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. (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.) 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? (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? (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&.("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., 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.) 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.
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 = subagent.history.to_a[parent_count..] || [] .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
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.
213 214 215 216 217 |
# File 'lib/clacky/agent.rb', line 213 def set_session_sub_model(model_name) @config. = 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.
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 |