openclacky 1.5.13 → 1.5.14
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 +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
data/lib/clacky/agent.rb
CHANGED
|
@@ -45,10 +45,10 @@ module Clacky
|
|
|
45
45
|
include FakeToolCallDetector
|
|
46
46
|
|
|
47
47
|
attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
:cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
|
|
49
|
+
:status, :error, :updated_at, :source, :config,
|
|
50
|
+
:latest_latency, # Hash of latency metrics from the most recent LLM call (see Client#send_messages_with_tools)
|
|
51
|
+
:reasoning_effort
|
|
52
52
|
attr_accessor :pinned
|
|
53
53
|
attr_accessor :channel_info
|
|
54
54
|
attr_accessor :project_id
|
|
@@ -475,6 +475,11 @@ module Clacky
|
|
|
475
475
|
end
|
|
476
476
|
|
|
477
477
|
def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil)
|
|
478
|
+
# Initialized here (not mid-body) because run's rescue/ensure are
|
|
479
|
+
# method-level and must be able to reference them on any exit path.
|
|
480
|
+
result = nil
|
|
481
|
+
run_turn_started = false
|
|
482
|
+
|
|
478
483
|
# Intercept /goal ... commands before any task/LLM work. Control-plane
|
|
479
484
|
# commands (status/pause/resume/clear) return immediately without a turn;
|
|
480
485
|
# `/goal <text>` sets the goal, then falls through to run the first turn.
|
|
@@ -716,287 +721,287 @@ module Clacky
|
|
|
716
721
|
@history.append({ role: "user", content: ctx, system_injected: true, task_id: task_id })
|
|
717
722
|
end
|
|
718
723
|
|
|
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
|
-
end
|
|
724
|
+
run_turn_started = true
|
|
725
|
+
# If the user typed a slash command targeting a skill with disable-model-invocation: true,
|
|
726
|
+
# inject the skill content as a synthetic assistant message so the LLM can act on it.
|
|
727
|
+
# Skills already in the system prompt (model_invocation_allowed?) are skipped.
|
|
728
|
+
# Covered by run's method-level ensure so a fork_subagent failure (e.g.
|
|
729
|
+
# skill-declared model not found) still stops the progress spinner.
|
|
730
|
+
inject_skill_command_as_assistant_message(skill_command, task_id)
|
|
731
|
+
|
|
732
|
+
@hooks.trigger(:on_start, user_input)
|
|
733
|
+
|
|
734
|
+
# Track if ask_user was called
|
|
735
|
+
awaiting_user_feedback = false
|
|
736
|
+
# Heuristic sibling of the above: the reply merely ended with a question
|
|
737
|
+
# mark. Kept separate because it must never reach build_result — the
|
|
738
|
+
# session status it feeds shows a "waiting" badge to the user, and a
|
|
739
|
+
# rhetorical closing question is not a request for input.
|
|
740
|
+
turn_unfinished = false
|
|
741
|
+
# Track if task was interrupted by user (denied tool execution)
|
|
742
|
+
task_interrupted = false
|
|
743
|
+
|
|
744
|
+
loop do
|
|
745
|
+
Clacky::Shutdown.checkpoint!
|
|
746
|
+
@iterations += 1
|
|
747
|
+
@hooks.trigger(:on_iteration, @iterations)
|
|
748
|
+
|
|
749
|
+
# Think: LLM reasoning with tool support
|
|
750
|
+
response = think
|
|
751
|
+
|
|
752
|
+
# Debug: check for potential infinite loops
|
|
753
|
+
if @config.verbose
|
|
754
|
+
@ui&.log("Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}", level: :debug)
|
|
755
|
+
end
|
|
752
756
|
|
|
753
|
-
|
|
754
|
-
|
|
757
|
+
# Skip if compression happened (response is nil)
|
|
758
|
+
next if response.nil?
|
|
755
759
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
end
|
|
770
|
-
Clacky::Logger.warn("agent.think_response",
|
|
771
|
-
session_id: @session_id,
|
|
772
|
-
iteration: @iterations,
|
|
773
|
-
finish_reason: response[:finish_reason].to_s,
|
|
774
|
-
tool_calls_count: tool_calls.size,
|
|
775
|
-
tool_calls: tc_summary,
|
|
776
|
-
content_len: response[:content].to_s.length,
|
|
777
|
-
completion_tokens: response.dig(:token_usage, :completion_tokens),
|
|
778
|
-
ttft_ms: response.dig(:latency, :ttft_ms),
|
|
779
|
-
suspicious_truncation: true
|
|
780
|
-
)
|
|
760
|
+
# [DIAG] Only log when finish_reason=="stop" AND tool_calls non-empty —
|
|
761
|
+
# the suspicious combo that indicates an upstream-truncated tool_use
|
|
762
|
+
# response. Normal responses produce no log line here to avoid noise.
|
|
763
|
+
begin
|
|
764
|
+
tool_calls = response[:tool_calls] || []
|
|
765
|
+
if response[:finish_reason] == "stop" && !tool_calls.empty?
|
|
766
|
+
tc_summary = tool_calls.map do |c|
|
|
767
|
+
args_str = c[:arguments].is_a?(String) ? c[:arguments] : c[:arguments].to_s
|
|
768
|
+
{
|
|
769
|
+
name: c[:name].to_s,
|
|
770
|
+
args_len: args_str.length,
|
|
771
|
+
args_head: args_str[0, 120]
|
|
772
|
+
}
|
|
781
773
|
end
|
|
782
|
-
|
|
783
|
-
|
|
774
|
+
Clacky::Logger.warn("agent.think_response",
|
|
775
|
+
session_id: @session_id,
|
|
776
|
+
iteration: @iterations,
|
|
777
|
+
finish_reason: response[:finish_reason].to_s,
|
|
778
|
+
tool_calls_count: tool_calls.size,
|
|
779
|
+
tool_calls: tc_summary,
|
|
780
|
+
content_len: response[:content].to_s.length,
|
|
781
|
+
completion_tokens: response.dig(:token_usage, :completion_tokens),
|
|
782
|
+
ttft_ms: response.dig(:latency, :ttft_ms),
|
|
783
|
+
suspicious_truncation: true
|
|
784
|
+
)
|
|
784
785
|
end
|
|
786
|
+
rescue StandardError => e
|
|
787
|
+
Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
|
|
788
|
+
end
|
|
785
789
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
end
|
|
790
|
+
# Detect fake tool-calls written as XML/text in content (model bug
|
|
791
|
+
# where it emits `<invoke name="...">` instead of using the
|
|
792
|
+
# structured tool_calls field). Only triggers when tool_calls is
|
|
793
|
+
# absent — a real call alongside stray XML is not our problem here.
|
|
794
|
+
if (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
|
|
795
|
+
fake_tool_call_in_content?(response[:content])
|
|
796
|
+
case handle_fake_tool_call(response)
|
|
797
|
+
when :retry then next
|
|
798
|
+
when :stop then break
|
|
796
799
|
end
|
|
800
|
+
end
|
|
797
801
|
|
|
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
|
-
end
|
|
840
|
-
if response[:content] && !response[:content].empty?
|
|
841
|
-
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], created_at: response[:created_at])
|
|
842
|
-
end
|
|
843
|
-
|
|
844
|
-
# Show token usage after the assistant message so WebUI renders it below the bubble
|
|
845
|
-
@ui&.show_token_usage(response[:token_usage]) if response[:token_usage]
|
|
846
|
-
|
|
847
|
-
# Debug: log why we're stopping
|
|
848
|
-
if @config.verbose && (response[:tool_calls].nil? || response[:tool_calls].empty?)
|
|
849
|
-
reason = response[:finish_reason] == "stop" ? "API returned finish_reason=stop" : "No tool calls in response"
|
|
850
|
-
@ui&.log("Stopping: #{reason}", level: :debug)
|
|
851
|
-
if response[:content] && response[:content].is_a?(String)
|
|
852
|
-
preview = response[:content].length > 200 ? response[:content][0...200] + "..." : response[:content]
|
|
853
|
-
@ui&.log("Response content: #{preview}", level: :debug)
|
|
854
|
-
end
|
|
855
|
-
end
|
|
856
|
-
|
|
857
|
-
# If the assistant ended its turn with a question, treat this as
|
|
858
|
-
# an in-flight conversation (agent is awaiting the user's reply)
|
|
859
|
-
# and skip skill evolution — the task isn't truly complete yet.
|
|
860
|
-
turn_unfinished = true if ends_with_question
|
|
861
|
-
|
|
862
|
-
break
|
|
802
|
+
# Check if done (no more tool calls needed).
|
|
803
|
+
#
|
|
804
|
+
# Defensive rule: we ONLY exit on empty/missing tool_calls.
|
|
805
|
+
# We used to also short-circuit on finish_reason=="stop", but
|
|
806
|
+
# upstream routers (OpenRouter → Anthropic/Bedrock) can return the
|
|
807
|
+
# contradictory combo `finish_reason=="stop" + non-empty tool_calls
|
|
808
|
+
# with truncated args`, which caused the agent to silently treat a
|
|
809
|
+
# truncated response as "task complete". Truncation is now caught
|
|
810
|
+
# earlier by LlmCaller#detect_upstream_truncation! (which raises
|
|
811
|
+
# UpstreamTruncatedError → RetryableError); this branch stays as
|
|
812
|
+
# a belt-and-braces guard: if that detector ever misses a new
|
|
813
|
+
# truncation pattern, we still won't silently exit while the model
|
|
814
|
+
# is mid-tool_call.
|
|
815
|
+
if response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
816
|
+
content_str = response[:content].to_s
|
|
817
|
+
stripped = content_str.strip
|
|
818
|
+
ends_with_question = stripped.end_with?("?", "?")
|
|
819
|
+
finish_reason_str = response[:finish_reason].to_s
|
|
820
|
+
completion_tokens = response.dig(:token_usage, :completion_tokens)
|
|
821
|
+
|
|
822
|
+
Clacky::Logger.info("agent.loop_break_normal",
|
|
823
|
+
session_id: @session_id,
|
|
824
|
+
iteration: @iterations,
|
|
825
|
+
branch: (response[:tool_calls].nil? ? "tool_calls_nil" : "tool_calls_empty"),
|
|
826
|
+
finish_reason: finish_reason_str,
|
|
827
|
+
tool_calls_count: (response[:tool_calls] || []).size,
|
|
828
|
+
completion_tokens: completion_tokens,
|
|
829
|
+
max_tokens: @config.max_tokens,
|
|
830
|
+
content_len: content_str.length,
|
|
831
|
+
content_ends_with_question: ends_with_question
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
if finish_reason_str == "length"
|
|
835
|
+
Clacky::Logger.warn("agent.loop_break_on_length",
|
|
836
|
+
session_id: @session_id,
|
|
837
|
+
iteration: @iterations,
|
|
838
|
+
completion_tokens: completion_tokens,
|
|
839
|
+
max_tokens: @config.max_tokens,
|
|
840
|
+
content_len: content_str.length,
|
|
841
|
+
content_tail: content_str[-200, 200]
|
|
842
|
+
)
|
|
863
843
|
end
|
|
864
|
-
|
|
865
|
-
# Show assistant message if there's content before tool calls
|
|
866
844
|
if response[:content] && !response[:content].empty?
|
|
867
|
-
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content],
|
|
845
|
+
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], created_at: response[:created_at])
|
|
868
846
|
end
|
|
869
847
|
|
|
870
|
-
# Show token usage after assistant message
|
|
871
|
-
# This ensures WebUI renders the token line below the assistant bubble.
|
|
848
|
+
# Show token usage after the assistant message so WebUI renders it below the bubble
|
|
872
849
|
@ui&.show_token_usage(response[:token_usage]) if response[:token_usage]
|
|
873
850
|
|
|
874
|
-
#
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
break
|
|
851
|
+
# Debug: log why we're stopping
|
|
852
|
+
if @config.verbose && (response[:tool_calls].nil? || response[:tool_calls].empty?)
|
|
853
|
+
reason = response[:finish_reason] == "stop" ? "API returned finish_reason=stop" : "No tool calls in response"
|
|
854
|
+
@ui&.log("Stopping: #{reason}", level: :debug)
|
|
855
|
+
if response[:content] && response[:content].is_a?(String)
|
|
856
|
+
preview = response[:content].length > 200 ? response[:content][0...200] + "..." : response[:content]
|
|
857
|
+
@ui&.log("Response content: #{preview}", level: :debug)
|
|
858
|
+
end
|
|
883
859
|
end
|
|
884
860
|
|
|
885
|
-
#
|
|
886
|
-
|
|
861
|
+
# If the assistant ended its turn with a question, treat this as
|
|
862
|
+
# an in-flight conversation (agent is awaiting the user's reply)
|
|
863
|
+
# and skip skill evolution — the task isn't truly complete yet.
|
|
864
|
+
turn_unfinished = true if ends_with_question
|
|
887
865
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
# producing a legal message sequence for all API providers (especially Bedrock).
|
|
891
|
-
flush_pending_injections
|
|
866
|
+
break
|
|
867
|
+
end
|
|
892
868
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
# If user provided feedback, treat it as a user question/instruction
|
|
897
|
-
if action_result[:feedback] && !action_result[:feedback].empty?
|
|
898
|
-
# Add user feedback as a new user message with system_injected marker
|
|
899
|
-
@history.append({
|
|
900
|
-
role: "user",
|
|
901
|
-
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.",
|
|
902
|
-
system_injected: true
|
|
903
|
-
})
|
|
904
|
-
# Continue loop to let agent respond to feedback
|
|
905
|
-
next
|
|
906
|
-
else
|
|
907
|
-
# User just said "no" without feedback - stop and wait
|
|
908
|
-
@ui&.show_assistant_message("Tool execution was denied. Please give more instructions...", files: [])
|
|
909
|
-
break
|
|
910
|
-
end
|
|
911
|
-
end
|
|
869
|
+
# Show assistant message if there's content before tool calls
|
|
870
|
+
if response[:content] && !response[:content].empty?
|
|
871
|
+
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content], interim: true, created_at: response[:created_at])
|
|
912
872
|
end
|
|
913
873
|
|
|
914
|
-
|
|
874
|
+
# Show token usage after assistant message (or immediately if no message).
|
|
875
|
+
# This ensures WebUI renders the token line below the assistant bubble.
|
|
876
|
+
@ui&.show_token_usage(response[:token_usage]) if response[:token_usage]
|
|
915
877
|
|
|
916
|
-
#
|
|
917
|
-
|
|
918
|
-
# Only for main agent (not subagents) to avoid recursive evolution
|
|
919
|
-
unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
|
|
920
|
-
run_skill_evolution_hooks
|
|
921
|
-
end
|
|
878
|
+
# Act: Execute tool calls
|
|
879
|
+
action_result = act(response[:tool_calls])
|
|
922
880
|
|
|
923
|
-
#
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
# Skip on interrupt / feedback / subagent (self-guarded inside too).
|
|
930
|
-
unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
|
|
931
|
-
run_memory_update_subagent
|
|
881
|
+
# Check if ask_user was called
|
|
882
|
+
if action_result[:awaiting_feedback]
|
|
883
|
+
awaiting_user_feedback = true
|
|
884
|
+
observe(response, action_result[:tool_results])
|
|
885
|
+
flush_pending_injections
|
|
886
|
+
break
|
|
932
887
|
end
|
|
933
888
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
889
|
+
# Observe: Add tool results to conversation context
|
|
890
|
+
observe(response, action_result[:tool_results])
|
|
891
|
+
|
|
892
|
+
# Flush any inline skill injections enqueued by invoke_skill during act().
|
|
893
|
+
# Must happen AFTER observe() so toolResult is appended before skill instructions,
|
|
894
|
+
# producing a legal message sequence for all API providers (especially Bedrock).
|
|
895
|
+
flush_pending_injections
|
|
896
|
+
|
|
897
|
+
# Check if user denied any tool
|
|
898
|
+
if action_result[:denied]
|
|
899
|
+
task_interrupted = true
|
|
900
|
+
# If user provided feedback, treat it as a user question/instruction
|
|
901
|
+
if action_result[:feedback] && !action_result[:feedback].empty?
|
|
902
|
+
# Add user feedback as a new user message with system_injected marker
|
|
903
|
+
@history.append({
|
|
904
|
+
role: "user",
|
|
905
|
+
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.",
|
|
906
|
+
system_injected: true
|
|
907
|
+
})
|
|
908
|
+
# Continue loop to let agent respond to feedback
|
|
909
|
+
next
|
|
910
|
+
else
|
|
911
|
+
# User just said "no" without feedback - stop and wait
|
|
912
|
+
@ui&.show_assistant_message("Tool execution was denied. Please give more instructions...", files: [])
|
|
913
|
+
break
|
|
914
|
+
end
|
|
958
915
|
end
|
|
916
|
+
end
|
|
959
917
|
|
|
960
|
-
|
|
961
|
-
rescue Clacky::AgentInterrupted
|
|
962
|
-
# A cancelled fan-out captured its subagents' progress but never reached
|
|
963
|
-
# observe() to persist it — anchor those trails now so a page reload
|
|
964
|
-
# after the interrupt still shows what the subagents did.
|
|
965
|
-
flush_pending_subagent_transcripts_on_interrupt
|
|
966
|
-
# Mark this run as interrupted so the next run() (e.g. user's
|
|
967
|
-
# supplementary message during a running task) keeps the existing
|
|
968
|
-
# task-start snapshot — the completion summary should reflect the
|
|
969
|
-
# entire task across the relay, not just the post-interrupt portion.
|
|
970
|
-
@last_run_interrupted = true
|
|
971
|
-
# Let CLI handle the interrupt message
|
|
972
|
-
raise
|
|
973
|
-
rescue StandardError => e
|
|
974
|
-
# Log complete error information to debug_logs for troubleshooting
|
|
975
|
-
@debug_logs << {
|
|
976
|
-
timestamp: Time.now.iso8601,
|
|
977
|
-
event: "agent_run_error",
|
|
978
|
-
error_class: e.class.name,
|
|
979
|
-
error_message: e.message,
|
|
980
|
-
backtrace: e.backtrace&.first(30) # Keep first 30 lines of backtrace
|
|
981
|
-
}
|
|
982
|
-
Clacky::Logger.error("agent_run_error", error: e)
|
|
918
|
+
result = build_result(awaiting_user_feedback: awaiting_user_feedback)
|
|
983
919
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
920
|
+
# Run skill evolution hooks after main loop completes
|
|
921
|
+
# Skip if task was interrupted by user (denied tool) or awaiting user feedback
|
|
922
|
+
# Only for main agent (not subagents) to avoid recursive evolution
|
|
923
|
+
unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
|
|
924
|
+
run_skill_evolution_hooks
|
|
925
|
+
end
|
|
988
926
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
927
|
+
# Run long-term memory update as a forked subagent BEFORE we print
|
|
928
|
+
# show_complete. Running it as a subagent (rather than inline in
|
|
929
|
+
# the main loop) gives us correct visual ordering structurally:
|
|
930
|
+
# the subagent blocks until done, its progress spinner finishes,
|
|
931
|
+
# and only then [OK] Task Complete is printed. No cleanup dance,
|
|
932
|
+
# no cross-method progress handle holding.
|
|
933
|
+
# Skip on interrupt / feedback / subagent (self-guarded inside too).
|
|
934
|
+
unless @is_subagent || task_interrupted || awaiting_user_feedback || turn_unfinished
|
|
935
|
+
run_memory_update_subagent
|
|
936
|
+
end
|
|
995
937
|
|
|
996
|
-
|
|
997
|
-
#
|
|
998
|
-
|
|
938
|
+
if @is_subagent
|
|
939
|
+
# Parent agent (skill_manager) prints the completion summary; skip here.
|
|
940
|
+
else
|
|
941
|
+
@ui&.show_complete(
|
|
942
|
+
task_id: result[:task_id],
|
|
943
|
+
iterations: result[:iterations],
|
|
944
|
+
cost: result[:total_cost_usd],
|
|
945
|
+
cost_source: result[:cost_source],
|
|
946
|
+
duration: result[:duration_seconds],
|
|
947
|
+
cache_stats: result[:cache_stats],
|
|
948
|
+
awaiting_user_feedback: awaiting_user_feedback
|
|
949
|
+
)
|
|
999
950
|
end
|
|
951
|
+
@hooks.trigger(:on_complete, result)
|
|
952
|
+
|
|
953
|
+
# Standing-goal loop: after a completed turn, ask the judge whether the
|
|
954
|
+
# goal is met. If not (and budget/health allow), auto-run the next turn
|
|
955
|
+
# in this same thread. Skipped for subagents and interrupts.
|
|
956
|
+
# awaiting_user_feedback (agent ended with '?') is intentionally not
|
|
957
|
+
# checked here - maybe_continue_goal is a no-op when no goal is active,
|
|
958
|
+
# and when one is active the judge decides done/continue, not punctuation.
|
|
959
|
+
unless @is_subagent || task_interrupted
|
|
960
|
+
continuation = maybe_continue_goal(result)
|
|
961
|
+
return continuation if continuation
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
result
|
|
965
|
+
rescue Clacky::AgentInterrupted
|
|
966
|
+
# A cancelled fan-out captured its subagents' progress but never reached
|
|
967
|
+
# observe() to persist it — anchor those trails now so a page reload
|
|
968
|
+
# after the interrupt still shows what the subagents did.
|
|
969
|
+
flush_pending_subagent_transcripts_on_interrupt
|
|
970
|
+
# Mark this run as interrupted so the next run() (e.g. user's
|
|
971
|
+
# supplementary message during a running task) keeps the existing
|
|
972
|
+
# task-start snapshot — the completion summary should reflect the
|
|
973
|
+
# entire task across the relay, not just the post-interrupt portion.
|
|
974
|
+
@last_run_interrupted = true
|
|
975
|
+
# Let CLI handle the interrupt message
|
|
976
|
+
raise
|
|
977
|
+
rescue StandardError => e
|
|
978
|
+
# Log complete error information to debug_logs for troubleshooting
|
|
979
|
+
@debug_logs << {
|
|
980
|
+
timestamp: Time.now.iso8601,
|
|
981
|
+
event: "agent_run_error",
|
|
982
|
+
error_class: e.class.name,
|
|
983
|
+
error_message: e.message,
|
|
984
|
+
backtrace: e.backtrace&.first(30) # Keep first 30 lines of backtrace
|
|
985
|
+
}
|
|
986
|
+
Clacky::Logger.error("agent_run_error", error: e)
|
|
987
|
+
|
|
988
|
+
# 400 errors mean our request was malformed — roll back history so the bad
|
|
989
|
+
# message is not replayed on the next user turn.
|
|
990
|
+
# Other errors (auth, network, etc.) leave history intact for retry.
|
|
991
|
+
@pending_error_rollback = true if e.is_a?(Clacky::BadRequestError)
|
|
992
|
+
|
|
993
|
+
# Build error result for session data, but let CLI handle error display
|
|
994
|
+
result = build_result(:error, error: e.message)
|
|
995
|
+
raise
|
|
996
|
+
ensure
|
|
997
|
+
# Safety net: ensure any lingering progress spinner is stopped.
|
|
998
|
+
@ui&.show_progress(phase: "done")
|
|
999
|
+
|
|
1000
|
+
# Fire-and-forget telemetry after every agent run.
|
|
1001
|
+
# Tracks daily active users (distinct devices per day) and task volume.
|
|
1002
|
+
# Guarded by run_turn_started so goal control commands (which return
|
|
1003
|
+
# before the task turn) are not counted as agent runs.
|
|
1004
|
+
Clacky::Telemetry.task!(result: result) if run_turn_started
|
|
1000
1005
|
end
|
|
1001
1006
|
|
|
1002
1007
|
private def think
|
|
@@ -1089,10 +1094,10 @@ module Clacky
|
|
|
1089
1094
|
# Create a response that tells the user to break down the task
|
|
1090
1095
|
error_response = {
|
|
1091
1096
|
content: "I apologize, but this task is too complex to complete in a single response. " \
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1097
|
+
"Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
|
|
1098
|
+
"For example, when creating a long document:\n" \
|
|
1099
|
+
"1. First create the file with a basic structure\n" \
|
|
1100
|
+
"2. Then use edit() to add content section by section",
|
|
1096
1101
|
finish_reason: "stop",
|
|
1097
1102
|
tool_calls: nil
|
|
1098
1103
|
}
|
|
@@ -1128,11 +1133,11 @@ module Clacky
|
|
|
1128
1133
|
@history.append({
|
|
1129
1134
|
role: "user",
|
|
1130
1135
|
content: "[SYSTEM] Your previous response was truncated because it exceeded the output token limit (max_tokens=#{@config.max_tokens}). " \
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
+
"The incomplete tool call has been discarded. Please retry with a different approach:\n" \
|
|
1137
|
+
"- For long file content: create the file with a basic structure first, then use edit() to add content section by section\n" \
|
|
1138
|
+
"- Break down large tasks into multiple smaller tool calls\n" \
|
|
1139
|
+
"- Keep each tool call argument under 2000 characters\n" \
|
|
1140
|
+
"- Use multiple tool calls instead of one large call",
|
|
1136
1141
|
truncated: true,
|
|
1137
1142
|
system_injected: true
|
|
1138
1143
|
})
|
|
@@ -1274,8 +1279,8 @@ module Clacky
|
|
|
1274
1279
|
remaining_calls = tool_calls[(index + 1)..-1] || []
|
|
1275
1280
|
remaining_calls.each do |remaining_call|
|
|
1276
1281
|
reason = user_feedback && !user_feedback.empty? ?
|
|
1277
|
-
|
|
1278
|
-
|
|
1282
|
+
user_feedback :
|
|
1283
|
+
"Auto-denied due to user rejection of previous tool"
|
|
1279
1284
|
results << build_denied_result(remaining_call, reason, system_injected)
|
|
1280
1285
|
end
|
|
1281
1286
|
break
|
|
@@ -1384,7 +1389,7 @@ module Clacky
|
|
|
1384
1389
|
# A rejected call (no usable question) falls through to the normal
|
|
1385
1390
|
# result path so the model sees the error and can retry.
|
|
1386
1391
|
if Tools::AskUser.feedback_tool?(call[:name]) &&
|
|
1387
|
-
|
|
1392
|
+
result.is_a?(Hash) && result[:awaiting_feedback]
|
|
1388
1393
|
# Pass the raw call arguments to show_tool_call so the WebUI controller
|
|
1389
1394
|
# can extract the questions and emit a "request_feedback" event
|
|
1390
1395
|
# (renders as a clickable card in the browser).
|
|
@@ -1736,7 +1741,7 @@ module Clacky
|
|
|
1736
1741
|
@tool_registry.register(Tools::TodoManager.new)
|
|
1737
1742
|
@tool_registry.register(Tools::AskUser.new)
|
|
1738
1743
|
@tool_registry.register(Tools::InvokeSkill.new)
|
|
1739
|
-
@tool_registry.register(Tools::Browser.new)
|
|
1744
|
+
@tool_registry.register(Tools::Browser.new) if Tools::Browser.available?
|
|
1740
1745
|
end
|
|
1741
1746
|
|
|
1742
1747
|
# Register tools the agent declared via `tools:` — each id maps to
|
|
@@ -1757,7 +1762,7 @@ module Clacky
|
|
|
1757
1762
|
@tool_registry.register(tool)
|
|
1758
1763
|
rescue StandardError, ScriptError => e
|
|
1759
1764
|
Clacky::Logger.warn("agent.register_extension_tool",
|
|
1760
|
-
|
|
1765
|
+
error: e.message, tool: id)
|
|
1761
1766
|
end
|
|
1762
1767
|
end
|
|
1763
1768
|
|
|
@@ -1849,7 +1854,7 @@ module Clacky
|
|
|
1849
1854
|
end
|
|
1850
1855
|
|
|
1851
1856
|
Fanout.new(max_concurrency: max_concurrency, timeout: timeout)
|
|
1852
|
-
|
|
1857
|
+
.run(wrapped, on_cancel: -> { @cancel_flag&.cancel! })
|
|
1853
1858
|
end
|
|
1854
1859
|
|
|
1855
1860
|
private def within_phase(label, kind:, concurrent:, &block)
|
|
@@ -2005,11 +2010,11 @@ module Clacky
|
|
|
2005
2010
|
|
|
2006
2011
|
# Build forbidden tools notice if any tools are forbidden
|
|
2007
2012
|
forbidden_notice = if forbidden_tools.any?
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
+
tool_list = forbidden_tools.map { |t| "`#{t}`" }.join(", ")
|
|
2014
|
+
"\n\n[System Notice] The following tools are disabled in this subagent and will be rejected if called: #{tool_list}"
|
|
2015
|
+
else
|
|
2016
|
+
""
|
|
2017
|
+
end
|
|
2013
2018
|
|
|
2014
2019
|
subagent_history.append({
|
|
2015
2020
|
role: "user",
|