rubik 0.2.0 → 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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +4 -0
  3. data/CHANGELOG.md +81 -0
  4. data/README.md +137 -268
  5. data/bin/rubik +9 -0
  6. data/lib/rubik/cli/workspace_trust.rb +78 -0
  7. data/lib/rubik/kernel/init.rb +15 -0
  8. data/lib/rubik/orchestrator/auto_apply.rb +1 -1
  9. data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
  10. data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
  11. data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
  12. data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
  13. data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
  14. data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
  15. data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
  16. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
  17. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
  18. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
  19. data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
  20. data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
  21. data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
  22. data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
  23. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
  24. data/lib/rubik/platform/process.rb +78 -1
  25. data/lib/rubik/prompt/context_builder.rb +54 -20
  26. data/lib/rubik/prompt/doctrine.rb +158 -22
  27. data/lib/rubik/prompt/token_estimator.rb +5 -3
  28. data/lib/rubik/prompt/tool_teachings.rb +19 -12
  29. data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
  30. data/lib/rubik/sandbox/driver/docker.rb +14 -60
  31. data/lib/rubik/sandbox/driver/podman.rb +13 -56
  32. data/lib/rubik/sandbox/driver/process.rb +12 -64
  33. data/lib/rubik/security/guard.rb +1 -1
  34. data/lib/rubik/storage/store.rb +52 -11
  35. data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
  36. data/lib/rubik/tools/builtins/storage.rb +8 -5
  37. data/lib/rubik/tools/builtins/subagent.rb +20 -53
  38. data/lib/rubik/tools/builtins/synthesis.rb +6 -1
  39. data/lib/rubik/tools/builtins.rb +1 -0
  40. data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
  41. data/lib/rubik/version.rb +1 -1
  42. metadata +10 -1
@@ -18,7 +18,6 @@ module Rubik
18
18
  end
19
19
 
20
20
  def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0")
21
- start_time = Time.now
22
21
  image = @options[:image] || DEFAULT_IMAGE
23
22
  workspace = File.expand_path(chdir)
24
23
  container_name = "rubik-box-#{SecureRandom.hex(6)}"
@@ -40,70 +39,25 @@ module Rubik
40
39
  docker_args.push("--network", "none") unless allow_network
41
40
  docker_args.push(image, "sh", "-c", cmd)
42
41
 
43
- stdout_str = ""
44
- stderr_str = ""
45
- exit_code = 127
46
- wait_thr_ref = nil
47
-
48
- begin
49
- Timeout.timeout(timeout) do
50
- Open3.popen3(*docker_args) do |stdin, stdout, stderr, wait_thr|
51
- wait_thr_ref = wait_thr
52
- begin
53
- stdin.close
54
- rescue StandardError
55
- nil
56
- end
57
- t_out = Thread.new do
58
- stdout.read
59
- rescue StandardError
60
- ""
61
- end
62
- t_err = Thread.new do
63
- stderr.read
64
- rescue StandardError
65
- ""
66
- end
67
- begin
68
- stdout_str = t_out.value.to_s
69
- stderr_str = t_err.value.to_s
70
- status = wait_thr.value
71
- exit_code = status.exitstatus || (status.signaled? ? (128 + (status.termsig || 1)) : 1)
72
- ensure
73
- begin
74
- t_out.kill
75
- rescue StandardError
76
- nil
77
- end
78
- begin
79
- t_err.kill
80
- rescue StandardError
81
- nil
82
- end
83
- end
84
- end
85
- end
86
- rescue Timeout::Error
87
- Rubik::Platform.kill_tree(wait_thr_ref.pid) if wait_thr_ref.respond_to?(:pid) && wait_thr_ref.pid
88
- system("docker rm -f #{container_name} > #{Rubik::Platform.dev_null} 2>&1")
89
- stderr_str = "Docker sandbox execution timed out after #{timeout} seconds"
90
- exit_code = 124
91
- rescue StandardError => e
92
- stderr_str = "Docker sandbox error: #{e.message}"
93
- exit_code = 1
42
+ res = begin
43
+ Rubik::Platform.popen_capture(
44
+ *docker_args,
45
+ timeout: timeout,
46
+ chdir: workspace,
47
+ timeout_message: "Docker sandbox execution timed out after #{timeout} seconds",
48
+ error_message: "Docker sandbox error"
49
+ )
94
50
  ensure
95
- Rubik::Platform.kill_tree(wait_thr_ref.pid) if wait_thr_ref.respond_to?(:pid) && wait_thr_ref.pid
96
51
  system("docker rm -f #{container_name} > #{Rubik::Platform.dev_null} 2>&1")
97
52
  end
98
53
 
99
- duration = Time.now - start_time
100
54
  {
101
- ok: exit_code.zero?,
102
- exit_code: exit_code,
103
- stdout: stdout_str,
104
- stderr: stderr_str,
105
- output: [stdout_str, stderr_str].reject(&:empty?).join("\n"),
106
- duration: duration.round(4),
55
+ ok: res[:ok],
56
+ exit_code: res[:exit_code],
57
+ stdout: res[:stdout],
58
+ stderr: res[:stderr],
59
+ output: res[:output],
60
+ duration: res[:duration],
107
61
  driver: "docker"
108
62
  }
109
63
  end
@@ -16,7 +16,6 @@ module Rubik
16
16
  end
17
17
 
18
18
  def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0")
19
- start_time = Time.now
20
19
  image = @options[:image] || DEFAULT_IMAGE
21
20
  workspace = File.expand_path(chdir)
22
21
 
@@ -32,63 +31,21 @@ module Rubik
32
31
  podman_args.push("--network", "none") unless allow_network
33
32
  podman_args.push(image, "sh", "-c", cmd)
34
33
 
35
- stdout_str = ""
36
- stderr_str = ""
37
- exit_code = 127
34
+ res = Rubik::Platform.popen_capture(
35
+ *podman_args,
36
+ timeout: timeout,
37
+ chdir: workspace,
38
+ timeout_message: "Podman sandbox execution timed out after #{timeout} seconds",
39
+ error_message: "Podman sandbox error"
40
+ )
38
41
 
39
- begin
40
- Timeout.timeout(timeout) do
41
- Open3.popen3(*podman_args) do |stdin, stdout, stderr, wait_thr|
42
- begin
43
- stdin.close
44
- rescue
45
- nil
46
- end
47
- t_out = Thread.new do
48
- stdout.read
49
- rescue
50
- ""
51
- end
52
- t_err = Thread.new do
53
- stderr.read
54
- rescue
55
- ""
56
- end
57
- begin
58
- stdout_str = t_out.value.to_s
59
- stderr_str = t_err.value.to_s
60
- status = wait_thr.value
61
- exit_code = status.exitstatus || (status.signaled? ? (128 + (status.termsig || 1)) : 1)
62
- ensure
63
- begin
64
- t_out.kill
65
- rescue
66
- nil
67
- end
68
- begin
69
- t_err.kill
70
- rescue
71
- nil
72
- end
73
- end
74
- end
75
- end
76
- rescue Timeout::Error
77
- stderr_str = "Podman sandbox execution timed out after #{timeout} seconds"
78
- exit_code = 124
79
- rescue StandardError => e
80
- stderr_str = "Podman sandbox error: #{e.message}"
81
- exit_code = 1
82
- end
83
-
84
- duration = Time.now - start_time
85
42
  {
86
- ok: exit_code.zero?,
87
- exit_code: exit_code,
88
- stdout: stdout_str,
89
- stderr: stderr_str,
90
- output: [stdout_str, stderr_str].reject(&:empty?).join("\n"),
91
- duration: duration.round(4),
43
+ ok: res[:ok],
44
+ exit_code: res[:exit_code],
45
+ stdout: res[:stdout],
46
+ stderr: res[:stderr],
47
+ output: res[:output],
48
+ duration: res[:duration],
92
49
  driver: "podman"
93
50
  }
94
51
  end
@@ -15,80 +15,28 @@ module Rubik
15
15
  end
16
16
 
17
17
  def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0") # rubocop:disable Lint/UnusedMethodArgument -- sürücü arayüzü kwarg'leri korunuyor
18
- start_time = Time.now
19
- stdout_str = ""
20
- stderr_str = ""
21
- exit_code = 127
22
-
23
- # Explicit-argv spawn (Plan 55 D-1): never leave interpreter choice
24
- # to Open3's string form — on Windows that silently means cmd.exe.
25
18
  resolved = ShellResolver.resolve(cmd, preference: @options[:shell] || "auto")
26
-
27
19
  env = {}
28
- # Direct host execution does NOT inject misleading dead proxy variables (127.0.0.1:9999).
29
- # Hardware/namespace network isolation is only provided when a true sandbox
30
- # (Bubblewrap, Docker, Podman) is enabled.
31
20
 
32
- pid = nil
33
- begin
34
- Timeout.timeout(timeout) do
35
- Open3.popen3(env, *spawn_args_for(resolved, cmd), chdir: chdir) do |stdin, stdout, stderr, wait_thr|
36
- pid = wait_thr.pid
37
- begin
38
- stdin.close
39
- rescue
40
- nil
41
- end
42
- t_out = Thread.new do
43
- stdout.read
44
- rescue
45
- ""
46
- end
47
- t_err = Thread.new do
48
- stderr.read
49
- rescue
50
- ""
51
- end
52
- begin
53
- stdout_str = t_out.value.to_s
54
- stderr_str = t_err.value.to_s
55
- status = wait_thr.value
56
- exit_code = status.exitstatus || (status.signaled? ? (128 + (status.termsig || 1)) : 1)
57
- ensure
58
- begin
59
- t_out.kill
60
- rescue
61
- nil
62
- end
63
- begin
64
- t_err.kill
65
- rescue
66
- nil
67
- end
68
- end
69
- end
70
- end
71
- rescue Timeout::Error
72
- Rubik::Platform.kill_tree(pid) if pid
73
- stderr_str = "Execution timed out after #{timeout} seconds"
74
- exit_code = 124
75
- rescue StandardError => e
76
- stderr_str = "Execution error: #{e.message}"
77
- exit_code = 1
78
- end
21
+ res = Rubik::Platform.popen_capture(
22
+ env,
23
+ *spawn_args_for(resolved, cmd),
24
+ timeout: timeout,
25
+ chdir: chdir
26
+ )
79
27
 
80
- duration = Time.now - start_time
81
- stderr_out = resolved[:note] ? "#{resolved[:note]}\n#{stderr_str}" : stderr_str
28
+ stderr_out = resolved[:note] ? "#{resolved[:note]}\n#{res[:stderr]}" : res[:stderr]
82
29
  require_relative "../../security/redactor" unless defined?(Rubik::Security::Redactor)
83
- clean_stdout = Rubik::Security::Redactor.redact(stdout_str)
30
+ clean_stdout = Rubik::Security::Redactor.redact(res[:stdout])
84
31
  clean_stderr = Rubik::Security::Redactor.redact(stderr_out)
32
+
85
33
  {
86
- ok: exit_code.zero?,
87
- exit_code: exit_code,
34
+ ok: res[:ok],
35
+ exit_code: res[:exit_code],
88
36
  stdout: clean_stdout,
89
37
  stderr: clean_stderr,
90
38
  output: [clean_stdout, clean_stderr].reject(&:empty?).join("\n"),
91
- duration: duration.round(4),
39
+ duration: res[:duration],
92
40
  driver: "process",
93
41
  shell: resolved[:shell]
94
42
  }
@@ -808,7 +808,7 @@ module Rubik
808
808
 
809
809
  # ── Synthesis capability table (Candidate 07) ───────────────────────
810
810
  # The ONE declaration point for everything the synthesis AST walk
811
- # denies (ARCHITECTURE.md §2 "single declaration point" doctrine; §8.3
811
+ # denies (docs/explanation/architecture.md §2 "single declaration point" doctrine; §8.3
812
812
  # "the seam is enforced where it is declared"). Each row is a capability
813
813
  # a synthesized tool must never exercise; layer fields say WHERE the
814
814
  # denial bites:
@@ -178,7 +178,8 @@ module Rubik
178
178
  next if records.empty?
179
179
 
180
180
  haystacks = haystacks_for(t, records)
181
- { records: records, haystacks: haystacks,
181
+ folded_fields = folded_fields_for(t, records)
182
+ { records: records, haystacks: haystacks, folded_fields: folded_fields,
182
183
  has: tokens.map { |token| haystacks.map { |h| h.include?(token) } } }
183
184
  end
184
185
  return [] if corpus.empty?
@@ -194,14 +195,17 @@ module Rubik
194
195
  df = dfs[ti]
195
196
  next 0.0 if df.zero?
196
197
 
197
- tf = c[:haystacks][i].scan(token).size
198
+ # PERF: count_occurrences using String#index avoids Array allocation of String#scan
199
+ tf = count_occurrences(c[:haystacks][i], token)
198
200
  next 0.0 if tf.zero?
199
201
 
200
202
  idf = Math.log(((total - df + 0.5) / (df + 0.5)) + 1)
201
203
  dl = c[:haystacks][i].bytesize
202
204
  idf * (tf * (BM25_K1 + 1)) / (tf + (BM25_K1 * (1 - BM25_B + (BM25_B * dl / avgdl))))
203
205
  end
204
- [score + field_tier_bonus(tokens, c[:records][i]), c[:records][i]]
206
+ # PERF: pass cached folded title and tags to avoid String folding & array join allocations per score
207
+ f_title, f_tags = c[:folded_fields][i]
208
+ [score + field_tier_bonus(tokens, f_title, f_tags), c[:records][i]]
205
209
  end
206
210
  end
207
211
  ordered = scored.sort do |(score_a, rec_a), (score_b, rec_b)|
@@ -224,22 +228,44 @@ module Rubik
224
228
  end
225
229
  end
226
230
 
231
+ # PERF: Zero-allocation string occurrence counting via String#index loop.
232
+ # Replaces String#scan(token).size to avoid array allocation per matched term.
233
+ def count_occurrences(haystack, token)
234
+ return 0 if token.empty?
235
+
236
+ count = 0
237
+ offset = 0
238
+ token_len = token.length
239
+ while (idx = haystack.index(token, offset))
240
+ count += 1
241
+ offset = idx + token_len
242
+ end
243
+ count
244
+ end
245
+
227
246
  # Terim başına alan izi: katlanmış başlıkta geçen terim +1.0,
228
247
  # yalnız katlanmış etiketlerde geçen +0.5, gövde izi 0 — bkz.
229
248
  # TITLE_TIER_BONUS/TAGS_TIER_BONUS. Yalnız AND eşiğini geçen
230
249
  # kayıtlar için hesaplanır (ek tarama yok).
231
- def field_tier_bonus(tokens, record)
232
- title = Folding.fold(record["title"])
233
- tags = Folding.fold(Array(record["tags"]).join("\n"))
234
- tokens.sum do |token|
250
+ # PERF: accepts pre-folded title & tags strings or fallback record for backward compatibility.
251
+ def field_tier_bonus(tokens, title_or_record, tags = nil)
252
+ if tags.nil? && title_or_record.is_a?(Hash)
253
+ title = Folding.fold(title_or_record["title"])
254
+ tags = Folding.fold(Array(title_or_record["tags"]).join("\n"))
255
+ else
256
+ title = title_or_record.to_s
257
+ tags = tags.to_s
258
+ end
259
+
260
+ sum = 0.0
261
+ tokens.each do |token|
235
262
  if title.include?(token)
236
- TITLE_TIER_BONUS
263
+ sum += TITLE_TIER_BONUS
237
264
  elsif tags.include?(token)
238
- TAGS_TIER_BONUS
239
- else
240
- 0.0
265
+ sum += TAGS_TIER_BONUS
241
266
  end
242
267
  end
268
+ sum
243
269
  end
244
270
 
245
271
  def set_ref(name, id)
@@ -430,6 +456,21 @@ module Rubik
430
456
  entry[:haystacks] ||= records.map { |r| haystack_of(r) }.freeze
431
457
  end
432
458
 
459
+ # PERF: Memoize folded title and tags per record in @records_cache
460
+ # to avoid re-folding and array joining on every search_ranked query.
461
+ def folded_fields_for(type, records)
462
+ entry = @records_cache[type]
463
+ unless entry
464
+ return records.map do |r|
465
+ [Folding.fold(r["title"]), Folding.fold(Array(r["tags"]).join("\n"))]
466
+ end
467
+ end
468
+
469
+ entry[:folded_fields] ||= records.map do |r|
470
+ [Folding.fold(r["title"]), Folding.fold(Array(r["tags"]).join("\n"))].freeze
471
+ end.freeze
472
+ end
473
+
433
474
  def haystack_of(record)
434
475
  Folding.fold("#{record['title']}\n#{record['body']}\n#{Array(record['tags']).join("\n")}")
435
476
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "../output"
5
+ require_relative "../../orchestrator/subagent/profile_contract"
6
+ require_relative "subagent"
7
+
8
+ module Rubik
9
+ module Tools
10
+ # Tool for dynamically defining a new subagent archetype at runtime (Plan 94).
11
+ class DefineSubagentTool < BaseTool
12
+ include SubagentToolHelpers
13
+
14
+ DECLARATION = Declaration.new(
15
+ name: "define_subagent", aliases: ["register_subagent_type", "create_subagent_type"],
16
+ description: "Defines a new archetype of subagent that can be invoked via invoke_subagent.",
17
+ risk: :execute, subagent_spawn: true, maestro_blocked: false, # self-evolution plane (TOOLS.md maestro policy)
18
+ params_schema: {
19
+ type: "object",
20
+ properties: {
21
+ name: { type: "string", maxLength: Rubik::Orchestrator::Subagent::ProfileContract::FIELD_LIMITS.fetch(:name),
22
+ description: "Unique name for archetype" },
23
+ description: { type: "string", maxLength: Rubik::Orchestrator::Subagent::ProfileContract::FIELD_LIMITS.fetch(:description),
24
+ description: "Human-readable description" },
25
+ system_prompt: { type: "string", maxLength: Rubik::Orchestrator::Subagent::ProfileContract::FIELD_LIMITS.fetch(:system_prompt),
26
+ description: "Detailed system prompt" },
27
+ requires: { type: "array", maxItems: Rubik::Orchestrator::Subagent::ProfileContract::MAX_COEFFECTS,
28
+ items: { type: "string", maxLength: Rubik::Orchestrator::Subagent::ProfileContract::MAX_COEFFECT_LENGTH },
29
+ description: "Runtime coeffect keys required by this profile" },
30
+ grants: { type: "array", maxItems: Rubik::Orchestrator::Subagent::ProfileContract::MAX_GRANTS,
31
+ items: { type: "string", enum: Rubik::Orchestrator::Subagent::ProfileContract::CAPABILITY_GRANTS },
32
+ description: "Optional capabilities: write and subagent_spawn (default: none)" },
33
+ replace: { type: "boolean", description: "Explicitly allow replacing an existing dynamic archetype (default: false)" }
34
+ },
35
+ required: ["name", "description", "system_prompt"]
36
+ }
37
+ )
38
+
39
+ def initialize(supervisor: nil, runtime_context: nil)
40
+ super(DECLARATION)
41
+ @supervisor = supervisor
42
+ @runtime_context = runtime_context
43
+ end
44
+
45
+ def execute(args = {})
46
+ sup = resolve_supervisor(@supervisor, @runtime_context)
47
+ return ToolOutput.error("SubagentSupervisor is not available in runtime context.") unless sup
48
+
49
+ argument = ->(key) { args.key?(key.to_s) ? args[key.to_s] : args[key.to_sym] }
50
+ provided = ->(key) { args.key?(key.to_s) || args.key?(key.to_sym) }
51
+
52
+ owner_ctx = @runtime_context || (sup.respond_to?(:root_context) ? sup.root_context : nil)
53
+ kwargs = {
54
+ name: argument.call(:name), description: argument.call(:description),
55
+ system_prompt: argument.call(:system_prompt), replace: argument.call(:replace) || false
56
+ }
57
+ [:requires, :grants, :enable_write_tools, :enable_subagent_tools].each do |key|
58
+ kwargs[key] = argument.call(key) if provided.call(key)
59
+ end
60
+ kwargs[:context] = owner_ctx if owner_ctx
61
+
62
+ type_def = sup.type_registry.define_custom(**kwargs)
63
+ "Subagent archetype '#{type_def.name}' successfully defined and available for invoke_subagent."
64
+ rescue ArgumentError => e
65
+ ToolOutput.error(e.message)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -24,9 +24,12 @@ module Rubik
24
24
  DECLARATION = Declaration.new(
25
25
  name: "storage", aliases: ["rubik_storage"],
26
26
  description: "Central rubik://storage database for goals, plans, memory, wiki, transcripts, reviews, " \
27
- "and notes. Actions: 'put' (create/update; returns id rubik://storage URI), 'get', 'list', " \
28
- "'search', 'set_ref' (alias), 'delete'. Keep agent workfiles HERE — not in the worktree. " \
29
- "Read back any entity with read_file path 'rubik://storage/<type>/<id>'.",
27
+ "and notes. Actions: 'put' (create/update work artifacts — goal, plan, review, note; " \
28
+ "returns id and rubik://storage URI), 'get', 'list', 'search', 'set_ref' (alias), 'delete'. " \
29
+ "When `vakanuvis` is in your toolbelt, write durable `memory` via `vakanuvis(action: 'record')` " \
30
+ "and compiled `wiki` via `vakanuvis(action: 'digest')`, reserving direct `put` on `memory`/`wiki` " \
31
+ "for roles or sessions where `vakanuvis` is unavailable. Keep agent workfiles HERE — not in " \
32
+ "the worktree. Read back any entity with read_file path 'rubik://storage/<type>/<id>'.",
30
33
  risk: :write, archetypes: [:coder], write_grant: true,
31
34
  maestro_blocked: false, # storage plane stays direct at root (TOOLS.md maestro policy)
32
35
  params_schema: {
@@ -35,12 +38,12 @@ module Rubik
35
38
  action: {
36
39
  type: "string",
37
40
  enum: ["put", "get", "list", "search", "set_ref", "delete"],
38
- description: "Operation (default: 'list')."
41
+ description: "Operation (default: 'list'). Use 'put' for goal/plan/review/note workfiles (or fallback writes when vakanuvis is unavailable)."
39
42
  },
40
43
  type: {
41
44
  type: "string",
42
45
  enum: Rubik::Storage::Store::TYPES,
43
- description: "Entity type: goal, plan, memory, wiki, transcript, review, or note."
46
+ description: "Entity type: goal, plan, memory, wiki, transcript, review, or note (prefer vakanuvis record/digest for memory/wiki writes when available)."
44
47
  },
45
48
  title: { type: "string", description: "Entity title (required for put)." },
46
49
  body: { type: "string", description: "Entity body (markdown text)." },
@@ -101,20 +101,32 @@ module Rubik
101
101
  return ToolOutput.error("Failed to invoke subagent(s): #{e.message}")
102
102
  end
103
103
 
104
- launched, queued = results.partition { |outcome, _obj| outcome == :launched }
105
- lines = ["Launched #{launched.size} background subagent(s):"] +
106
- launched.map do |_o, i|
107
- " • [#{i.id}] '#{i.role}' (#{i.type_name}) - #{i.state.to_s.upcase} | transcript: #{i.transcript_ref}"
108
- end
104
+ launched, remaining = results.partition { |outcome, _obj| outcome == :launched }
105
+ queued, failed = remaining.partition { |outcome, _obj| outcome == :queued }
106
+ lines = launched.empty? ? ["No subagents launched."] : ["Launched #{launched.size} background subagent(s):"]
107
+ lines.concat(launched.map do |_o, i|
108
+ " • [#{i.id}] '#{i.role}' (#{i.type_name}) - #{i.state.to_s.upcase} | transcript: #{i.transcript_ref}"
109
+ end)
109
110
  unless queued.empty?
110
111
  rows = queued.map { |_o, s| " • [#{s.id}] '#{s.role}' - QUEUED (waiting on: #{s.depends_on.join(', ')})" }
111
112
  lines << ("\nQueued #{queued.size} subagent(s) on unmet dependencies (no slot, no cost until released): " + rows.join("\n"))
112
113
  end
114
+ unless failed.empty?
115
+ rows = failed.map do |_outcome, spec|
116
+ reason = spec.respond_to?(:last_error) && spec.last_error
117
+ reason = "cancelled before launch" if reason.to_s.empty?
118
+ " • [#{spec.id}] '#{spec.role}' - FAILED TO START (#{reason})"
119
+ end
120
+ lines << ("\n#{failed.size} subagent(s) failed to start: " + rows.join("\n"))
121
+ end
113
122
  all_ids = (launched + queued).map { |_o, obj| obj.id }
114
123
 
115
- lines << "\nSubagents run asynchronously in the background. Conclude your turn to yield the prompt to the user, or call " \
116
- "#{Rubik::Orchestrator::Subagent::Notices.wait_hint(all_ids)} only if you strictly need results in this turn (queued ids block until the chain settles)."
117
- lines.join("\n")
124
+ unless all_ids.empty?
125
+ lines << "\nSubagents run asynchronously in the background. Conclude your turn to yield the prompt to the user, or call " \
126
+ "#{Rubik::Orchestrator::Subagent::Notices.wait_hint(all_ids)} only if you strictly need results in this turn (queued ids block until the chain settles)."
127
+ end
128
+ result = lines.join("\n")
129
+ failed.empty? ? result : ToolOutput.error(result)
118
130
  end
119
131
 
120
132
  private
@@ -315,50 +327,5 @@ module Rubik
315
327
  "Result: #{res[0, 1500]}... [truncated — full result: #{more}]"
316
328
  end
317
329
  end
318
-
319
- # Tool for dynamically defining a new subagent archetype at runtime (Plan 94).
320
- class DefineSubagentTool < BaseTool
321
- include SubagentToolHelpers
322
-
323
- DECLARATION = Declaration.new(
324
- name: "define_subagent", aliases: ["register_subagent_type", "create_subagent_type"],
325
- description: "Defines a new archetype of subagent that can be invoked via invoke_subagent.",
326
- risk: :execute, subagent_spawn: true, maestro_blocked: false, # self-evolution plane (TOOLS.md maestro policy)
327
- params_schema: {
328
- type: "object",
329
- properties: {
330
- name: { type: "string", description: "Unique name for archetype" },
331
- description: { type: "string", description: "Human-readable description" },
332
- system_prompt: { type: "string", description: "Detailed system prompt" },
333
- enable_write_tools: { type: "boolean", description: "Equip subagent with write tools (default: false)" },
334
- enable_subagent_tools: { type: "boolean", description: "Allow subagent to spawn subagents (default: false)" }
335
- },
336
- required: ["name", "description", "system_prompt"]
337
- }
338
- )
339
- def initialize(supervisor: nil, runtime_context: nil)
340
- super(DECLARATION)
341
- @supervisor = supervisor
342
- @runtime_context = runtime_context
343
- end
344
-
345
- def execute(args = {})
346
- sup = resolve_supervisor(@supervisor, @runtime_context)
347
- return ToolOutput.error("SubagentSupervisor is not available in runtime context.") unless sup
348
-
349
- name = (args["name"] || args[:name]).to_s.strip
350
- desc = (args["description"] || args[:description]).to_s.strip
351
- prompt = (args["system_prompt"] || args[:system_prompt]).to_s.strip
352
- return ToolOutput.error("Parameters 'name', 'description', and 'system_prompt' are required.") if name.empty? || desc.empty? || prompt.empty?
353
-
354
- type_def = sup.type_registry.define_custom(
355
- name: name, description: desc, system_prompt: prompt,
356
- enable_write_tools: args["enable_write_tools"] || args[:enable_write_tools] || false,
357
- enable_subagent_tools: args["enable_subagent_tools"] || args[:enable_subagent_tools] || false
358
- )
359
-
360
- "Subagent archetype '#{type_def.name}' successfully defined and available for invoke_subagent."
361
- end
362
- end
363
330
  end
364
331
  end
@@ -176,7 +176,12 @@ module Rubik
176
176
  description: "Storage scope: session (in-memory only), project (.rubik/tools), global (~/.rubik/tools)" },
177
177
  risk: { type: "string", enum: ["read", "write", "execute"], default: "execute",
178
178
  description: "Risk level of tool (optional; read/write/execute, default: execute)" },
179
- params_schema: { type: "object", description: "JSON Schema definition of the tool parameters" },
179
+ params_schema: {
180
+ type: "object",
181
+ description: "JSON Schema definition of the tool parameters (provide properties/required when the tool " \
182
+ "accepts arguments; defaults to an empty object schema `{ type: 'object', properties: {} }` " \
183
+ "for tools that take no arguments)"
184
+ },
180
185
  code: { type: "string", description: "Ruby implementation of tool handler: ->(args) { ... } or class extending Rubik::Tools::BaseTool" },
181
186
  test_args: { type: "object", description: "Optional test arguments to perform pre-flight verification in an isolated sandbox" }
182
187
  },
@@ -20,6 +20,7 @@ require_relative "builtins/runtime"
20
20
  require_relative "builtins/lifecycle"
21
21
  require_relative "builtins/interactive"
22
22
  require_relative "builtins/subagent"
23
+ require_relative "builtins/define_subagent"
23
24
  require_relative "builtins/send_message"
24
25
  require_relative "builtins/subagent_lifecycle"
25
26
  require_relative "builtins/dmesg"
@@ -119,7 +119,8 @@ module Rubik
119
119
 
120
120
  lines << "💡 Need a specialized capability?"
121
121
  lines << " You can dynamically create and register your own custom tool at runtime using:"
122
- lines << " `synthesize_tool(name: '#{tool_name.to_s.downcase}', description: '...', code: '->(args) { ... }')`"
122
+ lines << " `synthesize_tool(name: '#{tool_name.to_s.downcase}', description: '...', " \
123
+ "params_schema: { type: 'object', properties: { ... } }, code: '->(args) { ... }')`"
123
124
 
124
125
  lines.join("\n")
125
126
  end
data/lib/rubik/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end