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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. 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
- :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
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
- result = nil
720
- begin
721
- # If the user typed a slash command targeting a skill with disable-model-invocation: true,
722
- # inject the skill content as a synthetic assistant message so the LLM can act on it.
723
- # Skills already in the system prompt (model_invocation_allowed?) are skipped.
724
- # Inside the begin block so a fork_subagent failure (e.g. skill-declared model
725
- # not found) still reaches the ensure block that stops the progress spinner.
726
- inject_skill_command_as_assistant_message(skill_command, task_id)
727
-
728
- @hooks.trigger(:on_start, user_input)
729
-
730
- # Track if ask_user was called
731
- awaiting_user_feedback = false
732
- # Heuristic sibling of the above: the reply merely ended with a question
733
- # mark. Kept separate because it must never reach build_result the
734
- # session status it feeds shows a "waiting" badge to the user, and a
735
- # rhetorical closing question is not a request for input.
736
- turn_unfinished = false
737
- # Track if task was interrupted by user (denied tool execution)
738
- task_interrupted = false
739
-
740
- loop do
741
- Clacky::Shutdown.checkpoint!
742
- @iterations += 1
743
- @hooks.trigger(:on_iteration, @iterations)
744
-
745
- # Think: LLM reasoning with tool support
746
- response = think
747
-
748
- # Debug: check for potential infinite loops
749
- if @config.verbose
750
- @ui&.log("Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}", level: :debug)
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
- # Skip if compression happened (response is nil)
754
- next if response.nil?
757
+ # Skip if compression happened (response is nil)
758
+ next if response.nil?
755
759
 
756
- # [DIAG] Only log when finish_reason=="stop" AND tool_calls non-empty —
757
- # the suspicious combo that indicates an upstream-truncated tool_use
758
- # response. Normal responses produce no log line here to avoid noise.
759
- begin
760
- tool_calls = response[:tool_calls] || []
761
- if response[:finish_reason] == "stop" && !tool_calls.empty?
762
- tc_summary = tool_calls.map do |c|
763
- args_str = c[:arguments].is_a?(String) ? c[:arguments] : c[:arguments].to_s
764
- {
765
- name: c[:name].to_s,
766
- args_len: args_str.length,
767
- args_head: args_str[0, 120]
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
- rescue StandardError => e
783
- Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
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
- # Detect fake tool-calls written as XML/text in content (model bug
787
- # where it emits `<invoke name="...">` instead of using the
788
- # structured tool_calls field). Only triggers when tool_calls is
789
- # absent — a real call alongside stray XML is not our problem here.
790
- if (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
791
- fake_tool_call_in_content?(response[:content])
792
- case handle_fake_tool_call(response)
793
- when :retry then next
794
- when :stop then break
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
- # Check if done (no more tool calls needed).
799
- #
800
- # Defensive rule: we ONLY exit on empty/missing tool_calls.
801
- # We used to also short-circuit on finish_reason=="stop", but
802
- # upstream routers (OpenRouter → Anthropic/Bedrock) can return the
803
- # contradictory combo `finish_reason=="stop" + non-empty tool_calls
804
- # with truncated args`, which caused the agent to silently treat a
805
- # truncated response as "task complete". Truncation is now caught
806
- # earlier by LlmCaller#detect_upstream_truncation! (which raises
807
- # UpstreamTruncatedError → RetryableError); this branch stays as
808
- # a belt-and-braces guard: if that detector ever misses a new
809
- # truncation pattern, we still won't silently exit while the model
810
- # is mid-tool_call.
811
- if response[:tool_calls].nil? || response[:tool_calls].empty?
812
- content_str = response[:content].to_s
813
- stripped = content_str.strip
814
- ends_with_question = stripped.end_with?("?", "?")
815
- finish_reason_str = response[:finish_reason].to_s
816
- completion_tokens = response.dig(:token_usage, :completion_tokens)
817
-
818
- Clacky::Logger.info("agent.loop_break_normal",
819
- session_id: @session_id,
820
- iteration: @iterations,
821
- branch: (response[:tool_calls].nil? ? "tool_calls_nil" : "tool_calls_empty"),
822
- finish_reason: finish_reason_str,
823
- tool_calls_count: (response[:tool_calls] || []).size,
824
- completion_tokens: completion_tokens,
825
- max_tokens: @config.max_tokens,
826
- content_len: content_str.length,
827
- content_ends_with_question: ends_with_question
828
- )
829
-
830
- if finish_reason_str == "length"
831
- Clacky::Logger.warn("agent.loop_break_on_length",
832
- session_id: @session_id,
833
- iteration: @iterations,
834
- completion_tokens: completion_tokens,
835
- max_tokens: @config.max_tokens,
836
- content_len: content_str.length,
837
- content_tail: content_str[-200, 200]
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], interim: true, created_at: response[:created_at])
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 (or immediately if no 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
- # Act: Execute tool calls
875
- action_result = act(response[:tool_calls])
876
-
877
- # Check if ask_user was called
878
- if action_result[:awaiting_feedback]
879
- awaiting_user_feedback = true
880
- observe(response, action_result[:tool_results])
881
- flush_pending_injections
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
- # Observe: Add tool results to conversation context
886
- observe(response, action_result[:tool_results])
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
- # Flush any inline skill injections enqueued by invoke_skill during act().
889
- # Must happen AFTER observe() so toolResult is appended before skill instructions,
890
- # producing a legal message sequence for all API providers (especially Bedrock).
891
- flush_pending_injections
866
+ break
867
+ end
892
868
 
893
- # Check if user denied any tool
894
- if action_result[:denied]
895
- task_interrupted = true
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
- result = build_result(awaiting_user_feedback: awaiting_user_feedback)
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
- # Run skill evolution hooks after main loop completes
917
- # Skip if task was interrupted by user (denied tool) or awaiting user feedback
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
- # Run long-term memory update as a forked subagent BEFORE we print
924
- # show_complete. Running it as a subagent (rather than inline in
925
- # the main loop) gives us correct visual ordering structurally:
926
- # the subagent blocks until done, its progress spinner finishes,
927
- # and only then [OK] Task Complete is printed. No cleanup dance,
928
- # no cross-method progress handle holding.
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
- if @is_subagent
935
- # Parent agent (skill_manager) prints the completion summary; skip here.
936
- else
937
- @ui&.show_complete(
938
- task_id: result[:task_id],
939
- iterations: result[:iterations],
940
- cost: result[:total_cost_usd],
941
- cost_source: result[:cost_source],
942
- duration: result[:duration_seconds],
943
- cache_stats: result[:cache_stats],
944
- awaiting_user_feedback: awaiting_user_feedback
945
- )
946
- end
947
- @hooks.trigger(:on_complete, result)
948
-
949
- # Standing-goal loop: after a completed turn, ask the judge whether the
950
- # goal is met. If not (and budget/health allow), auto-run the next turn
951
- # in this same thread. Skipped for subagents and interrupts.
952
- # awaiting_user_feedback (agent ended with '?') is intentionally not
953
- # checked here - maybe_continue_goal is a no-op when no goal is active,
954
- # and when one is active the judge decides done/continue, not punctuation.
955
- unless @is_subagent || task_interrupted
956
- continuation = maybe_continue_goal(result)
957
- return continuation if continuation
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
- result
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
- # 400 errors mean our request was malformed — roll back history so the bad
985
- # message is not replayed on the next user turn.
986
- # Other errors (auth, network, etc.) leave history intact for retry.
987
- @pending_error_rollback = true if e.is_a?(Clacky::BadRequestError)
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
- # Build error result for session data, but let CLI handle error display
990
- result = build_result(:error, error: e.message)
991
- raise
992
- ensure
993
- # Safety net: ensure any lingering progress spinner is stopped.
994
- @ui&.show_progress(phase: "done")
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
- # Fire-and-forget telemetry after every agent run.
997
- # Tracks daily active users (distinct devices per day) and task volume.
998
- Clacky::Telemetry.task!(result: result)
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
- "Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
1093
- "For example, when creating a long document:\n" \
1094
- "1. First create the file with a basic structure\n" \
1095
- "2. Then use edit() to add content section by section",
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
- "The incomplete tool call has been discarded. Please retry with a different approach:\n" \
1132
- "- For long file content: create the file with a basic structure first, then use edit() to add content section by section\n" \
1133
- "- Break down large tasks into multiple smaller tool calls\n" \
1134
- "- Keep each tool call argument under 2000 characters\n" \
1135
- "- Use multiple tool calls instead of one large call",
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
- user_feedback :
1278
- "Auto-denied due to user rejection of previous tool"
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
- result.is_a?(Hash) && result[:awaiting_feedback]
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
- error: e.message, tool: id)
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
- .run(wrapped, on_cancel: -> { @cancel_flag&.cancel! })
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
- tool_list = forbidden_tools.map { |t| "`#{t}`" }.join(", ")
2009
- "\n\n[System Notice] The following tools are disabled in this subagent and will be rejected if called: #{tool_list}"
2010
- else
2011
- ""
2012
- end
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",