openclacky 1.5.9 → 1.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.clackyrules +1 -0
  3. data/CHANGELOG.md +34 -0
  4. data/lib/clacky/agent/memory_updater.rb +1 -43
  5. data/lib/clacky/agent/session_serializer.rb +3 -1
  6. data/lib/clacky/agent/skill_auto_creator.rb +12 -9
  7. data/lib/clacky/agent/skill_evolution.rb +4 -3
  8. data/lib/clacky/agent/skill_manager.rb +10 -12
  9. data/lib/clacky/agent/skill_reflector.rb +2 -2
  10. data/lib/clacky/agent.rb +139 -48
  11. data/lib/clacky/agent_config.rb +66 -39
  12. data/lib/clacky/agent_profile.rb +36 -2
  13. data/lib/clacky/cli.rb +24 -5
  14. data/lib/clacky/client.rb +47 -12
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1 -1
  16. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +50 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +18 -17
  18. data/lib/clacky/default_skills/skill-creator/SKILL.md +8 -7
  19. data/lib/clacky/extension/cli_commands.rb +1 -1
  20. data/lib/clacky/extension/loader.rb +36 -3
  21. data/lib/clacky/extension/packager.rb +22 -0
  22. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +3 -1
  23. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +8 -1
  24. data/lib/clacky/extension/scaffold/templates/full/tools/hello.rb +28 -0
  25. data/lib/clacky/extension/scaffold.rb +3 -2
  26. data/lib/clacky/extension/verifier.rb +4 -2
  27. data/lib/clacky/locales/en.rb +1 -1
  28. data/lib/clacky/locales/zh.rb +1 -1
  29. data/lib/clacky/mcp/http_transport.rb +28 -12
  30. data/lib/clacky/media/base.rb +22 -13
  31. data/lib/clacky/message_format/open_ai.rb +17 -1
  32. data/lib/clacky/message_history.rb +1 -1
  33. data/lib/clacky/providers.rb +40 -8
  34. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -2
  35. data/lib/clacky/server/channel/adapters/feishu/bot.rb +42 -20
  36. data/lib/clacky/server/channel/channel_config.rb +22 -8
  37. data/lib/clacky/server/channel/channel_manager.rb +11 -5
  38. data/lib/clacky/server/channel/channel_ui_controller.rb +20 -7
  39. data/lib/clacky/server/http_server.rb +206 -36
  40. data/lib/clacky/server/model_prices.rb +39 -0
  41. data/lib/clacky/server/project_manager.rb +4 -1
  42. data/lib/clacky/server/server_master.rb +12 -1
  43. data/lib/clacky/server/web_ui_controller.rb +3 -1
  44. data/lib/clacky/skill.rb +9 -0
  45. data/lib/clacky/skill_loader.rb +8 -3
  46. data/lib/clacky/tools/base.rb +4 -0
  47. data/lib/clacky/tools/web_search.rb +24 -11
  48. data/lib/clacky/ui2/components/command_suggestions.rb +1 -2
  49. data/lib/clacky/ui2/ui_controller.rb +96 -0
  50. data/lib/clacky/utils/logger.rb +3 -1
  51. data/lib/clacky/utils/model_pricing.rb +77 -8
  52. data/lib/clacky/version.rb +1 -1
  53. data/lib/clacky/web/app.css +216 -35
  54. data/lib/clacky/web/features/channels/store.js +17 -2
  55. data/lib/clacky/web/features/channels/view.js +33 -0
  56. data/lib/clacky/web/features/extensions/store.js +25 -0
  57. data/lib/clacky/web/features/extensions/view.js +24 -0
  58. data/lib/clacky/web/features/model-tester/store.js +2 -1
  59. data/lib/clacky/web/i18n.js +54 -2
  60. data/lib/clacky/web/index.html +44 -3
  61. data/lib/clacky/web/projects.js +29 -1
  62. data/lib/clacky/web/sessions.js +79 -6
  63. data/lib/clacky/web/settings.js +175 -7
  64. data/lib/clacky/web/skills.js +86 -10
  65. data/lib/clacky/web/ws-dispatcher.js +2 -2
  66. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 565adf42059330fb821c57cb3b1200035fb866a1f2190ca87e093bc34de9edc0
4
- data.tar.gz: 89db4dd987f39d4b77acd52121aa31bef54858f81d614557ac7b9984a1066ba0
3
+ metadata.gz: '038138bc72f3f740ed3ccee6da3f6464f80fd0cf1d0466788879a4472ac127b1'
4
+ data.tar.gz: dfb75e0b2e832e923c34a1f5e61b4a005bd29435f3724763128b0fadc0b6ec1b
5
5
  SHA512:
6
- metadata.gz: e3bbb8988d833e3c6e6ce4cd13da64111e2ee15b400c2eb34b78bca9f51ae80525336baf923ba18cdbf85b5320d542f8050567792e7d5a437b28e2d09d29729d
7
- data.tar.gz: ae508f3bdb44a35b8edd0c89ebcc53a64d3f7340214ded6c8dd47d0c757c66f5f99700f466c86278e8ee0a38ab838b8509c80ba8af2f253653f967b91f52a814
6
+ metadata.gz: c72531566a2b744a279e54907f750ff67e518963c9d42216dde3cb05048e4c86688dbb2ebfe908b31b7f7df8d2332df0ab0b11e0977ab5c486ca5c83b565eafc
7
+ data.tar.gz: 584975067a26ebfc123e9f9a7f168ef45aab93f783e91da576510f416554c0ba4598e88064db7f71c0f6a30dfda949a4f36cc35d69aa35973489be800422a6b2
data/.clackyrules CHANGED
@@ -70,6 +70,7 @@ the CI matrix - a green local `bundle exec rspec` on Ruby 3.x proves nothing abo
70
70
  - **IMPORTANT**: When developing new features, write RSpec tests as needed and ensure they pass
71
71
  - **DO NOT** write custom test scripts unless explicitly requested by the user
72
72
  - **DO NOT** create markdown documentation unless explicitly requested by the user
73
+ - **DO NOT** run RuboCop for verification. `bundle exec rspec` is the only required check; the codebase has many pre-existing offenses, so RuboCop output is noise. Only run it when the user explicitly asks
73
74
  - **IMPORTANT**: When testing clacky commands or debugging, always use `bundle exec ruby bin/clacky` instead of the global `clacky` command. The global command loads the system-installed gem version (e.g., `openclacky-0.7.0`), not your local development code
74
75
 
75
76
  ### Tool Development
data/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.10] - 2026-08-19
8
+
9
+ ### Added
10
+ - Import extensions from a local zip file
11
+ - Per-model API format override (#484 - @kylezhang)
12
+ - Project pinning (pin to top)
13
+ - Copy Session ID action in session menu
14
+ - Price ratio on model switcher panel
15
+ - Model search filter and provider filter dropdown in settings
16
+ - Remark field for model configs
17
+ - Custom vision model config
18
+ - GPT-5.6 Sol/Terra/Luna presets and pricing
19
+ - GLM-5.3 model support
20
+ - Global IM status-messages toggle (default off)
21
+ - Extension-defined tools support; extensions can disable skills
22
+ - Server restart state and HTTP restart flag
23
+ - Actionable web search provider errors
24
+ - Localized display names for broadcast skill commands
25
+
26
+ ### Improved
27
+ - Skill autocomplete dropdown UI with localized source labels
28
+ - Skill auto-creation and evolution reporting refinements
29
+
30
+ ### Fixed
31
+ - MCP: preserve CRLF pairs split across SSE chunk boundaries (#489 - @MohammedAlkindi)
32
+ - Feishu: auto-refresh token and retry when revoked (99991663) (#483 - @chengoak)
33
+ - Preserve head+tail for oversized terminal output (#455 - @shipinliang)
34
+ - openclacky start failed when no TTY
35
+ - Capture master/worker crash logs under LaunchAgent
36
+ - ReferenceError in model modal _syncRemarkField
37
+ - Remark field always visible in model modal
38
+ - api_format field only shown for custom providers and validated before mutating model config
39
+ - Media gen output dir handling
40
+
7
41
  ## [1.5.9] - 2026-08-14
8
42
 
9
43
  ### Added
@@ -74,7 +74,7 @@ module Clacky
74
74
  private def with_memory_update_phase
75
75
  return yield unless @ui.respond_to?(:with_phase)
76
76
 
77
- pid = @ui.phase_start(kind: "memory_update", label: "Updating long-term memory")
77
+ pid = @ui.phase_start(kind: "memory_update", label: Clacky::I18n.t("phase.memory_update"))
78
78
  summary = nil
79
79
  begin
80
80
  summary = yield
@@ -127,11 +127,6 @@ module Clacky
127
127
  # still reflects ONLY the user's task, not the memory update.
128
128
  subagent_cost = absorb_subagent_cost(result)
129
129
 
130
- # A summary is only worth showing if the subagent actually wrote
131
- # something. The common "No memory updates needed." path returns nil
132
- # so the phase leaves no trace at all.
133
- return unless subagent_wrote_memory?(subagent)
134
-
135
130
  "#{result[:iterations]} steps · $#{subagent_cost.round(4)}"
136
131
  end
137
132
 
@@ -142,43 +137,6 @@ module Clacky
142
137
  @config.memory_update_enabled != false
143
138
  end
144
139
 
145
- # Inspect the subagent's history for a successful write/edit tool
146
- # call targeting a memory file. Used to decide whether to surface a
147
- # "Memory updated" info line (option C — silent when nothing changed).
148
- # @param subagent [Clacky::Agent]
149
- # @return [Boolean]
150
- private def subagent_wrote_memory?(subagent)
151
- return false unless subagent.respond_to?(:history) && subagent.history
152
-
153
- subagent.history.to_a.any? do |msg|
154
- next false unless msg.is_a?(Hash)
155
-
156
- # Match OpenAI-style tool_calls on assistant messages …
157
- tool_calls = msg[:tool_calls] || msg["tool_calls"]
158
- if tool_calls.is_a?(Array) && tool_calls.any?
159
- next true if tool_calls.any? do |tc|
160
- name = tc.dig(:function, :name) || tc.dig("function", "name") || tc[:name] || tc["name"]
161
- %w[write edit].include?(name.to_s)
162
- end
163
- end
164
-
165
- # … and Anthropic-style content blocks with type=tool_use.
166
- content = msg[:content] || msg["content"]
167
- if content.is_a?(Array)
168
- next true if content.any? do |block|
169
- block.is_a?(Hash) &&
170
- (block[:type] == "tool_use" || block["type"] == "tool_use") &&
171
- %w[write edit].include?((block[:name] || block["name"]).to_s)
172
- end
173
- end
174
-
175
- false
176
- end
177
- rescue StandardError
178
- # Defensive: never let introspection errors break memory update.
179
- false
180
- end
181
-
182
140
  # Build the memory update prompt for the forked subagent.
183
141
  #
184
142
  # Architecture:
@@ -392,7 +392,9 @@ module Clacky
392
392
  }
393
393
  all_files = image_files + disk_files
394
394
  ui.show_user_message(raw_text, created_at: msg[:created_at], files: all_files,
395
- editable: round[:editable] != false)
395
+ editable: round[:editable] != false,
396
+ skill_command: msg[:skill_command],
397
+ skill_command_display: msg[:skill_command_display])
396
398
  replay_ext_events(msg, ui)
397
399
 
398
400
  round[:events].each do |ev|
@@ -22,8 +22,10 @@ module Clacky
22
22
 
23
23
  # Fork an isolated subagent to evaluate + create — does NOT touch main history
24
24
  subagent = fork_subagent
25
- subagent.run(build_skill_creation_prompt)
26
- nil
25
+ result = subagent.run(build_skill_creation_prompt)
26
+ cost = absorb_subagent_cost(result)
27
+
28
+ "$#{cost.round(4)}"
27
29
  end
28
30
 
29
31
  # Determine if this task is a candidate for skill auto-creation
@@ -84,19 +86,20 @@ module Clacky
84
86
  ## Action
85
87
 
86
88
  If **ALL** criteria are met:
87
- → Call invoke_skill with:
89
+ → Call invoke_skill with EXACTLY these two parameters:
88
90
  - skill_name: "skill-creator"
89
- - task: A clear description of what to automate and how (be specific)
90
- - mode: "quick" (enables fast auto-creation without user interviews)
91
- - suggested_name: A descriptive identifier (lowercase, hyphens OK)
91
+ - task: A clear description of what to automate and how (be specific).
92
+ Start it with "Quick mode:" and state the suggested skill name
93
+ (lowercase, hyphens OK) inside this text.
94
+
95
+ invoke_skill accepts ONLY skill_name and task. Passing any other
96
+ parameter raises an ArgumentError and the creation fails.
92
97
 
93
98
  Example invocation:
94
99
  ```
95
100
  invoke_skill(
96
101
  skill_name: "skill-creator",
97
- task: "Create a skill to extract and summarize content from URLs. The skill should: 1) fetch the URL content, 2) parse the main text, 3) generate a concise summary. Expected input: URL. Expected output: markdown summary.",
98
- mode: "quick",
99
- suggested_name: "url-summarizer"
102
+ task: "Quick mode: create a skill named 'url-summarizer' (no user interviews). It should extract and summarize content from URLs: 1) fetch the URL content, 2) parse the main text, 3) generate a concise summary. Expected input: URL. Expected output: markdown summary."
100
103
  )
101
104
  ```
102
105
 
@@ -26,11 +26,12 @@ module Clacky
26
26
  def run_skill_evolution_hooks
27
27
  return unless skill_evolution_enabled?
28
28
  return if @is_subagent
29
+ # Preconditions unmet (short task, default skill, ...): the evolution
30
+ # never ran, so there is nothing to report — stay completely silent.
31
+ return unless skill_evolution_has_work?
29
32
 
30
33
  with_skill_evolution_phase do
31
- if !skill_evolution_has_work?
32
- Clacky::I18n.t("phase.skipped")
33
- elsif @skill_execution_context
34
+ if @skill_execution_context
34
35
  maybe_reflect_on_skill
35
36
  else
36
37
  maybe_create_skill_from_task
@@ -82,7 +82,7 @@ module Clacky
82
82
  return { matched: true, found: false, skill_name: skill_name, reason: :not_user_invocable, skill: skill }
83
83
  end
84
84
 
85
- if @agent_profile && !skill.allowed_for_agent?(@agent_profile.name)
85
+ if @agent_profile && !@agent_profile.skill_allowed?(skill)
86
86
  return { matched: true, found: false, skill_name: skill_name, reason: :agent_not_allowed, skill: skill }
87
87
  end
88
88
 
@@ -266,22 +266,20 @@ module Clacky
266
266
  # is injected instructing the LLM to inform the user in their own language and
267
267
  # suggest similar skills — no error is raised, the LLM handles the reply.
268
268
  #
269
- # @param user_input [String] Raw user input
269
+ # @param skill_command [Hash] Result of parse_skill_command for the user input
270
270
  # @param task_id [Integer] Current task ID (for message tagging)
271
271
  # @return [void]
272
- def inject_skill_command_as_assistant_message(user_input, task_id)
273
- result = parse_skill_command(user_input)
274
-
272
+ def inject_skill_command_as_assistant_message(skill_command, task_id)
275
273
  # Not a slash command at all — nothing to do
276
- return unless result[:matched]
274
+ return unless skill_command[:matched]
277
275
 
278
- skill_name = result[:skill_name]
276
+ skill_name = skill_command[:skill_name]
279
277
 
280
278
  # Slash command recognised but skill could not be dispatched — inject an
281
279
  # LLM-facing notice so the model explains the situation to the user in
282
280
  # their own language instead of silently ignoring the command.
283
- unless result[:found]
284
- notice = case result[:reason]
281
+ unless skill_command[:found]
282
+ notice = case skill_command[:reason]
285
283
  when :not_found
286
284
  suggestions = suggest_similar_skills(skill_name)
287
285
  msg = "[SYSTEM] The user entered the slash command /#{skill_name} but no matching skill was found. " \
@@ -302,8 +300,8 @@ module Clacky
302
300
  return
303
301
  end
304
302
 
305
- skill = result[:skill]
306
- arguments = result[:arguments]
303
+ skill = skill_command[:skill]
304
+ arguments = skill_command[:arguments]
307
305
 
308
306
  # fork_agent skills run in an isolated subagent
309
307
  if skill.fork_agent?
@@ -443,7 +441,7 @@ module Clacky
443
441
  def filter_skills_by_profile(skills)
444
442
  return skills unless @agent_profile
445
443
 
446
- skills.select { |skill| skill.allowed_for_agent?(@agent_profile.name) }
444
+ skills.select { |skill| @agent_profile.skill_allowed?(skill) }
447
445
  end
448
446
 
449
447
  # Build template context for skill content expansion.
@@ -26,11 +26,11 @@ module Clacky
26
26
  subagent = fork_subagent
27
27
  result = subagent.run(build_skill_reflection_prompt(skill_name))
28
28
 
29
- cost = absorb_subagent_cost(result) if result
29
+ cost = absorb_subagent_cost(result)
30
30
 
31
31
  @skill_execution_context = nil
32
32
 
33
- "#{skill_name} · $#{cost.round(4)}" if cost
33
+ "#{skill_name} · $#{cost.round(4)}"
34
34
  end
35
35
 
36
36
  private def should_reflect_on_skill?
data/lib/clacky/agent.rb CHANGED
@@ -149,6 +149,11 @@ module Clacky
149
149
  # Register built-in tools
150
150
  register_builtin_tools
151
151
 
152
+ # Register tools contributed by ext.yml containers (contributes.tools).
153
+ # Each tool file must define at least one Clacky::Tools::Base subclass —
154
+ # every subclass defined in that file is instantiated and registered.
155
+ register_extension_tools
156
+
152
157
  # Load declarative shell hooks from ~/.clacky/hooks.yml. Entries with
153
158
  # `type: rewrite` use the rich JSON protocol (updatedInput rewrite);
154
159
  # entries without `type` use the simple exit-code protocol.
@@ -217,11 +222,15 @@ module Clacky
217
222
  # Rebuild the underlying Client (and dependent components) to pick up
218
223
  # credentials/model name from the currently-selected model in @config.
219
224
  private def rebuild_client_for_current_model!
225
+ entry = @config.current_model
220
226
  @client = Clacky::Client.new(
221
227
  @config.api_key,
222
228
  base_url: @config.effective_base_url,
223
229
  model: @config.model_name,
224
- anthropic_format: @config.anthropic_format?
230
+ anthropic_format: @config.anthropic_format?,
231
+ api_format: @config.api_format,
232
+ provider_id: @config.provider_id_for(entry),
233
+ capabilities: entry && entry["capabilities"]
225
234
  )
226
235
  # Update message compressor with new client and model
227
236
  @message_compressor = MessageCompressor.new(@client, model: current_model)
@@ -257,6 +266,7 @@ module Clacky
257
266
  model: model["model"],
258
267
  base_url: model["base_url"],
259
268
  provider_id: model["provider_id"],
269
+ remark: model["remark"],
260
270
  card_model: base_entry&.dig("model"),
261
271
  sub_model: sub_model
262
272
  }
@@ -587,9 +597,22 @@ module Clacky
587
597
  preview_path: f[:preview_path] || f["preview_path"] }
588
598
  end
589
599
 
600
+ # Resolved once here (not after append) so the user message can carry the
601
+ # confirmed skill name: only a skill that actually dispatches gets marked,
602
+ # so the UI never highlights a typo'd or unavailable command. The display
603
+ # name is resolved against the client's language (Thread.current[:lang],
604
+ # seeded from the WS message / X-Lang header) so the Web UI and third-party
605
+ # clients can render a localized label without re-resolving the skill.
606
+ skill_command = parse_skill_command(user_input)
607
+ skill_command_display = if skill_command[:found] && skill_command[:skill]
608
+ skill_command[:skill].display_name(Thread.current[:lang])
609
+ end
610
+
590
611
  created_at ||= Time.now.to_f
591
612
  @history.append({ role: "user", content: user_content, task_id: task_id, created_at: created_at,
592
613
  display_text: display_text,
614
+ skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
615
+ skill_command_display: skill_command_display,
593
616
  display_files: display_files.empty? ? nil : display_files })
594
617
  @total_tasks += 1
595
618
 
@@ -659,7 +682,7 @@ module Clacky
659
682
  # If the user typed a slash command targeting a skill with disable-model-invocation: true,
660
683
  # inject the skill content as a synthetic assistant message so the LLM can act on it.
661
684
  # Skills already in the system prompt (model_invocation_allowed?) are skipped.
662
- inject_skill_command_as_assistant_message(user_input, task_id)
685
+ inject_skill_command_as_assistant_message(skill_command, task_id)
663
686
 
664
687
  @hooks.trigger(:on_start, user_input)
665
688
 
@@ -1374,9 +1397,17 @@ module Clacky
1374
1397
  # the new task's @current_task_id, orphaned from its assistant.
1375
1398
  check_stale!
1376
1399
 
1400
+ # Build a tool_call_id → tool_name lookup so truncate_oversized_tool_content
1401
+ # can apply tool-specific truncation strategies (e.g. terminal head+tail).
1402
+ tool_name_by_id = {}
1403
+ response[:tool_calls]&.each do |tc|
1404
+ tool_name_by_id[tc[:id]] = tc[:name]
1405
+ end
1406
+
1377
1407
  formatted_messages = @client.format_tool_results(response, tool_results, model: current_model)
1378
1408
  formatted_messages.each do |msg|
1379
- truncated = truncate_oversized_tool_content(msg)
1409
+ tool_name = tool_name_by_id[msg[:tool_call_id]]
1410
+ truncated = truncate_oversized_tool_content(msg, tool_name: tool_name)
1380
1411
  @history.append(truncated.merge(task_id: @current_task_id))
1381
1412
  end
1382
1413
 
@@ -1453,16 +1484,37 @@ module Clacky
1453
1484
  # are handled by the image_inject path above.
1454
1485
  MAX_TOOL_RESULT_CHARS = 80_000
1455
1486
 
1456
- private def truncate_oversized_tool_content(msg)
1487
+ # For terminal output, keep both head and tail because build/test logs
1488
+ # put the most actionable information (error summaries, exit codes) at
1489
+ # the end. Splitting the budget evenly preserves both the command echo
1490
+ # and the final error summary.
1491
+ TERMINAL_HEAD_CHARS = 40_000
1492
+ TERMINAL_TAIL_CHARS = 40_000
1493
+
1494
+ private def truncate_oversized_tool_content(msg, tool_name: nil)
1457
1495
  content = msg[:content]
1458
1496
  return msg unless content.is_a?(String) && content.length > MAX_TOOL_RESULT_CHARS
1459
1497
 
1460
1498
  original_len = content.length
1461
- head = content[0, MAX_TOOL_RESULT_CHARS]
1462
- truncated = head + "\n\n[Tool result truncated: #{original_len} chars total, " \
1463
- "showing first #{MAX_TOOL_RESULT_CHARS}. Use a more specific query/limit, " \
1464
- "or read the raw output via file_reader/grep on the underlying source.]"
1465
- msg.merge(content: truncated)
1499
+
1500
+ if tool_name == "terminal"
1501
+ head = content[0, TERMINAL_HEAD_CHARS]
1502
+ tail_start = content.length - TERMINAL_TAIL_CHARS
1503
+ tail = content[tail_start, TERMINAL_TAIL_CHARS]
1504
+ omitted = original_len - TERMINAL_HEAD_CHARS - TERMINAL_TAIL_CHARS
1505
+ truncated = head + "\n\n" \
1506
+ "[... #{omitted} chars omitted — terminal output truncated: " \
1507
+ "#{original_len} chars total, showing first #{TERMINAL_HEAD_CHARS} + " \
1508
+ "last #{TERMINAL_TAIL_CHARS}. Use a more specific command or redirect " \
1509
+ "to a file and read the relevant section. ...]\n\n" + tail
1510
+ msg.merge(content: truncated)
1511
+ else
1512
+ head = content[0, MAX_TOOL_RESULT_CHARS]
1513
+ truncated = head + "\n\n[Tool result truncated: #{original_len} chars total, " \
1514
+ "showing first #{MAX_TOOL_RESULT_CHARS}. Use a more specific query/limit, " \
1515
+ "or read the raw output via file_reader/grep on the underlying source.]"
1516
+ msg.merge(content: truncated)
1517
+ end
1466
1518
  end
1467
1519
 
1468
1520
  # Enqueue an inline skill injection to be flushed after observe().
@@ -1593,6 +1645,37 @@ module Clacky
1593
1645
  @tool_registry.register(Tools::Browser.new)
1594
1646
  end
1595
1647
 
1648
+ # Register tools the agent declared via `tools:` — each id maps to
1649
+ # <container>/tools/<id>.rb, and the file name maps to the class name
1650
+ # (Clacky::Tools::<Camelized id>), so an id alone gives the path and the
1651
+ # class. A failing file is logged and skipped so one broken tool never
1652
+ # blocks agent startup.
1653
+ private def register_extension_tools
1654
+ dir = @agent_profile.container_dir
1655
+ return unless dir
1656
+ @agent_profile.tools.each do |id|
1657
+ require File.join(dir, "tools", "#{id}.rb")
1658
+ klass = extension_tool_class_for(id)
1659
+ next unless klass
1660
+
1661
+ tool = klass.new
1662
+ tool.agent = self if tool.respond_to?(:agent=)
1663
+ @tool_registry.register(tool)
1664
+ rescue StandardError, ScriptError => e
1665
+ Clacky::Logger.warn("agent.register_extension_tool",
1666
+ error: e.message, tool: id)
1667
+ end
1668
+ end
1669
+
1670
+ # tools/<id>.rb must define Clacky::Tools::<Camelized id> — the file name
1671
+ # IS the class-name mapping (web-search → Clacky::Tools::WebSearch).
1672
+ private def extension_tool_class_for(id)
1673
+ const_name = id.split(/[_-]/).map(&:capitalize).join
1674
+ Clacky::Tools.const_get(const_name)
1675
+ rescue NameError
1676
+ nil
1677
+ end
1678
+
1596
1679
  # Run a one-off task on a forked subagent and return its final reply text,
1597
1680
  # WITHOUT mutating this (parent) agent's history. Used by extensions that
1598
1681
  # need a side analysis (e.g. meeting annotate) which must reuse the parent's
@@ -1615,61 +1698,51 @@ module Clacky
1615
1698
  # to the parent's session_id) would broadcast the subagent's raw output
1616
1699
  # into the parent chat transcript. Swap in a no-op UI so nothing leaks.
1617
1700
  subagent.instance_variable_set(:@ui, NullUIController.new)
1618
- parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1619
1701
  result = subagent.run(task)
1620
1702
 
1621
1703
  # A detached run stays invisible, so its cost is merged silently — the
1622
1704
  # sessionbar refresh would be the one thing that gives it away.
1623
1705
  absorb_subagent_cost(result, notify_ui: false)
1624
1706
 
1625
- final_assistant_text(subagent, parent_count)
1707
+ final_reply(subagent)
1626
1708
  end
1627
1709
 
1628
- # Run several independent tasks on forked subagents at the same time.
1710
+ # Run labeled jobs in parallel, each inside its own concurrent UI phase.
1629
1711
  #
1630
- # Each subagent is forked on the calling thread (forking deep-copies parent
1631
- # config + history, which must not race) and only the blocking run is moved
1632
- # onto the pool. Results keep the order of `tasks`; a task that raises or
1633
- # overruns the budget yields a failed slot instead of aborting its siblings.
1712
+ # Exposed for extension tools that build their own subagents (e.g. one per
1713
+ # skill) but still need the UI wiring to be correct: the web UI folds each
1714
+ # phase into its own live card, and the CLI collapses concurrent phases into
1715
+ # a single progress line. Getting that right by hand is easy to botch, so
1716
+ # the orchestration lives here while job construction stays with the caller.
1634
1717
  #
1635
- # Subagents run silently by default: they share the parent's session, so a
1636
- # live UI would interleave several raw transcripts into one chat.
1718
+ # Callers must build their subagents on the calling thread before handing
1719
+ # the jobs over forking deep-copies parent config + history, which must
1720
+ # not race. Only the blocking run belongs in the lambda.
1637
1721
  #
1638
- # @param tasks [Array<String>] one prompt per subagent
1639
- # @param model [String, nil] model for every subagent (nil = current)
1640
- # @param forbidden_tools [Array<String>] tool names blocked at runtime
1641
- # @param max_concurrency [Integer] subagents allowed to run at once
1722
+ # @param jobs [Array<Hash>] each { label: String, run: #call }
1723
+ # @param max_concurrency [Integer] jobs allowed to run at once
1642
1724
  # @param timeout [Numeric, nil] wall-clock budget for the whole batch
1643
- # @return [Array<Fanout::Result>] value is the subagent's final reply text
1644
- def fan_out_subagents(tasks, model: nil, forbidden_tools: [], max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY,
1645
- timeout: nil)
1646
- return [] if tasks.empty?
1647
-
1648
- jobs = tasks.each_with_index.map do |task, index|
1649
- subagent = fork_subagent(
1650
- model: model,
1651
- forbidden_tools: forbidden_tools,
1652
- system_prompt_suffix: "You are one of several subagents running in parallel on independent tasks. " \
1653
- "Do your task and return only the requested output. Do not ask follow-up questions."
1654
- )
1655
- parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1656
- label = "Subagent #{index + 1}/#{tasks.size}"
1657
- # Fanout workers are fresh threads, so the epoch that lets the web
1658
- # broadcaster drop events from superseded tasks has to be carried over
1659
- # by hand — otherwise interrupted subagents keep writing to the new task.
1660
- epoch = Thread.current[:task_epoch]
1725
+ # @return [Array<Fanout::Result>] aligned to the input order
1726
+ def fan_out_labeled(jobs, max_concurrency: Fanout::DEFAULT_MAX_CONCURRENCY, timeout: nil)
1727
+ return [] if jobs.empty?
1728
+
1729
+ # Fanout workers are fresh threads, so the epoch that lets the web
1730
+ # broadcaster drop events from superseded tasks has to be carried over
1731
+ # by hand — otherwise interrupted subagents keep writing to the new task.
1732
+ epoch = Thread.current[:task_epoch]
1733
+
1734
+ wrapped = jobs.each_with_index.map do |job, index|
1735
+ label = job[:label] || job["label"] || "Subagent #{index + 1}/#{jobs.size}"
1736
+ run = job[:run] || job["run"]
1737
+ raise ArgumentError, "job #{index} must provide a callable :run" unless run.respond_to?(:call)
1661
1738
 
1662
1739
  lambda do
1663
1740
  Thread.current[:task_epoch] = epoch
1664
- within_phase(label, kind: "fanout_subagent", concurrent: true) do
1665
- result = subagent.run(task)
1666
- absorb_subagent_cost(result, notify_ui: false)
1667
- final_assistant_text(subagent, parent_count)
1668
- end
1741
+ within_phase(label, kind: "fanout_subagent", concurrent: true) { run.call }
1669
1742
  end
1670
1743
  end
1671
1744
 
1672
- Fanout.new(max_concurrency: max_concurrency, timeout: timeout).run(jobs)
1745
+ Fanout.new(max_concurrency: max_concurrency, timeout: timeout).run(wrapped)
1673
1746
  end
1674
1747
 
1675
1748
  private def within_phase(label, kind:, concurrent:, &block)
@@ -1678,7 +1751,21 @@ module Clacky
1678
1751
  @ui.with_phase(kind: kind, label: label, concurrent: concurrent, &block)
1679
1752
  end
1680
1753
 
1681
- private def final_assistant_text(subagent, parent_count)
1754
+ # The subagent's last non-empty assistant message — its actual answer.
1755
+ #
1756
+ # A subagent's `run` result carries cost and iteration counts but no reply
1757
+ # text, and its trailing history entries are usually tool results, so the
1758
+ # answer has to be found by scanning backwards from the end. Only messages
1759
+ # appended after the fork are considered; earlier ones are the inherited
1760
+ # parent conversation.
1761
+ #
1762
+ # Use this when the caller wants the raw answer to pass on programmatically.
1763
+ # For a human-facing digest use {#generate_subagent_summary} instead.
1764
+ #
1765
+ # @param subagent [Agent] a subagent produced by {#fork_subagent}
1766
+ # @return [String] the reply, or "" when the subagent never answered
1767
+ def final_reply(subagent)
1768
+ parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1682
1769
  new_messages = subagent.history.to_a[parent_count..] || []
1683
1770
  new_messages
1684
1771
  .reverse
@@ -1742,11 +1829,15 @@ module Clacky
1742
1829
  end
1743
1830
 
1744
1831
  # Create new client for subagent
1832
+ subagent_entry = subagent_config.current_model
1745
1833
  subagent_client = Clacky::Client.new(
1746
1834
  subagent_config.api_key,
1747
1835
  base_url: subagent_config.base_url,
1748
1836
  model: subagent_config.model_name,
1749
- anthropic_format: subagent_config.anthropic_format?
1837
+ anthropic_format: subagent_config.anthropic_format?,
1838
+ api_format: subagent_config.api_format,
1839
+ provider_id: subagent_config.provider_id_for(subagent_entry),
1840
+ capabilities: subagent_entry && subagent_entry["capabilities"]
1750
1841
  )
1751
1842
 
1752
1843
  # Create subagent (reuses all tools from parent, inherits agent profile from parent)