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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +5 -1
- data/CHANGELOG.md +169 -0
- data/README.md +137 -255
- data/bin/rubik +9 -0
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/cli/workspace_trust.rb +78 -0
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/kernel/init.rb +15 -0
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/orchestrator/auto_apply.rb +1 -1
- data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
- data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
- data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
- data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
- data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
- data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
- data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
- data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
- data/lib/rubik/platform/process.rb +152 -3
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/prompt/context_builder.rb +54 -20
- data/lib/rubik/prompt/doctrine.rb +158 -22
- data/lib/rubik/prompt/token_estimator.rb +5 -3
- data/lib/rubik/prompt/tool_teachings.rb +19 -12
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
- data/lib/rubik/sandbox/driver/docker.rb +14 -60
- data/lib/rubik/sandbox/driver/podman.rb +13 -56
- data/lib/rubik/sandbox/driver/process.rb +12 -64
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +116 -7
- data/lib/rubik/storage/store.rb +52 -11
- data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/tools/builtins/storage.rb +8 -5
- data/lib/rubik/tools/builtins/subagent.rb +20 -53
- data/lib/rubik/tools/builtins/synthesis.rb +6 -1
- data/lib/rubik/tools/builtins.rb +1 -0
- data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
- data/lib/rubik/version.rb +1 -1
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|