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.
- checksums.yaml +4 -4
- data/.clackyrules +1 -0
- data/CHANGELOG.md +34 -0
- data/lib/clacky/agent/memory_updater.rb +1 -43
- data/lib/clacky/agent/session_serializer.rb +3 -1
- data/lib/clacky/agent/skill_auto_creator.rb +12 -9
- data/lib/clacky/agent/skill_evolution.rb +4 -3
- data/lib/clacky/agent/skill_manager.rb +10 -12
- data/lib/clacky/agent/skill_reflector.rb +2 -2
- data/lib/clacky/agent.rb +139 -48
- data/lib/clacky/agent_config.rb +66 -39
- data/lib/clacky/agent_profile.rb +36 -2
- data/lib/clacky/cli.rb +24 -5
- data/lib/clacky/client.rb +47 -12
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1 -1
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +50 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +18 -17
- data/lib/clacky/default_skills/skill-creator/SKILL.md +8 -7
- data/lib/clacky/extension/cli_commands.rb +1 -1
- data/lib/clacky/extension/loader.rb +36 -3
- data/lib/clacky/extension/packager.rb +22 -0
- data/lib/clacky/extension/scaffold/templates/full/README.md.erb +3 -1
- data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +8 -1
- data/lib/clacky/extension/scaffold/templates/full/tools/hello.rb +28 -0
- data/lib/clacky/extension/scaffold.rb +3 -2
- data/lib/clacky/extension/verifier.rb +4 -2
- data/lib/clacky/locales/en.rb +1 -1
- data/lib/clacky/locales/zh.rb +1 -1
- data/lib/clacky/mcp/http_transport.rb +28 -12
- data/lib/clacky/media/base.rb +22 -13
- data/lib/clacky/message_format/open_ai.rb +17 -1
- data/lib/clacky/message_history.rb +1 -1
- data/lib/clacky/providers.rb +40 -8
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -2
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +42 -20
- data/lib/clacky/server/channel/channel_config.rb +22 -8
- data/lib/clacky/server/channel/channel_manager.rb +11 -5
- data/lib/clacky/server/channel/channel_ui_controller.rb +20 -7
- data/lib/clacky/server/http_server.rb +206 -36
- data/lib/clacky/server/model_prices.rb +39 -0
- data/lib/clacky/server/project_manager.rb +4 -1
- data/lib/clacky/server/server_master.rb +12 -1
- data/lib/clacky/server/web_ui_controller.rb +3 -1
- data/lib/clacky/skill.rb +9 -0
- data/lib/clacky/skill_loader.rb +8 -3
- data/lib/clacky/tools/base.rb +4 -0
- data/lib/clacky/tools/web_search.rb +24 -11
- data/lib/clacky/ui2/components/command_suggestions.rb +1 -2
- data/lib/clacky/ui2/ui_controller.rb +96 -0
- data/lib/clacky/utils/logger.rb +3 -1
- data/lib/clacky/utils/model_pricing.rb +77 -8
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +216 -35
- data/lib/clacky/web/features/channels/store.js +17 -2
- data/lib/clacky/web/features/channels/view.js +33 -0
- data/lib/clacky/web/features/extensions/store.js +25 -0
- data/lib/clacky/web/features/extensions/view.js +24 -0
- data/lib/clacky/web/features/model-tester/store.js +2 -1
- data/lib/clacky/web/i18n.js +54 -2
- data/lib/clacky/web/index.html +44 -3
- data/lib/clacky/web/projects.js +29 -1
- data/lib/clacky/web/sessions.js +79 -6
- data/lib/clacky/web/settings.js +175 -7
- data/lib/clacky/web/skills.js +86 -10
- data/lib/clacky/web/ws-dispatcher.js +2 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '038138bc72f3f740ed3ccee6da3f6464f80fd0cf1d0466788879a4472ac127b1'
|
|
4
|
+
data.tar.gz: dfb75e0b2e832e923c34a1f5e61b4a005bd29435f3724763128b0fadc0b6ec1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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: "
|
|
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
|
|
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 &&
|
|
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
|
|
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(
|
|
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
|
|
274
|
+
return unless skill_command[:matched]
|
|
277
275
|
|
|
278
|
-
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
|
|
284
|
-
notice = case
|
|
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 =
|
|
306
|
-
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|
|
|
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)
|
|
29
|
+
cost = absorb_subagent_cost(result)
|
|
30
30
|
|
|
31
31
|
@skill_execution_context = nil
|
|
32
32
|
|
|
33
|
-
"#{skill_name} · $#{cost.round(4)}"
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1707
|
+
final_reply(subagent)
|
|
1626
1708
|
end
|
|
1627
1709
|
|
|
1628
|
-
# Run
|
|
1710
|
+
# Run labeled jobs in parallel, each inside its own concurrent UI phase.
|
|
1629
1711
|
#
|
|
1630
|
-
#
|
|
1631
|
-
#
|
|
1632
|
-
#
|
|
1633
|
-
#
|
|
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
|
-
#
|
|
1636
|
-
#
|
|
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
|
|
1639
|
-
# @param
|
|
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>]
|
|
1644
|
-
def
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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)
|