rubik 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +105 -2
  4. data/lib/rubik/adapter/process.rb +11 -8
  5. data/lib/rubik/ast/go_extractor.rb +9 -2
  6. data/lib/rubik/ast/graph.rb +20 -1
  7. data/lib/rubik/ast/graph_extensions.rb +89 -1
  8. data/lib/rubik/ast/python_extractor.rb +5 -5
  9. data/lib/rubik/ast/rust_extractor.rb +9 -2
  10. data/lib/rubik/ast/typescript_extractor.rb +13 -7
  11. data/lib/rubik/buffer/manager.rb +78 -66
  12. data/lib/rubik/config/defaults.rb +1 -0
  13. data/lib/rubik/context/git_lister.rb +3 -1
  14. data/lib/rubik/diagnostics/parser.rb +53 -1
  15. data/lib/rubik/llm/anthropic.rb +5 -2
  16. data/lib/rubik/llm/openai_compatible.rb +83 -68
  17. data/lib/rubik/llm/registry.rb +81 -74
  18. data/lib/rubik/lsp/client.rb +320 -96
  19. data/lib/rubik/lsp/manager.rb +83 -12
  20. data/lib/rubik/lsp/server_command.rb +102 -0
  21. data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
  22. data/lib/rubik/orchestrator/step_executor.rb +138 -154
  23. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
  24. data/lib/rubik/prompt/token_estimator.rb +16 -12
  25. data/lib/rubik/repl/commands/agent_context.rb +126 -104
  26. data/lib/rubik/repl/commands/agent_execution.rb +83 -68
  27. data/lib/rubik/repl/commands/agent_models.rb +112 -87
  28. data/lib/rubik/repl/commands/buffer.rb +122 -88
  29. data/lib/rubik/repl/commands/mcp.rb +120 -92
  30. data/lib/rubik/repl/commands/session.rb +90 -59
  31. data/lib/rubik/repl/commands/workspace.rb +110 -94
  32. data/lib/rubik/repl/shell.rb +10 -1
  33. data/lib/rubik/runtime/discovery.rb +12 -6
  34. data/lib/rubik/security/classifier/classifier.rb +3 -1
  35. data/lib/rubik/storage/vakanuvis/distill_gate.rb +6 -2
  36. data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
  37. data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
  38. data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
  39. data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
  40. data/lib/rubik/tools/builtins/lsp.rb +36 -8
  41. data/lib/rubik/tools/truncator.rb +21 -8
  42. data/lib/rubik/version.rb +1 -1
  43. metadata +2 -1
@@ -19,20 +19,9 @@ module Rubik
19
19
  include MaestroGate
20
20
 
21
21
  ERROR_PREFIXES = [
22
- "-- ",
23
- "Error:",
24
- "Error ",
25
- "Usage:",
26
- "Pre-flight verification failed",
27
- "Bilinmeyen komut",
28
- "Failed",
29
- "Security blocked",
30
- "Command error",
31
- "[EXIT ",
32
- "Test run [FAILED]",
33
- "Lint [FAILED]",
34
- "[CONFLICT]",
35
- "[CONFLICT "
22
+ "-- ", "Error:", "Error ", "Usage:", "Pre-flight verification failed",
23
+ "Bilinmeyen komut", "Failed", "Security blocked", "Command error",
24
+ "[EXIT ", "Test run [FAILED]", "Lint [FAILED]", "[CONFLICT]", "[CONFLICT "
36
25
  ].freeze
37
26
 
38
27
  # Plan 87 E-1: state is reached through the ShellDeps façade. Accepts a
@@ -98,7 +87,6 @@ module Rubik
98
87
 
99
88
  def execute_registered_tool(tool_instance, tool_name, tool_args, call_id, auto_apply_mode = nil)
100
89
  @deps.on_step&.call(type: :tool_call, tool: tool_name, args: tool_args, step: @deps.step_count, max_steps: @deps.max_steps, call_id: call_id)
101
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
90
 
103
91
  # Opt-in circuit breaker (G19): when enabled and the circuit for this
104
92
  # tool is open, raise into the normal error-result plumbing below so
@@ -107,111 +95,13 @@ module Rubik
107
95
  breaker = circuit_breaker_for
108
96
  blocked_by_circuit = breaker && !breaker.allow?(tool_instance.name)
109
97
 
110
- tool_result = begin
111
- if blocked_by_circuit
112
- raise ToolCircuitBreaker::CircuitOpenError,
113
- "circuit open for '#{tool_instance.name}' — repeated failures; waiting out the cooldown before retrying"
114
- end
98
+ raw_output, tool_result = invoke_tool_and_normalize(tool_instance, tool_name, tool_args, call_id, blocked_by_circuit: blocked_by_circuit)
115
99
 
116
- raw_output = tool_instance.execute(tool_args)
117
- t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
118
- dur = ((t1 - t0) * 1000).round(1)
119
-
120
- max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", 12_000) || 12_000
121
- max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", 300) || 300
122
- trunc_strat = @deps.shell&.config&.fetch("tools.truncation_strategy", "head_tail") || "head_tail"
123
-
124
- if raw_output.is_a?(Rubik::Tools::Result)
125
- trunc_res = Rubik::Tools::Truncator.truncate(raw_output.output, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
126
- effective_dur = raw_output.duration_ms.positive? ? raw_output.duration_ms : dur
127
- first_line = raw_output.output.lines.first.to_s.strip
128
- first_line = nil if first_line.empty?
129
- Rubik::Tools::Result.new(
130
- tool_name: raw_output.tool_name.to_s.empty? ? tool_name : raw_output.tool_name,
131
- call_id: raw_output.call_id.to_s.empty? ? call_id : raw_output.call_id,
132
- status: raw_output.status,
133
- error: raw_output.error || (raw_output.error? ? first_line : nil),
134
- exit_code: raw_output.exit_code,
135
- output: trunc_res[:text],
136
- duration_ms: effective_dur,
137
- truncated: raw_output.truncated || trunc_res[:truncated],
138
- metadata: raw_output.metadata
139
- )
140
- elsif raw_output.respond_to?(:status) && raw_output.respond_to?(:output)
141
- out_str = raw_output.output.to_s
142
- trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
143
- st = raw_output.status.to_sym
144
- first_line = out_str.lines.first.to_s.strip
145
- first_line = nil if first_line.empty?
146
- err = (raw_output.respond_to?(:error) && raw_output.error) || (st == :error ? first_line : nil)
147
- Rubik::Tools::Result.new(
148
- tool_name: tool_name,
149
- call_id: call_id,
150
- status: st,
151
- error: err,
152
- output: trunc_res[:text],
153
- duration_ms: dur,
154
- truncated: trunc_res[:truncated],
155
- # Structured fields ride along when the tool provides them
156
- # (issue #127): the same Result contract for every carrier.
157
- exit_code: raw_output.respond_to?(:exit_code) ? raw_output.exit_code : nil,
158
- metadata: raw_output.respond_to?(:metadata) ? raw_output.metadata : {}
159
- )
160
- else
161
- out_str = raw_output.to_s
162
- trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
163
-
164
- is_err = if raw_output.respond_to?(:error?)
165
- raw_output.error?
166
- elsif raw_output.respond_to?(:failed?)
167
- raw_output.failed?
168
- elsif raw_output.is_a?(String)
169
- ERROR_PREFIXES.any? { |pfx| raw_output.start_with?(pfx) }
170
- else
171
- false
172
- end
173
-
174
- first_line = out_str.lines.first.to_s.strip
175
- first_line = nil if first_line.empty?
176
-
177
- Rubik::Tools::Result.new(
178
- tool_name: tool_name,
179
- call_id: call_id,
180
- status: is_err ? :error : :ok,
181
- error: is_err ? first_line : nil,
182
- output: trunc_res[:text],
183
- duration_ms: dur,
184
- truncated: trunc_res[:truncated]
185
- )
186
- end
187
- rescue StandardError => e
188
- t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
- dur = ((t1 - t0) * 1000).round(1)
190
- err_msg = "Error executing tool '#{tool_name}': #{e.class} - #{e.message}"
191
- Rubik::Tools::Result.new(
192
- tool_name: tool_name,
193
- call_id: call_id,
194
- status: :error,
195
- error: e.message,
196
- output: err_msg,
197
- duration_ms: dur
198
- )
199
- end
100
+ record_circuit_breaker_result(breaker, tool_instance.name, tool_result.status) if breaker && !blocked_by_circuit
200
101
 
201
102
  # always-mode flush after every tool — was historically silent; the
202
103
  # post_tool event makes the disk write visible like every other apply
203
- if breaker && !blocked_by_circuit
204
- if tool_result.status == :ok
205
- breaker.record_success(tool_instance.name)
206
- else
207
- breaker.record_failure(tool_instance.name)
208
- end
209
- end
210
-
211
- if AutoApply.apply?(@deps.config, trigger: :post_tool, mode: auto_apply_mode) && @deps.buffer_manager&.dirty?
212
- applied = AutoApply.apply_and_sync(@deps.buffer_manager, @deps.shell)
213
- @deps.on_step&.call(type: :auto_apply, reason: "post_tool", files: applied, step: @deps.step_count, max_steps: @deps.max_steps)
214
- end
104
+ perform_post_tool_auto_apply(auto_apply_mode)
215
105
 
216
106
  # Real-time LSP In-Buffer Diagnostics feedback loop (Plan 56 D-5,
217
107
  # extracted to LspFeedback for the line budget).
@@ -222,39 +112,9 @@ module Rubik
222
112
  # çıktısı tam içerikle kullanıcıya :user_output olarak akar; model,
223
113
  # history ve :tool_result özeti yalnız stub görür. Event birincil
224
114
  # teslimat olduğu için onsuz stub yalan olur — kaynak yoksa warn.
225
- if raw_output.respond_to?(:user_display?) && raw_output.user_display? && tool_result.status == :ok
226
- sink = @deps.respond_to?(:on_step) ? @deps.on_step : nil
227
- if sink
228
- sink.call(type: :user_output, content: raw_output.to_s, label: raw_output.display_stub,
229
- step: @deps.step_count, max_steps: @deps.max_steps)
230
- else
231
- warn("[show_file] no :user_output sink wired — display content dropped: #{raw_output.display_stub}")
232
- end
233
- tool_result = Rubik::Tools::Result.new(tool_name: tool_name, call_id: call_id, status: :ok,
234
- output: raw_output.display_stub, duration_ms: tool_result.duration_ms)
235
- end
115
+ tool_result = handle_user_display_output(raw_output, tool_result, tool_name, call_id)
236
116
 
237
- @deps.on_step&.call(
238
- type: :tool_result,
239
- tool: tool_name,
240
- output: tool_result.output,
241
- status: tool_result.status,
242
- duration_ms: tool_result.duration_ms,
243
- step: @deps.step_count,
244
- max_steps: @deps.max_steps,
245
- call_id: call_id,
246
- exit_code: tool_result.exit_code,
247
- metadata: tool_result.metadata,
248
- line_count: tool_result.output.to_s.lines.size,
249
- byte_size: tool_result.output.to_s.bytesize,
250
- tokens: @deps.total_tokens
251
- )
252
-
253
- Rubik::Orchestrator::ToolHistory.write(@deps.history, tool_call_id: call_id, tool_name: tool_name,
254
- status: tool_result.status, duration_ms: tool_result.duration_ms,
255
- content: tool_result.to_xml, exit_code: tool_result.exit_code,
256
- metadata: tool_result.metadata)
257
- @deps.activity.record(tool_name, tool_args, tool_result.status) if @deps.respond_to?(:activity)
117
+ record_tool_execution_results(tool_name, tool_args, call_id, tool_result)
258
118
  @deps.reset_invalid_tools!
259
119
  tool_result
260
120
  end
@@ -280,12 +140,8 @@ module Rubik
280
140
  diagnostic_msg = Rubik::Tools::DiagnosticReporter.render_unknown_tool(tool_name, candidate_tools: candidate_tools, all_tools: all_tool_names)
281
141
 
282
142
  err_result = Rubik::Tools::Result.new(
283
- tool_name: tool_name,
284
- call_id: call_id,
285
- status: :error,
286
- error: "Tool '#{tool_name}' does not exist.",
287
- output: diagnostic_msg,
288
- duration_ms: 0.0
143
+ tool_name: tool_name, call_id: call_id, status: :error,
144
+ error: "Tool '#{tool_name}' does not exist.", output: diagnostic_msg, duration_ms: 0.0
289
145
  )
290
146
  Rubik::Orchestrator::ToolHistory.write(@deps.history, tool_call_id: call_id, tool_name: tool_name,
291
147
  status: :error, duration_ms: 0.0, content: err_result.to_xml)
@@ -294,6 +150,134 @@ module Rubik
294
150
 
295
151
  private
296
152
 
153
+ def invoke_tool_and_normalize(tool_instance, tool_name, tool_args, call_id, blocked_by_circuit:)
154
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ raw_output = nil
156
+
157
+ tool_result = begin
158
+ if blocked_by_circuit
159
+ raise ToolCircuitBreaker::CircuitOpenError,
160
+ "circuit open for '#{tool_instance.name}' — repeated failures; waiting out the cooldown before retrying"
161
+ end
162
+
163
+ raw_output = tool_instance.execute(tool_args)
164
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ dur = ((t1 - t0) * 1000).round(1)
166
+
167
+ normalize_raw_output(raw_output, tool_name, call_id, dur)
168
+ rescue StandardError => e
169
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ dur = ((t1 - t0) * 1000).round(1)
171
+ err_msg = "Error executing tool '#{tool_name}': #{e.class} - #{e.message}"
172
+ Rubik::Tools::Result.new(tool_name: tool_name, call_id: call_id, status: :error, error: e.message, output: err_msg, duration_ms: dur)
173
+ end
174
+
175
+ [raw_output, tool_result]
176
+ end
177
+
178
+ def normalize_raw_output(raw_output, tool_name, call_id, dur)
179
+ max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", 12_000) || 12_000
180
+ max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", 300) || 300
181
+ trunc_strat = @deps.shell&.config&.fetch("tools.truncation_strategy", "head_tail") || "head_tail"
182
+
183
+ if raw_output.is_a?(Rubik::Tools::Result)
184
+ trunc_res = Rubik::Tools::Truncator.truncate(raw_output.output, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
185
+ effective_dur = raw_output.duration_ms.positive? ? raw_output.duration_ms : dur
186
+ first_line = raw_output.output.lines.first.to_s.strip
187
+ first_line = nil if first_line.empty?
188
+ Rubik::Tools::Result.new(
189
+ tool_name: raw_output.tool_name.to_s.empty? ? tool_name : raw_output.tool_name,
190
+ call_id: raw_output.call_id.to_s.empty? ? call_id : raw_output.call_id,
191
+ status: raw_output.status, error: raw_output.error || (raw_output.error? ? first_line : nil),
192
+ # Structured fields ride along when the tool provides them
193
+ # (issue #127): the same Result contract for every carrier.
194
+ exit_code: raw_output.exit_code, output: trunc_res[:text], duration_ms: effective_dur,
195
+ truncated: raw_output.truncated || trunc_res[:truncated], metadata: raw_output.metadata
196
+ )
197
+ elsif raw_output.respond_to?(:status) && raw_output.respond_to?(:output)
198
+ out_str = raw_output.output.to_s
199
+ trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
200
+ st = raw_output.status.to_sym
201
+ first_line = out_str.lines.first.to_s.strip
202
+ first_line = nil if first_line.empty?
203
+ err = (raw_output.respond_to?(:error) && raw_output.error) || (st == :error ? first_line : nil)
204
+ Rubik::Tools::Result.new(
205
+ tool_name: tool_name, call_id: call_id, status: st, error: err, output: trunc_res[:text],
206
+ duration_ms: dur, truncated: trunc_res[:truncated],
207
+ exit_code: raw_output.respond_to?(:exit_code) ? raw_output.exit_code : nil,
208
+ metadata: raw_output.respond_to?(:metadata) ? raw_output.metadata : {}
209
+ )
210
+ else
211
+ out_str = raw_output.to_s
212
+ trunc_res = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
213
+
214
+ is_err = if raw_output.respond_to?(:error?)
215
+ raw_output.error?
216
+ elsif raw_output.respond_to?(:failed?)
217
+ raw_output.failed?
218
+ elsif raw_output.is_a?(String)
219
+ ERROR_PREFIXES.any? { |pfx| raw_output.start_with?(pfx) }
220
+ else
221
+ false
222
+ end
223
+
224
+ first_line = out_str.lines.first.to_s.strip
225
+ first_line = nil if first_line.empty?
226
+
227
+ Rubik::Tools::Result.new(
228
+ tool_name: tool_name, call_id: call_id, status: is_err ? :error : :ok,
229
+ error: is_err ? first_line : nil, output: trunc_res[:text], duration_ms: dur, truncated: trunc_res[:truncated]
230
+ )
231
+ end
232
+ end
233
+
234
+ def record_circuit_breaker_result(breaker, tool_name, status)
235
+ if status == :ok
236
+ breaker.record_success(tool_name)
237
+ else
238
+ breaker.record_failure(tool_name)
239
+ end
240
+ end
241
+
242
+ def perform_post_tool_auto_apply(auto_apply_mode)
243
+ return unless AutoApply.apply?(@deps.config, trigger: :post_tool, mode: auto_apply_mode) && @deps.buffer_manager&.dirty?
244
+
245
+ applied = AutoApply.apply_and_sync(@deps.buffer_manager, @deps.shell)
246
+ @deps.on_step&.call(type: :auto_apply, reason: "post_tool", files: applied, step: @deps.step_count, max_steps: @deps.max_steps)
247
+ end
248
+
249
+ def handle_user_display_output(raw_output, tool_result, tool_name, call_id)
250
+ return tool_result unless raw_output.respond_to?(:user_display?) && raw_output.user_display? && tool_result.status == :ok
251
+
252
+ sink = @deps.respond_to?(:on_step) ? @deps.on_step : nil
253
+ if sink
254
+ sink.call(type: :user_output, content: raw_output.to_s, label: raw_output.display_stub,
255
+ step: @deps.step_count, max_steps: @deps.max_steps)
256
+ else
257
+ warn("[show_file] no :user_output sink wired — display content dropped: #{raw_output.display_stub}")
258
+ end
259
+
260
+ Rubik::Tools::Result.new(
261
+ tool_name: tool_name, call_id: call_id, status: :ok, output: raw_output.display_stub, duration_ms: tool_result.duration_ms
262
+ )
263
+ end
264
+
265
+ def record_tool_execution_results(tool_name, tool_args, call_id, tool_result)
266
+ @deps.on_step&.call(
267
+ type: :tool_result, tool: tool_name, output: tool_result.output, status: tool_result.status,
268
+ duration_ms: tool_result.duration_ms, step: @deps.step_count, max_steps: @deps.max_steps,
269
+ call_id: call_id, exit_code: tool_result.exit_code, metadata: tool_result.metadata,
270
+ line_count: tool_result.output.to_s.lines.size, byte_size: tool_result.output.to_s.bytesize, tokens: @deps.total_tokens
271
+ )
272
+
273
+ Rubik::Orchestrator::ToolHistory.write(
274
+ @deps.history, tool_call_id: call_id, tool_name: tool_name, status: tool_result.status,
275
+ duration_ms: tool_result.duration_ms, content: tool_result.to_xml, exit_code: tool_result.exit_code, metadata: tool_result.metadata
276
+ )
277
+
278
+ @deps.activity.record(tool_name, tool_args, tool_result.status) if @deps.respond_to?(:activity)
279
+ end
280
+
297
281
  # G19: one breaker per executor, built from the first enabled config
298
282
  # sighting. Threshold/cooldown changes via live !config set are picked
299
283
  # up on the next executor instantiation — acceptable for an opt-in
@@ -20,10 +20,7 @@ module Rubik
20
20
  def spawn(role:, prompt:, type_name: "research", system_prompt: nil,
21
21
  parent_id: ConversationId::ROOT_ID, workspace: "inherit", model: "inherit",
22
22
  max_steps: nil, provider: nil, depends_on: nil, reserved_id: nil, owner: nil)
23
- role_str = role.to_s.strip
24
- prompt_str = prompt.to_s.strip
25
- raise ArgumentError, "Subagent role cannot be empty" if role_str.empty?
26
- raise ArgumentError, "Subagent prompt cannot be empty" if prompt_str.empty?
23
+ role_str, prompt_str = validate_spawn_args!(role, prompt)
27
24
 
28
25
  # Issue #125: the owner's task was cancelled — refuse before any
29
26
  # reservation, slot or context work happens.
@@ -44,7 +41,6 @@ module Rubik
44
41
 
45
42
  profile_snapshot = resolve_profile_snapshot(type_name: type_name, role: role_str,
46
43
  system_prompt: system_prompt)
47
- effective_sys_prompt = profile_snapshot.effective_system_prompt
48
44
  # A profile update must not redirect rollback to a newly provided
49
45
  # init supervisor after this spawn registered its unit.
50
46
  activation_init = init_for_activation
@@ -52,40 +48,23 @@ module Rubik
52
48
  # unchanged built-in prompt can be reconstructed from its type.
53
49
  prompt_reconstructible = system_prompt.to_s.strip.empty? && !profile_snapshot.custom_prompt
54
50
 
55
- # Queued specs already reserved this id; fresh spawns reserve the
56
- # next sequence number here and return it if activation rolls back.
57
- generated_id = reserved_id.nil?
58
- subagent_id = generated_id ? "subagent-#{@counter + 1}" : reserved_id.to_s
59
- id_in_use = @instances.key?(subagent_id) || (@finished || {}).key?(subagent_id)
60
- raise ArgumentError, "Subagent id '#{subagent_id}' is already in use." if id_in_use
61
-
62
- @counter += 1 if generated_id
51
+ subagent_id, generated_id = reserve_subagent_id!(reserved_id)
63
52
 
64
53
  child_ctx = nil
65
54
  inst = nil
66
55
  begin
67
56
  child_ctx = extend_root_context!(:spawn)
68
- effective_buffer = branch_or_inherit_buffer(workspace)
69
- scoped_registry = build_scoped_registry(profile_snapshot, child_ctx, effective_buffer)
70
- prov, effective_model = resolve_provider_and_model(model)
71
- prov = provider if provider
72
-
73
- inst = Instance.new(
74
- id: subagent_id, parent_id: parent_id, role: role_str, type_name: profile_snapshot.name,
75
- prompt: prompt_str, system_prompt: effective_sys_prompt, workspace: workspace,
76
- model: effective_model, context: child_ctx, tool_registry: scoped_registry,
77
- provider: prov, buffer_manager: effective_buffer, max_steps: (max_steps || @default_max_steps).to_i,
78
- provider_gate: @provider_gate, root_dir: @root_dir, config: @config,
79
- context_limit: (@config.respond_to?(:fetch) ? @config.fetch("context.max_tokens", 32_768) : 32_768),
80
- event_bus: @event_bus, transcript_storage: absorb_storage, owner_task: owner,
81
- prompt_reconstructible: prompt_reconstructible, profile_snapshot: profile_snapshot
57
+ inst = build_subagent_instance(
58
+ subagent_id: subagent_id, parent_id: parent_id, role_str: role_str, prompt_str: prompt_str,
59
+ profile_snapshot: profile_snapshot, workspace: workspace, model: model, max_steps: max_steps,
60
+ provider: provider, owner: owner, prompt_reconstructible: prompt_reconstructible,
61
+ child_ctx: child_ctx
82
62
  )
83
-
84
63
  @instances[subagent_id] = inst
85
- activate_instance!(inst, child_ctx: child_ctx, owner_token: owner,
86
- init_supervisor: activation_init, defer_start: true)
87
- raise "Subagent worker start gate could not be released." unless inst.release_start!
88
64
 
65
+ activate_and_release_subagent!(
66
+ inst: inst, child_ctx: child_ctx, owner: owner, activation_init: activation_init
67
+ )
89
68
  inst
90
69
  rescue StandardError
91
70
  rollback_spawn!(subagent_id: subagent_id, inst: inst, child_ctx: child_ctx,
@@ -93,6 +72,7 @@ module Rubik
93
72
  raise
94
73
  end
95
74
  end
75
+
96
76
  # SS bırakma kancası: yeni başlayan instance, onu bekleyen kuyruk
97
77
  # spesifikasyonlarını serbest bırakır (kilit DIŞINDA — emit kuralı).
98
78
  begin
@@ -105,6 +85,55 @@ module Rubik
105
85
 
106
86
  private
107
87
 
88
+ def validate_spawn_args!(role, prompt)
89
+ role_str = role.to_s.strip
90
+ prompt_str = prompt.to_s.strip
91
+ raise ArgumentError, "Subagent role cannot be empty" if role_str.empty?
92
+ raise ArgumentError, "Subagent prompt cannot be empty" if prompt_str.empty?
93
+
94
+ [role_str, prompt_str]
95
+ end
96
+
97
+ def reserve_subagent_id!(reserved_id)
98
+ # Queued specs already reserved this id; fresh spawns reserve the
99
+ # next sequence number here and return it if activation rolls back.
100
+ generated_id = reserved_id.nil?
101
+ subagent_id = generated_id ? "subagent-#{@counter + 1}" : reserved_id.to_s
102
+ id_in_use = @instances.key?(subagent_id) || (@finished || {}).key?(subagent_id)
103
+ raise ArgumentError, "Subagent id '#{subagent_id}' is already in use." if id_in_use
104
+
105
+ @counter += 1 if generated_id
106
+ [subagent_id, generated_id]
107
+ end
108
+
109
+ def build_subagent_instance(subagent_id:, parent_id:, role_str:, prompt_str:,
110
+ profile_snapshot:, workspace:, model:, max_steps:,
111
+ provider:, owner:, prompt_reconstructible:, child_ctx:)
112
+ effective_buffer = branch_or_inherit_buffer(workspace)
113
+ scoped_registry = build_scoped_registry(profile_snapshot, child_ctx, effective_buffer)
114
+ prov, effective_model = resolve_provider_and_model(model)
115
+ prov = provider if provider
116
+
117
+ context_limit = @config.respond_to?(:fetch) ? @config.fetch("context.max_tokens", 32_768) : 32_768
118
+
119
+ Instance.new(
120
+ id: subagent_id, parent_id: parent_id, role: role_str, type_name: profile_snapshot.name,
121
+ prompt: prompt_str, system_prompt: profile_snapshot.effective_system_prompt, workspace: workspace,
122
+ model: effective_model, context: child_ctx, tool_registry: scoped_registry,
123
+ provider: prov, buffer_manager: effective_buffer, max_steps: (max_steps || @default_max_steps).to_i,
124
+ provider_gate: @provider_gate, root_dir: @root_dir, config: @config,
125
+ context_limit: context_limit, event_bus: @event_bus,
126
+ transcript_storage: absorb_storage, owner_task: owner,
127
+ prompt_reconstructible: prompt_reconstructible, profile_snapshot: profile_snapshot
128
+ )
129
+ end
130
+
131
+ def activate_and_release_subagent!(inst:, child_ctx:, owner:, activation_init:)
132
+ activate_instance!(inst, child_ctx: child_ctx, owner_token: owner,
133
+ init_supervisor: activation_init, defer_start: true)
134
+ raise "Subagent worker start gate could not be released." unless inst.release_start!
135
+ end
136
+
108
137
  def resolve_profile_snapshot(type_name:, role:, system_prompt:)
109
138
  reserved_name = RESERVED_ROLE_PROFILES[role.to_s.downcase.strip]
110
139
  if reserved_name
@@ -22,27 +22,31 @@ module Rubik
22
22
  ((len * 10) + 37) / 38
23
23
  end
24
24
 
25
+ # PERF: Fast-path message token estimator respecting ConversationRows abstraction.
25
26
  def estimate_message(msg)
26
27
  return 0 unless ConversationRows.row?(msg)
27
28
 
28
29
  tokens = 4 # Base message overhead (role delimiter, headers)
29
- role = ConversationRows.value_of(msg, :role)
30
- tokens += estimate_text(role) if role
31
30
 
32
- content = ConversationRows.value_of(msg, :content)
33
- tokens += estimate_text(content) if content
31
+ if (role = ConversationRows.value_of(msg, :role))
32
+ tokens += estimate_text(role)
33
+ end
34
34
 
35
- tool_calls = msg[:tool_calls] || msg["tool_calls"]
36
- if tool_calls && !tool_calls.empty?
37
- tool_calls_str = serialize_for_estimation(tool_calls)
38
- tokens += estimate_text(tool_calls_str)
35
+ if (content = ConversationRows.value_of(msg, :content))
36
+ tokens += estimate_text(content)
39
37
  end
40
38
 
41
- tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
42
- tokens += estimate_text(tool_call_id) if tool_call_id
39
+ if (tool_calls = ConversationRows.value_of(msg, :tool_calls)) && !tool_calls.empty?
40
+ tokens += estimate_text(serialize_for_estimation(tool_calls))
41
+ end
43
42
 
44
- tool_name = msg[:tool_name] || msg["tool_name"]
45
- tokens += estimate_text(tool_name) if tool_name
43
+ if (tool_call_id = ConversationRows.value_of(msg, :tool_call_id))
44
+ tokens += estimate_text(tool_call_id)
45
+ end
46
+
47
+ if (tool_name = ConversationRows.value_of(msg, :tool_name))
48
+ tokens += estimate_text(tool_name)
49
+ end
46
50
 
47
51
  tokens
48
52
  end