rubik 0.1.5 → 0.1.6

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +42 -0
  4. data/lib/rubik/coeffects.rb +14 -15
  5. data/lib/rubik/config/defaults.rb +9 -6
  6. data/lib/rubik/config.rb +8 -0
  7. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  8. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  9. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  10. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  11. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  12. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  13. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  14. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  15. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  16. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  17. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  18. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  19. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  20. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  21. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  22. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  23. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  24. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  25. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  26. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  27. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  28. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  29. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  30. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  31. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  32. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  33. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  34. data/lib/rubik/prompt/doctrine.rb +5 -3
  35. data/lib/rubik/repl/commands/subagents.rb +2 -0
  36. data/lib/rubik/repl/input_execution.rb +42 -0
  37. data/lib/rubik/repl/input_loop.rb +57 -7
  38. data/lib/rubik/repl/prompt_grace.rb +103 -0
  39. data/lib/rubik/security/redactor.rb +7 -7
  40. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  41. data/lib/rubik/tools/builtins/exec.rb +76 -28
  42. data/lib/rubik/tools/builtins/files.rb +6 -1
  43. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  44. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  45. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  46. data/lib/rubik/tools/truncator.rb +19 -2
  47. data/lib/rubik/tools/uri_resolver.rb +84 -20
  48. data/lib/rubik/uris.rb +11 -0
  49. data/lib/rubik/version.rb +1 -1
  50. metadata +15 -1
@@ -150,51 +150,99 @@ module Rubik
150
150
  def execute(args = {})
151
151
  cmd = (args["command"] || args[:command]).to_s.strip
152
152
  background = args["background"] || args[:background]
153
- if background
154
- task_mgr = @task_manager || (@runtime_context.respond_to?(:get) ? Rubik::Coeffects.get(@runtime_context, Rubik::Coeffects::TASK_MANAGER) : nil)
155
- return ToolOutput.error("TaskManager is not available to run commands in background.") unless task_mgr
153
+ return execute_background(cmd) if background
156
154
 
157
- @guard.verify_command_safety!(cmd)
158
- task = task_mgr.start(cmd, chdir: @root_dir, ctx: @runtime_context)
159
- return "Command dispatched to background task [#{task.id}] (PID #{task.pid}): #{cmd}\nUse `manage_task` with action 'status' or 'logs' to inspect."
155
+ timeout = effective_timeout(args)
156
+ @guard.verify_command_safety!(cmd)
157
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
+ res = @runner.run(cmd, timeout: timeout, chdir: @root_dir)
159
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
160
+ command_result(cmd, res, duration_ms: duration_ms)
161
+ rescue Rubik::Security::BlockedWriteError => e
162
+ # Authorization denial (issue #127): nothing executed, so no process
163
+ # exit code is fabricated — the metadata says denied / not executed.
164
+ # The prefix stays first for display consumers (StepExecutor::ERROR_PREFIXES).
165
+ Rubik::Tools::Result.new(
166
+ tool_name: "run_command", status: :error,
167
+ output: Rubik::Tools::RunCommandTool.blocked_guidance(e),
168
+ error: "command denied by security policy (not executed)",
169
+ exit_code: nil, metadata: { "executed" => false, "denied" => "authorization" }
170
+ )
171
+ rescue StandardError => e
172
+ Rubik::Tools::Result.new(
173
+ tool_name: "run_command", status: :error,
174
+ output: "Command error: #{e.message}", error: e.message,
175
+ exit_code: nil, metadata: { "executed" => false }
176
+ )
177
+ end
178
+
179
+ # Stable public seam (RunCommandTool#execute, ManageTaskTool, tests and
180
+ # dev/review_fix_check call it). The renderer lives in the security
181
+ # domain next to the error it renders: Rubik::Security::BlockedGuidance
182
+ # — also keeps this file under the builtin line budget.
183
+ def self.blocked_guidance(err)
184
+ Rubik::Security::BlockedGuidance.render(err)
185
+ end
186
+
187
+ private
188
+
189
+ def execute_background(cmd)
190
+ task_mgr = @task_manager ||
191
+ (@runtime_context.respond_to?(:get) ? Rubik::Coeffects.get(@runtime_context, Rubik::Coeffects::TASK_MANAGER) : nil)
192
+ if task_mgr.nil?
193
+ return Rubik::Tools::Result.new(tool_name: "run_command", status: :error,
194
+ output: "TaskManager is not available to run commands in background.",
195
+ error: "task manager unavailable",
196
+ metadata: { "executed" => false, "background" => true })
160
197
  end
161
198
 
199
+ @guard.verify_command_safety!(cmd)
200
+ task = task_mgr.start(cmd, chdir: @root_dir, ctx: @runtime_context)
201
+ text = "Command dispatched to background task [#{task.id}] (PID #{task.pid}): #{cmd}\n" \
202
+ "Use `manage_task` with action 'status' or 'logs' to inspect."
203
+ Rubik::Tools::Result.new(tool_name: "run_command", status: :ok, output: text, exit_code: nil,
204
+ metadata: { "executed" => true, "background" => true, "task_id" => task.id })
205
+ end
206
+
207
+ def effective_timeout(args)
162
208
  # Timeout contract: runners expose their config via a public `config`
163
209
  # reader (Sandbox::Runner does via attr_reader). Never reach into an
164
210
  # arbitrary object with instance_variable_get — that couples us to an
165
211
  # ivar name we do not own and silently falls back if it is ever renamed.
166
212
  cfg = @runner.config if @runner.respond_to?(:config)
167
213
  cfg_timeout = cfg&.fetch("tools.timeout_seconds", 30) || 30
168
- timeout = (args["timeout"] || args[:timeout] || cfg_timeout).to_i.clamp(1, 600)
214
+ (args["timeout"] || args[:timeout] || cfg_timeout).to_i.clamp(1, 600)
215
+ end
169
216
 
170
- @guard.verify_command_safety!(cmd)
171
- res = @runner.run(cmd, timeout: timeout, chdir: @root_dir)
172
- status = res[:ok] ? "OK" : "EXIT #{res[:exit_code]}"
173
- # Shell tag (Plan 55 D-5) teaches the model which interpreter ran the
174
- # command; drivers that resolve no shell simply omit it.
217
+ # The structured command outcome (issue #127): the display text keeps
218
+ # the historical [OK]/[EXIT n] prefix and diagnostics, while the machine
219
+ # fields (status / exit_code / truncated / metadata) ride the Result
220
+ # contract through BOTH dispatch paths — no display-text parsing.
221
+ def command_result(cmd, res, duration_ms:)
222
+ exit_code = res[:exit_code]
223
+ timed_out = exit_code.to_i == 124
224
+ status_text = res[:ok] ? "OK" : "EXIT #{exit_code}"
175
225
  shell_tag = res[:shell] ? " (#{res[:shell]})" : ""
176
- out = "[#{status}]#{shell_tag} #{res[:output]}"
226
+ out = "[#{status_text}]#{shell_tag} #{res[:output]}"
177
227
  out += "\n#{res[:isolation]}" if res[:isolation]
178
228
 
229
+ diagnostic = nil
179
230
  unless res[:ok]
180
231
  require_relative "../../runtime/diagnostic_engine" unless defined?(Rubik::Runtime::DiagnosticEngine)
181
- diag = Rubik::Runtime::DiagnosticEngine.analyze_process_result(res, cmd: cmd)
182
- out += "\n\n#{diag.format_for_llm}" if diag
232
+ diagnostic = Rubik::Runtime::DiagnosticEngine.analyze_process_result(res, cmd: cmd)
233
+ out += "\n\n#{diagnostic.format_for_llm}" if diagnostic
183
234
  end
184
- out
185
- rescue Rubik::Security::BlockedWriteError => e
186
- # Prefix stays first: StepExecutor::ERROR_PREFIXES classifies by start_with?.
187
- ToolOutput.error(Rubik::Tools::RunCommandTool.blocked_guidance(e))
188
- rescue StandardError => e
189
- ToolOutput.error("Command error: #{e.message}")
190
- end
191
235
 
192
- # Stable public seam (RunCommandTool#execute, ManageTaskTool, tests and
193
- # dev/review_fix_check call it). The renderer lives in the security
194
- # domain next to the error it renders: Rubik::Security::BlockedGuidance
195
- # — also keeps this file under the builtin line budget.
196
- def self.blocked_guidance(err)
197
- Rubik::Security::BlockedGuidance.render(err)
236
+ timeout_suffix = timed_out ? " (timed out)" : ""
237
+ error_message = res[:ok] ? nil : "command exited with status #{exit_code}#{timeout_suffix}"
238
+
239
+ Rubik::Tools::Result.new(
240
+ tool_name: "run_command", status: res[:ok] ? :ok : :error, output: out,
241
+ error: error_message, exit_code: exit_code, duration_ms: duration_ms,
242
+ metadata: { "executed" => true, "timed_out" => timed_out,
243
+ "diagnostic_category" => diagnostic && diagnostic.category.to_s,
244
+ "shell" => res[:shell].to_s, "driver" => res[:driver].to_s }.reject { |_k, v| v.nil? || v == "" }
245
+ )
198
246
  end
199
247
  end
200
248
 
@@ -19,7 +19,12 @@ module Rubik
19
19
  type: "object",
20
20
  properties: {
21
21
  path: { type: "string", description: "File path, or virtual rubik:// URI: 'rubik://storage/<project>/<type>/<id>' " \
22
- "(browse from 'rubik://storage/index'), 'rubik://goal/state', 'rubik://subagent/<id>/transcript'" },
22
+ "(browse from 'rubik://storage/index'), 'rubik://goal/state', " \
23
+ "'rubik://subagent/<storage-id>/transcript' (the UNIQUE storage id from " \
24
+ "manage_subagents status 'transcript_ref' or wait rows — live while running, " \
25
+ "persisted after the first save; a display id like 'subagent-2' or a disk path " \
26
+ "is NOT a transcript address), 'rubik://subagent/<storage-id>/result[/<run>]' " \
27
+ "(retained completed results)" },
23
28
  offset: { type: "integer", description: "1-based line number to start reading from (optional)" },
24
29
  limit: { type: "integer", description: "Maximum number of lines to read (optional)" },
25
30
  from_disk: { type: "boolean", description: "Read current disk content without modifying in-memory buffer" },
@@ -49,6 +49,8 @@ module Rubik
49
49
  workspace: { type: "string", enum: ["inherit", "branch", "share"], description: "Isolation mode" },
50
50
  model: { type: "string", enum: ["inherit", "flash_lite", "flash", "pro"], description: "Model tier" },
51
51
  max_steps: { type: "integer", description: "Step budget for subagent execution (default: #{steps_default})" },
52
+ detach: { type: "boolean", description: "true = this subagent is NOT owned by the current task: a foreground " \
53
+ "interrupt (^C) will not stop it (default: false)" },
52
54
  depends_on: { type: "array", description: "Dispatch constraints: ['subagent-1'] = FS (launch after it completes); " \
53
55
  "{'id': 'subagent-1', 'type': 'SS'} = launch when it starts. Queued specs hold no slot until released." }
54
56
  },
@@ -58,7 +60,8 @@ module Rubik
58
60
  role: { type: "string", description: "Role for single subagent invocation" },
59
61
  prompt: { type: "string", description: "Prompt for single subagent invocation" },
60
62
  type_name: { type: "string", description: "Archetype: 'research', 'coder', or 'self'" },
61
- max_steps: { type: "integer", description: "Step budget for single subagent invocation (default: #{steps_default})" }
63
+ max_steps: { type: "integer", description: "Step budget for single subagent invocation (default: #{steps_default})" },
64
+ detach: { type: "boolean", description: "true = single-spawn variant of the detach flag above" }
62
65
  }
63
66
  }
64
67
  end
@@ -87,14 +90,22 @@ module Rubik
87
90
  specs = extract_subagent_specs(args)
88
91
  return ToolOutput.error("No valid subagents specified to invoke.") if specs.empty?
89
92
 
93
+ # Task ownership (issue #125): the spawning context's token owns the new
94
+ # work (detached specs opt out per spec). Resolved at call time, so a
95
+ # nested subagent inherits its ancestor's task through its child ctx.
96
+ owner = Rubik::Coeffects.get(@runtime_context, Rubik::Coeffects::TASK_CANCELLATION)
97
+
90
98
  begin
91
- results = sup.spawn_batch(specs)
99
+ results = sup.spawn_batch(specs, owner: owner)
92
100
  rescue StandardError => e
93
101
  return ToolOutput.error("Failed to invoke subagent(s): #{e.message}")
94
102
  end
95
103
 
96
104
  launched, queued = results.partition { |outcome, _obj| outcome == :launched }
97
- lines = ["Launched #{launched.size} background subagent(s):"] + launched.map { |_o, i| " • [#{i.id}] '#{i.role}' (#{i.type_name}) - #{i.state.to_s.upcase}" }
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
98
109
  unless queued.empty?
99
110
  rows = queued.map { |_o, s| " • [#{s.id}] '#{s.role}' - QUEUED (waiting on: #{s.depends_on.join(', ')})" }
100
111
  lines << ("\nQueued #{queued.size} subagent(s) on unmet dependencies (no slot, no cost until released): " + rows.join("\n"))
@@ -120,6 +131,7 @@ module Rubik
120
131
  type_name: args["type_name"] || args[:type_name], role: role, prompt: prompt,
121
132
  system_prompt: args["system_prompt"] || args[:system_prompt], workspace: args["workspace"] || args[:workspace] || "inherit",
122
133
  model: args["model"] || args[:model] || "inherit", max_steps: args["max_steps"] || args[:max_steps],
134
+ detach: args["detach"] || args[:detach],
123
135
  depends_on: args["depends_on"] || args[:depends_on]
124
136
  )]
125
137
  end
@@ -158,7 +170,15 @@ module Rubik
158
170
  timeout: { type: "integer", description: "Max seconds to wait for actions 'wait' or 'status' if running " \
159
171
  "(default: #{Rubik::Orchestrator::Subagent::Notices::DEFAULT_WAIT_TIMEOUT} for wait)" },
160
172
  conversation_ids: { type: "array", items: { type: "string" }, description: "IDs of subagents to manage/wait" },
161
- conversation_id: { type: "string", description: "Single subagent ID" },
173
+ conversation_id: { type: "string", description: "Single subagent ID (display id; actions 'transcript'/'result' also " \
174
+ "accept the unique storage id — the only valid transcript address)" },
175
+ run: { type: "integer", description: "Run sequence for action 'result' (default: latest retained run)" },
176
+ cursor: { type: "integer", description: "Character offset for 'result'/'transcript' pagination (default: 0)" },
177
+ limit: { type: "integer", description: "Max characters per page for 'result'/'transcript' " \
178
+ "(defaults: #{Rubik::Orchestrator::Subagent::ResultArchive::DEFAULT_PAGE_CHARS} / " \
179
+ "#{Rubik::Orchestrator::Subagent::TranscriptReader::DEFAULT_PAGE_CHARS})" },
180
+ snapshot: { type: "string", description: "Snapshot token from a previous 'transcript' page — pins the page " \
181
+ "sequence; a transcript changed since that snapshot answers explicitly" },
162
182
  prompt: { type: "string", description: "New task text for action 'redeploy'" },
163
183
  steps: { type: "integer", description: "Additional steps (extend_budget) or redeploy budget" }
164
184
  },
@@ -189,12 +209,16 @@ module Rubik
189
209
  return ToolOutput.error("Subagent '#{id}' not found.") unless inst
190
210
 
191
211
  timeout = (args["timeout"] || args[:timeout]).to_i
212
+ wait_reason = nil
192
213
  if timeout.positive? && inst.running?
193
- sup.wait_for([id], timeout: timeout)
214
+ outcome = sup.respond_to?(:wait) ? sup.wait([id], timeout: timeout) : nil
215
+ sup.wait_for([id], timeout: timeout) unless outcome
216
+ wait_reason = outcome&.reason
194
217
  inst = sup.get(id) || inst
195
218
  end
196
219
 
197
220
  data = inst.to_h
221
+ data[:wait_reason] = wait_reason if wait_reason
198
222
  guidance = Rubik::Orchestrator::Subagent::Narration.guidance(inst.state, id: inst.id, max_steps: inst.max_steps)
199
223
  if guidance
200
224
  data[:guidance] = guidance
@@ -205,28 +229,11 @@ module Rubik
205
229
  end
206
230
  JSON.pretty_generate(data)
207
231
  when "wait"
208
- timeout = [(args["timeout"] || args[:timeout] || Rubik::Orchestrator::Subagent::Notices::DEFAULT_WAIT_TIMEOUT).to_i, 1].max
209
- ids = resolve_ids(args) # empty = wait for ALL (wait_any_hint contract)
210
- results = sup.wait_for(ids, timeout: timeout)
211
- return "No active subagents to wait for.#{unknown_ids_hint(sup, ids)}" if results.empty?
212
-
213
- lines = ["Subagents wait finished (#{results.size} tracked):"]
214
- results.each do |r|
215
- lines << " • [#{r[:id]}] '#{r[:role]}' (#{r[:type]}) - #{r[:state].to_s.upcase}"
216
- res = (r[:result] || r[:result_summary]).to_s.strip
217
- lines << " #{truncated_result_line(res, r)}" unless res.empty?
218
- lines << " Guidance: #{guidance}" if (guidance = Rubik::Orchestrator::Subagent::Narration.guidance(r[:state], id: r[:id], max_steps: r[:max_steps]))
219
- end
220
- lines.join("\n")
232
+ wait_action(sup, args)
221
233
  when "transcript"
222
- id = resolve_id(args)
223
- return ToolOutput.error("Missing required parameter 'conversation_id' for action 'transcript'.") if id.empty?
224
-
225
- inst = sup.get(id)
226
- return ToolOutput.error("Subagent '#{id}' not found.") unless inst
227
- return ToolOutput.error("Subagent '#{id}' is #{inst.state} — no transcript yet.") unless inst.respond_to?(:format_transcript)
228
-
229
- inst.format_transcript
234
+ transcript_action(sup, args)
235
+ when "result"
236
+ result_action(sup, args)
230
237
  when "extend_budget"
231
238
  id = resolve_id(args)
232
239
  return ToolOutput.error("Missing required parameter 'conversation_id' for action 'extend_budget'.") if id.empty?
@@ -296,14 +303,15 @@ module Rubik
296
303
  missing.empty? ? "" : " Unknown id(s): #{missing.join(', ')}.#{id_format_hint}"
297
304
  end
298
305
 
299
- # Truncation hands the model the DIRECT read path (transcript URI) —
300
- # session review B10: retrieving the full report cost an extra call.
301
- # Returns the line WITHOUT indentation; the caller adds the prefix.
306
+ # Truncation hands the model the CANONICAL retained-result reference
307
+ # (issue #122) — a read route that never touches the workspace gate;
308
+ # the live transcript action stays the fallback when no result
309
+ # reference exists. Returns the line WITHOUT indentation.
302
310
  def truncated_result_line(res, row)
303
311
  return "Result: #{res}" if res.length <= 1500
304
312
 
305
- uri = row[:transcript].to_s
306
- more = uri.empty? ? "manage_subagents(action: 'transcript', conversation_id: '#{row[:id]}')" : "read_file(path: '#{uri}')"
313
+ ref = row[:result_ref].to_s
314
+ more = ref.empty? ? "manage_subagents(action: 'transcript', conversation_id: '#{row[:id]}')" : "read_file(path: '#{ref}')"
307
315
  "Result: #{res[0, 1500]}... [truncated — full result: #{more}]"
308
316
  end
309
317
  end
@@ -4,6 +4,8 @@ require_relative "../output"
4
4
  require_relative "../../orchestrator/subagent/budget"
5
5
  require_relative "../../orchestrator/subagent/notices"
6
6
  require_relative "../../orchestrator/subagent/narration"
7
+ require_relative "../../orchestrator/subagent/result_archive"
8
+ require_relative "../../orchestrator/subagent/transcript_reader"
7
9
 
8
10
  module Rubik
9
11
  module Tools
@@ -37,6 +39,78 @@ module Rubik
37
39
  "transcripts remain readable via their transcript URIs and rubik://storage search."
38
40
  end
39
41
 
42
+ # The wait surface (issue #126): rows keep the per-execution story
43
+ # (state, truncated result + canonical reference, guidance) and the
44
+ # header now names WHY the wait returned, with an actionable hint for
45
+ # every non-settled reason (deadline / pause / dependency queue / unknown
46
+ # ids) — a timeout no longer reads as a mystery.
47
+ def wait_action(sup, args)
48
+ timeout = [(args["timeout"] || args[:timeout] || Rubik::Orchestrator::Subagent::Notices::DEFAULT_WAIT_TIMEOUT).to_i, 1].max
49
+ ids = resolve_ids(args) # empty = wait for ALL (wait_any_hint contract)
50
+ outcome = sup.respond_to?(:wait) ? sup.wait(ids, timeout: timeout) : nil
51
+ results = outcome ? outcome.rows : sup.wait_for(ids, timeout: timeout)
52
+ return "No active subagents to wait for.#{unknown_ids_hint(sup, ids)}" if results.empty?
53
+
54
+ reason_suffix = outcome ? " — wait reason: #{outcome.reason}" : ""
55
+ lines = ["Subagents wait finished (#{results.size} tracked)#{reason_suffix}:"]
56
+ if outcome && (hint = Rubik::Orchestrator::Subagent::Narration.wait_reason_hint(outcome.reason))
57
+ lines << " #{hint}"
58
+ end
59
+ results.each do |r|
60
+ lines << " • [#{r[:id]}] '#{r[:role]}' (#{r[:type]}) - #{r[:state].to_s.upcase}"
61
+ res = (r[:result] || r[:result_summary]).to_s.strip
62
+ lines << " #{truncated_result_line(res, r)}" unless res.empty?
63
+ guidance = Rubik::Orchestrator::Subagent::Narration.guidance(r[:state], id: r[:id], max_steps: r[:max_steps])
64
+ lines << " Guidance: #{guidance}" if guidance
65
+ end
66
+ lines.join("\n")
67
+ end
68
+
69
+ # Canonical transcript read (issue #123): the supervisor seam serves the
70
+ # live memory snapshot while the execution is active and the persisted
71
+ # snapshot once available — one address, snapshot-stable pages. Accepts
72
+ # the display id or the raw storage id (after reaping/eviction); unknown,
73
+ # pending and expired executions answer distinctly.
74
+ def transcript_action(sup, args)
75
+ id = resolve_id(args)
76
+ return ToolOutput.error("Missing required parameter 'conversation_id' for action 'transcript'.") if id.empty?
77
+ return ToolOutput.error("Canonical transcript reads require the supervisor transcript seam; this runtime has none.") unless sup.respond_to?(:read_transcript)
78
+
79
+ inst = sup.get(id)
80
+ if inst.respond_to?(:queued?) && inst.queued?
81
+ return ToolOutput.error("Subagent '#{id}' is QUEUED (waiting on dependencies) — no transcript exists until it is released and runs.")
82
+ end
83
+
84
+ storage_id = inst.respond_to?(:storage_id) && inst.storage_id ? inst.storage_id : id
85
+ page = sup.read_transcript(storage_id,
86
+ cursor: args["cursor"] || args[:cursor],
87
+ limit: args["limit"] || args[:limit],
88
+ snapshot: args["snapshot"] || args[:snapshot])
89
+ page.ok? ? Rubik::Orchestrator::Subagent::TranscriptReader.render_page(page, id: id) : ToolOutput.error(page.message)
90
+ end
91
+
92
+ # Paginated retained-result read (issue #122): the archive serves a
93
+ # settled run's full result in bounded pages without a redeploy, a model
94
+ # call, or any execution-state change. Accepts the display id (roster or
95
+ # tombstone lookup) or the raw unique storage id (post-reap), plus an
96
+ # optional run/cursor/limit; unknown and expired references answer
97
+ # explicitly instead of guessing another run.
98
+ def result_action(sup, args)
99
+ id = resolve_id(args)
100
+ return ToolOutput.error("Missing required parameter 'conversation_id' for action 'result'.") if id.empty?
101
+ return ToolOutput.error("Result archive is not available in this runtime.") unless sup.respond_to?(:result_archive)
102
+
103
+ archive = sup.result_archive
104
+ inst = sup.get(id)
105
+ storage_id = inst.respond_to?(:storage_id) ? inst.storage_id : nil
106
+ storage_id ||= id if archive.known?(id)
107
+ return ToolOutput.error("Subagent '#{id}' not found and no retained result exists for that id.") unless storage_id
108
+
109
+ page = archive.read(storage_id: storage_id, run: args["run"] || args[:run],
110
+ cursor: args["cursor"] || args[:cursor], limit: args["limit"] || args[:limit])
111
+ page.ok? ? Rubik::Orchestrator::Subagent::ResultArchive.render_page(page) : ToolOutput.error(page.message)
112
+ end
113
+
40
114
  # Blocked states that need maestro action before their chain proceeds
41
115
  # (bug-llm-timeout-observability ④): a bare JSON list made a stalled
42
116
  # subagent indistinguishable from a healthy one at a glance.
@@ -128,24 +128,39 @@ module Rubik
128
128
  # PERF: memoize raw file lines once before loop to avoid re-splitting inside loop on score updates
129
129
  f_raw_lines = f_raw.lines
130
130
 
131
+ # PERF: Pre-compute line membership and prefix sum array to prune window evaluations
132
+ target_set = Set.new(t_lines)
133
+ pref = Array.new(f_lines.size + 1, 0)
134
+ f_lines.each_with_index { |l, idx| pref[idx + 1] = pref[idx] + (target_set.include?(l) ? 1 : 0) }
135
+
131
136
  (0...(f_lines.size)).each do |i|
132
137
  window_len = [k, f_lines.size - i].min
133
- # PERF: Array#[start, len] slice avoids Range object allocation inside hot loop
134
- window = f_lines[i, window_len]
135
-
136
- matches, total =
137
- if defined?(Diff::LCS) && Diff::LCS.respond_to?(:LCS)
138
- [Diff::LCS.LCS(t_lines, window).size, [t_lines.size, window.size].max]
139
- else
140
- # Fallback line matching
141
- [(0...[t_lines.size, window.size].min).count { |idx| t_lines[idx] == window[idx] },
142
- [t_lines.size, window.size].max]
143
- end
144
- score = total.positive? ? (matches.to_f / total) : 0.0
138
+ total = [k, window_len].max
139
+
140
+ overlap_count = pref[i + window_len] - pref[i]
145
141
 
146
142
  if k == 1 && window_len == 1
147
- char_score = similarity_ratio(t_lines.first, window.first)
148
- score = [score, char_score].max
143
+ window_line = f_lines[i]
144
+ score = if overlap_count == 1
145
+ 1.0
146
+ else
147
+ similarity_ratio(t_lines.first, window_line, min_threshold: best_score)
148
+ end
149
+ else
150
+ # PERF: Upper-bound check — if maximum possible overlap cannot beat best_score, skip LCS
151
+ next if (overlap_count.to_f / total) <= best_score
152
+
153
+ # PERF: Array#[start, len] slice avoids Range object allocation inside hot loop
154
+ window = f_lines[i, window_len]
155
+
156
+ matches =
157
+ if defined?(Diff::LCS) && Diff::LCS.respond_to?(:LCS)
158
+ Diff::LCS.LCS(t_lines, window).size
159
+ else
160
+ # Fallback line matching
161
+ (0...[t_lines.size, window.size].min).count { |idx| t_lines[idx] == window[idx] }
162
+ end
163
+ score = total.positive? ? (matches.to_f / total) : 0.0
149
164
  end
150
165
 
151
166
  next unless score > best_score
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
4
+
3
5
  module Rubik
4
6
  module Tools
5
7
  module Truncator
@@ -13,12 +15,27 @@ module Rubik
13
15
  # whole downstream pipeline (JSON generation, regex work, token
14
16
  # estimation). Scrub exactly once, after the last byteslice.
15
17
  str = str.scrub("") unless str.valid_encoding?
16
- lines = str.lines
17
18
 
18
19
  max_c = (max_chars || DEFAULT_MAX_CHARS).to_i
19
20
  max_l = (max_lines || DEFAULT_MAX_LINES).to_i
20
21
 
21
- return { text: str, truncated: false, omitted_lines: 0, omitted_chars: 0 } if str.bytesize <= max_c && lines.size <= max_l
22
+ # PERF: With the default input separator, calculate the line upper bound
23
+ # using zero-allocation C-level #count("\n") and short-circuit BEFORE
24
+ # calling #lines (which allocates an Array of line strings). Keep the
25
+ # original #lines behavior when callers customize Ruby's global record
26
+ # separator, since String#lines uses $/ when no separator is supplied.
27
+ if $INPUT_RECORD_SEPARATOR == "\n"
28
+ line_bound = if str.empty?
29
+ 0
30
+ elsif str.end_with?("\n")
31
+ str.count("\n")
32
+ else
33
+ str.count("\n") + 1
34
+ end
35
+ return { text: str, truncated: false, omitted_lines: 0, omitted_chars: 0 } if str.bytesize <= max_c && line_bound <= max_l
36
+ end
37
+
38
+ lines = str.lines
22
39
 
23
40
  strategy_sym = strategy.to_s.downcase.to_sym
24
41
 
@@ -4,8 +4,9 @@ require "monitor"
4
4
  require_relative "../uris"
5
5
  require_relative "../coeffects"
6
6
  require_relative "../orchestrator/subagent/transcript_formatter"
7
+ require_relative "../orchestrator/subagent/transcript_reader"
8
+ require_relative "../orchestrator/subagent/result_archive"
7
9
  require_relative "output"
8
- require_relative "diagnostic_reporter"
9
10
  require_relative "../storage"
10
11
 
11
12
  module Rubik
@@ -115,39 +116,102 @@ module Rubik
115
116
  end
116
117
  end
117
118
 
118
- # rubik://subagent/<storage-id>/transcript reads the transcript FILE
119
- # directly — never through the buffer manager. The URL must carry the
120
- # unique storage dir name: the absolute path advertised by
121
- # manage_subagents status and Instance#to_h. A bare display id
122
- # ("subagent-1") resolves to a nonexistent dir and returns an honest
123
- # not-found — deliberately no glob-newest fallback, which would serve
124
- # a previous session's transcript while a live unsaved instance may
125
- # exist (the stale-evidence failure mode of 2026-09-27). One route,
126
- # one truth. The sandbox workspace gate does not apply: the id is
127
- # charset-validated and the path is an internal artifact under the
128
- # transcript root, not a user-supplied filesystem location.
119
+ # rubik://subagent/<storage-id>/transcript[#cursor=N&snapshot=TOK] is
120
+ # THE canonical transcript reference (issue #123): it resolves the
121
+ # EXECUTION by its unique storage id and then serves the right source —
122
+ # a synchronized live memory snapshot while the run is active, the
123
+ # persisted JSONL snapshot once available — through one interface with
124
+ # snapshot-stable, bounded pages. The URL must carry the unique storage
125
+ # dir name (a bare display id is not an address): deliberately no
126
+ # glob-newest fallback, which would serve a previous session's transcript
127
+ # while a live instance exists (the stale-evidence failure mode of
128
+ # 2026-09-27). The sandbox workspace gate does not apply: live reads come
129
+ # from the supervisor roster, disk reads from the execution's own
130
+ # storage-id path — never a user-supplied filesystem location.
131
+ #
132
+ # rubik://subagent/<storage-id>/result[/<run>] reads the RETAINED RESULT
133
+ # from the session's ResultArchive (issue #122): memory-only as well, and
134
+ # no model call or execution state is touched. One scheme owns one
135
+ # adapter slot, so the grammars dispatch inside here (the
136
+ # GoalStateAdapter precedent — a second registration would shadow this
137
+ # one).
129
138
  class SubagentTranscriptAdapter
130
139
  def match?(uri)
131
- Rubik::Uris::SUBAGENT_TRANSCRIPT.match?(uri)
140
+ Rubik::Uris::SUBAGENT_TRANSCRIPT.match?(uri) || Rubik::Uris::SUBAGENT_RESULT.match?(uri)
132
141
  end
133
142
 
134
143
  def read(uri, ctx:, buffer_manager:)
144
+ if (result = Rubik::Uris::SUBAGENT_RESULT.match(uri))
145
+ return read_result(result[1], result[2], ctx: ctx)
146
+ end
147
+
135
148
  sub_id = uri[Rubik::Uris::SUBAGENT_TRANSCRIPT, 1]
136
149
  unless sub_id.match?(/\A[a-zA-Z0-9_-]+\z/)
137
150
  return ToolOutput.error("Invalid subagent id: #{sub_id} (allowed: letters, digits, _ and -). Use the " \
138
151
  "storage-id dir name from the absolute 'transcript' path in manage_subagents status.")
139
152
  end
140
153
 
141
- storage = Rubik::Coeffects.get(ctx, Rubik::Coeffects::STORAGE_STORE)
142
- file = Rubik::Orchestrator::Subagent::TranscriptFormatter.resolve(buffer_manager.root_dir, sub_id, storage: storage)
143
- return File.read(file) if File.exist?(file)
144
-
145
- ToolOutput.error(DiagnosticReporter.render_file_not_found(file.to_s, candidates: []) +
146
- "\nHint: for a running subagent the live copy is manage_subagents action 'transcript' " \
147
- "(in-memory); the on-disk file appears only after the subagent's first save.")
154
+ read_transcript(sub_id, uri, ctx: ctx, buffer_manager: buffer_manager)
148
155
  rescue StandardError => e
149
156
  ToolOutput.error("Transcript read failed: #{e.class}: #{e.message}")
150
157
  end
158
+
159
+ private
160
+
161
+ # Live-or-disk resolution lives in the supervisor seam when one is
162
+ # wired; a bare construction (no supervisor coeffect) falls back to
163
+ # the disk snapshot only, exactly as before the live plane existed.
164
+ def read_transcript(storage_id, uri, ctx:, buffer_manager:)
165
+ params = fragment_params(uri)
166
+ supervisor = Rubik::Coeffects.get(ctx, Rubik::Coeffects::SUBAGENT_SUPERVISOR)
167
+ page =
168
+ if supervisor.respond_to?(:read_transcript)
169
+ supervisor.read_transcript(storage_id, cursor: params["cursor"], limit: params["limit"], snapshot: params["snapshot"])
170
+ else
171
+ storage = Rubik::Coeffects.get(ctx, Rubik::Coeffects::STORAGE_STORE)
172
+ Rubik::Orchestrator::Subagent::TranscriptReader.read(
173
+ storage_id: storage_id, root_dir: buffer_manager.root_dir, storage: storage,
174
+ cursor: params["cursor"], limit: params["limit"], snapshot: params["snapshot"]
175
+ )
176
+ end
177
+ return ToolOutput.error(page.message) unless page.ok?
178
+
179
+ Rubik::Orchestrator::Subagent::TranscriptReader.render_page(page)
180
+ end
181
+
182
+ # Fragment grammar: "#cursor=N&snapshot=TOK" — values are charset-safe
183
+ # (digits / "live:hex"), so no percent-decoding is needed.
184
+ def fragment_params(uri)
185
+ uri.split("#", 2)[1].to_s.split("&").each_with_object({}) do |pair, acc|
186
+ key, value = pair.split("=", 2)
187
+ acc[key] = value if key && !key.empty? && value
188
+ end
189
+ end
190
+
191
+ # The result half of the scheme: resolve the retained page through the
192
+ # supervisor's archive coeffect, never through the filesystem. Page 1
193
+ # carries its own continuation hint when more content remains.
194
+ def read_result(storage_id, run, ctx:)
195
+ unless storage_id.match?(/\A[a-zA-Z0-9_-]+\z/)
196
+ return ToolOutput.error("Invalid subagent storage id: #{storage_id} (allowed: letters, digits, _ and -). " \
197
+ "Use the execution-specific reference reported by manage_subagents status/wait.")
198
+ end
199
+
200
+ supervisor = Rubik::Coeffects.get(ctx, Rubik::Coeffects::SUBAGENT_SUPERVISOR)
201
+ archive = supervisor.respond_to?(:result_archive) ? supervisor.result_archive : nil
202
+ unless archive
203
+ return ToolOutput.error("Retained results are not available in this runtime: no subagent supervisor " \
204
+ "with a result archive is wired. Use manage_subagents action 'transcript' for " \
205
+ "the live execution, or wait for a completion notice carrying a result reference.")
206
+ end
207
+
208
+ page = archive.read(storage_id: storage_id, run: run)
209
+ return ToolOutput.error(page.message) unless page.ok?
210
+
211
+ Rubik::Orchestrator::Subagent::ResultArchive.render_page(page)
212
+ rescue StandardError => e
213
+ ToolOutput.error("Result read failed: #{e.class}: #{e.message}")
214
+ end
151
215
  end
152
216
 
153
217
  # rubik://goal/state + rubik://goal/ledger: canlı !goal anlık
data/lib/rubik/uris.rb CHANGED
@@ -27,6 +27,9 @@ module Rubik
27
27
  # /ledger'ı EŞLEMEZ — iki ayrı gramer, tek adapter içinde iç dispatch.
28
28
  GOAL_LEDGER = %r{\Arubik://goal/ledger(?:\.xml)?\z}i
29
29
  SUBAGENT_TRANSCRIPT = %r{\Arubik://subagent/([^/]+)/transcript(?:\.jsonl)?(?:#.*)?\z}i
30
+ # Execution-specific retained result (issue #122): capture 1 = unique
31
+ # storage id, capture 2 = run sequence (nil = latest retained run).
32
+ SUBAGENT_RESULT = %r{\Arubik://subagent/([^/]+)/result(?:/(\d+))?\z}i
30
33
 
31
34
  # TranscriptIndexer ref sözleşimi: "subagent-<storage-id>".
32
35
  TRANSCRIPT_REF_PREFIX = "subagent-"
@@ -88,6 +91,14 @@ module Rubik
88
91
  "#{SCHEME}://#{SUBAGENT}/#{storage_id}/transcript"
89
92
  end
90
93
 
94
+ # Execution-specific retained-result reference: run nil = the latest
95
+ # retained run of that execution (the archive resolves it explicitly).
96
+ def subagent_result(storage_id, run: nil)
97
+ seq = run.to_i
98
+ suffix = seq.positive? ? "/#{seq}" : ""
99
+ "#{SCHEME}://#{SUBAGENT}/#{storage_id}/result#{suffix}"
100
+ end
101
+
91
102
  # TranscriptIndexer ref sözleşimi: "subagent-<storage-id>". storage-id
92
103
  # kendisi zaten "subagent-" önekli doğar (ör. "subagent-1-12456-...") —
93
104
  # öneki körlemesine eklemek çift önekli, çözümlenemez alias üretiyordu
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.1.5"
4
+ VERSION = "0.1.6"
5
5
  end