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,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module IDE
|
|
7
|
+
module Adapters
|
|
8
|
+
# Wraps every tool invocation in IDE mode. Emits JSON-RPC notifications
|
|
9
|
+
# that the VS Code extension consumes, precomputes file edits so the
|
|
10
|
+
# editor can render a diff before any write touches disk, and gates
|
|
11
|
+
# mutating operations behind acceptance/approval from the IDE client.
|
|
12
|
+
#
|
|
13
|
+
# Gating policy depends on the permission mode:
|
|
14
|
+
# :default → approve every mutating tool + every file edit
|
|
15
|
+
# :accept_edits → auto-approve file edits, prompt for bash/other
|
|
16
|
+
# :plan_only → read-only, block all writes
|
|
17
|
+
# :auto → auto-approve everything except deny-listed
|
|
18
|
+
# :dont_ask → auto-deny all non-read-only tools
|
|
19
|
+
# :bypass → no checks (legacy yolo)
|
|
20
|
+
#
|
|
21
|
+
# In all modes the adapter emits notifications so the UI reflects
|
|
22
|
+
# what's happening.
|
|
23
|
+
class ToolOutput
|
|
24
|
+
# Wake up periodically to check for thread interrupts (cancel).
|
|
25
|
+
# No auto-deny — waits indefinitely until the user decides.
|
|
26
|
+
WAIT_POLL_INTERVAL = 5 # seconds
|
|
27
|
+
|
|
28
|
+
READ_ONLY_TOOLS = %w[
|
|
29
|
+
read_file glob grep
|
|
30
|
+
git_status git_diff git_log git_commit
|
|
31
|
+
memory_search web_fetch web_search
|
|
32
|
+
run_specs
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
FILE_WRITE_TOOLS = %w[write_file edit_file].freeze
|
|
36
|
+
|
|
37
|
+
VALID_PERMISSION_MODES = %i[default accept_edits plan_only auto dont_ask bypass].freeze
|
|
38
|
+
|
|
39
|
+
attr_accessor :permission_mode
|
|
40
|
+
|
|
41
|
+
def initialize(server, permission_mode: :default, yolo: false, hook_runner: nil)
|
|
42
|
+
@server = server
|
|
43
|
+
@permission_mode = yolo ? :bypass : permission_mode.to_sym
|
|
44
|
+
@hook_runner = hook_runner
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
|
|
47
|
+
# { request_id => { cv: ConditionVariable, approved: nil|true|false } }
|
|
48
|
+
@pending_approvals = {}
|
|
49
|
+
|
|
50
|
+
# { edit_id => { cv: ConditionVariable, accepted: nil|true|false } }
|
|
51
|
+
@pending_edits = {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Main entry point. Wraps a tool call, emitting IDE notifications
|
|
55
|
+
# and gating execution behind acceptance when required.
|
|
56
|
+
#
|
|
57
|
+
# adapter.wrap_execution("write_file", { path: "foo.rb", content: "..." }) do
|
|
58
|
+
# executor.execute("write_file", params)
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
def wrap_execution(tool_name, args, &block)
|
|
62
|
+
request_id = generate_id
|
|
63
|
+
args = stringify_keys(args)
|
|
64
|
+
|
|
65
|
+
return execute_and_notify(request_id, tool_name, args, &block) if read_only?(tool_name)
|
|
66
|
+
|
|
67
|
+
# Non-read-only tools: gating depends on permission mode
|
|
68
|
+
case @permission_mode
|
|
69
|
+
when :bypass, :auto
|
|
70
|
+
# Auto-approve everything — run without waiting
|
|
71
|
+
execute_and_notify(request_id, tool_name, args, &block)
|
|
72
|
+
when :plan_only
|
|
73
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: false)
|
|
74
|
+
msg = 'Plan mode: write operations blocked'
|
|
75
|
+
emit_tool_result(request_id, tool_name, msg, success: false, args: args)
|
|
76
|
+
raise RubynCode::UserDeniedError, msg
|
|
77
|
+
when :dont_ask
|
|
78
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: false)
|
|
79
|
+
msg = 'Auto-denied: permission mode is dont_ask'
|
|
80
|
+
emit_tool_result(request_id, tool_name, msg, success: false, args: args)
|
|
81
|
+
raise RubynCode::UserDeniedError, msg
|
|
82
|
+
when :accept_edits
|
|
83
|
+
if file_write?(tool_name)
|
|
84
|
+
execute_with_edit_gate(request_id, tool_name, args, &block)
|
|
85
|
+
else
|
|
86
|
+
execute_with_approval(request_id, tool_name, args, &block)
|
|
87
|
+
end
|
|
88
|
+
else # :default
|
|
89
|
+
if file_write?(tool_name)
|
|
90
|
+
execute_with_edit_gate(request_id, tool_name, args, &block)
|
|
91
|
+
else
|
|
92
|
+
execute_with_approval(request_id, tool_name, args, &block)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Called by ApproveToolUseHandler when the IDE client responds.
|
|
98
|
+
def resolve_approval(request_id, approved)
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
pending = @pending_approvals[request_id]
|
|
101
|
+
return false unless pending
|
|
102
|
+
|
|
103
|
+
pending[:approved] = approved
|
|
104
|
+
pending[:cv].signal
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Called by AcceptEditHandler when the IDE client responds.
|
|
110
|
+
def resolve_edit(edit_id, accepted)
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
pending = @pending_edits[edit_id]
|
|
113
|
+
return false unless pending
|
|
114
|
+
|
|
115
|
+
pending[:accepted] = accepted
|
|
116
|
+
pending[:cv].signal
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# ── Read-only and streaming paths ────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def execute_and_notify(request_id, tool_name, args)
|
|
126
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: false)
|
|
127
|
+
result = yield
|
|
128
|
+
emit_tool_result(request_id, tool_name, result, success: true, args: args)
|
|
129
|
+
result
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
|
|
132
|
+
raise
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ── File write tools (write_file, edit_file) ─────────────────────
|
|
136
|
+
|
|
137
|
+
def execute_with_edit_gate(request_id, tool_name, args, &)
|
|
138
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: false)
|
|
139
|
+
|
|
140
|
+
preview = compute_preview(tool_name, args)
|
|
141
|
+
return emit_error(request_id, tool_name, preview[:error]) if preview[:error]
|
|
142
|
+
|
|
143
|
+
# Always emit the file/edit or file/create notification so the
|
|
144
|
+
# extension can surface the change — opens a diff editor in normal
|
|
145
|
+
# mode or flashes "Rubyn auto-applied…" and applies via workspace
|
|
146
|
+
# edit in yolo mode. Either way the user sees what changed.
|
|
147
|
+
accepted = notify_and_await_edit(tool_name, preview, args)
|
|
148
|
+
return deny_edit(request_id, tool_name, preview[:type]) unless accepted
|
|
149
|
+
|
|
150
|
+
apply_edit(request_id, tool_name, args, &)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def compute_preview(tool_name, args)
|
|
154
|
+
tool = build_tool(tool_name)
|
|
155
|
+
sym_args = symbolize_keys(args)
|
|
156
|
+
result = tool.preview_content(**sym_args)
|
|
157
|
+
{ content: result[:content], type: result[:type] }
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
{ error: e.message }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_tool(tool_name)
|
|
163
|
+
klass = Tools::Registry.get(tool_name)
|
|
164
|
+
klass.new(project_root: @server.workspace_path || Dir.pwd)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def notify_and_await_edit(tool_name, preview, args)
|
|
168
|
+
edit_id = generate_id
|
|
169
|
+
path = args['path']
|
|
170
|
+
method = preview[:type] == 'create' ? 'file/create' : 'file/edit'
|
|
171
|
+
|
|
172
|
+
params = { 'editId' => edit_id, 'path' => path, 'content' => preview[:content] }
|
|
173
|
+
params['type'] = preview[:type] if method == 'file/edit'
|
|
174
|
+
|
|
175
|
+
@server.notify(method, params)
|
|
176
|
+
|
|
177
|
+
# In accept_edits mode, auto-approve file writes without waiting
|
|
178
|
+
return true if @permission_mode == :accept_edits
|
|
179
|
+
|
|
180
|
+
fire_permission_request(tool_name, edit_id)
|
|
181
|
+
wait_for_edit(edit_id)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def apply_edit(request_id, tool_name, args)
|
|
185
|
+
result = yield
|
|
186
|
+
emit_tool_result(request_id, tool_name, result, success: true, args: args)
|
|
187
|
+
result
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
|
|
190
|
+
raise
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def deny_edit(request_id, tool_name, type)
|
|
194
|
+
summary = "User rejected this #{type}. Do not retry the same content."
|
|
195
|
+
emit_tool_result(request_id, tool_name, summary, success: false)
|
|
196
|
+
raise RubynCode::UserDeniedError, summary
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def emit_error(request_id, tool_name, message)
|
|
200
|
+
summary = "Error: #{message}"
|
|
201
|
+
emit_tool_result(request_id, tool_name, summary, success: false)
|
|
202
|
+
summary
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# ── Approval-gated tools (bash, etc.) ────────────────────────────
|
|
206
|
+
|
|
207
|
+
def execute_with_approval(request_id, tool_name, args)
|
|
208
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: true)
|
|
209
|
+
|
|
210
|
+
fire_permission_request(tool_name, request_id)
|
|
211
|
+
approved = wait_for_approval(request_id)
|
|
212
|
+
unless approved
|
|
213
|
+
summary = 'User refused this tool invocation. Do not retry the same call.'
|
|
214
|
+
emit_tool_result(request_id, tool_name, summary, success: false, args: args)
|
|
215
|
+
raise RubynCode::UserDeniedError, summary
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
result = yield
|
|
219
|
+
emit_tool_result(request_id, tool_name, result, success: true, args: args)
|
|
220
|
+
result
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
|
|
223
|
+
raise
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ── Notifications ────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def emit_tool_use(request_id, tool_name, args, requires_approval:)
|
|
229
|
+
@server.notify('tool/use', {
|
|
230
|
+
'requestId' => request_id,
|
|
231
|
+
'tool' => tool_name,
|
|
232
|
+
'args' => args,
|
|
233
|
+
'requiresApproval' => requires_approval
|
|
234
|
+
})
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def emit_tool_result(request_id, tool_name, result, success:, args: {})
|
|
238
|
+
@server.notify('tool/result', {
|
|
239
|
+
'requestId' => request_id,
|
|
240
|
+
'tool' => tool_name,
|
|
241
|
+
'success' => success,
|
|
242
|
+
'summary' => build_summary(tool_name, result, success, args)
|
|
243
|
+
})
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Ask the tool class for its one-line summary ("Edited foo.rb (1
|
|
247
|
+
# replacement)", "grep pattern (12 lines)", etc.). Tools that don't
|
|
248
|
+
# override Base.summarize return "", and the UI renders a clean
|
|
249
|
+
# "Done". The full tool output always lives in the conversation —
|
|
250
|
+
# summary is display-only. On failure we include the error so the
|
|
251
|
+
# user can see what went wrong.
|
|
252
|
+
def build_summary(tool_name, result, success, args)
|
|
253
|
+
return result.to_s[0, 500] unless success
|
|
254
|
+
|
|
255
|
+
klass = tool_class(tool_name)
|
|
256
|
+
return '' unless klass
|
|
257
|
+
|
|
258
|
+
klass.summarize(result.to_s, args || {}).to_s[0, 500]
|
|
259
|
+
rescue StandardError
|
|
260
|
+
''
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def tool_class(tool_name)
|
|
264
|
+
Tools::Registry.get(tool_name)
|
|
265
|
+
rescue ToolNotFoundError
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# ── Blocking waits ───────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
def wait_for_approval(request_id)
|
|
272
|
+
cv = ConditionVariable.new
|
|
273
|
+
@mutex.synchronize { @pending_approvals[request_id] = { cv: cv, approved: nil } }
|
|
274
|
+
|
|
275
|
+
@mutex.synchronize do
|
|
276
|
+
# Wait indefinitely — no timeout, no auto-deny. The user decides
|
|
277
|
+
# when they're ready. Poll periodically so thread interrupts
|
|
278
|
+
# (cancel) can break us out.
|
|
279
|
+
while @pending_approvals[request_id][:approved].nil?
|
|
280
|
+
cv.wait(@mutex, WAIT_POLL_INTERVAL)
|
|
281
|
+
end
|
|
282
|
+
@pending_approvals.delete(request_id)[:approved]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def wait_for_edit(edit_id)
|
|
287
|
+
cv = ConditionVariable.new
|
|
288
|
+
@mutex.synchronize { @pending_edits[edit_id] = { cv: cv, accepted: nil } }
|
|
289
|
+
|
|
290
|
+
@mutex.synchronize do
|
|
291
|
+
while @pending_edits[edit_id][:accepted].nil?
|
|
292
|
+
cv.wait(@mutex, WAIT_POLL_INTERVAL)
|
|
293
|
+
end
|
|
294
|
+
@pending_edits.delete(edit_id)[:accepted]
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# ── Hook helpers ───────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def fire_permission_request(tool_name, request_id)
|
|
301
|
+
@hook_runner&.fire(:permission_request, tool_name: tool_name, request_id: request_id)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# ── Helpers ──────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def read_only?(tool_name)
|
|
307
|
+
READ_ONLY_TOOLS.include?(tool_name)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def file_write?(tool_name)
|
|
311
|
+
FILE_WRITE_TOOLS.include?(tool_name)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def stringify_keys(hash)
|
|
315
|
+
return {} unless hash.is_a?(Hash)
|
|
316
|
+
|
|
317
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def symbolize_keys(hash)
|
|
321
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def generate_id
|
|
325
|
+
"#{Time.now.to_i}-#{SecureRandom.hex(4)}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module IDE
|
|
7
|
+
# Sends JSON-RPC requests from the CLI server to the VS Code extension
|
|
8
|
+
# and awaits responses. Enables the CLI to ask the IDE to do things like
|
|
9
|
+
# open diffs, read diagnostics, or navigate to a file.
|
|
10
|
+
#
|
|
11
|
+
# Uses the server's write mutex for thread-safe output. Tracks pending
|
|
12
|
+
# responses via a { id => ConditionVariable } map.
|
|
13
|
+
class Client
|
|
14
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
15
|
+
|
|
16
|
+
def initialize(server)
|
|
17
|
+
@server = server
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@next_id = 1000 # Start high to avoid collisions with client IDs
|
|
20
|
+
@pending = {} # { id => { cv: ConditionVariable, result: nil, error: nil } }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Send a JSON-RPC request to the extension and block until the response
|
|
24
|
+
# arrives or the timeout expires.
|
|
25
|
+
#
|
|
26
|
+
# @param method [String] the RPC method name (e.g. "ide/readSelection")
|
|
27
|
+
# @param params [Hash] the request params
|
|
28
|
+
# @param timeout [Numeric] seconds to wait for a response
|
|
29
|
+
# @return [Hash] the result from the extension
|
|
30
|
+
# @raise [TimeoutError] if no response within timeout
|
|
31
|
+
# @raise [StandardError] if the extension returns an RPC error
|
|
32
|
+
def request(method, params = {}, timeout: DEFAULT_TIMEOUT)
|
|
33
|
+
id = allocate_id
|
|
34
|
+
cv = ConditionVariable.new
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@pending[id] = { cv: cv, result: nil, error: nil }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Write the request via the server's write path
|
|
41
|
+
write_raw({
|
|
42
|
+
'jsonrpc' => Protocol::JSONRPC_VERSION,
|
|
43
|
+
'id' => id,
|
|
44
|
+
'method' => method,
|
|
45
|
+
'params' => Protocol.send(:stringify_keys_deep, params)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Block until the extension responds or we time out
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
deadline = Time.now + timeout
|
|
51
|
+
while @pending[id][:result].nil? && @pending[id][:error].nil?
|
|
52
|
+
remaining = deadline - Time.now
|
|
53
|
+
if remaining <= 0
|
|
54
|
+
@pending.delete(id)
|
|
55
|
+
raise TimeoutError, "IDE RPC request '#{method}' timed out after #{timeout}s"
|
|
56
|
+
end
|
|
57
|
+
cv.wait(@mutex, remaining)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
entry = @pending.delete(id)
|
|
61
|
+
raise StandardError, entry[:error] if entry[:error]
|
|
62
|
+
|
|
63
|
+
entry[:result]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Called by the server when it receives a response message (has id + result/error,
|
|
68
|
+
# no method) that matches one of our pending outbound requests.
|
|
69
|
+
#
|
|
70
|
+
# @param id [Integer] the response id
|
|
71
|
+
# @param result [Hash, nil] the result payload
|
|
72
|
+
# @param error [Hash, nil] the error payload
|
|
73
|
+
def resolve(id, result: nil, error: nil)
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
entry = @pending[id]
|
|
76
|
+
return unless entry
|
|
77
|
+
|
|
78
|
+
if error
|
|
79
|
+
entry[:error] = "RPC error #{error['code']}: #{error['message']}"
|
|
80
|
+
else
|
|
81
|
+
entry[:result] = result || {}
|
|
82
|
+
end
|
|
83
|
+
entry[:cv].signal
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if we have a pending request with this id.
|
|
88
|
+
def pending?(id)
|
|
89
|
+
@mutex.synchronize { @pending.key?(id) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def allocate_id
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
id = @next_id
|
|
97
|
+
@next_id += 1
|
|
98
|
+
id
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Write using the server's write method for thread-safe, testable output.
|
|
103
|
+
def write_raw(msg_hash)
|
|
104
|
+
@server.send(:write, msg_hash)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class TimeoutError < StandardError; end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles the "acceptEdit" JSON-RPC request.
|
|
7
|
+
#
|
|
8
|
+
# The extension sends this after the user accepts or rejects a proposed
|
|
9
|
+
# file edit surfaced via a file/edit or file/create notification. All
|
|
10
|
+
# pending-edit state lives in the per-session ToolOutput adapter; this
|
|
11
|
+
# handler is a thin delegate so the server has something to register at
|
|
12
|
+
# the method name.
|
|
13
|
+
class AcceptEditHandler
|
|
14
|
+
def initialize(server)
|
|
15
|
+
@server = server
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(params)
|
|
19
|
+
edit_id = params['editId']
|
|
20
|
+
accepted = params['accepted']
|
|
21
|
+
|
|
22
|
+
return { 'applied' => false, 'error' => 'Missing editId' } unless edit_id
|
|
23
|
+
|
|
24
|
+
adapter = @server.tool_output_adapter
|
|
25
|
+
return { 'applied' => false, 'error' => 'No active session' } unless adapter
|
|
26
|
+
|
|
27
|
+
resolved = adapter.resolve_edit(edit_id, accepted ? true : false)
|
|
28
|
+
return { 'applied' => false, 'error' => "No pending edit: #{edit_id}" } unless resolved
|
|
29
|
+
|
|
30
|
+
{ 'applied' => accepted ? true : false }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles the "approveToolUse" JSON-RPC request.
|
|
7
|
+
#
|
|
8
|
+
# The extension sends this after the user approves or denies a tool
|
|
9
|
+
# invocation surfaced with requiresApproval=true. All pending-approval
|
|
10
|
+
# state lives in the per-session ToolOutput adapter; this handler is a
|
|
11
|
+
# thin delegate so the server has something registered at the method name.
|
|
12
|
+
class ApproveToolUseHandler
|
|
13
|
+
def initialize(server)
|
|
14
|
+
@server = server
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(params)
|
|
18
|
+
request_id = params['requestId']
|
|
19
|
+
approved = params['approved']
|
|
20
|
+
|
|
21
|
+
return { 'resolved' => false, 'error' => 'Missing requestId' } unless request_id
|
|
22
|
+
|
|
23
|
+
adapter = @server.tool_output_adapter
|
|
24
|
+
return { 'resolved' => false, 'error' => 'No active session' } unless adapter
|
|
25
|
+
|
|
26
|
+
resolved = adapter.resolve_approval(request_id, approved ? true : false)
|
|
27
|
+
return { 'resolved' => false, 'error' => "No pending request: #{request_id}" } unless resolved
|
|
28
|
+
|
|
29
|
+
{ 'resolved' => true, 'requestId' => request_id }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles the "cancel" JSON-RPC request.
|
|
7
|
+
#
|
|
8
|
+
# Signals the agent loop thread for the given session to stop,
|
|
9
|
+
# then returns confirmation.
|
|
10
|
+
class CancelHandler
|
|
11
|
+
def initialize(server)
|
|
12
|
+
@server = server
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(params)
|
|
16
|
+
session_id = params['sessionId']
|
|
17
|
+
|
|
18
|
+
return { 'cancelled' => false, 'error' => 'Missing sessionId' } unless session_id
|
|
19
|
+
|
|
20
|
+
# Delegate to the PromptHandler which owns the session threads
|
|
21
|
+
prompt_handler = @server.handler_instance(:prompt)
|
|
22
|
+
prompt_handler&.cancel_session(session_id)
|
|
23
|
+
|
|
24
|
+
# Fire the stop hook so extensions can react to session cancellation
|
|
25
|
+
fire_stop_hook(session_id)
|
|
26
|
+
|
|
27
|
+
{ 'cancelled' => true, 'sessionId' => session_id }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def fire_stop_hook(session_id)
|
|
33
|
+
hook_registry = Hooks::Registry.new
|
|
34
|
+
hook_runner = Hooks::Runner.new(registry: hook_registry)
|
|
35
|
+
Hooks::BuiltIn.register_all!(hook_registry)
|
|
36
|
+
hook_runner.fire(:stop, session_id: session_id)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles "config/get" JSON-RPC requests from the IDE extension.
|
|
7
|
+
#
|
|
8
|
+
# When a key is provided, returns that single setting with its source.
|
|
9
|
+
# When no key is provided, returns all configurable settings plus
|
|
10
|
+
# provider definitions so the extension can populate its UI.
|
|
11
|
+
class ConfigGetHandler
|
|
12
|
+
EXPOSED_KEYS = %w[
|
|
13
|
+
provider model model_mode max_iterations max_sub_agent_iterations max_output_chars
|
|
14
|
+
context_threshold_tokens session_budget_usd daily_budget_usd permission_mode
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(server)
|
|
18
|
+
@server = server
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(params)
|
|
22
|
+
settings = Config::Settings.new
|
|
23
|
+
key = params['key']
|
|
24
|
+
|
|
25
|
+
if key
|
|
26
|
+
single_key_response(settings, key)
|
|
27
|
+
else
|
|
28
|
+
all_settings_response(settings)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def single_key_response(settings, key)
|
|
35
|
+
unless EXPOSED_KEYS.include?(key.to_s)
|
|
36
|
+
return { 'key' => key, 'value' => nil, 'source' => 'unknown',
|
|
37
|
+
'error' => "Unknown config key: #{key}" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
value = settings.get(key)
|
|
41
|
+
source = settings.data.key?(key.to_s) ? 'config_file' : 'default'
|
|
42
|
+
|
|
43
|
+
{ 'key' => key, 'value' => value, 'source' => source }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def all_settings_response(settings)
|
|
47
|
+
result = {}
|
|
48
|
+
|
|
49
|
+
EXPOSED_KEYS.each do |key|
|
|
50
|
+
sym = key.to_sym
|
|
51
|
+
value = settings.get(key)
|
|
52
|
+
default = Config::Settings::DEFAULT_MAP[sym]
|
|
53
|
+
result[key] = { 'value' => value, 'default' => default }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
providers = settings.data['providers'] || {}
|
|
57
|
+
|
|
58
|
+
{ 'settings' => result, 'providers' => providers }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|