openclacky 1.3.4 → 1.3.5
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/CHANGELOG.md +27 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +74 -9
- data/lib/clacky/api_extension.rb +81 -0
- data/lib/clacky/api_extension_loader.rb +13 -1
- data/lib/clacky/client.rb +14 -17
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +144 -9
- data/lib/clacky/server/session_registry.rb +4 -2
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +140 -31
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +145 -22
- data/lib/clacky/web/components/onboard.js +1 -14
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +6 -21
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/i18n.js +24 -1
- data/lib/clacky/web/index.html +15 -0
- data/lib/clacky/web/sessions.js +141 -51
- data/lib/clacky/web/settings.js +34 -2
- data/lib/clacky/web/ws-dispatcher.js +11 -3
- data/lib/clacky.rb +12 -5
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a7474760c07220891bc62795e95ab0f5b9f83387b4881e1cc9eec4133545222
|
|
4
|
+
data.tar.gz: '099b846d3a8b44af563403c05f5bc8c855b0925122aabeb3f01b77f8f0c8d18f'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e494c9032f35cf631a91dbbff72c89004c1aa8991a6f630dc21c65a9d97b45bceb2afa9c69b2f25b0d4c0e909d317b64eff5ed7b972fd475edc2f19e81a2f779
|
|
7
|
+
data.tar.gz: 7fb32e090e6cfd780cd0b4218bbfb00a0f939ce77089cc124e35d03a8870abdbb27f09de976304526b8e905d14107fc861914ba08f80d447a72e4d24a6711c8f
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.3.5] - 2026-06-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Run agent inside meeting sessions; launch new meetings directly from session context
|
|
12
|
+
- Long video generation support
|
|
13
|
+
- Show raw LLM error message in error card with collapsible detail
|
|
14
|
+
- Fake tool call detector to catch hallucinated tool invocations
|
|
15
|
+
- Accent color customization for Web UI theme
|
|
16
|
+
|
|
17
|
+
### Improved
|
|
18
|
+
- Refine auto-confirm wait TUI display
|
|
19
|
+
- Improve split button UX and billing period tab hover behavior
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Terminal: adaptive GBK/UTF-8 decoding for PTY output to fix garbled text on Windows
|
|
23
|
+
- Resolve vision model detection against the actual request model
|
|
24
|
+
- Fix FrozenError when pressing Ctrl+U to clear text in modal
|
|
25
|
+
- Detect scrollbar-drag correctly to prevent unwanted auto-scroll in chat
|
|
26
|
+
- Fix split button dropdown closing when clicking inside button wrap area
|
|
27
|
+
- Show success toast when saving profile or memory
|
|
28
|
+
- Preserve single line breaks in profile/memory preview
|
|
29
|
+
- Make text selection visible in profile/memory editor
|
|
30
|
+
- Preserve memory card expand state across re-render
|
|
31
|
+
- Refresh time machine on task completion
|
|
32
|
+
- Re-check version on WebSocket reconnect so upgrade badge updates
|
|
33
|
+
- Harden DashScope TTS routing and error handling
|
|
34
|
+
|
|
8
35
|
## [1.3.4] - 2026-06-25
|
|
9
36
|
|
|
10
37
|
### Added
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
class Agent
|
|
5
|
+
module FakeToolCallDetector
|
|
6
|
+
FAKE_TOOL_CALL_PATTERNS = [
|
|
7
|
+
/<\s*invoke\s+name\s*=\s*["'][\w\-]+["']/i,
|
|
8
|
+
/<\s*function_calls\s*>/i,
|
|
9
|
+
/<\s*tool_use\s*[\s>]/i,
|
|
10
|
+
/<\s*antml:invoke\s+name\s*=/i,
|
|
11
|
+
/<\s*antml:function_calls\s*>/i
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
MAX_FAKE_TOOL_CALL_RETRIES = 2
|
|
15
|
+
|
|
16
|
+
private def fake_tool_call_in_content?(content)
|
|
17
|
+
return false if content.nil? || content.empty?
|
|
18
|
+
FAKE_TOOL_CALL_PATTERNS.any? { |re| content.match?(re) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private def handle_fake_tool_call(response)
|
|
22
|
+
@task_fake_tool_call_count = (@task_fake_tool_call_count || 0) + 1
|
|
23
|
+
|
|
24
|
+
Clacky::Logger.warn("agent.fake_tool_call_detected",
|
|
25
|
+
session_id: @session_id,
|
|
26
|
+
iteration: @iterations,
|
|
27
|
+
retry_count: @task_fake_tool_call_count,
|
|
28
|
+
content_head: response[:content].to_s[0, 200],
|
|
29
|
+
finish_reason: response[:finish_reason].to_s
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if @task_fake_tool_call_count > MAX_FAKE_TOOL_CALL_RETRIES
|
|
33
|
+
@ui&.show_error("Model repeatedly emitted text-formatted tool calls instead of using the tool_calls API. Stopping.")
|
|
34
|
+
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content]) if response[:content] && !response[:content].empty?
|
|
35
|
+
return :stop
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@history.append({ role: "assistant", content: response[:content].to_s })
|
|
39
|
+
@history.append({
|
|
40
|
+
role: "user",
|
|
41
|
+
content: "Your previous reply contained tool-call XML written as text " \
|
|
42
|
+
"(e.g. `<invoke name=\"...\">`). That syntax is NOT executed — " \
|
|
43
|
+
"it was rendered to the user as raw text. " \
|
|
44
|
+
"Re-issue the call using the structured tool_calls field provided by the runtime, " \
|
|
45
|
+
"or, if no tool is needed, just answer normally.",
|
|
46
|
+
system_injected: true
|
|
47
|
+
})
|
|
48
|
+
:retry
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -153,7 +153,7 @@ module Clacky
|
|
|
153
153
|
# @param status [Symbol] Status of the last task: :success, :error, or :interrupted
|
|
154
154
|
# @param error_message [String] Error message if status is :error
|
|
155
155
|
# @return [Hash] Session data ready for serialization
|
|
156
|
-
def to_session_data(status: :success, error_message: nil, updated_at: nil, preserve_updated_at: false)
|
|
156
|
+
def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil, preserve_updated_at: false)
|
|
157
157
|
stats_data = {
|
|
158
158
|
total_tasks: @total_tasks,
|
|
159
159
|
total_iterations: @iterations,
|
|
@@ -167,7 +167,8 @@ module Clacky
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
# Add error message if status is error
|
|
170
|
-
stats_data[:last_error]
|
|
170
|
+
stats_data[:last_error] = error_message if status == :error && error_message
|
|
171
|
+
stats_data[:last_error_raw] = raw_message if status == :error && raw_message
|
|
171
172
|
|
|
172
173
|
{
|
|
173
174
|
session_id: @session_id,
|
|
@@ -254,18 +254,6 @@ module Clacky
|
|
|
254
254
|
}
|
|
255
255
|
end
|
|
256
256
|
|
|
257
|
-
# Show countdown before auto-executing in auto_approve mode.
|
|
258
|
-
# Gives the user time to see what's happening and Ctrl+C to cancel.
|
|
259
|
-
# @param seconds [Integer] Countdown duration
|
|
260
|
-
private def auto_approve_countdown(seconds: 10)
|
|
261
|
-
return unless @ui
|
|
262
|
-
|
|
263
|
-
seconds.downto(1) do |remaining|
|
|
264
|
-
@ui.show_info(" Auto-executing in #{remaining}s... (Ctrl+C to cancel)", prefix_newline: false)
|
|
265
|
-
sleep 1
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
|
|
269
257
|
# Check if a tool is potentially slow and should show progress
|
|
270
258
|
# @param tool_name [String] Name of the tool
|
|
271
259
|
# @param args [Hash] Tool arguments
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "cgi"
|
|
6
6
|
require "tty-prompt"
|
|
7
7
|
require "set"
|
|
8
|
+
require_relative "null_ui_controller"
|
|
8
9
|
require_relative "utils/arguments_parser"
|
|
9
10
|
require_relative "utils/file_processor"
|
|
10
11
|
require_relative "utils/environment_detector"
|
|
@@ -23,6 +24,7 @@ require_relative "agent/memory_updater"
|
|
|
23
24
|
require_relative "agent/skill_evolution"
|
|
24
25
|
require_relative "agent/skill_reflector"
|
|
25
26
|
require_relative "agent/skill_auto_creator"
|
|
27
|
+
require_relative "agent/fake_tool_call_detector"
|
|
26
28
|
|
|
27
29
|
module Clacky
|
|
28
30
|
class Agent
|
|
@@ -39,6 +41,7 @@ module Clacky
|
|
|
39
41
|
include SkillEvolution
|
|
40
42
|
include SkillReflector
|
|
41
43
|
include SkillAutoCreator
|
|
44
|
+
include FakeToolCallDetector
|
|
42
45
|
|
|
43
46
|
attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
|
|
44
47
|
:cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
|
|
@@ -274,6 +277,7 @@ module Clacky
|
|
|
274
277
|
else
|
|
275
278
|
@start_time = Time.now
|
|
276
279
|
@task_truncation_count = 0 # Reset truncation counter for each task
|
|
280
|
+
@task_fake_tool_call_count = 0 # Reset fake tool-call counter for each task
|
|
277
281
|
@task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
|
|
278
282
|
@task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
|
|
279
283
|
@task_cost_source = :estimated # Reset for new task
|
|
@@ -488,6 +492,18 @@ module Clacky
|
|
|
488
492
|
Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
|
|
489
493
|
end
|
|
490
494
|
|
|
495
|
+
# Detect fake tool-calls written as XML/text in content (model bug
|
|
496
|
+
# where it emits `<invoke name="...">` instead of using the
|
|
497
|
+
# structured tool_calls field). Only triggers when tool_calls is
|
|
498
|
+
# absent — a real call alongside stray XML is not our problem here.
|
|
499
|
+
if (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
|
|
500
|
+
fake_tool_call_in_content?(response[:content])
|
|
501
|
+
case handle_fake_tool_call(response)
|
|
502
|
+
when :retry then next
|
|
503
|
+
when :stop then break
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
491
507
|
# Check if done (no more tool calls needed).
|
|
492
508
|
#
|
|
493
509
|
# Defensive rule: we ONLY exit on empty/missing tool_calls.
|
|
@@ -949,10 +965,9 @@ module Clacky
|
|
|
949
965
|
end
|
|
950
966
|
|
|
951
967
|
# Special handling for request_user_feedback
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
else
|
|
968
|
+
# The interactive countdown (auto_approve) is handled after the tool
|
|
969
|
+
# executes, once the question itself has been rendered to the user.
|
|
970
|
+
unless call[:name] == "request_user_feedback"
|
|
956
971
|
@ui&.show_tool_call(call[:name], redact_tool_args(call[:arguments]))
|
|
957
972
|
end
|
|
958
973
|
|
|
@@ -1037,11 +1052,26 @@ module Clacky
|
|
|
1037
1052
|
@ui&.show_tool_call(call[:name], call[:arguments])
|
|
1038
1053
|
|
|
1039
1054
|
if @config.permission_mode == :auto_approve
|
|
1040
|
-
# auto_approve means
|
|
1041
|
-
#
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
)
|
|
1055
|
+
# auto_approve means the agent runs unattended by default, but a
|
|
1056
|
+
# human MAY be watching the terminal. Show a short interactive
|
|
1057
|
+
# countdown: if the user steps in, hand control over and wait for
|
|
1058
|
+
# their answer; otherwise auto-decide and keep going.
|
|
1059
|
+
countdown = @ui&.request_feedback_with_countdown(seconds: 10)
|
|
1060
|
+
|
|
1061
|
+
if @ui.nil? || countdown == :timeout
|
|
1062
|
+
result = result.merge(
|
|
1063
|
+
auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
|
|
1064
|
+
)
|
|
1065
|
+
elsif countdown.is_a?(String) && !countdown.strip.empty?
|
|
1066
|
+
# User stepped in and typed an answer right away. Route it through
|
|
1067
|
+
# the denied+feedback path so the agent responds to it immediately
|
|
1068
|
+
# instead of breaking and forcing the user to re-type.
|
|
1069
|
+
denied = true
|
|
1070
|
+
feedback = countdown
|
|
1071
|
+
else
|
|
1072
|
+
# User stepped in but gave no text — hand control back to the CLI.
|
|
1073
|
+
awaiting_feedback = true
|
|
1074
|
+
end
|
|
1045
1075
|
else
|
|
1046
1076
|
# confirm_all / confirm_safes — a human is present, truly wait for user input.
|
|
1047
1077
|
awaiting_feedback = true
|
|
@@ -1288,6 +1318,41 @@ module Clacky
|
|
|
1288
1318
|
@tool_registry.register(Tools::Browser.new)
|
|
1289
1319
|
end
|
|
1290
1320
|
|
|
1321
|
+
# Run a one-off task on a forked subagent and return its final reply text,
|
|
1322
|
+
# WITHOUT mutating this (parent) agent's history. Used by extensions that
|
|
1323
|
+
# need a side analysis (e.g. meeting annotate) which must reuse the parent's
|
|
1324
|
+
# cached context + unified billing, but must NOT pollute the main conversation.
|
|
1325
|
+
#
|
|
1326
|
+
# The subagent deep-clones the parent history (cache prefix + task state), runs
|
|
1327
|
+
# to completion, and is discarded. Only the cost is merged back into the parent.
|
|
1328
|
+
#
|
|
1329
|
+
# @param task [String] The task/prompt for the subagent
|
|
1330
|
+
# @param model [String, nil] Model name ("lite" for the lite companion, nil = current)
|
|
1331
|
+
# @param forbidden_tools [Array<String>] Tool names to block at runtime
|
|
1332
|
+
# @return [String] Subagent's final assistant reply (empty string if none)
|
|
1333
|
+
def run_detached(task, model: nil, forbidden_tools: [])
|
|
1334
|
+
subagent = fork_subagent(
|
|
1335
|
+
model: model,
|
|
1336
|
+
forbidden_tools: forbidden_tools,
|
|
1337
|
+
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."
|
|
1338
|
+
)
|
|
1339
|
+
# Detached runs must stay invisible: a real UI (e.g. WebUIController bound
|
|
1340
|
+
# to the parent's session_id) would broadcast the subagent's raw output
|
|
1341
|
+
# into the parent chat transcript. Swap in a no-op UI so nothing leaks.
|
|
1342
|
+
subagent.instance_variable_set(:@ui, NullUIController.new)
|
|
1343
|
+
parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
|
|
1344
|
+
result = subagent.run(task)
|
|
1345
|
+
|
|
1346
|
+
@total_cost += result[:total_cost_usd] || 0.0
|
|
1347
|
+
|
|
1348
|
+
new_messages = subagent.history.to_a[parent_count..] || []
|
|
1349
|
+
new_messages
|
|
1350
|
+
.reverse
|
|
1351
|
+
.find { |m| m[:role] == "assistant" && m[:content] && !m[:content].to_s.empty? }
|
|
1352
|
+
&.dig(:content)
|
|
1353
|
+
.to_s
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1291
1356
|
# Fork a subagent with specified configuration
|
|
1292
1357
|
# The subagent inherits all messages and tools from parent agent
|
|
1293
1358
|
# Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks
|
data/lib/clacky/api_extension.rb
CHANGED
|
@@ -238,6 +238,87 @@ module Clacky
|
|
|
238
238
|
@http_server&.instance_variable_get(:@agent_config)
|
|
239
239
|
end
|
|
240
240
|
|
|
241
|
+
def registry
|
|
242
|
+
@http_server&.instance_variable_get(:@registry)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Create a brand-new session and optionally kick off its first task.
|
|
246
|
+
# Returns the new session_id. When a prompt is given, the task is
|
|
247
|
+
# submitted immediately (the session starts running); display_message
|
|
248
|
+
# controls the user-facing bubble shown in place of the raw prompt.
|
|
249
|
+
def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
|
|
250
|
+
source: :manual, display_message: nil)
|
|
251
|
+
error!("server not ready", status: 503) unless @http_server
|
|
252
|
+
|
|
253
|
+
session_id = @http_server.send(
|
|
254
|
+
:build_session,
|
|
255
|
+
name: name,
|
|
256
|
+
working_dir: working_dir,
|
|
257
|
+
profile: profile,
|
|
258
|
+
source: source
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
|
|
262
|
+
|
|
263
|
+
session_id
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Submit a prompt to an existing session for execution.
|
|
267
|
+
# The session must be idle; returns the session_id on success.
|
|
268
|
+
# Raises Halt (409) if the session is already running.
|
|
269
|
+
def submit_task(session_id, prompt, display_message: nil)
|
|
270
|
+
reg = registry
|
|
271
|
+
error!("server not ready", status: 503) unless reg
|
|
272
|
+
|
|
273
|
+
unless reg.exist?(session_id)
|
|
274
|
+
reg.ensure(session_id)
|
|
275
|
+
error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
session = reg.get(session_id)
|
|
279
|
+
error!("session is busy", status: 409) if session[:status] == :running
|
|
280
|
+
|
|
281
|
+
@http_server.send(:run_session_task, session_id, prompt, display_message: display_message)
|
|
282
|
+
session_id
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Run a one-off side task on an existing session's agent and return its
|
|
286
|
+
# reply text SYNCHRONOUSLY, without polluting the main conversation.
|
|
287
|
+
#
|
|
288
|
+
# Unlike submit_task (which enqueues a turn into the live conversation and
|
|
289
|
+
# returns immediately), this forks the session's agent — reusing its cached
|
|
290
|
+
# context and unified billing — runs the task to completion on the fork, and
|
|
291
|
+
# returns the fork's final reply. The main conversation is never touched.
|
|
292
|
+
#
|
|
293
|
+
# Strategy A (parent-busy → skip): if the session is currently running, or the
|
|
294
|
+
# server is at its concurrency limit, this returns { busy: true } without
|
|
295
|
+
# running. Callers (e.g. periodic analysis) should treat that as "try later".
|
|
296
|
+
#
|
|
297
|
+
# @param session_id [String]
|
|
298
|
+
# @param prompt [String]
|
|
299
|
+
# @param model [String, nil] "lite" for the lite companion, nil = current
|
|
300
|
+
# @param forbidden_tools [Array<String>] tool names blocked in the fork
|
|
301
|
+
# @return [Hash] { text: "..." } on success, or { busy: true } when skipped
|
|
302
|
+
def dispatch_to_session(session_id, prompt, model: nil, forbidden_tools: [])
|
|
303
|
+
reg = registry
|
|
304
|
+
error!("server not ready", status: 503) unless reg
|
|
305
|
+
|
|
306
|
+
unless reg.exist?(session_id)
|
|
307
|
+
reg.ensure(session_id)
|
|
308
|
+
error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
return { busy: true } if reg.respond_to?(:running_full?) && reg.running_full?
|
|
312
|
+
|
|
313
|
+
session = reg.get(session_id)
|
|
314
|
+
return { busy: true } if session[:status] == :running
|
|
315
|
+
|
|
316
|
+
agent = session[:agent]
|
|
317
|
+
error!("session agent not available", status: 503) unless agent
|
|
318
|
+
|
|
319
|
+
{ text: agent.run_detached(prompt, model: model, forbidden_tools: forbidden_tools) }
|
|
320
|
+
end
|
|
321
|
+
|
|
241
322
|
def server_start_time
|
|
242
323
|
@http_server&.instance_variable_get(:@start_time)
|
|
243
324
|
end
|
|
@@ -12,15 +12,27 @@ module Clacky
|
|
|
12
12
|
# isolated: skipped with a logged warning, never aborts the load of others.
|
|
13
13
|
module ApiExtensionLoader
|
|
14
14
|
DEFAULT_DIR = File.expand_path("~/.clacky/api_ext")
|
|
15
|
+
BUILTIN_DIR = File.expand_path("../default_extensions", __FILE__)
|
|
15
16
|
DISABLED_DIR = "_disabled"
|
|
16
17
|
|
|
17
18
|
Result = Struct.new(:loaded, :skipped, keyword_init: true)
|
|
18
19
|
|
|
19
20
|
class << self
|
|
20
|
-
def load_all(dir: DEFAULT_DIR)
|
|
21
|
+
def load_all(dir: DEFAULT_DIR, builtin: true)
|
|
21
22
|
result = Result.new(loaded: [], skipped: [])
|
|
22
23
|
Clacky::ApiExtension.reset_registry!
|
|
23
24
|
|
|
25
|
+
# Load built-in (gem-shipped) extensions first (lowest priority)
|
|
26
|
+
if builtin && Dir.exist?(BUILTIN_DIR)
|
|
27
|
+
Dir.glob(File.join(BUILTIN_DIR, "*", "handler.rb")).sort.each do |handler_path|
|
|
28
|
+
ext_dir = File.dirname(handler_path)
|
|
29
|
+
ext_id = File.basename(ext_dir)
|
|
30
|
+
next if ext_id.start_with?("_")
|
|
31
|
+
load_one(ext_id, ext_dir, handler_path, result)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Load user extensions (higher priority — same ext_id overwrites built-in)
|
|
24
36
|
if Dir.exist?(dir)
|
|
25
37
|
Dir.glob(File.join(dir, "*", "handler.rb")).sort.each do |handler_path|
|
|
26
38
|
ext_dir = File.dirname(handler_path)
|
data/lib/clacky/client.rb
CHANGED
|
@@ -34,11 +34,6 @@ module Clacky
|
|
|
34
34
|
# some OpenRouter-compatible relays only honour Bearer — send both).
|
|
35
35
|
@provider_id = provider_id
|
|
36
36
|
|
|
37
|
-
# Determine vision support once at construction time.
|
|
38
|
-
# Non-vision models (DeepSeek, Kimi, MiniMax, etc.) reject image_url
|
|
39
|
-
# content blocks; the conversion layer strips them when this is false.
|
|
40
|
-
@vision_supported = Providers.supports?(provider_id, :vision, model_name: @model)
|
|
41
|
-
|
|
42
37
|
# Optional override for Faraday read_timeout (e.g. benchmark calls).
|
|
43
38
|
# nil means use the default (300s for streaming).
|
|
44
39
|
@read_timeout = read_timeout
|
|
@@ -343,9 +338,12 @@ module Clacky
|
|
|
343
338
|
# OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
|
|
344
339
|
messages = apply_message_caching(messages) if caching_enabled
|
|
345
340
|
|
|
341
|
+
# Vision support is resolved against the request's actual model (which may
|
|
342
|
+
# differ from @model after a runtime switch or fallback override), so the
|
|
343
|
+
# conversion layer strips image_url blocks for non-vision models.
|
|
346
344
|
body = MessageFormat::OpenAI.build_request_body(
|
|
347
345
|
messages, model, tools, max_tokens, caching_enabled,
|
|
348
|
-
vision_supported: @
|
|
346
|
+
vision_supported: Providers.supports?(@provider_id, :vision, model_name: model),
|
|
349
347
|
reasoning_effort: reasoning_effort
|
|
350
348
|
)
|
|
351
349
|
return send_openai_stream_request(body, on_chunk) if on_chunk
|
|
@@ -641,35 +639,34 @@ module Clacky
|
|
|
641
639
|
raise InsufficientCreditError.new(
|
|
642
640
|
"[LLM] #{I18n.t("llm.error.insufficient_credit")}",
|
|
643
641
|
error_code: "insufficient_credit",
|
|
644
|
-
provider_id: @provider_id
|
|
642
|
+
provider_id: @provider_id,
|
|
643
|
+
raw_message: error_message
|
|
645
644
|
)
|
|
646
645
|
end
|
|
647
646
|
|
|
648
647
|
case response.status
|
|
649
648
|
when 400
|
|
650
|
-
# Well-behaved APIs (Anthropic, OpenAI) never put quota/availability issues in 400.
|
|
651
|
-
# However, some proxy/relay providers do — so we inspect the message first.
|
|
652
|
-
# Also, Bedrock returns ThrottlingException as 400 instead of 429.
|
|
653
649
|
if error_message.match?(/ThrottlingException|unavailable|quota/i)
|
|
654
650
|
raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_400")}"
|
|
655
651
|
end
|
|
656
652
|
|
|
657
|
-
# True bad request — our message was malformed. Roll back history so the
|
|
658
|
-
# broken message is not replayed on the next user turn.
|
|
659
653
|
raise BadRequestError.new(
|
|
660
654
|
"[LLM] Client request error: #{error_message}",
|
|
661
|
-
display_message: "[LLM] #{I18n.t("llm.error.bad_request")}"
|
|
655
|
+
display_message: "[LLM] #{I18n.t("llm.error.bad_request")}",
|
|
656
|
+
raw_message: error_message
|
|
662
657
|
)
|
|
663
|
-
when 401
|
|
658
|
+
when 401
|
|
659
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.invalid_api_key")}", raw_message: error_message)
|
|
664
660
|
when 403
|
|
665
661
|
i18n_key = "llm.error.403.#{error_code}"
|
|
666
662
|
translated = I18n.t(i18n_key)
|
|
667
663
|
translated = I18n.t("llm.error.403.default") if translated == i18n_key
|
|
668
|
-
raise AgentError
|
|
669
|
-
when 404
|
|
664
|
+
raise AgentError.new("[LLM] #{translated}", raw_message: error_message)
|
|
665
|
+
when 404
|
|
666
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.endpoint_not_found")}", raw_message: error_message)
|
|
670
667
|
when 429 then raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
|
|
671
668
|
when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
|
|
672
|
-
else raise AgentError
|
|
669
|
+
else raise AgentError.new("[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}", raw_message: error_message)
|
|
673
670
|
end
|
|
674
671
|
end
|
|
675
672
|
|
|
@@ -19,6 +19,25 @@
|
|
|
19
19
|
(() => {
|
|
20
20
|
if (!window.Clacky || !Clacky.ext) return;
|
|
21
21
|
|
|
22
|
+
// The currently mounted panel's state, refreshed on every mount. A single WS
|
|
23
|
+
// hook (registered once below) reloads it when the active session completes a
|
|
24
|
+
// task, so new snapshots appear without a manual refresh. Kept as a closure
|
|
25
|
+
// singleton because WS.onEvent has no unsubscribe and the panel re-mounts on
|
|
26
|
+
// each session switch.
|
|
27
|
+
let _activeState = null;
|
|
28
|
+
let _wsHooked = false;
|
|
29
|
+
|
|
30
|
+
function _hookWs() {
|
|
31
|
+
if (_wsHooked || typeof WS === "undefined") return;
|
|
32
|
+
_wsHooked = true;
|
|
33
|
+
WS.onEvent((ev) => {
|
|
34
|
+
if (ev && ev.type === "complete" && _activeState &&
|
|
35
|
+
ev.session_id === _activeState.sessionId) {
|
|
36
|
+
loadHistory(_activeState);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
const t = (k, fallback) => {
|
|
23
42
|
const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
|
|
24
43
|
return (v && v !== k) ? v : fallback;
|
|
@@ -630,6 +649,9 @@
|
|
|
630
649
|
d.mask.onclick = onMaskClick;
|
|
631
650
|
document.addEventListener("keydown", onKey);
|
|
632
651
|
|
|
652
|
+
_activeState = state;
|
|
653
|
+
_hookWs();
|
|
654
|
+
|
|
633
655
|
loadHistory(state);
|
|
634
656
|
return root;
|
|
635
657
|
}, {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
- **ALWAYS use `glob` tool to find files — NEVER use shell `find` command for file discovery**
|
|
11
11
|
- **All operations default to the working directory** (shown in session context)
|
|
12
|
+
- **NEVER write tool calls as text** (e.g. `<invoke name=...>`, `<function_calls>`). Use the structured tool_calls field — text won't execute.
|
|
12
13
|
|
|
13
14
|
## Response Style
|
|
14
15
|
|