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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +42 -0
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- data/lib/rubik/orchestrator/step_executor.rb +9 -2
- data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
- data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
- data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
- data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
- data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
- data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
- data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
- data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
- data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
- data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
- data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
- data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
- data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
- data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
- data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
- data/lib/rubik/orchestrator/tool_history.rb +8 -3
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/subagents.rb +2 -0
- data/lib/rubik/repl/input_execution.rb +42 -0
- data/lib/rubik/repl/input_loop.rb +57 -7
- data/lib/rubik/repl/prompt_grace.rb +103 -0
- data/lib/rubik/security/redactor.rb +7 -7
- data/lib/rubik/storage/transcript_indexer.rb +21 -11
- data/lib/rubik/tools/builtins/exec.rb +76 -28
- data/lib/rubik/tools/builtins/files.rb +6 -1
- data/lib/rubik/tools/builtins/subagent.rb +39 -31
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
- data/lib/rubik/tools/truncator.rb +19 -2
- data/lib/rubik/tools/uri_resolver.rb +84 -20
- data/lib/rubik/uris.rb +11 -0
- data/lib/rubik/version.rb +1 -1
- 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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
214
|
+
(args["timeout"] || args[:timeout] || cfg_timeout).to_i.clamp(1, 600)
|
|
215
|
+
end
|
|
169
216
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 = "[#{
|
|
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
|
-
|
|
182
|
-
out += "\n\n#{
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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',
|
|
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):"] +
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
|
300
|
-
#
|
|
301
|
-
#
|
|
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
|
-
|
|
306
|
-
more =
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
148
|
-
score =
|
|
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
|
-
|
|
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
|
|
119
|
-
#
|
|
120
|
-
# unique storage
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
# a
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
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
|
-
|
|
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