rubik 0.1.6 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +5 -1
  3. data/CHANGELOG.md +169 -0
  4. data/README.md +137 -255
  5. data/bin/rubik +9 -0
  6. data/lib/rubik/ast/extraction_result.rb +37 -0
  7. data/lib/rubik/ast/extractor.rb +35 -9
  8. data/lib/rubik/ast/graph.rb +27 -1
  9. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  10. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  11. data/lib/rubik/buffer/apply_contract.rb +78 -0
  12. data/lib/rubik/buffer/manager.rb +61 -5
  13. data/lib/rubik/cli/workspace_trust.rb +78 -0
  14. data/lib/rubik/concurrency/event_loop.rb +56 -44
  15. data/lib/rubik/concurrency/scheduler.rb +7 -24
  16. data/lib/rubik/context/git_lister.rb +6 -1
  17. data/lib/rubik/context/scanner.rb +15 -3
  18. data/lib/rubik/kernel/init.rb +15 -0
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/auto_apply.rb +1 -1
  21. data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
  22. data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
  23. data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
  24. data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
  25. data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
  26. data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
  27. data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
  28. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
  29. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
  30. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
  31. data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
  32. data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
  33. data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
  34. data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
  35. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
  36. data/lib/rubik/platform/process.rb +152 -3
  37. data/lib/rubik/plugin/manager.rb +148 -71
  38. data/lib/rubik/plugin/package_inventory.rb +84 -0
  39. data/lib/rubik/plugin/requires_dag.rb +40 -0
  40. data/lib/rubik/plugin/storage.rb +100 -14
  41. data/lib/rubik/prompt/context_builder.rb +54 -20
  42. data/lib/rubik/prompt/doctrine.rb +158 -22
  43. data/lib/rubik/prompt/token_estimator.rb +5 -3
  44. data/lib/rubik/prompt/tool_teachings.rb +19 -12
  45. data/lib/rubik/repl/commands/buffer.rb +28 -18
  46. data/lib/rubik/runtime/repair_probe.rb +98 -0
  47. data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
  48. data/lib/rubik/sandbox/driver/docker.rb +14 -60
  49. data/lib/rubik/sandbox/driver/podman.rb +13 -56
  50. data/lib/rubik/sandbox/driver/process.rb +12 -64
  51. data/lib/rubik/security/blocked_guidance.rb +19 -0
  52. data/lib/rubik/security/guard.rb +116 -7
  53. data/lib/rubik/storage/store.rb +52 -11
  54. data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
  55. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  56. data/lib/rubik/tools/builtins/storage.rb +8 -5
  57. data/lib/rubik/tools/builtins/subagent.rb +20 -53
  58. data/lib/rubik/tools/builtins/synthesis.rb +6 -1
  59. data/lib/rubik/tools/builtins.rb +1 -0
  60. data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
  61. data/lib/rubik/version.rb +1 -1
  62. metadata +15 -1
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Orchestrator
5
+ module Subagent
6
+ # Reversible dynamic registration stack management for TypeRegistry.
7
+ # Tracks monotonic revisions, LIFO unregistration, and dead-context pruning.
8
+ module DynamicStore
9
+ def init_dynamic_store
10
+ @stacks = {}
11
+ @registrations = {}
12
+ @revision_counter = 0
13
+ end
14
+
15
+ def register_dynamic(type_def, context: nil, replace: false)
16
+ owner_ctx = context || type_def.context || @default_context
17
+ raise ArgumentError, "Dynamic archetypes require an active owner context." unless owner_ctx
18
+
19
+ raise ArgumentError, "Cannot define archetype in a disposed context." if owner_ctx.respond_to?(:disposed?) && owner_ctx.disposed?
20
+
21
+ raise ArgumentError, "Dynamic archetype owner context must support disposal cleanup." unless
22
+ owner_ctx.respond_to?(:register_disposable) || owner_ctx.respond_to?(:on_dispose)
23
+
24
+ clean_name = type_def.name.to_s.downcase.strip
25
+ raise ArgumentError, "Subagent type name cannot be empty" if clean_name.empty?
26
+
27
+ handle = previous_type_state = nil
28
+ @monitor.synchronize do
29
+ prune_dead_entries(clean_name)
30
+
31
+ raise ArgumentError, "Cannot define archetype '#{clean_name}' because built-in system archetypes are reserved." if @builtins.key?(clean_name)
32
+
33
+ stack = @stacks[clean_name]
34
+ if stack&.any? && !replace
35
+ raise ArgumentError, "Cannot define archetype '#{clean_name}' because it is already registered. To fix, choose a different name or specify replace: true."
36
+ end
37
+
38
+ # Keep older revisions isolated when the same definition is reused.
39
+ type_def = type_def.dup if @registrations.values.any? { |registration| registration.type_def.equal?(type_def) }
40
+ @revision_counter += 1
41
+ rev = @revision_counter
42
+ previous_type_state = [type_def.name, type_def.context, type_def.custom_prompt, type_def.handle, type_def.revision]
43
+ type_def.name = clean_name
44
+ type_def.context = owner_ctx
45
+ type_def.custom_prompt = true
46
+ handle = RegistrationHandle.new(revision: rev, type_def: type_def, context: owner_ctx, registry: self)
47
+ type_def.handle = handle
48
+ type_def.revision = rev
49
+
50
+ reg = Registration.new(revision: rev, name: clean_name, type_def: type_def, handle: handle, context: owner_ctx, active: true)
51
+ @registrations[rev] = reg
52
+ (@stacks[clean_name] ||= []) << reg
53
+ end
54
+
55
+ begin
56
+ cleanup_method = owner_ctx.respond_to?(:register_disposable) ? :register_disposable : :on_dispose
57
+ owner_ctx.public_send(cleanup_method) { unregister_dynamic(handle) }
58
+ raise ArgumentError, "Cannot define archetype in a disposed context." unless handle.active?
59
+ rescue StandardError
60
+ unregister_dynamic(handle)
61
+ type_def.name, type_def.context, type_def.custom_prompt, type_def.handle, type_def.revision = previous_type_state
62
+ raise
63
+ end
64
+ handle
65
+ end
66
+
67
+ def unregister_dynamic(target)
68
+ @monitor.synchronize do
69
+ rev = resolve_revision(target)
70
+ reg = @registrations[rev]
71
+ return false unless reg&.active
72
+
73
+ reg.active = false
74
+ handle = reg.handle
75
+ handle&.instance_variable_set(:@active, false)
76
+
77
+ stack = @stacks[reg.name]
78
+ if stack&.last == reg
79
+ stack.pop
80
+ prune_dead_entries(reg.name)
81
+ elsif stack
82
+ stack.delete(reg)
83
+ end
84
+ @registrations.delete(rev)
85
+ true
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def resolve_revision(target)
92
+ return target.revision if target.respond_to?(:revision)
93
+ return target if target.is_a?(Integer)
94
+
95
+ clean = target.to_s.downcase.strip
96
+ stack = @stacks[clean]
97
+ stack&.last&.revision
98
+ end
99
+
100
+ def prune_dead_entries(clean_name)
101
+ stack = @stacks[clean_name]
102
+ return unless stack
103
+
104
+ while stack.any?
105
+ top = stack.last
106
+ is_dead = !top.active || (top.context.respond_to?(:disposed?) && top.context.disposed?)
107
+ break unless is_dead
108
+
109
+ top.active = false
110
+ top.handle&.instance_variable_set(:@active, false)
111
+ @registrations.delete(top.revision)
112
+ stack.pop
113
+ end
114
+ @stacks.delete(clean_name) if stack.empty?
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "monitor"
4
4
  require_relative "transcript_formatter"
5
+ require_relative "start_gate"
5
6
  require_relative "storage_id"
6
7
  require_relative "lifecycle"
7
8
  require_relative "step_runner"
@@ -29,6 +30,7 @@ module Rubik
29
30
  include Lifecycle
30
31
  include StepRunner
31
32
  include RunCycle
33
+ include StartGate
32
34
  include Teardown
33
35
  include Questioner
34
36
  include Redeploy
@@ -41,6 +43,7 @@ module Rubik
41
43
  # through #state_note — derived, nothing stored to go stale (B3).
42
44
  attr_reader :id, :role, :type_name, :prompt, :system_prompt,
43
45
  :workspace, :model, :context, :tool_registry, :buffer_manager,
46
+ :profile_snapshot,
44
47
  :state, :started_at, :ended_at, :result,
45
48
  :last_error, :storage_id, :transcript_uri, :history,
46
49
  :context_limit, :max_output_tokens, :config, :pause_facts, :prompt_reconstructible,
@@ -54,13 +57,14 @@ module Rubik
54
57
  system_prompt: nil, workspace: "inherit", model: "inherit",
55
58
  provider: nil, buffer_manager: nil, max_steps: Budget::DEFAULT_MAX_STEPS, root_dir: Dir.pwd,
56
59
  context_limit: nil, config: nil, event_bus: nil, provider_gate: nil, transcript_storage: nil,
57
- prompt_reconstructible: false, owner_task: nil)
60
+ prompt_reconstructible: false, owner_task: nil, profile_snapshot: nil)
58
61
  @id = id.to_s
59
62
  @parent_id = parent_id.to_s
60
63
  @role = role.to_s.strip
61
64
  @type_name = type_name.to_s.downcase.strip
62
65
  @prompt = prompt.to_s.strip
63
- @system_prompt = system_prompt
66
+ @system_prompt = system_prompt.nil? ? nil : system_prompt.to_s.dup.freeze
67
+ @profile_snapshot = profile_snapshot
64
68
  @prompt_reconstructible = prompt_reconstructible # spawn kararı: kurulabilir prompt → kalıcı kopya stub'a iner
65
69
  @workspace = workspace.to_s.downcase.strip
66
70
  @model = model
@@ -104,6 +108,8 @@ module Rubik
104
108
  @error_cond = @monitor.new_cond
105
109
  @context_cleaned_up = false
106
110
  @thread = nil
111
+ @start_gate = nil
112
+ @start_aborted = false
107
113
  end
108
114
 
109
115
  def uptime = @started_at ? ((@ended_at || Time.now) - @started_at).to_f : 0.0
@@ -180,40 +186,6 @@ module Rubik
180
186
  grant_budget!(additional_steps, note: ->(extra, total) { Budget.grant_note(extra, total) })
181
187
  end
182
188
 
183
- def start_async(&on_complete)
184
- @monitor.synchronize do
185
- return false if @thread&.alive?
186
-
187
- @started_at = Time.now
188
- @run_seq += 1
189
- transition!(:running)
190
- @unit&.sync_state!
191
- @journal&.record_run_started(run: @run_seq, role: @role, type: @type_name, prompt: @prompt)
192
- end
193
-
194
- ready = Queue.new
195
- run_gate = Queue.new
196
- @thread = Thread.new do
197
- Thread.handle_interrupt(Object => :never) do
198
- ready << true # cleanup is armed before cancellation is enabled
199
- Thread.handle_interrupt(Object => :immediate) do
200
- run_gate.pop
201
- run_execution_cycle
202
- end
203
- ensure
204
- @ended_at ||= Time.now
205
- @journal&.record_run_finished(run: @run_seq, state: @state.to_s, result: @result,
206
- result_ref: (result_ref if settled?))
207
- save_transcript! if @transcript_path.nil?
208
- @cancelled_by_operator ? cleanup : cleanup_context
209
- on_complete&.call(self)
210
- end
211
- end
212
- ready.pop
213
- run_gate << true
214
- true
215
- end
216
-
217
189
  def to_h
218
190
  @monitor.synchronize do
219
191
  { id: @id, parent_id: @parent_id, role: @role, type: @type_name, state: @state.to_s,
@@ -58,6 +58,7 @@ module Rubik
58
58
  @bytes = File.exist?(@path) ? File.size(@path) : 0
59
59
  @closed = false
60
60
  @last_error = nil
61
+ @run_metrics = {}
61
62
  end
62
63
 
63
64
  # The generic append core: sequence, run, timestamp and type are the
@@ -85,14 +86,24 @@ module Rubik
85
86
 
86
87
  # --- semantic record vocabulary (the only place event shapes live) ---
87
88
 
88
- def record_run_started(run:, role:, type:, prompt:)
89
- data = { "role" => role.to_s, "type" => type.to_s, "prompt_preview" => clip(prompt, PROMPT_CHARS) }
89
+ def record_run_started(run:, role:, type:, prompt:, profile_name: nil, profile_version: "unknown")
90
+ data = { "role" => role.to_s, "type" => type.to_s,
91
+ "profile_name" => (profile_name || type).to_s, "profile_version" => profile_version.to_s,
92
+ "prompt_preview" => clip(prompt, PROMPT_CHARS) }
90
93
  append("run_started", run: run, data: data)
91
94
  end
92
95
 
93
- def record_run_finished(run:, state:, result: nil, result_ref: nil)
96
+ def record_run_finished(run:, state:, result: nil, result_ref: nil, outcome: nil, error_summary: nil,
97
+ duration_ms: nil, step_count: nil, profile_name: nil, profile_version: "unknown")
94
98
  text = result.to_s
95
- data = { "state" => state.to_s, "result_chars" => text.length,
99
+ metrics = consume_run_metrics(run)
100
+ data = { "state" => state.to_s, "outcome" => (outcome || state).to_s,
101
+ "profile_name" => profile_name&.to_s, "profile_version" => profile_version.to_s,
102
+ "error_summary" => error_summary.nil? ? nil : clip(error_summary, 400),
103
+ "duration_ms" => duration_ms, "step_count" => step_count,
104
+ "tool_usage" => metrics.fetch("tool_usage"), "token_usage" => metrics.fetch("token_usage"),
105
+ "cost" => { "status" => "unavailable" },
106
+ "result_chars" => text.length,
96
107
  "result_preview" => clip(text, PROMPT_CHARS), "result_ref" => result_ref }.compact
97
108
  append("run_finished", run: run, data: data)
98
109
  end
@@ -102,6 +113,12 @@ module Rubik
102
113
  end
103
114
 
104
115
  def record_tool_call(run:, call_id:, tool:, args:)
116
+ @monitor.synchronize do
117
+ metrics = (@run_metrics[run.to_i] ||= empty_run_metrics)
118
+ name = tool.to_s
119
+ metrics[:tool_usage][name] = metrics[:tool_usage].fetch(name, 0) + 1
120
+ end
121
+
105
122
  json = Rubik::Platform.safe_json_generate(Rubik::Security::Redactor.mask_value(args))
106
123
  data = { "tool" => tool.to_s, "args_chars" => json.length }
107
124
  if json.length > ARGS_CHARS
@@ -113,6 +130,25 @@ module Rubik
113
130
  append("tool_call", run: run, call_id: call_id, data: data)
114
131
  end
115
132
 
133
+ # Record only provider-reported token counts. Missing and malformed
134
+ # counts remain unavailable; this layer never estimates usage or cost.
135
+ def record_token_usage(run:, usage:)
136
+ @monitor.synchronize do
137
+ metrics = (@run_metrics[run.to_i] ||= empty_run_metrics)
138
+ metrics[:model_responses] += 1
139
+ input_tokens = usage_value(usage, :input_tokens)
140
+ output_tokens = usage_value(usage, :output_tokens)
141
+ if input_tokens
142
+ metrics[:input_tokens] += input_tokens
143
+ metrics[:input_token_responses] += 1
144
+ end
145
+ if output_tokens
146
+ metrics[:output_tokens] += output_tokens
147
+ metrics[:output_token_responses] += 1
148
+ end
149
+ end
150
+ end
151
+
116
152
  def record_tool_result(run:, call_id:, tool:, status:, duration_ms:, output:, artifact_ref: nil, exit_code: nil,
117
153
  truncated: false)
118
154
  text = output.to_s
@@ -163,6 +199,43 @@ module Rubik
163
199
 
164
200
  private
165
201
 
202
+ def empty_run_metrics
203
+ { tool_usage: {}, model_responses: 0, input_tokens: 0, output_tokens: 0,
204
+ input_token_responses: 0, output_token_responses: 0 }
205
+ end
206
+
207
+ def consume_run_metrics(run)
208
+ metrics = @monitor.synchronize { @run_metrics.delete(run.to_i) || empty_run_metrics }
209
+ token_usage = if metrics[:model_responses].zero? ||
210
+ (metrics[:input_token_responses].zero? && metrics[:output_token_responses].zero?)
211
+ { "status" => "unavailable", "model_responses" => metrics[:model_responses] }
212
+ else
213
+ status = if metrics[:input_token_responses] == metrics[:model_responses] &&
214
+ metrics[:output_token_responses] == metrics[:model_responses]
215
+ "reported"
216
+ else
217
+ "partial"
218
+ end
219
+ { "status" => status, "model_responses" => metrics[:model_responses],
220
+ "input_token_responses" => metrics[:input_token_responses],
221
+ "output_token_responses" => metrics[:output_token_responses] }.tap do |usage|
222
+ usage["input_tokens"] = metrics[:input_tokens] if metrics[:input_token_responses].positive?
223
+ usage["output_tokens"] = metrics[:output_tokens] if metrics[:output_token_responses].positive?
224
+ end
225
+ end
226
+
227
+ { "tool_usage" => metrics[:tool_usage].sort.to_h, "token_usage" => token_usage }
228
+ end
229
+
230
+ def usage_value(usage, key)
231
+ value = if usage.is_a?(Hash)
232
+ usage.key?(key) ? usage[key] : usage[key.to_s]
233
+ elsif usage.respond_to?(key)
234
+ usage.public_send(key)
235
+ end
236
+ value if value.is_a?(Integer) && value >= 0
237
+ end
238
+
166
239
  def mark_truncated
167
240
  @closed = true
168
241
  marker = { "seq" => @seq + 1, "run" => (@last_run || 1).to_i, "ts" => Time.now.utc.iso8601(3),
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../coeffects"
5
+
6
+ module Rubik
7
+ module Orchestrator
8
+ module Subagent
9
+ class ProfileValidationError < ArgumentError
10
+ attr_reader :errors
11
+
12
+ def initialize(errors)
13
+ @errors = errors.map(&:freeze).freeze
14
+ super(JSON.generate(to_h))
15
+ end
16
+
17
+ def to_h
18
+ { "error" => "invalid_subagent_profile", "errors" => @errors }
19
+ end
20
+ end
21
+
22
+ # Validates the model-facing archetype contract before the registry can
23
+ # observe it. Tool grants expand only to the existing declaration groups.
24
+ module ProfileContract
25
+ FIELD_LIMITS = { name: 64, description: 500, system_prompt: 12_000 }.freeze
26
+ MAX_COEFFECTS = 16
27
+ MAX_COEFFECT_LENGTH = 64
28
+ MAX_GRANTS = 2
29
+ CAPABILITY_GRANTS = ["write", "subagent_spawn"].map(&:freeze).freeze
30
+ UNSET = Object.new.freeze
31
+
32
+ KNOWN_COEFFECTS = Rubik::Coeffects.constants(false).filter_map do |constant|
33
+ value = Rubik::Coeffects.const_get(constant, false)
34
+ Rubik::Coeffects.canonical(value) if value.is_a?(String)
35
+ end.uniq.freeze
36
+
37
+ Profile = Struct.new(:name, :description, :system_prompt, :requires, :grants, keyword_init: true)
38
+
39
+ module_function
40
+
41
+ def validate(name:, description:, system_prompt:, requires: [], grants: UNSET,
42
+ enable_write_tools: nil, enable_subagent_tools: nil, reserved_names: [], override_requested: false)
43
+ errors = []
44
+ profile_name = normalize_text(name, :name, errors, allow_symbol: true)
45
+ profile_description = normalize_text(description, :description, errors)
46
+ profile_prompt = normalize_text(system_prompt, :system_prompt, errors)
47
+
48
+ errors << error("name", "too_long", "Name must be at most #{FIELD_LIMITS.fetch(:name)} characters.") if profile_name.length > FIELD_LIMITS.fetch(:name)
49
+ if profile_description.length > FIELD_LIMITS.fetch(:description)
50
+ errors << error("description", "too_long", "Description must be at most #{FIELD_LIMITS.fetch(:description)} characters.")
51
+ end
52
+ if profile_prompt.length > FIELD_LIMITS.fetch(:system_prompt)
53
+ errors << error("system_prompt", "too_long", "System prompt must be at most #{FIELD_LIMITS.fetch(:system_prompt)} characters.")
54
+ end
55
+
56
+ reserved = Array(reserved_names).map { |reserved_name| reserved_name.to_s.strip.downcase }
57
+ if reserved.include?(profile_name.downcase)
58
+ detail = override_requested ? " even when an override flag is supplied" : ""
59
+ errors << error("name", "reserved_name", "built-in system archetypes are reserved#{detail}.")
60
+ end
61
+
62
+ normalized_requires = normalize_list(
63
+ requires, field: "requires", allowed: KNOWN_COEFFECTS, maximum: MAX_COEFFECTS,
64
+ unknown_code: "unknown_coeffect", duplicate_code: "duplicate_coeffect", errors: errors
65
+ ) do |value|
66
+ Rubik::Coeffects.canonical(value)
67
+ end
68
+
69
+ normalized_grants = normalize_grants(
70
+ grants, enable_write_tools, enable_subagent_tools, errors
71
+ )
72
+
73
+ raise ProfileValidationError, errors unless errors.empty?
74
+
75
+ Profile.new(
76
+ name: profile_name, description: profile_description, system_prompt: profile_prompt,
77
+ requires: normalized_requires.map(&:freeze).freeze, grants: normalized_grants.map(&:freeze).freeze
78
+ )
79
+ end
80
+
81
+ def tool_filter(grants)
82
+ enabled = grants.freeze
83
+ write_tools = Rubik::Tools::Catalog.write_grant_tools
84
+ spawn_tools = Rubik::Tools::Catalog.spawn_tools
85
+
86
+ lambda do |tool_name|
87
+ next false if write_tools.include?(tool_name) && !enabled.include?("write")
88
+ next false if spawn_tools.include?(tool_name) && !enabled.include?("subagent_spawn")
89
+
90
+ true
91
+ end
92
+ end
93
+
94
+ def normalize_text(value, field, errors, allow_symbol: false)
95
+ if value.nil?
96
+ errors << error(field.to_s, "required", "#{field} is required.")
97
+ return ""
98
+ end
99
+
100
+ valid_type = value.is_a?(String) || (allow_symbol && value.is_a?(Symbol))
101
+ unless valid_type
102
+ errors << error(field.to_s, "invalid_type", "#{field} must be a string.")
103
+ return ""
104
+ end
105
+
106
+ normalized = value.to_s.strip
107
+ errors << error(field.to_s, "required", "#{field} must not be empty.") if normalized.empty?
108
+ normalized
109
+ end
110
+ private_class_method :normalize_text
111
+
112
+ def normalize_list(values, field:, allowed:, maximum:, unknown_code:, duplicate_code:, errors:)
113
+ unless values.is_a?(Array)
114
+ errors << error(field, "invalid_type", "#{field} must be an array of strings.")
115
+ return []
116
+ end
117
+
118
+ errors << error(field, "too_many_values", "#{field} may contain at most #{maximum} values.") if values.length > maximum
119
+
120
+ normalized = values.filter_map.with_index do |value, index|
121
+ unless value.is_a?(String) || value.is_a?(Symbol)
122
+ errors << error(field, "invalid_type", "#{field}[#{index}] must be a string.")
123
+ next
124
+ end
125
+
126
+ text = value.to_s.strip.downcase
127
+ if field == "requires" && text.length > MAX_COEFFECT_LENGTH
128
+ errors << error(field, "too_long", "#{field}[#{index}] must be at most #{MAX_COEFFECT_LENGTH} characters.")
129
+ next
130
+ end
131
+
132
+ key = yield(text)
133
+ if key.empty?
134
+ errors << error(field, "empty_value", "#{field}[#{index}] must not be empty.")
135
+ next
136
+ end
137
+ unless allowed.include?(key)
138
+ errors << error(field, unknown_code, "#{field}[#{index}] is not in the allowlist.")
139
+ next
140
+ end
141
+
142
+ key
143
+ end
144
+
145
+ duplicates = normalized.tally.select { |_value, count| count > 1 }.keys
146
+ duplicates.each do |duplicate|
147
+ errors << error(field, duplicate_code, "#{field} contains duplicate value '#{duplicate}'.")
148
+ end
149
+ normalized
150
+ end
151
+ private_class_method :normalize_list
152
+
153
+ def normalize_grants(grants, enable_write_tools, enable_subagent_tools, errors)
154
+ legacy = {
155
+ "write" => enable_write_tools,
156
+ "subagent_spawn" => enable_subagent_tools
157
+ }
158
+ legacy_present = legacy.values.any? { |value| !value.nil? }
159
+
160
+ unless grants.equal?(UNSET)
161
+ errors << error("grants", "conflicting_grant_sources", "Use grants or legacy enable_* flags, not both.") if legacy_present
162
+ return normalize_list(
163
+ grants, field: "grants", allowed: CAPABILITY_GRANTS, maximum: MAX_GRANTS,
164
+ unknown_code: "unknown_grant", duplicate_code: "duplicate_grant", errors: errors
165
+ ) { |value| value }
166
+ end
167
+
168
+ legacy.each_with_object([]) do |(grant, value), normalized|
169
+ next if value.nil?
170
+
171
+ unless [true, false].include?(value)
172
+ errors << error("grants", "invalid_legacy_grant", "Legacy capability flags must be booleans.")
173
+ next
174
+ end
175
+ normalized << grant if value
176
+ end
177
+ end
178
+ private_class_method :normalize_grants
179
+
180
+ def error(field, code, message)
181
+ { "field" => field, "code" => code, "message" => message }
182
+ end
183
+ private_class_method :error
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Orchestrator
5
+ module Subagent
6
+ class UnsatisfiedCoeffectsError < StandardError
7
+ attr_reader :profile_name, :missing
8
+
9
+ def initialize(profile_name, missing)
10
+ @profile_name = profile_name.to_s.freeze
11
+ @missing = missing.map { |name| name.to_s.freeze }.freeze
12
+ super("Subagent archetype '#{@profile_name}' requires unsatisfied coeffects: #{@missing.join(', ')}")
13
+ end
14
+ end
15
+
16
+ class ReservedProfileError < StandardError; end
17
+
18
+ # Per-spawn copy of the profile fields that affect activation and tool
19
+ # visibility. Registry updates replace future snapshots; they never
20
+ # mutate the copy held by an active instance.
21
+ class ProfileSnapshot
22
+ attr_reader :name, :description, :system_prompt, :effective_system_prompt,
23
+ :requires, :grants, :tool_filter, :custom_prompt, :revision,
24
+ :profile_version
25
+
26
+ def self.capture(type_definition, effective_system_prompt: nil)
27
+ legacy_grants = []
28
+ legacy_grants << "write" if type_definition.enable_write_tools
29
+ legacy_grants << "subagent_spawn" if type_definition.enable_subagent_tools
30
+ grants = type_definition.grants.nil? ? legacy_grants : type_definition.grants
31
+
32
+ new(
33
+ name: type_definition.name,
34
+ description: type_definition.description,
35
+ system_prompt: type_definition.system_prompt,
36
+ effective_system_prompt: effective_system_prompt.nil? ? type_definition.system_prompt : effective_system_prompt,
37
+ requires: type_definition.requires || [],
38
+ grants: grants,
39
+ tool_filter: type_definition.tool_filter,
40
+ custom_prompt: type_definition.custom_prompt,
41
+ revision: type_definition.revision
42
+ )
43
+ end
44
+
45
+ def initialize(name:, description:, system_prompt:, effective_system_prompt:, requires:, grants:,
46
+ tool_filter:, custom_prompt:, revision:)
47
+ @name = immutable_string(name)
48
+ @description = immutable_string(description)
49
+ @system_prompt = immutable_string(system_prompt)
50
+ @effective_system_prompt = immutable_string(effective_system_prompt)
51
+ @requires = immutable_list(requires)
52
+ @grants = immutable_list(grants)
53
+ @tool_filter = tool_filter&.freeze
54
+ @custom_prompt = custom_prompt == true
55
+ @revision = revision
56
+ @profile_version = revision || :builtin
57
+ freeze
58
+ end
59
+
60
+ private
61
+
62
+ def immutable_string(value)
63
+ value.nil? ? nil : value.to_s.dup.freeze
64
+ end
65
+
66
+ def immutable_list(values)
67
+ Array(values).map { |value| immutable_string(value) }.freeze
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -104,7 +104,8 @@ module Rubik
104
104
  refute_cancelled_owner!(eff_owner)
105
105
  validate_concurrency!
106
106
  child_ctx = extend_root_context!(:redeploy)
107
- scoped_registry = build_scoped_registry(@type_registry.get(inst.type_name), child_ctx, inst.buffer_manager)
107
+ profile = inst.profile_snapshot || @type_registry.get(inst.type_name)
108
+ scoped_registry = build_scoped_registry(profile, child_ctx, inst.buffer_manager)
108
109
  return false unless inst.redeploy_for!(prompt: prompt_str, max_steps: (max_steps || @default_max_steps).to_i,
109
110
  context: child_ctx, tool_registry: scoped_registry,
110
111
  owner_task: eff_owner)
@@ -77,6 +77,8 @@ module Rubik
77
77
  Rubik::Tools::InvokeSubagentTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
78
78
  when Rubik::Tools::ManageSubagentsTool
79
79
  Rubik::Tools::ManageSubagentsTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
80
+ when Rubik::Tools::DefineSubagentTool
81
+ Rubik::Tools::DefineSubagentTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
80
82
  else
81
83
  tool
82
84
  end