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.
- checksums.yaml +4 -4
- data/README.md +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +311 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +75 -247
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +10 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- 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
|