rubyn-code 0.2.2 → 0.4.0

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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. metadata +83 -1
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Filters tool schemas sent to the LLM based on detected task context.
6
+ # Instead of sending all 28+ tool schemas on every call, only include
7
+ # tools relevant to the current task. This reduces per-turn system
8
+ # prompt overhead by 30-50%.
9
+ module DynamicToolSchema
10
+ BASE_TOOLS = %w[
11
+ read_file write_file edit_file glob grep bash
12
+ ].freeze
13
+
14
+ TASK_TOOLS = {
15
+ testing: %w[run_specs].freeze,
16
+ git: %w[git_status git_diff git_log git_commit].freeze,
17
+ review: %w[review_pr git_diff].freeze,
18
+ explore: %w[spawn_agent].freeze,
19
+ web: %w[web_search web_fetch].freeze,
20
+ memory: %w[memory_search memory_write].freeze,
21
+ skills: %w[load_skill].freeze,
22
+ tasks: %w[task].freeze,
23
+ teams: %w[spawn_teammate send_message read_inbox].freeze,
24
+ rails: %w[rails_generate db_migrate bundle_install bundle_add].freeze,
25
+ background: %w[background_run].freeze,
26
+ interaction: %w[ask_user compact].freeze
27
+ }.freeze
28
+
29
+ class << self
30
+ # Returns tool names relevant to the detected task context.
31
+ #
32
+ # @param task_context [Symbol, nil] detected task type
33
+ # @param discovered_tools [Set<String>] tools already discovered this session
34
+ # @param codebase_index [RubynCode::Index::CodebaseIndex, nil] optional index for deeper context detection
35
+ # @param message [String, nil] original user message for index-based matching
36
+ # @return [Array<String>] tool names to include in the schema
37
+ def active_tools(task_context: nil, discovered_tools: Set.new, codebase_index: nil, message: nil)
38
+ tools = BASE_TOOLS.dup
39
+
40
+ # Always include interaction tools
41
+ tools.concat(TASK_TOOLS[:interaction])
42
+ tools.concat(TASK_TOOLS[:memory])
43
+
44
+ # Add task-specific tools
45
+ if task_context
46
+ context_tools = resolve_context_tools(task_context)
47
+ tools.concat(context_tools)
48
+ end
49
+
50
+ # Add index-aware tools when a codebase index and message are available
51
+ if codebase_index && message
52
+ index_contexts = detect_index_contexts(message, codebase_index)
53
+ index_contexts.each { |ctx| tools.concat(resolve_context_tools(ctx)) }
54
+ end
55
+
56
+ # Always include previously discovered tools
57
+ tools.concat(discovered_tools.to_a)
58
+
59
+ tools.uniq
60
+ end
61
+
62
+ # Detect task context from a user message.
63
+ #
64
+ # @param message [String]
65
+ # @param codebase_index [RubynCode::Index::CodebaseIndex, nil] optional index for deeper detection
66
+ # @return [Symbol, nil]
67
+ def detect_context(message, codebase_index: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- context detection dispatch
68
+ msg = message.to_s.downcase
69
+ return :testing if msg.match?(/\b(test|spec|rspec)\b/)
70
+ return :git if msg.match?(/\b(commit|push|diff|branch|merge|git)\b/)
71
+ return :review if msg.match?(/\b(review|pr|pull request)\b/)
72
+ return :rails if msg.match?(/\b(migrate|generate|scaffold|rails)\b/)
73
+ return :web if msg.match?(/\b(search|fetch|url|http|api)\b/)
74
+ return :explore if msg.match?(/\b(explore|architecture|structure)\b/)
75
+ return :teams if msg.match?(/\b(team|spawn|message|inbox)\b/)
76
+
77
+ # Fall back to index-based detection when keyword matching yields nothing
78
+ return nil unless codebase_index
79
+
80
+ index_contexts = detect_index_contexts(message, codebase_index)
81
+ index_contexts.first
82
+ end
83
+
84
+ # Detect additional tool contexts based on codebase index content.
85
+ #
86
+ # @param message [String] user message
87
+ # @param codebase_index [RubynCode::Index::CodebaseIndex] codebase index instance
88
+ # @return [Array<Symbol>] detected context symbols
89
+ def detect_index_contexts(message, codebase_index)
90
+ contexts = []
91
+ return contexts unless codebase_index
92
+
93
+ contexts << :rails if message_mentions_model?(message, codebase_index)
94
+ contexts << :testing if message_mentions_specced_file?(message, codebase_index)
95
+ contexts.uniq
96
+ rescue StandardError
97
+ []
98
+ end
99
+
100
+ # Filter full tool definitions to only include active tools.
101
+ #
102
+ # @param all_definitions [Array<Hash>] full tool schema list
103
+ # @param active_names [Array<String>] names of active tools
104
+ # @return [Array<Hash>] filtered definitions
105
+ def filter(all_definitions, active_names:)
106
+ name_set = active_names.to_set
107
+ all_definitions.select do |defn|
108
+ name = defn[:name] || defn['name']
109
+ name_set.include?(name)
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def resolve_context_tools(context)
116
+ case context
117
+ when Symbol
118
+ TASK_TOOLS.fetch(context, [])
119
+ when Array
120
+ context.flat_map { |c| TASK_TOOLS.fetch(c, []) }
121
+ else
122
+ []
123
+ end
124
+ end
125
+
126
+ # Check if the user message mentions a model name from the index.
127
+ def message_mentions_model?(message, codebase_index)
128
+ model_names = codebase_index.nodes
129
+ .select { |n| n['type'] == 'model' }
130
+ .map { |n| n['name'] }
131
+ return false if model_names.empty?
132
+
133
+ msg_lower = message.to_s.downcase
134
+ model_names.any? { |name| msg_lower.include?(name.downcase) }
135
+ end
136
+
137
+ # Check if the user message mentions a file that has specs in the index.
138
+ def message_mentions_specced_file?(message, codebase_index)
139
+ spec_edges = codebase_index.edges.select { |e| e['relationship'] == 'tests' }
140
+ return false if spec_edges.empty?
141
+
142
+ tested_files = spec_edges.map { |e| e['to'] }.compact
143
+ msg_lower = message.to_s.downcase
144
+ tested_files.any? do |file|
145
+ basename = File.basename(file, '.rb')
146
+ msg_lower.include?(basename)
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Detects positive/negative user feedback and reinforces learned instincts.
6
+ module FeedbackHandler
7
+ POSITIVE_PATTERNS =
8
+ /\b(yes that fixed it|that worked|perfect|thanks|exactly|great|nailed it|that.s right|correct)\b/i
9
+ NEGATIVE_PATTERNS =
10
+ /\b(no[, ]+use|wrong|that.s not right|instead use|don.t do that|actually[, ]+use|incorrect)\b/i
11
+
12
+ private
13
+
14
+ def check_user_feedback(user_input)
15
+ return unless @project_root
16
+
17
+ recent_instincts = fetch_recent_instincts
18
+ return if recent_instincts.empty?
19
+
20
+ reinforce_instincts(user_input, recent_instincts)
21
+ rescue StandardError
22
+ # Non-critical; don't interrupt the conversation
23
+ end
24
+
25
+ def fetch_recent_instincts
26
+ db = DB::Connection.instance
27
+ db.query(
28
+ 'SELECT id FROM instincts WHERE project_path = ? ORDER BY updated_at DESC LIMIT 5',
29
+ [@project_root]
30
+ ).to_a
31
+ end
32
+
33
+ def reinforce_instincts(user_input, recent_instincts)
34
+ if user_input.match?(POSITIVE_PATTERNS)
35
+ reinforce_top(recent_instincts, helpful: true)
36
+ elsif user_input.match?(NEGATIVE_PATTERNS)
37
+ reinforce_top(recent_instincts, helpful: false)
38
+ end
39
+ end
40
+
41
+ def reinforce_top(instincts, helpful:)
42
+ db = DB::Connection.instance
43
+ instincts.first(2).each do |row|
44
+ Learning::InstinctMethods.reinforce_in_db(row['id'], db, helpful: helpful)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Handles LLM chat calls, option building, prompt-too-long recovery,
6
+ # and maintenance tasks (compaction, budget, stall detection).
7
+ module LlmCaller # rubocop:disable Metrics/ModuleLength -- LLM call pipeline with routing + recovery
8
+ private
9
+
10
+ def call_llm
11
+ @hook_runner.fire(:pre_llm_call, conversation: @conversation)
12
+
13
+ opts = build_llm_opts
14
+ log_llm_call(opts)
15
+ response = @llm_client.chat(**opts)
16
+
17
+ @hook_runner.fire(:post_llm_call, response: response, conversation: @conversation)
18
+ track_usage(response)
19
+ update_task_budget(response)
20
+ response
21
+ rescue LLM::Client::PromptTooLongError
22
+ recover_prompt_too_long(opts)
23
+ end
24
+
25
+ def build_llm_opts
26
+ opts = {
27
+ messages: @conversation.to_api_format,
28
+ tools: @plan_mode ? read_only_tool_definitions : tool_definitions,
29
+ system: build_system_prompt,
30
+ on_text: @on_text
31
+ }
32
+ opts[:max_tokens] = @max_tokens_override if @max_tokens_override
33
+ opts[:model] = routed_model
34
+ if @task_budget_remaining
35
+ opts[:task_budget] = {
36
+ total: UsageTracker::TASK_BUDGET_TOTAL, remaining: @task_budget_remaining
37
+ }
38
+ end
39
+ opts
40
+ end
41
+
42
+ # Uses ModelRouter to pick the right model for the current task.
43
+ # Only returns models from the active provider — never crosses
44
+ # provider boundaries (e.g., won't send a GPT model to Anthropic).
45
+ # Falls back to nil (use client's default) if routing fails.
46
+ def routed_model # rubocop:disable Metrics/CyclomaticComplexity -- guard clauses for provider/mode checks
47
+ return nil if manual_model_mode?
48
+
49
+ last_user = last_user_message_text
50
+ return nil unless last_user
51
+
52
+ recent = @stall_detector.respond_to?(:recent_tools) ? @stall_detector.recent_tools : []
53
+ task = LLM::ModelRouter.detect_task(last_user, recent_tools: recent)
54
+ resolved = LLM::ModelRouter.resolve(task, client: @llm_client)
55
+
56
+ # Only use the routed model if it's from the same provider
57
+ active = @llm_client.respond_to?(:provider_name) ? @llm_client.provider_name : nil
58
+ return nil if active && resolved[:provider] != active
59
+
60
+ resolved[:model]
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ def manual_model_mode?
66
+ Config::Settings.new.get('model_mode', 'auto') == 'manual'
67
+ rescue StandardError
68
+ false
69
+ end
70
+
71
+ def last_user_message_text
72
+ msg = @conversation.messages.reverse_each.find { |m| m[:role] == 'user' }
73
+ return nil unless msg
74
+
75
+ content = msg[:content]
76
+ content.is_a?(String) ? content : nil
77
+ end
78
+
79
+ def log_llm_call(opts) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- safe accessor checks
80
+ default_model = @llm_client.respond_to?(:model) ? @llm_client.model : 'default'
81
+ routed = opts[:model]
82
+ effective = routed || default_model
83
+ provider = @llm_client.respond_to?(:provider_name) ? @llm_client.provider_name : 'unknown'
84
+ tool_count = opts[:tools]&.size || 0
85
+ routed_tag = routed && routed != default_model ? " (routed from #{default_model})" : ''
86
+ RubynCode::Debug.llm("chat provider=#{provider} model=#{effective}#{routed_tag} tools=#{tool_count}")
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
91
+ def recover_prompt_too_long(opts)
92
+ RubynCode::Debug.recovery(
93
+ '413 prompt too long — running emergency compaction'
94
+ )
95
+ @context_manager.check_compaction!(@conversation)
96
+
97
+ response = @llm_client.chat(**opts, messages: @conversation.to_api_format)
98
+ @hook_runner.fire(
99
+ :post_llm_call, response: response, conversation: @conversation
100
+ )
101
+ track_usage(response)
102
+ response
103
+ end
104
+
105
+ # ── Maintenance ──────────────────────────────────────────────────
106
+
107
+ def run_maintenance(_iteration)
108
+ run_compaction
109
+ check_budget
110
+ check_stall_detection
111
+ end
112
+
113
+ def run_compaction
114
+ before = @conversation.length
115
+ est = @context_manager.estimated_tokens(@conversation.messages)
116
+ RubynCode::Debug.token(
117
+ "context=#{est} tokens (~#{before} messages, " \
118
+ "threshold=#{Config::Defaults::CONTEXT_THRESHOLD_TOKENS})"
119
+ )
120
+
121
+ @context_manager.check_compaction!(@conversation)
122
+ log_compaction(before, est)
123
+ rescue NoMethodError
124
+ # context_manager does not implement check_compaction! yet
125
+ end
126
+
127
+ def log_compaction(before, est)
128
+ after = @conversation.length
129
+ return unless after < before
130
+
131
+ new_est = @context_manager.estimated_tokens(@conversation.messages)
132
+ RubynCode::Debug.loop_tick(
133
+ "Compacted: #{before} -> #{after} messages " \
134
+ "(#{est} -> #{new_est} tokens)"
135
+ )
136
+ end
137
+
138
+ def check_budget
139
+ return unless @budget_enforcer
140
+
141
+ @budget_enforcer.check!
142
+ rescue BudgetExceededError
143
+ raise
144
+ rescue NoMethodError
145
+ # budget_enforcer does not implement check! yet
146
+ end
147
+
148
+ def check_stall_detection
149
+ return unless @stall_detector.stalled?
150
+
151
+ nudge = @stall_detector.nudge_message
152
+ @conversation.add_user_message(nudge)
153
+ @stall_detector.reset!
154
+ end
155
+ end
156
+ end
157
+ end