rubyn-code 0.2.2 → 0.3.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 +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -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 +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -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 +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -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 +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- 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/index/codebase_index.rb +245 -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 +270 -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 +46 -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 +55 -252
- 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 +9 -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/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/skills/document.rb +33 -29
- 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/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- 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 +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- 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 +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- 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 +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- metadata +53 -1
|
@@ -1,115 +1,67 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'system_prompt_builder'
|
|
4
|
+
require_relative 'response_parser'
|
|
5
|
+
require_relative 'tool_processor'
|
|
6
|
+
require_relative 'background_job_handler'
|
|
7
|
+
require_relative 'feedback_handler'
|
|
8
|
+
require_relative 'llm_caller'
|
|
9
|
+
|
|
3
10
|
module RubynCode
|
|
4
11
|
module Agent
|
|
5
12
|
class Loop
|
|
13
|
+
include SystemPromptBuilder
|
|
14
|
+
include ResponseParser
|
|
15
|
+
include ToolProcessor
|
|
16
|
+
include BackgroundJobHandler
|
|
17
|
+
include FeedbackHandler
|
|
18
|
+
include LlmCaller
|
|
19
|
+
|
|
6
20
|
MAX_ITERATIONS = Config::Defaults::MAX_ITERATIONS
|
|
7
21
|
|
|
8
|
-
# @param
|
|
9
|
-
# @
|
|
10
|
-
# @
|
|
11
|
-
# @
|
|
12
|
-
# @
|
|
13
|
-
# @
|
|
14
|
-
# @
|
|
15
|
-
# @
|
|
16
|
-
# @
|
|
17
|
-
# @
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
stall_detector: LoopDetector.new,
|
|
29
|
-
on_tool_call: nil,
|
|
30
|
-
on_tool_result: nil,
|
|
31
|
-
on_text: nil,
|
|
32
|
-
skill_loader: nil,
|
|
33
|
-
project_root: nil
|
|
34
|
-
)
|
|
35
|
-
@llm_client = llm_client
|
|
36
|
-
@tool_executor = tool_executor
|
|
37
|
-
@context_manager = context_manager
|
|
38
|
-
@hook_runner = hook_runner
|
|
39
|
-
@conversation = conversation
|
|
40
|
-
@permission_tier = permission_tier
|
|
41
|
-
@deny_list = deny_list
|
|
42
|
-
@budget_enforcer = budget_enforcer
|
|
43
|
-
@background_manager = background_manager
|
|
44
|
-
@stall_detector = stall_detector
|
|
45
|
-
@on_tool_call = on_tool_call
|
|
46
|
-
@on_tool_result = on_tool_result
|
|
47
|
-
@on_text = on_text
|
|
48
|
-
@skill_loader = skill_loader
|
|
49
|
-
@project_root = project_root
|
|
50
|
-
@plan_mode = false
|
|
22
|
+
# @param opts [Hash] keyword arguments for loop configuration
|
|
23
|
+
# @option opts [LLM::Client] :llm_client
|
|
24
|
+
# @option opts [Tools::Executor] :tool_executor
|
|
25
|
+
# @option opts [Context::Manager] :context_manager
|
|
26
|
+
# @option opts [Hooks::Runner] :hook_runner
|
|
27
|
+
# @option opts [Agent::Conversation] :conversation
|
|
28
|
+
# @option opts [Symbol] :permission_tier
|
|
29
|
+
# @option opts [Permissions::DenyList] :deny_list
|
|
30
|
+
# @option opts [Observability::BudgetEnforcer] :budget_enforcer
|
|
31
|
+
# @option opts [Background::Worker] :background_manager
|
|
32
|
+
# @option opts [Agent::LoopDetector] :stall_detector
|
|
33
|
+
# @option opts [Proc] :on_tool_call
|
|
34
|
+
# @option opts [Proc] :on_tool_result
|
|
35
|
+
# @option opts [Proc] :on_text
|
|
36
|
+
# @option opts [Object] :skill_loader
|
|
37
|
+
# @option opts [String] :project_root
|
|
38
|
+
def initialize(**opts)
|
|
39
|
+
assign_dependencies(opts)
|
|
40
|
+
assign_callbacks(opts)
|
|
41
|
+
@plan_mode = false
|
|
51
42
|
end
|
|
52
43
|
|
|
53
44
|
# @return [Boolean]
|
|
54
45
|
attr_accessor :plan_mode
|
|
55
46
|
|
|
56
|
-
# Send a user message and run the agent loop until a final text
|
|
57
|
-
# is produced or the iteration limit is reached.
|
|
47
|
+
# Send a user message and run the agent loop until a final text
|
|
48
|
+
# response is produced or the iteration limit is reached.
|
|
58
49
|
#
|
|
59
50
|
# @param user_input [String]
|
|
60
51
|
# @return [String] the final assistant text response
|
|
61
52
|
def send_message(user_input)
|
|
53
|
+
initialize_session!
|
|
62
54
|
check_user_feedback(user_input)
|
|
63
|
-
|
|
64
|
-
# Drain any completed background jobs BEFORE adding the user message,
|
|
65
|
-
# so the LLM sees the results in the right order
|
|
66
55
|
drain_background_notifications
|
|
67
|
-
|
|
56
|
+
inject_skill_listing unless @skills_injected
|
|
57
|
+
@decision_compactor&.detect_topic_switch(user_input)
|
|
58
|
+
@skill_ttl&.tick!
|
|
68
59
|
@conversation.add_user_message(user_input)
|
|
69
|
-
|
|
70
|
-
@output_recovery_count = 0
|
|
71
|
-
@task_budget_remaining = nil
|
|
60
|
+
reset_iteration_state
|
|
72
61
|
|
|
73
62
|
MAX_ITERATIONS.times do |iteration|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
response = call_llm
|
|
77
|
-
tool_calls = extract_tool_calls(response)
|
|
78
|
-
stop_reason = response.respond_to?(:stop_reason) ? response.stop_reason : nil
|
|
79
|
-
|
|
80
|
-
RubynCode::Debug.llm("stop_reason=#{stop_reason} tool_calls=#{tool_calls.size} content_blocks=#{get_content(response).size}")
|
|
81
|
-
|
|
82
|
-
if tool_calls.empty?
|
|
83
|
-
if truncated?(response)
|
|
84
|
-
RubynCode::Debug.recovery('Text response truncated, entering recovery')
|
|
85
|
-
response = recover_truncated_response(response)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# If background jobs are running, wait for them instead of burning LLM calls
|
|
89
|
-
if has_pending_background_jobs?
|
|
90
|
-
@conversation.add_assistant_message(response_content(response))
|
|
91
|
-
wait_for_background_jobs
|
|
92
|
-
next
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
@conversation.add_assistant_message(response_content(response))
|
|
96
|
-
return extract_response_text(response)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Tier 1: If a tool-use response was truncated, silently escalate and retry
|
|
100
|
-
if truncated?(response) && !@max_tokens_override
|
|
101
|
-
RubynCode::Debug.recovery("Tier 1: Escalating max_tokens from #{Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS} to #{Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS}")
|
|
102
|
-
@max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
|
|
103
|
-
next
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
@conversation.add_assistant_message(get_content(response))
|
|
107
|
-
process_tool_calls(tool_calls)
|
|
108
|
-
|
|
109
|
-
# Drain notifications after tool execution — jobs may have finished
|
|
110
|
-
drain_background_notifications
|
|
111
|
-
|
|
112
|
-
run_maintenance(iteration)
|
|
63
|
+
result = run_iteration(iteration)
|
|
64
|
+
return result if result
|
|
113
65
|
end
|
|
114
66
|
|
|
115
67
|
RubynCode::Debug.warn("Hit MAX_ITERATIONS (#{MAX_ITERATIONS})")
|
|
@@ -118,658 +70,198 @@ module RubynCode
|
|
|
118
70
|
|
|
119
71
|
private
|
|
120
72
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def call_llm
|
|
126
|
-
@hook_runner.fire(:pre_llm_call, conversation: @conversation)
|
|
127
|
-
|
|
128
|
-
opts = {
|
|
129
|
-
messages: @conversation.to_api_format,
|
|
130
|
-
tools: @plan_mode ? read_only_tool_definitions : tool_definitions,
|
|
131
|
-
system: build_system_prompt,
|
|
132
|
-
on_text: @on_text
|
|
133
|
-
}
|
|
134
|
-
opts[:max_tokens] = @max_tokens_override if @max_tokens_override
|
|
135
|
-
|
|
136
|
-
# Task budget: tell the model how many tokens remain for this task
|
|
137
|
-
opts[:task_budget] = { total: TASK_BUDGET_TOTAL, remaining: @task_budget_remaining } if @task_budget_remaining
|
|
138
|
-
|
|
139
|
-
response = @llm_client.chat(**opts)
|
|
140
|
-
|
|
141
|
-
@hook_runner.fire(:post_llm_call, response: response, conversation: @conversation)
|
|
142
|
-
track_usage(response)
|
|
143
|
-
update_task_budget(response)
|
|
144
|
-
|
|
145
|
-
response
|
|
146
|
-
rescue LLM::Client::PromptTooLongError
|
|
147
|
-
# 413: context too large — compact and retry once
|
|
148
|
-
RubynCode::Debug.recovery('413 prompt too long — running emergency compaction')
|
|
149
|
-
@context_manager.check_compaction!(@conversation)
|
|
150
|
-
|
|
151
|
-
response = @llm_client.chat(**opts, messages: @conversation.to_api_format)
|
|
152
|
-
@hook_runner.fire(:post_llm_call, response: response, conversation: @conversation)
|
|
153
|
-
track_usage(response)
|
|
154
|
-
|
|
155
|
-
response
|
|
73
|
+
def assign_dependencies(opts)
|
|
74
|
+
assign_required_deps(opts)
|
|
75
|
+
assign_optional_deps(opts)
|
|
156
76
|
end
|
|
157
77
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
- Snarky but never mean. You tease the code, not the coder.
|
|
167
|
-
- You celebrate good Ruby — "Oh, a proper guard clause? You love to see it."
|
|
168
|
-
- You mourn bad Ruby — "A `for` loop? In MY Ruby? It's more likely than you think."
|
|
169
|
-
- Brief and punchy. No walls of text unless teaching something important.
|
|
170
|
-
- You use Ruby metaphors: "Let's refactor this like Matz intended."
|
|
171
|
-
- When something is genuinely good code, you say so. No notes.
|
|
172
|
-
|
|
173
|
-
## Ruby Convictions (non-negotiable)
|
|
174
|
-
- `frozen_string_literal: true` in every file. Every. Single. One.
|
|
175
|
-
- Prefer `each`, `map`, `select`, `reduce` over manual iteration. Always.
|
|
176
|
-
- Guard clauses over nested conditionals. Return early, return often.
|
|
177
|
-
- `Data.define` for value objects (Ruby 3.2+). `Struct` only if you need mutability.
|
|
178
|
-
- `snake_case` methods, `CamelCase` classes, `SCREAMING_SNAKE` constants. No exceptions.
|
|
179
|
-
- Single quotes unless you're interpolating. Fight me.
|
|
180
|
-
- Methods under 15 lines. Classes under 100. Extract or explain why not.
|
|
181
|
-
- Explicit over clever. Metaprogramming is a spice, not the main course.
|
|
182
|
-
- `raise` over `fail`. Rescue specific exceptions, never bare `rescue`.
|
|
183
|
-
- Prefer composition over inheritance. Mixins are not inheritance.
|
|
184
|
-
- `&&` / `||` over `and` / `or`. The precedence difference has burned too many.
|
|
185
|
-
- `dig` for nested hashes. `fetch` with defaults over `[]` with `||`.
|
|
186
|
-
- `freeze` your constants. Frozen arrays, frozen hashes, frozen regexps.
|
|
187
|
-
- No `OpenStruct`. Ever. It's slow, it's a footgun, and `Data.define` exists.
|
|
188
|
-
|
|
189
|
-
## Rails Convictions
|
|
190
|
-
- Skinny controllers, fat models is dead. Skinny controllers, skinny models, service objects.
|
|
191
|
-
- `has_many :through` over `has_and_belongs_to_many`. Every time.
|
|
192
|
-
- Add database indexes for every foreign key and every column you query.
|
|
193
|
-
- Migrations are generated, not handwritten. `rails generate migration`.
|
|
194
|
-
- Strong parameters in controllers. No `permit!`. Ever.
|
|
195
|
-
- Use `find_each` for batch processing. `each` on a large scope is a memory bomb.
|
|
196
|
-
- `exists?` over `present?` for checking DB existence. One is a COUNT, the other loads the record.
|
|
197
|
-
- Scopes over class methods for chainable queries.
|
|
198
|
-
- Background jobs for anything that takes more than 100ms.
|
|
199
|
-
- Don't put business logic in callbacks. That way lies madness.
|
|
200
|
-
|
|
201
|
-
## Testing Convictions
|
|
202
|
-
- RSpec > Minitest (but you'll work with either without complaining... much)
|
|
203
|
-
- FactoryBot over fixtures. Factories are explicit. Fixtures are magic.
|
|
204
|
-
- One assertion per test when practical. "It does three things" is three tests.
|
|
205
|
-
- `let` over instance variables. `let!` only when you need eager evaluation.
|
|
206
|
-
- `described_class` over repeating the class name.
|
|
207
|
-
- Test behavior, not implementation. Mock the boundary, not the internals.
|
|
208
|
-
|
|
209
|
-
## How You Work
|
|
210
|
-
- For greetings and casual chat, just respond naturally. No need to run tools.
|
|
211
|
-
- Only use tools when the user asks you to DO something (read, write, search, run, review).
|
|
212
|
-
- Read before you write. Always understand existing code before suggesting changes.
|
|
213
|
-
- Use tools to verify. Don't guess if a file exists — check.
|
|
214
|
-
- Show diffs when editing. The human should see what changed.
|
|
215
|
-
- Run specs after changes. If they break, fix them.
|
|
216
|
-
- When you are asked to work in a NEW directory you haven't seen yet, check for RUBYN.md, CLAUDE.md, or AGENT.md there. But don't do this unprompted on startup — those files are already loaded into your context.
|
|
217
|
-
- Load skills when you need deep knowledge on a topic. Don't wing it.
|
|
218
|
-
- You have 112 curated best-practice skill documents covering Ruby, Rails, RSpec, design patterns, and code quality. When writing new code or reviewing existing code, load the relevant skill BEFORE implementing. Don't reinvent patterns that are already documented.
|
|
219
|
-
- HOWEVER: always respect patterns already established in the codebase. If the project uses a specific convention (e.g. service objects, a particular test style, a custom base class), follow that convention even if it differs from the skill doc. Consistency with the codebase beats textbook best practice. Only break from established patterns if they are genuinely harmful (security issues, major performance problems, or bugs).
|
|
220
|
-
- Keep responses concise. Code speaks louder than paragraphs.
|
|
221
|
-
- Use spawn_agent sparingly — only for tasks that require reading many files (10+) or deep exploration. For simple reads or edits, use tools directly. Don't spawn a sub-agent when a single read_file or grep will do.
|
|
222
|
-
- IMPORTANT: You can call MULTIPLE tools in a single response. When you need to read several files, search multiple patterns, or perform independent operations, return all tool_use blocks at once rather than one at a time. This is dramatically faster and cheaper. For example, if you need to read 5 files, emit 5 read_file tool calls in one response — don't read them one by one across 5 turns.
|
|
223
|
-
|
|
224
|
-
## Memory
|
|
225
|
-
You have persistent memory across sessions via `memory_write` and `memory_search` tools.
|
|
226
|
-
Use them proactively:
|
|
227
|
-
- When the user tells you a preference or convention, save it: memory_write(content: "User prefers Grape over Rails controllers for APIs", category: "user_preference")
|
|
228
|
-
- When you discover a project pattern (e.g. "this app uses service objects in app/services/"), save it: memory_write(content: "...", category: "project_convention")
|
|
229
|
-
- When you fix a tricky bug, save the resolution: memory_write(content: "...", category: "error_resolution")
|
|
230
|
-
- When you learn a key architectural decision, save it: memory_write(content: "...", category: "decision")
|
|
231
|
-
- Before starting work on a project, search memory for context: memory_search(query: "project conventions")
|
|
232
|
-
- Don't save trivial things. Save what would be useful in a future session.
|
|
233
|
-
Categories: user_preference, project_convention, error_resolution, decision, code_pattern
|
|
234
|
-
PROMPT
|
|
235
|
-
|
|
236
|
-
PLAN_MODE_PROMPT = <<~PLAN
|
|
237
|
-
## 🧠 Plan Mode Active
|
|
238
|
-
|
|
239
|
-
You are in PLAN MODE. This means:
|
|
240
|
-
- Reason through the problem step by step
|
|
241
|
-
- You have READ-ONLY tools available — use them to explore the codebase
|
|
242
|
-
- Read files, grep, glob, check git status/log/diff — gather context
|
|
243
|
-
- Do NOT write, edit, execute, or modify anything
|
|
244
|
-
- Outline your plan with numbered steps
|
|
245
|
-
- Identify files you'd need to read or modify
|
|
246
|
-
- Call out risks, edge cases, and trade-offs
|
|
247
|
-
- Ask clarifying questions if the request is ambiguous
|
|
248
|
-
- When the user is satisfied with the plan, they'll toggle plan mode off with /plan
|
|
249
|
-
|
|
250
|
-
You CAN use read-only tools. You MUST NOT use any tool that writes, edits, or executes.
|
|
251
|
-
PLAN
|
|
252
|
-
|
|
253
|
-
PLAN_MODE_RISK_LEVELS = %i[read].freeze
|
|
254
|
-
|
|
255
|
-
def build_system_prompt
|
|
256
|
-
parts = [SYSTEM_PROMPT]
|
|
257
|
-
|
|
258
|
-
parts << PLAN_MODE_PROMPT if @plan_mode
|
|
259
|
-
parts << "Working directory: #{@project_root}" if @project_root
|
|
260
|
-
|
|
261
|
-
# Inject memories from previous sessions
|
|
262
|
-
memories = load_memories
|
|
263
|
-
parts << "\n## Your Memories (from previous sessions)\n#{memories}" unless memories.empty?
|
|
264
|
-
|
|
265
|
-
# Load RUBYN.md / CLAUDE.md / AGENT.md files
|
|
266
|
-
rubyn_instructions = load_rubyn_md
|
|
267
|
-
parts << "\n## Project Instructions\n#{rubyn_instructions}" unless rubyn_instructions.empty?
|
|
268
|
-
|
|
269
|
-
# Inject learned instincts from previous sessions
|
|
270
|
-
instincts = load_instincts
|
|
271
|
-
parts << "\n## Learned Instincts (from previous sessions)\n#{instincts}" unless instincts.empty?
|
|
272
|
-
|
|
273
|
-
# Load custom skills
|
|
274
|
-
if @skill_loader
|
|
275
|
-
descriptions = @skill_loader.descriptions_for_prompt
|
|
276
|
-
unless descriptions.empty?
|
|
277
|
-
parts << "\n## Available Skills (use load_skill tool to load full content)"
|
|
278
|
-
parts << descriptions
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
# List deferred tools so the LLM knows they exist
|
|
283
|
-
deferred = deferred_tool_names
|
|
284
|
-
unless deferred.empty?
|
|
285
|
-
parts << "\n## Additional Tools Available"
|
|
286
|
-
parts << 'These tools are available but not loaded yet. Just call them by name and they will work:'
|
|
287
|
-
parts << deferred.map { |n| "- #{n}" }.join("\n")
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
parts.join("\n")
|
|
78
|
+
def assign_required_deps(opts)
|
|
79
|
+
@llm_client = opts.fetch(:llm_client)
|
|
80
|
+
@tool_executor = opts.fetch(:tool_executor)
|
|
81
|
+
@context_manager = opts.fetch(:context_manager)
|
|
82
|
+
@hook_runner = opts.fetch(:hook_runner)
|
|
83
|
+
@conversation = opts.fetch(:conversation)
|
|
84
|
+
@permission_tier = opts.fetch(:permission_tier, Permissions::Tier::ALLOW_READ)
|
|
85
|
+
@deny_list = opts.fetch(:deny_list, Permissions::DenyList.new)
|
|
291
86
|
end
|
|
292
87
|
|
|
293
|
-
def
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
88
|
+
def assign_optional_deps(opts)
|
|
89
|
+
@budget_enforcer = opts[:budget_enforcer]
|
|
90
|
+
@background_manager = opts[:background_manager]
|
|
91
|
+
@stall_detector = opts.fetch(:stall_detector, LoopDetector.new)
|
|
92
|
+
@skill_loader = opts[:skill_loader]
|
|
93
|
+
@project_root = opts[:project_root]
|
|
94
|
+
@decision_compactor = build_decision_compactor
|
|
95
|
+
@skill_ttl = Skills::TtlManager.new
|
|
96
|
+
@session_initialized = false
|
|
297
97
|
end
|
|
298
98
|
|
|
299
|
-
def
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
db = DB::Connection.instance
|
|
303
|
-
search = Memory::Search.new(db, project_path: @project_root)
|
|
304
|
-
recent = search.recent(limit: 20)
|
|
305
|
-
|
|
306
|
-
return '' if recent.empty?
|
|
307
|
-
|
|
308
|
-
recent.map do |m|
|
|
309
|
-
category = m.respond_to?(:category) ? m.category : (m[:category] || m['category'])
|
|
310
|
-
content = m.respond_to?(:content) ? m.content : (m[:content] || m['content'])
|
|
311
|
-
"[#{category}] #{content}"
|
|
312
|
-
end.join("\n")
|
|
99
|
+
def build_decision_compactor
|
|
100
|
+
Context::DecisionCompactor.new(context_manager: @context_manager)
|
|
313
101
|
rescue StandardError
|
|
314
|
-
|
|
102
|
+
nil
|
|
315
103
|
end
|
|
316
104
|
|
|
317
|
-
|
|
318
|
-
|
|
105
|
+
# One-time session initialization: build project profile and
|
|
106
|
+
# codebase index so the AI doesn't have to explore from scratch.
|
|
107
|
+
def initialize_session!
|
|
108
|
+
return if @session_initialized || !@project_root
|
|
319
109
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
''
|
|
110
|
+
@session_initialized = true
|
|
111
|
+
build_project_profile!
|
|
112
|
+
build_codebase_index!
|
|
324
113
|
end
|
|
325
114
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
return unless @project_root
|
|
333
|
-
|
|
334
|
-
db = DB::Connection.instance
|
|
335
|
-
recent_instincts = db.query(
|
|
336
|
-
'SELECT id FROM instincts WHERE project_path = ? ORDER BY updated_at DESC LIMIT 5',
|
|
337
|
-
[@project_root]
|
|
338
|
-
).to_a
|
|
339
|
-
|
|
340
|
-
return if recent_instincts.empty?
|
|
341
|
-
|
|
342
|
-
if user_input.match?(POSITIVE_PATTERNS)
|
|
343
|
-
recent_instincts.first(2).each do |row|
|
|
344
|
-
Learning::InstinctMethods.reinforce_in_db(row['id'], db, helpful: true)
|
|
345
|
-
end
|
|
346
|
-
elsif user_input.match?(NEGATIVE_PATTERNS)
|
|
347
|
-
recent_instincts.first(2).each do |row|
|
|
348
|
-
Learning::InstinctMethods.reinforce_in_db(row['id'], db, helpful: false)
|
|
349
|
-
end
|
|
350
|
-
end
|
|
351
|
-
rescue StandardError
|
|
352
|
-
# Non-critical; don't interrupt the conversation
|
|
115
|
+
def build_project_profile!
|
|
116
|
+
profile = Config::ProjectProfile.new(project_root: @project_root)
|
|
117
|
+
profile.load_or_detect!
|
|
118
|
+
RubynCode::Debug.agent("Project profile loaded (#{profile.data.size} keys)")
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
RubynCode::Debug.warn("Project profile failed: #{e.message}")
|
|
353
121
|
end
|
|
354
122
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
found = []
|
|
362
|
-
|
|
363
|
-
if @project_root
|
|
364
|
-
# Walk UP from project root to find parent instruction files
|
|
365
|
-
walk_up_for_instructions(@project_root, found)
|
|
366
|
-
|
|
367
|
-
# Project root
|
|
368
|
-
INSTRUCTION_FILES.each do |name|
|
|
369
|
-
collect_instruction(File.join(@project_root, name), found)
|
|
370
|
-
end
|
|
371
|
-
collect_instruction(File.join(@project_root, '.rubyn-code', 'RUBYN.md'), found)
|
|
372
|
-
|
|
373
|
-
# One level of child directories
|
|
374
|
-
INSTRUCTION_FILES.each do |name|
|
|
375
|
-
Dir.glob(File.join(@project_root, '*', name)).each do |path|
|
|
376
|
-
collect_instruction(path, found)
|
|
377
|
-
end
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
# User global
|
|
382
|
-
collect_instruction(File.join(Config::Defaults::HOME_DIR, 'RUBYN.md'), found)
|
|
383
|
-
|
|
384
|
-
found.uniq.join("\n\n")
|
|
123
|
+
def build_codebase_index!
|
|
124
|
+
index = Index::CodebaseIndex.new(project_root: @project_root)
|
|
125
|
+
index.load_or_build!
|
|
126
|
+
RubynCode::Debug.agent("Codebase index: #{index.stats[:nodes]} nodes, #{index.stats[:files_indexed]} files")
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
RubynCode::Debug.warn("Codebase index failed: #{e.message}")
|
|
385
129
|
end
|
|
386
130
|
|
|
387
|
-
def
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
INSTRUCTION_FILES.each do |name|
|
|
393
|
-
collect_instruction(File.join(dir, name), found)
|
|
394
|
-
end
|
|
395
|
-
break if dir == home
|
|
396
|
-
|
|
397
|
-
dir = File.dirname(dir)
|
|
398
|
-
end
|
|
131
|
+
def assign_callbacks(opts)
|
|
132
|
+
@on_tool_call = opts[:on_tool_call]
|
|
133
|
+
@on_tool_result = opts[:on_tool_result]
|
|
134
|
+
@on_text = opts[:on_text]
|
|
135
|
+
@skills_injected = false
|
|
399
136
|
end
|
|
400
137
|
|
|
401
|
-
def
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
406
|
-
.strip
|
|
407
|
-
return if content.empty?
|
|
408
|
-
|
|
409
|
-
found << "# From #{path}\n#{content}"
|
|
138
|
+
def reset_iteration_state
|
|
139
|
+
@max_tokens_override = nil
|
|
140
|
+
@output_recovery_count = 0
|
|
141
|
+
@task_budget_remaining = nil
|
|
410
142
|
end
|
|
411
143
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
144
|
+
def run_iteration(iteration)
|
|
145
|
+
log_iteration(iteration)
|
|
146
|
+
compact_if_needed # ensure context is under threshold before LLM call
|
|
147
|
+
response = call_llm
|
|
148
|
+
tool_calls = extract_tool_calls(response)
|
|
149
|
+
log_response(response, tool_calls)
|
|
417
150
|
|
|
418
|
-
|
|
419
|
-
all_tools = @tool_executor.tool_definitions
|
|
420
|
-
return all_tools if all_tools.size <= CORE_TOOLS.size
|
|
151
|
+
return handle_text_response(response) if tool_calls.empty?
|
|
421
152
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
all_tools.select do |t|
|
|
425
|
-
name = t[:name] || t['name']
|
|
426
|
-
CORE_TOOLS.include?(name) || @discovered_tools.include?(name)
|
|
427
|
-
end
|
|
153
|
+
handle_tool_response(response, tool_calls, iteration)
|
|
428
154
|
end
|
|
429
155
|
|
|
430
|
-
def
|
|
431
|
-
|
|
432
|
-
|
|
156
|
+
def log_iteration(iteration)
|
|
157
|
+
RubynCode::Debug.loop_tick(
|
|
158
|
+
"iteration=#{iteration} messages=#{@conversation.length} " \
|
|
159
|
+
"max_tokens_override=#{@max_tokens_override || 'default'}"
|
|
160
|
+
)
|
|
433
161
|
end
|
|
434
162
|
|
|
435
|
-
def
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
163
|
+
def log_response(response, tool_calls)
|
|
164
|
+
stop_reason = extract_stop_reason(response)
|
|
165
|
+
RubynCode::Debug.llm(
|
|
166
|
+
"stop_reason=#{stop_reason} tool_calls=#{tool_calls.size} " \
|
|
167
|
+
"content_blocks=#{get_content(response).size}"
|
|
168
|
+
)
|
|
439
169
|
end
|
|
440
170
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
poll_interval = 3
|
|
446
|
-
|
|
447
|
-
RubynCode::Debug.agent("Waiting for background jobs to finish (polling every #{poll_interval}s, max #{max_wait}s)")
|
|
448
|
-
|
|
449
|
-
elapsed = 0
|
|
450
|
-
while elapsed < max_wait && has_pending_background_jobs?
|
|
451
|
-
sleep poll_interval
|
|
452
|
-
elapsed += poll_interval
|
|
453
|
-
drain_background_notifications
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
# Final drain to pick up any last results
|
|
457
|
-
drain_background_notifications
|
|
458
|
-
RubynCode::Debug.agent("Background wait done (#{elapsed}s)")
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
# ── Tool processing ──────────────────────────────────────────────
|
|
462
|
-
|
|
463
|
-
def process_tool_calls(tool_calls)
|
|
464
|
-
aggregate_chars = 0
|
|
465
|
-
budget = Config::Defaults::MAX_MESSAGE_TOOL_RESULTS_CHARS
|
|
466
|
-
|
|
467
|
-
tool_calls.each do |tool_call|
|
|
468
|
-
tool_name = field(tool_call, :name)
|
|
469
|
-
tool_input = field(tool_call, :input) || {}
|
|
470
|
-
tool_id = field(tool_call, :id)
|
|
471
|
-
|
|
472
|
-
decision = Permissions::Policy.check(
|
|
473
|
-
tool_name: tool_name,
|
|
474
|
-
tool_input: tool_input,
|
|
475
|
-
tier: @permission_tier,
|
|
476
|
-
deny_list: @deny_list
|
|
171
|
+
def handle_text_response(response)
|
|
172
|
+
if truncated?(response)
|
|
173
|
+
RubynCode::Debug.recovery(
|
|
174
|
+
'Text response truncated, entering recovery'
|
|
477
175
|
)
|
|
478
|
-
|
|
479
|
-
begin
|
|
480
|
-
@on_tool_call&.call(tool_name, tool_input)
|
|
481
|
-
rescue StandardError
|
|
482
|
-
nil
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
result, is_error = execute_with_permission(decision, tool_name, tool_input, tool_id)
|
|
486
|
-
|
|
487
|
-
# Enforce per-message aggregate tool result budget
|
|
488
|
-
aggregate_chars += result.to_s.length
|
|
489
|
-
if aggregate_chars > budget
|
|
490
|
-
remaining = [budget - (aggregate_chars - result.to_s.length), 500].max
|
|
491
|
-
result = "#{result.to_s[0,
|
|
492
|
-
remaining]}\n\n[truncated — tool result budget exceeded (#{budget} chars/message)]"
|
|
493
|
-
RubynCode::Debug.token("Tool result budget exceeded: #{aggregate_chars}/#{budget} chars")
|
|
494
|
-
end
|
|
495
|
-
|
|
496
|
-
begin
|
|
497
|
-
@on_tool_result&.call(tool_name, result, is_error)
|
|
498
|
-
rescue StandardError
|
|
499
|
-
nil
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
@stall_detector.record(tool_name, tool_input)
|
|
503
|
-
# CRITICAL: always add tool_result to conversation — without this the
|
|
504
|
-
# API will reject the next request with "tool_use without tool_result"
|
|
505
|
-
@conversation.add_tool_result(tool_id, tool_name, result, is_error: is_error)
|
|
506
|
-
end
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
def execute_with_permission(decision, tool_name, tool_input, _tool_id)
|
|
510
|
-
case decision
|
|
511
|
-
when :deny
|
|
512
|
-
["Tool '#{tool_name}' is blocked by the deny list.", true]
|
|
513
|
-
when :ask
|
|
514
|
-
if prompt_user(tool_name, tool_input)
|
|
515
|
-
execute_tool(tool_name, tool_input)
|
|
516
|
-
else
|
|
517
|
-
["User denied permission for '#{tool_name}'.", true]
|
|
518
|
-
end
|
|
519
|
-
when :allow
|
|
520
|
-
execute_tool(tool_name, tool_input)
|
|
521
|
-
else
|
|
522
|
-
["Unknown permission decision: #{decision}", true]
|
|
176
|
+
response = recover_truncated_response(response)
|
|
523
177
|
end
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
def execute_tool(tool_name, tool_input)
|
|
527
|
-
# Auto-discover tools on first use so they appear in future calls
|
|
528
|
-
discover_tool(tool_name)
|
|
529
|
-
|
|
530
|
-
@hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
|
|
531
|
-
|
|
532
|
-
result = @tool_executor.execute(tool_name, symbolize_keys(tool_input))
|
|
533
|
-
@hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
|
|
534
|
-
|
|
535
|
-
[result.to_s, false]
|
|
536
|
-
rescue StandardError => e
|
|
537
|
-
["Error executing #{tool_name}: #{e.message}", true]
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
def prompt_user(tool_name, tool_input)
|
|
541
|
-
risk = resolve_tool_risk(tool_name)
|
|
542
|
-
|
|
543
|
-
if risk == :destructive
|
|
544
|
-
Permissions::Prompter.confirm_destructive(tool_name, tool_input)
|
|
545
|
-
else
|
|
546
|
-
Permissions::Prompter.confirm(tool_name, tool_input)
|
|
547
|
-
end
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
def resolve_tool_risk(tool_name)
|
|
551
|
-
tool_class = Tools::Registry.get(tool_name)
|
|
552
|
-
tool_class.risk_level
|
|
553
|
-
rescue ToolNotFoundError
|
|
554
|
-
:unknown
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
# ── Maintenance ──────────────────────────────────────────────────
|
|
558
|
-
|
|
559
|
-
def run_maintenance(_iteration)
|
|
560
|
-
run_compaction
|
|
561
|
-
check_budget
|
|
562
|
-
check_stall_detection
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
def run_compaction
|
|
566
|
-
before = @conversation.length
|
|
567
|
-
est = @context_manager.estimated_tokens(@conversation.messages)
|
|
568
|
-
RubynCode::Debug.token("context=#{est} tokens (~#{before} messages, threshold=#{Config::Defaults::CONTEXT_THRESHOLD_TOKENS})")
|
|
569
|
-
|
|
570
|
-
@context_manager.check_compaction!(@conversation)
|
|
571
|
-
|
|
572
|
-
after = @conversation.length
|
|
573
|
-
if after < before
|
|
574
|
-
new_est = @context_manager.estimated_tokens(@conversation.messages)
|
|
575
|
-
RubynCode::Debug.loop_tick("Compacted: #{before} -> #{after} messages (#{est} -> #{new_est} tokens)")
|
|
576
|
-
end
|
|
577
|
-
rescue NoMethodError
|
|
578
|
-
# context_manager does not implement check_compaction! yet
|
|
579
|
-
end
|
|
580
|
-
|
|
581
|
-
def check_budget
|
|
582
|
-
return unless @budget_enforcer
|
|
583
|
-
|
|
584
|
-
@budget_enforcer.check!
|
|
585
|
-
rescue BudgetExceededError
|
|
586
|
-
raise
|
|
587
|
-
rescue NoMethodError
|
|
588
|
-
# budget_enforcer does not implement check! yet
|
|
589
|
-
end
|
|
590
178
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
@stall_detector.reset!
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
def drain_background_notifications
|
|
600
|
-
return unless @background_manager
|
|
601
|
-
|
|
602
|
-
notifications = @background_manager.drain_notifications
|
|
603
|
-
return if notifications.nil? || notifications.empty?
|
|
604
|
-
|
|
605
|
-
summary = notifications.map { |n| format_background_notification(n) }.join("\n\n")
|
|
606
|
-
@conversation.add_user_message("[Background job results]\n#{summary}")
|
|
607
|
-
rescue NoMethodError
|
|
608
|
-
# background_manager does not support drain_notifications yet
|
|
609
|
-
end
|
|
610
|
-
|
|
611
|
-
def has_pending_background_jobs?
|
|
612
|
-
return false unless @background_manager
|
|
613
|
-
|
|
614
|
-
@background_manager.active_count.positive?
|
|
615
|
-
rescue NoMethodError
|
|
616
|
-
false
|
|
617
|
-
end
|
|
618
|
-
|
|
619
|
-
def format_background_notification(notification)
|
|
620
|
-
case notification
|
|
621
|
-
when Hash
|
|
622
|
-
status = notification[:status] || 'unknown'
|
|
623
|
-
job_id = notification[:job_id]&.[](0..7) || 'unknown'
|
|
624
|
-
duration = notification[:duration] ? "#{'%.1f' % notification[:duration]}s" : 'unknown'
|
|
625
|
-
result = notification[:result] || '(no output)'
|
|
626
|
-
"Job #{job_id} [#{status}] (#{duration}):\n#{result}"
|
|
627
|
-
else
|
|
628
|
-
notification.to_s
|
|
179
|
+
# Wait for background jobs before finalizing
|
|
180
|
+
if pending_background_jobs?
|
|
181
|
+
@conversation.add_assistant_message(response_content(response))
|
|
182
|
+
wait_for_background_jobs
|
|
183
|
+
return nil # signal: keep iterating
|
|
629
184
|
end
|
|
630
|
-
end
|
|
631
185
|
|
|
632
|
-
|
|
633
|
-
#
|
|
634
|
-
# Tier 1: Silent escalation (8K → 32K) — handled in send_message
|
|
635
|
-
# Tier 2: Multi-turn recovery — inject continuation message, retry up to 3x
|
|
636
|
-
# Tier 3: Surface what we have — return partial response after exhausting retries
|
|
637
|
-
|
|
638
|
-
def truncated?(response)
|
|
639
|
-
reason = if response.respond_to?(:stop_reason)
|
|
640
|
-
response.stop_reason
|
|
641
|
-
elsif response.is_a?(Hash)
|
|
642
|
-
response[:stop_reason] || response['stop_reason']
|
|
643
|
-
end
|
|
644
|
-
reason == 'max_tokens'
|
|
645
|
-
end
|
|
186
|
+
text = extract_response_text(response)
|
|
646
187
|
|
|
647
|
-
|
|
648
|
-
@max_tokens_override ||= Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
|
|
188
|
+
return handle_empty_response if text.strip.empty?
|
|
649
189
|
|
|
650
190
|
@conversation.add_assistant_message(response_content(response))
|
|
651
191
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
max_retries.times do |attempt|
|
|
655
|
-
@output_recovery_count += 1
|
|
656
|
-
RubynCode::Debug.recovery("Tier 2: Recovery attempt #{attempt + 1}/#{max_retries}")
|
|
657
|
-
|
|
658
|
-
@conversation.add_user_message(
|
|
659
|
-
'Output token limit hit. Resume directly — no apology, no recap, ' \
|
|
660
|
-
'just continue exactly where you left off.'
|
|
661
|
-
)
|
|
662
|
-
|
|
663
|
-
response = call_llm
|
|
192
|
+
# Decision-based compaction (topic switch, milestone)
|
|
193
|
+
@decision_compactor&.check!(@conversation)
|
|
664
194
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
break
|
|
668
|
-
end
|
|
195
|
+
# Compact after the response if context is over threshold
|
|
196
|
+
compact_if_needed
|
|
669
197
|
|
|
670
|
-
|
|
671
|
-
@conversation.add_assistant_message(response_content(response))
|
|
672
|
-
end
|
|
673
|
-
|
|
674
|
-
if truncated?(response)
|
|
675
|
-
RubynCode::Debug.recovery("Tier 3: Exhausted #{max_retries} recovery attempts, returning partial response")
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
response
|
|
198
|
+
text
|
|
679
199
|
end
|
|
680
200
|
|
|
681
|
-
#
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
get_content(response)
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
def extract_response_text(response)
|
|
692
|
-
blocks = get_content(response)
|
|
693
|
-
blocks.select { |b| block_type(b) == 'text' }
|
|
694
|
-
.map { |b| b.respond_to?(:text) ? b.text : (b[:text] || b['text']) }
|
|
695
|
-
.compact.join("\n")
|
|
696
|
-
end
|
|
201
|
+
# Empty LLM response (0 content blocks). Common after dispatching
|
|
202
|
+
# background_run — the LLM has nothing to say until results arrive.
|
|
203
|
+
# Wait briefly for jobs, then either continue or accept the empty response.
|
|
204
|
+
def handle_empty_response
|
|
205
|
+
RubynCode::Debug.llm('Empty response — waiting for background jobs')
|
|
206
|
+
sleep 2 # give jobs a moment to register as active
|
|
207
|
+
drain_background_notifications
|
|
697
208
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
Array(response.content)
|
|
702
|
-
when Hash
|
|
703
|
-
Array(response[:content] || response['content'])
|
|
209
|
+
if pending_background_jobs?
|
|
210
|
+
wait_for_background_jobs
|
|
211
|
+
nil # keep iterating — job results are now in conversation
|
|
704
212
|
else
|
|
705
|
-
|
|
213
|
+
RubynCode::Debug.llm('No background jobs — accepting empty response')
|
|
214
|
+
'' # return empty string to stop the loop
|
|
706
215
|
end
|
|
707
216
|
end
|
|
708
217
|
|
|
709
|
-
def
|
|
710
|
-
if
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
(block[:type] || block['type']).to_s
|
|
218
|
+
def handle_tool_response(response, tool_calls, iteration)
|
|
219
|
+
if truncated?(response) && !@max_tokens_override
|
|
220
|
+
escalate_max_tokens
|
|
221
|
+
return nil
|
|
714
222
|
end
|
|
715
|
-
end
|
|
716
223
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
end
|
|
723
|
-
return unless usage
|
|
724
|
-
|
|
725
|
-
input_tokens = usage.respond_to?(:input_tokens) ? usage.input_tokens : usage[:input_tokens]
|
|
726
|
-
output_tokens = usage.respond_to?(:output_tokens) ? usage.output_tokens : usage[:output_tokens]
|
|
727
|
-
cache_create = usage.respond_to?(:cache_creation_input_tokens) ? usage.cache_creation_input_tokens.to_i : 0
|
|
728
|
-
cache_read = usage.respond_to?(:cache_read_input_tokens) ? usage.cache_read_input_tokens.to_i : 0
|
|
729
|
-
cache_info = cache_create.positive? || cache_read.positive? ? " cache_create=#{cache_create} cache_read=#{cache_read}" : ''
|
|
730
|
-
RubynCode::Debug.token("in=#{input_tokens} out=#{output_tokens}#{cache_info}")
|
|
731
|
-
|
|
732
|
-
@context_manager.track_usage(usage)
|
|
733
|
-
rescue NoMethodError
|
|
734
|
-
# context_manager does not implement track_usage yet
|
|
224
|
+
@conversation.add_assistant_message(get_content(response))
|
|
225
|
+
process_tool_calls(tool_calls)
|
|
226
|
+
drain_background_notifications
|
|
227
|
+
run_maintenance(iteration)
|
|
228
|
+
nil
|
|
735
229
|
end
|
|
736
230
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
231
|
+
# Check if context needs compaction. Runs before LLM calls and
|
|
232
|
+
# after text responses — mirrors Claude Code's "pause for compaction"
|
|
233
|
+
# behavior that keeps context manageable in long sessions.
|
|
234
|
+
def compact_if_needed
|
|
235
|
+
return unless @context_manager.needs_compaction?(@conversation.messages)
|
|
740
236
|
|
|
741
|
-
|
|
742
|
-
|
|
237
|
+
est = @context_manager.estimated_tokens(@conversation.messages)
|
|
238
|
+
RubynCode::Debug.token(
|
|
239
|
+
"Context over threshold (#{est}) — running compaction"
|
|
240
|
+
)
|
|
241
|
+
@context_manager.check_compaction!(@conversation)
|
|
743
242
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
243
|
+
after = @context_manager.estimated_tokens(@conversation.messages)
|
|
244
|
+
RubynCode::Debug.token("Compacted: #{est} → #{after} tokens")
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
RubynCode::Debug.warn("Compaction failed: #{e.message}")
|
|
247
|
+
end
|
|
747
248
|
|
|
748
|
-
|
|
249
|
+
def escalate_max_tokens
|
|
250
|
+
RubynCode::Debug.recovery(
|
|
251
|
+
'Tier 1: Escalating max_tokens from ' \
|
|
252
|
+
"#{Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS} to " \
|
|
253
|
+
"#{Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS}"
|
|
254
|
+
)
|
|
255
|
+
@max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
|
|
749
256
|
end
|
|
750
257
|
|
|
751
258
|
def max_iterations_warning
|
|
752
259
|
warning = "Reached maximum iteration limit (#{MAX_ITERATIONS}). " \
|
|
753
|
-
'The conversation may be incomplete. Please review the
|
|
754
|
-
'and continue if needed.'
|
|
260
|
+
'The conversation may be incomplete. Please review the ' \
|
|
261
|
+
'current state and continue if needed.'
|
|
755
262
|
@conversation.add_assistant_message([{ type: 'text', text: warning }])
|
|
756
263
|
warning
|
|
757
264
|
end
|
|
758
|
-
|
|
759
|
-
# Extract a field from a Data object or Hash
|
|
760
|
-
def field(obj, key)
|
|
761
|
-
if obj.respond_to?(key)
|
|
762
|
-
obj.send(key)
|
|
763
|
-
elsif obj.is_a?(Hash)
|
|
764
|
-
obj[key] || obj[key.to_s]
|
|
765
|
-
end
|
|
766
|
-
end
|
|
767
|
-
|
|
768
|
-
def symbolize_keys(hash)
|
|
769
|
-
return {} unless hash.is_a?(Hash)
|
|
770
|
-
|
|
771
|
-
hash.transform_keys(&:to_sym)
|
|
772
|
-
end
|
|
773
265
|
end
|
|
774
266
|
end
|
|
775
267
|
end
|