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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Rubik
|
|
7
|
+
module Orchestrator
|
|
8
|
+
module Subagent
|
|
9
|
+
# Operation-level telemetry (issue #126): what an execution is doing
|
|
10
|
+
# RIGHT NOW and how its time split between model waits and tool runs,
|
|
11
|
+
# measured with a monotonic clock (injectable for tests). Pure state —
|
|
12
|
+
# no IO, no notices, no locks shared with the instance; the status and
|
|
13
|
+
# wait surfaces read snapshots from here instead of guessing whether an
|
|
14
|
+
# execution is stalled.
|
|
15
|
+
class OperationTracker
|
|
16
|
+
MODEL = "model"
|
|
17
|
+
TOOL = "tool"
|
|
18
|
+
EMPTY_TIMINGS = { "model_seconds" => 0.0, "tool_seconds" => 0.0, "model_calls" => 0, "tool_calls" => 0 }.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(clock: nil)
|
|
21
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
22
|
+
@monitor = Monitor.new
|
|
23
|
+
@current = nil
|
|
24
|
+
@timings = EMPTY_TIMINGS.dup
|
|
25
|
+
@last_progress_at = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Marks the start of an in-flight operation. detail is already
|
|
29
|
+
# redacted/clipped by the caller (it is model-visible telemetry).
|
|
30
|
+
def start(kind, name: nil, detail: nil)
|
|
31
|
+
now = @clock.call
|
|
32
|
+
@monitor.synchronize do
|
|
33
|
+
@current = { kind: kind.to_s, name: name.to_s, detail: detail.to_s.empty? ? nil : detail.to_s,
|
|
34
|
+
started_at: Time.now.utc.iso8601(3), started_monotonic: now }
|
|
35
|
+
@last_progress_at = now
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Ends the tracked operation and accumulates its duration into the
|
|
40
|
+
# matching model/tool bucket. Returns the elapsed seconds, or nil when
|
|
41
|
+
# the operation is not the one being tracked (e.g. after a reset).
|
|
42
|
+
def finish(kind)
|
|
43
|
+
@monitor.synchronize do
|
|
44
|
+
return nil unless @current && @current[:kind] == kind.to_s
|
|
45
|
+
|
|
46
|
+
elapsed = [@clock.call - @current[:started_monotonic], 0.0].max
|
|
47
|
+
bucket = kind.to_s == MODEL ? "model_seconds" : "tool_seconds"
|
|
48
|
+
counter = kind.to_s == MODEL ? "model_calls" : "tool_calls"
|
|
49
|
+
@timings[bucket] = (@timings[bucket] + elapsed).round(3)
|
|
50
|
+
@timings[counter] += 1
|
|
51
|
+
@current = nil
|
|
52
|
+
# Completing an operation is observable progress too.
|
|
53
|
+
@last_progress_at = @clock.call
|
|
54
|
+
elapsed
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def progress!
|
|
59
|
+
now = @clock.call
|
|
60
|
+
@monitor.synchronize { @last_progress_at = now }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A redeployed run is a new lineage: no stale operation, fresh buckets.
|
|
64
|
+
def reset!
|
|
65
|
+
@monitor.synchronize do
|
|
66
|
+
@current = nil
|
|
67
|
+
@timings = EMPTY_TIMINGS.dup
|
|
68
|
+
@last_progress_at = nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# JSON-ready snapshot of the in-flight operation (nil when idle).
|
|
73
|
+
def current
|
|
74
|
+
@monitor.synchronize do
|
|
75
|
+
return nil unless @current
|
|
76
|
+
|
|
77
|
+
{ kind: @current[:kind], name: @current[:name], detail: @current[:detail],
|
|
78
|
+
started_at: @current[:started_at],
|
|
79
|
+
elapsed_seconds: [@clock.call - @current[:started_monotonic], 0.0].max.round(3) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def timings = @monitor.synchronize { @timings.dup }
|
|
84
|
+
|
|
85
|
+
def last_progress_seconds_ago
|
|
86
|
+
@monitor.synchronize do
|
|
87
|
+
@last_progress_at ? [@clock.call - @last_progress_at, 0.0].max.round(3) : nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "../../security/redactor"
|
|
5
|
+
require_relative "../../uris"
|
|
6
|
+
|
|
7
|
+
module Rubik
|
|
8
|
+
module Orchestrator
|
|
9
|
+
module Subagent
|
|
10
|
+
# Bounded, redacted, paginated access to completed run results (issue #122).
|
|
11
|
+
#
|
|
12
|
+
# Every existing surface truncates: an Instance keeps only the latest
|
|
13
|
+
# run's result under the terminal-only contract, wait rows show a 1500-char
|
|
14
|
+
# preview, transcript rendering and storage summaries have their own caps.
|
|
15
|
+
# This archive is the ONE retrieval contract for the remainder: each
|
|
16
|
+
# settled run's result is retained under an execution-specific reference
|
|
17
|
+
# (unique storage id + run sequence — redeploy runs never overwrite an
|
|
18
|
+
# earlier one), redacted at retention time, and served back in bounded
|
|
19
|
+
# pages with stable cursors (`has_more` / `next_cursor`).
|
|
20
|
+
#
|
|
21
|
+
# Reads are pure memory: no model call, no spawn/redeploy, no execution
|
|
22
|
+
# state change, no filesystem access (so no workspace allowlisting is
|
|
23
|
+
# involved). Retention is session-scoped and bounded by
|
|
24
|
+
# `subagents.result_archive.*` — newest `max_runs_per_instance` runs per
|
|
25
|
+
# execution, `ttl_seconds` lifetime — and unknown, evicted, or expired
|
|
26
|
+
# references answer explicitly instead of silently serving another run.
|
|
27
|
+
class ResultArchive
|
|
28
|
+
Entry = Struct.new(:ref, :storage_id, :id, :run, :state, :body, :retained_at, :expires_at, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
Page = Struct.new(:ref, :storage_id, :id, :run, :state, :body, :cursor, :next_cursor,
|
|
31
|
+
:total_chars, :returned_chars, :has_more, :redacted,
|
|
32
|
+
:retained_at, :expires_at, keyword_init: true) do
|
|
33
|
+
def ok? = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Missing = Struct.new(:ref, :reason, :message, keyword_init: true) do
|
|
37
|
+
def ok? = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
DEFAULT_TTL_SECONDS = 3600
|
|
41
|
+
DEFAULT_MAX_RUNS = 8
|
|
42
|
+
DEFAULT_PAGE_CHARS = 4000
|
|
43
|
+
MAX_PAGE_CHARS = 40_000
|
|
44
|
+
|
|
45
|
+
def initialize(config: nil, clock: nil, ttl_seconds: nil, max_runs_per_instance: nil, page_chars: nil)
|
|
46
|
+
@clock = clock || -> { Time.now }
|
|
47
|
+
@ttl_seconds = (ttl_seconds || config_get(config, "subagents.result_archive.ttl_seconds", DEFAULT_TTL_SECONDS)).to_f
|
|
48
|
+
@max_runs = (max_runs_per_instance || config_get(config, "subagents.result_archive.max_runs_per_instance", DEFAULT_MAX_RUNS)).to_i
|
|
49
|
+
@page_chars = (page_chars || config_get(config, "subagents.result_archive.page_chars", DEFAULT_PAGE_CHARS)).to_i
|
|
50
|
+
@max_runs = [@max_runs, 0].max
|
|
51
|
+
@page_chars = @page_chars.clamp(1, MAX_PAGE_CHARS)
|
|
52
|
+
@entries = {} # storage_id => { run => Entry }
|
|
53
|
+
@monitor = Monitor.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Retains one settled run's result. Redaction happens HERE (the raw
|
|
57
|
+
# in-memory history keeps its wire-faithful copy), the body is never
|
|
58
|
+
# mutated afterwards, and the run sequence keys the entry so a
|
|
59
|
+
# redeployed run cannot overwrite its predecessor.
|
|
60
|
+
def retain(storage_id:, id:, run:, state:, body:)
|
|
61
|
+
sid = storage_id.to_s
|
|
62
|
+
seq = [run.to_i, 1].max
|
|
63
|
+
now = @clock.call
|
|
64
|
+
entry = Entry.new(ref: Rubik::Uris.subagent_result(sid, run: seq), storage_id: sid, id: id.to_s,
|
|
65
|
+
run: seq, state: state.to_s, body: Rubik::Security::Redactor.redact(body.to_s),
|
|
66
|
+
retained_at: now, expires_at: now + @ttl_seconds)
|
|
67
|
+
@monitor.synchronize do
|
|
68
|
+
sweep_expired!(now)
|
|
69
|
+
runs = (@entries[sid] ||= {})
|
|
70
|
+
runs[seq] = entry
|
|
71
|
+
runs.delete(runs.keys.min) while runs.size > @max_runs
|
|
72
|
+
end
|
|
73
|
+
entry
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Bounded read by run (nil = latest retained run). Returns a Page on
|
|
77
|
+
# success — with cursor/next_cursor/has_more and explicit truncation
|
|
78
|
+
# metadata — or a Missing result with an explicit reason.
|
|
79
|
+
def read(storage_id:, run: nil, cursor: 0, limit: nil)
|
|
80
|
+
sid = storage_id.to_s
|
|
81
|
+
now = @clock.call
|
|
82
|
+
@monitor.synchronize do
|
|
83
|
+
runs = @entries[sid]
|
|
84
|
+
return missing(Rubik::Uris.subagent_result(sid, run: run&.to_i), :unknown, unknown_message(sid)) if runs.nil? || runs.empty?
|
|
85
|
+
|
|
86
|
+
entry = run ? runs[run.to_i] : runs[runs.keys.max]
|
|
87
|
+
unless entry
|
|
88
|
+
return missing(Rubik::Uris.subagent_result(sid, run: run.to_i), :unknown,
|
|
89
|
+
"No retained run #{run} for execution '#{sid}' — retained run(s): #{runs.keys.sort.join(', ')}. #{retention_note}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if expired?(entry, now)
|
|
93
|
+
runs.delete(entry.run)
|
|
94
|
+
@entries.delete(sid) if runs.empty?
|
|
95
|
+
return missing(entry.ref, :expired,
|
|
96
|
+
"Retained result #{entry.ref} expired at #{entry.expires_at.utc.iso8601} (retention: #{@ttl_seconds.round}s). #{retention_note}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
page_for(entry, cursor, limit)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Any non-expired retained entry for this execution id? Lets callers
|
|
104
|
+
# accept a raw storage id after the instance itself was reaped.
|
|
105
|
+
def known?(storage_id)
|
|
106
|
+
sid = storage_id.to_s
|
|
107
|
+
now = @clock.call
|
|
108
|
+
@monitor.synchronize do
|
|
109
|
+
runs = @entries[sid]
|
|
110
|
+
next false unless runs
|
|
111
|
+
|
|
112
|
+
runs.delete_if { |_run, entry| expired?(entry, now) }
|
|
113
|
+
!runs.empty?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def latest_run(storage_id)
|
|
118
|
+
@monitor.synchronize { (@entries[storage_id.to_s] || {}).keys.max }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# THE page rendering (tool action + rubik:// read share it, so the two
|
|
122
|
+
# access paths cannot drift). The continuation is the paginated API
|
|
123
|
+
# call — the reference itself always identifies the exact run.
|
|
124
|
+
def self.render_page(page, id: nil)
|
|
125
|
+
who = id || page.id
|
|
126
|
+
lines = ["Retained result #{page.ref} (run #{page.run}, state #{page.state}, execution '#{who}')",
|
|
127
|
+
"chars #{page.cursor}-#{page.cursor + page.returned_chars} of #{page.total_chars}; " \
|
|
128
|
+
"credentials redacted; expires #{page.expires_at&.utc&.iso8601}",
|
|
129
|
+
"---", page.body, "---"]
|
|
130
|
+
lines << if page.has_more
|
|
131
|
+
"More content follows — next page: manage_subagents(action: 'result', conversation_id: '#{who}', " \
|
|
132
|
+
"run: #{page.run}, cursor: #{page.next_cursor})"
|
|
133
|
+
else
|
|
134
|
+
"(complete result)"
|
|
135
|
+
end
|
|
136
|
+
lines.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def page_for(entry, cursor, limit)
|
|
142
|
+
body = entry.body
|
|
143
|
+
total = body.length
|
|
144
|
+
cur = [cursor.to_i, 0].max
|
|
145
|
+
return missing(entry.ref, :cursor, "Cursor #{cur} exceeds the retained result length (#{total} chars). Use cursor 0.") if cur > total
|
|
146
|
+
|
|
147
|
+
size = limit.to_i
|
|
148
|
+
size = @page_chars if size <= 0
|
|
149
|
+
size = [size, MAX_PAGE_CHARS].min
|
|
150
|
+
text = body[cur, size].to_s
|
|
151
|
+
nxt = cur + text.length
|
|
152
|
+
has_more = nxt < total
|
|
153
|
+
Page.new(ref: entry.ref, storage_id: entry.storage_id, id: entry.id, run: entry.run, state: entry.state,
|
|
154
|
+
body: text, cursor: cur, next_cursor: has_more ? nxt : nil, total_chars: total,
|
|
155
|
+
returned_chars: text.length, has_more: has_more, redacted: true,
|
|
156
|
+
retained_at: entry.retained_at, expires_at: entry.expires_at)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def missing(ref, reason, message)
|
|
160
|
+
Missing.new(ref: ref, reason: reason, message: message)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def unknown_message(sid)
|
|
164
|
+
"No retained result for execution '#{sid}'. #{retention_note}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def retention_note
|
|
168
|
+
"The archive keeps the newest #{@max_runs} run(s) per execution for #{@ttl_seconds.round}s in this session; " \
|
|
169
|
+
"durable history remains in the subagent transcript."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def expired?(entry, now)
|
|
173
|
+
@ttl_seconds.positive? && entry.expires_at && entry.expires_at <= now
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def sweep_expired!(now)
|
|
177
|
+
@entries.each do |sid, runs|
|
|
178
|
+
runs.delete_if { |_run, entry| expired?(entry, now) }
|
|
179
|
+
@entries.delete(sid) if runs.empty?
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def config_get(config, key, default)
|
|
184
|
+
return default if config.nil?
|
|
185
|
+
|
|
186
|
+
val = if config.is_a?(Hash)
|
|
187
|
+
config[key] || config.dig(*key.split("."))
|
|
188
|
+
elsif config.respond_to?(:fetch)
|
|
189
|
+
config.fetch(key, nil)
|
|
190
|
+
end
|
|
191
|
+
val.nil? ? default : val
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -52,7 +52,7 @@ module Rubik
|
|
|
52
52
|
# ve taze scoped registry bağlanır (eskisinin ask_question'ı
|
|
53
53
|
# dispose'lu ctx'e rebind'lıydı). Yeni görev history'ye user
|
|
54
54
|
# mesajı olarak eklenir; run cycle yalnız BOŞ history seed eder.
|
|
55
|
-
def redeploy_for!(prompt:, max_steps:, context:, tool_registry:)
|
|
55
|
+
def redeploy_for!(prompt:, max_steps:, context:, tool_registry:, owner_task: nil)
|
|
56
56
|
@monitor.synchronize do
|
|
57
57
|
return false unless redeployable?
|
|
58
58
|
|
|
@@ -65,6 +65,13 @@ module Rubik
|
|
|
65
65
|
# the old run's wait fact must not narrate the new one.
|
|
66
66
|
@result = @last_error = @ended_at = @transcript_path = nil
|
|
67
67
|
@pause_facts = nil
|
|
68
|
+
# Task ownership (issue #125): the redeployed run belongs to the
|
|
69
|
+
# CURRENT task's token (or stays detached); a fresh lineage is not
|
|
70
|
+
# born "cancelled by operator". Operation telemetry (#126) starts
|
|
71
|
+
# clean with it — no stale operation survives a redeploy.
|
|
72
|
+
@owner_task = owner_task
|
|
73
|
+
@cancelled_by_operator = false
|
|
74
|
+
@operations.reset!
|
|
68
75
|
@context = context
|
|
69
76
|
@tool_registry = tool_registry
|
|
70
77
|
@context_cleaned_up = false
|
|
@@ -81,7 +88,10 @@ module Rubik
|
|
|
81
88
|
# imkânsız — bkz. activation.rb). provider/buffer/provider_gate/
|
|
82
89
|
# storage_id/kimlik AYNEN KALIR (reuse'un kendisi bu).
|
|
83
90
|
module RedeploySupervisor
|
|
84
|
-
|
|
91
|
+
# `owner: :keep` (the default) keeps the instance's existing task
|
|
92
|
+
# ownership; the tool plane passes the CURRENT turn's token so a
|
|
93
|
+
# redeploy issued in this turn is cancellable with it (issue #125).
|
|
94
|
+
def redeploy(id, prompt:, max_steps: nil, owner: :keep)
|
|
85
95
|
prompt_str = prompt.to_s.strip
|
|
86
96
|
raise ArgumentError, "Redeploy prompt cannot be empty" if prompt_str.empty?
|
|
87
97
|
|
|
@@ -90,15 +100,18 @@ module Rubik
|
|
|
90
100
|
inst = @instances[key] || (@finished || {})[key]
|
|
91
101
|
raise ArgumentError, redeploy_refusal(key, inst) unless inst&.redeployable?
|
|
92
102
|
|
|
103
|
+
eff_owner = owner == :keep ? inst.owner_task : owner
|
|
104
|
+
refute_cancelled_owner!(eff_owner)
|
|
93
105
|
validate_concurrency!
|
|
94
106
|
child_ctx = extend_root_context!(:redeploy)
|
|
95
107
|
scoped_registry = build_scoped_registry(@type_registry.get(inst.type_name), child_ctx, inst.buffer_manager)
|
|
96
108
|
return false unless inst.redeploy_for!(prompt: prompt_str, max_steps: (max_steps || @default_max_steps).to_i,
|
|
97
|
-
context: child_ctx, tool_registry: scoped_registry
|
|
109
|
+
context: child_ctx, tool_registry: scoped_registry,
|
|
110
|
+
owner_task: eff_owner)
|
|
98
111
|
|
|
99
112
|
@finished&.delete(inst.id)
|
|
100
113
|
@instances[inst.id] = inst
|
|
101
|
-
activate_instance!(inst, child_ctx: child_ctx)
|
|
114
|
+
activate_instance!(inst, child_ctx: child_ctx, owner_token: eff_owner)
|
|
102
115
|
end
|
|
103
116
|
# SS bırakma kancası: redeploy da bir başlangıçtır — taze :running
|
|
104
117
|
# flip'i, bu id'yi SS ile bekleyen kuyruk spesifikasyonlarını
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transcript_formatter"
|
|
4
|
+
require_relative "narration"
|
|
5
|
+
|
|
6
|
+
module Rubik
|
|
7
|
+
module Orchestrator
|
|
8
|
+
module Subagent
|
|
9
|
+
# The per-run entry/exit choreography, extracted from Instance to keep
|
|
10
|
+
# it under its module-hygiene cap (issue #122 added the run-sequence
|
|
11
|
+
# reference plumbing there): history seeding, the nil-provider terminal
|
|
12
|
+
# stub, the single failure drain, effective-prompt assembly and the
|
|
13
|
+
# durable transcript save belong to ONE vertical slice — a fresh run and
|
|
14
|
+
# a redeploy both enter through run_execution_cycle.
|
|
15
|
+
module RunCycle
|
|
16
|
+
# The human-readable rendering of the current history (public read
|
|
17
|
+
# seam; lives beside the durable-save choreography it mirrors).
|
|
18
|
+
def format_transcript
|
|
19
|
+
TranscriptFormatter.format(self, @history, @monitor)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def run_execution_cycle
|
|
25
|
+
# Fresh runs seed; a redeployed run continues its preserved history.
|
|
26
|
+
@history.push({ role: "system", content: build_effective_system_prompt }, { role: "user", content: @prompt }) if @history.empty?
|
|
27
|
+
|
|
28
|
+
unless @provider
|
|
29
|
+
@budget.consume
|
|
30
|
+
@result = Narration.completed_stub_line(@id, @role, @prompt)
|
|
31
|
+
transition!(:completed)
|
|
32
|
+
@unit&.sync_state!
|
|
33
|
+
save_transcript!
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
execute_steps_with_provider
|
|
38
|
+
@unit&.sync_state!
|
|
39
|
+
save_transcript!
|
|
40
|
+
rescue Lifecycle::StateError => e
|
|
41
|
+
fail_run("LIFECYCLE INVARIANT VIOLATED: #{e.class}: #{e.message}")
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
fail_run("#{e.class}: #{e.message}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The run's single failure drain: record why, land on :failed, sync
|
|
47
|
+
# the unit, persist — the loud prefix keeps an FSM violation
|
|
48
|
+
# distinguishable from a provider failure.
|
|
49
|
+
def fail_run(reason)
|
|
50
|
+
@monitor.synchronize do
|
|
51
|
+
@last_error = reason
|
|
52
|
+
transition!(:failed)
|
|
53
|
+
@unit&.sync_state!
|
|
54
|
+
end
|
|
55
|
+
save_transcript!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Private by design: the transcript URI is minted once at construction
|
|
59
|
+
# and every save mirrors the in-memory history (redacted at the file
|
|
60
|
+
# boundary) — callers own the retention decision, not the path.
|
|
61
|
+
def save_transcript!
|
|
62
|
+
saved = TranscriptFormatter.save!(@transcript_uri, @history, @monitor,
|
|
63
|
+
prompt_reconstructible: @prompt_reconstructible)
|
|
64
|
+
@transcript_path = saved
|
|
65
|
+
# A failed save must not be silent: the advertised path would 404 silently.
|
|
66
|
+
@last_error ||= "Transcript save failed for #{@transcript_uri}: see stderr" unless saved
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_effective_system_prompt
|
|
70
|
+
base = "You are a specialized subagent operating under autonomous supervision.\n\n" \
|
|
71
|
+
"Subagent ID: #{@id} | Role: #{@role} | Type: #{@type_name}\n" \
|
|
72
|
+
"Allocated Step Budget: #{@budget.max_steps} steps.\n\n" \
|
|
73
|
+
"A [Step X/Y] status line precedes each of your steps (X = current step, Y = total budget). Pace yourself: " \
|
|
74
|
+
"when remaining steps (Y - X) run low, conclude the task or prepare a progress summary for the maestro " \
|
|
75
|
+
"supervisor — it may extend your budget or steer you. If a requirement is genuinely ambiguous, ask the maestro directly with the " \
|
|
76
|
+
"ask_question tool — it blocks until the maestro answers or times out; ask only when the answer truly changes your work.\n\n" \
|
|
77
|
+
"Perform your assigned task with high precision and provide a structured synthesis report."
|
|
78
|
+
@system_prompt && !@system_prompt.strip.empty? ? "#{base}\n\nCustom Instructions:\n#{@system_prompt}" : base
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../coeffects"
|
|
4
|
+
|
|
3
5
|
module Rubik
|
|
4
6
|
module Orchestrator
|
|
5
7
|
module Subagent
|
|
@@ -51,14 +53,16 @@ module Rubik
|
|
|
51
53
|
end
|
|
52
54
|
end
|
|
53
55
|
|
|
54
|
-
# Subagent düzlemine geçen araçlar (ask_question + send_message
|
|
55
|
-
#
|
|
56
|
+
# Subagent düzlemine geçen araçlar (ask_question + send_message +
|
|
57
|
+
# orchestration plane): coeffect çözümü yalnız child ctx'te sağlanır
|
|
56
58
|
# (supervisor#spawn/redeploy provide). The rebind MUST precede the
|
|
57
59
|
# buffer guard conceptually: research-type subagents inherit
|
|
58
60
|
# workspace buffers, but ask_question still has to swap to the child
|
|
59
61
|
# ctx — otherwise it would keep the ROOT tool (live shell) and pop a
|
|
60
62
|
# HUMAN dialog from the worker thread; send_message swaps so the
|
|
61
|
-
# subagent-plane message carries its REAL sender id to the maestro
|
|
63
|
+
# subagent-plane message carries its REAL sender id to the maestro;
|
|
64
|
+
# invoke/manage swap so a nested spawn resolves the INSTANCE's task
|
|
65
|
+
# token (issue #125) instead of the root turn's.
|
|
62
66
|
# Class-matched (not name-matched): a plugin/dynamic tool registered
|
|
63
67
|
# under the same name keeps its own implementation in every plane.
|
|
64
68
|
def rebind_to_context(tool, ctx)
|
|
@@ -69,10 +73,18 @@ module Rubik
|
|
|
69
73
|
Rubik::Tools::AskQuestionTool.new(shell: nil, runtime_context: ctx)
|
|
70
74
|
when Rubik::Tools::SendMessageTool
|
|
71
75
|
Rubik::Tools::SendMessageTool.new(runtime_context: ctx)
|
|
76
|
+
when Rubik::Tools::InvokeSubagentTool
|
|
77
|
+
Rubik::Tools::InvokeSubagentTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
|
|
78
|
+
when Rubik::Tools::ManageSubagentsTool
|
|
79
|
+
Rubik::Tools::ManageSubagentsTool.new(supervisor: supervisor_for(ctx), runtime_context: ctx)
|
|
72
80
|
else
|
|
73
81
|
tool
|
|
74
82
|
end
|
|
75
83
|
end
|
|
84
|
+
|
|
85
|
+
def supervisor_for(ctx)
|
|
86
|
+
Rubik::Coeffects.get(ctx, Rubik::Coeffects::SUBAGENT_SUPERVISOR)
|
|
87
|
+
end
|
|
76
88
|
end
|
|
77
89
|
end
|
|
78
90
|
end
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "../../prompt"
|
|
5
|
+
require_relative "../../platform"
|
|
6
|
+
require_relative "../../security/redactor"
|
|
5
7
|
require_relative "../../tools/truncator"
|
|
8
|
+
require_relative "../../uris"
|
|
6
9
|
require_relative "../provider_recovery"
|
|
7
10
|
require_relative "../auto_apply"
|
|
8
11
|
require_relative "../tool_call_id"
|
|
@@ -13,6 +16,9 @@ require_relative "../../coeffects"
|
|
|
13
16
|
require_relative "notices"
|
|
14
17
|
require_relative "resilience"
|
|
15
18
|
require_relative "conversation_id"
|
|
19
|
+
require_relative "context_compaction"
|
|
20
|
+
require_relative "operation_tracker"
|
|
21
|
+
require_relative "tool_dispatch"
|
|
16
22
|
|
|
17
23
|
module Rubik
|
|
18
24
|
module Orchestrator
|
|
@@ -24,6 +30,9 @@ module Rubik
|
|
|
24
30
|
# spine (agent-turn-executor plan); this module keeps the invocation path, budget
|
|
25
31
|
# mechanics, and the subagent dispatch.
|
|
26
32
|
module StepRunner
|
|
33
|
+
include ContextCompaction
|
|
34
|
+
include ToolDispatch
|
|
35
|
+
|
|
27
36
|
# The shared response-processing spine, built lazily on first use so
|
|
28
37
|
# objects that skip initialize (test doubles) stay safe.
|
|
29
38
|
def turn_executor
|
|
@@ -78,21 +87,6 @@ module Rubik
|
|
|
78
87
|
transition!(:failed)
|
|
79
88
|
end
|
|
80
89
|
|
|
81
|
-
def evaluate_subagent_context_compaction!
|
|
82
|
-
return unless @context_limit&.positive?
|
|
83
|
-
|
|
84
|
-
context_cfg = ProviderRecovery.compaction_config(@config)
|
|
85
|
-
needs = Rubik::Prompt::ContextCompactor.needs_compaction?(
|
|
86
|
-
@history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
|
|
87
|
-
)
|
|
88
|
-
return unless needs
|
|
89
|
-
|
|
90
|
-
compacted = Rubik::Prompt::ContextCompactor.compact_history(
|
|
91
|
-
@history, context_limit: @context_limit, max_output_tokens: @max_output_tokens, config: context_cfg
|
|
92
|
-
)
|
|
93
|
-
@history.replace(compacted)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
90
|
def invoke_provider_with_recovery(opts)
|
|
97
91
|
ProviderRecovery.with_recovery(config: @config, compact: method(:emergency_compact_and_notify!)) do
|
|
98
92
|
Resilience.retry_transient(config: @config, gate: @provider_gate) do
|
|
@@ -105,22 +99,6 @@ module Rubik
|
|
|
105
99
|
@config.respond_to?(:fetch) ? @config.fetch("subagents.auto_pause_on_network_error", true) != false : true
|
|
106
100
|
end
|
|
107
101
|
|
|
108
|
-
# Compaction actuator for the subagent path (mirrors Loop's public
|
|
109
|
-
# seam of the same name): the compaction body lives in
|
|
110
|
-
# TurnExecutor#emergency_compact! (one home for both channels); this
|
|
111
|
-
# delegates and the channel announces on the bus. Runs on the worker
|
|
112
|
-
# thread between monitor sections — never call it while holding
|
|
113
|
-
# @monitor (the emit stays lock-free).
|
|
114
|
-
def emergency_compact_and_notify!(reason)
|
|
115
|
-
turn_executor.emergency_compact!(reason)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# The object that owns the fact emits it — outside the monitor, so
|
|
119
|
-
# subscribers never run under the instance lock.
|
|
120
|
-
def emit_compacted_event(reason, layer)
|
|
121
|
-
Notices.emit(@event_bus, Notices::COMPACTED_EVENT, { id: @id, role: @role, state: @state, reason: reason.to_s, layer: layer })
|
|
122
|
-
end
|
|
123
|
-
|
|
124
102
|
def invoke_provider(opts)
|
|
125
103
|
effective_opts = opts.dup
|
|
126
104
|
effective_opts[:timeout] ||= ProviderRecovery::SUBAGENT_REQUEST_TIMEOUT
|
|
@@ -128,10 +106,18 @@ module Rubik
|
|
|
128
106
|
effective_opts[:context_limit] ||= @context_limit if @context_limit
|
|
129
107
|
effective_opts[:max_tokens] ||= @max_output_tokens if @max_output_tokens&.positive?
|
|
130
108
|
|
|
131
|
-
|
|
132
|
-
|
|
109
|
+
# Operation telemetry (issue #126): the model wait is observable
|
|
110
|
+
# while it lasts (status shows kind/name/elapsed) and its duration
|
|
111
|
+
# lands in the run's model bucket even when the call raises.
|
|
112
|
+
@operations.start(OperationTracker::MODEL, name: @model || "default")
|
|
113
|
+
begin
|
|
114
|
+
return @provider.complete(@history, effective_opts) if @provider.respond_to?(:complete)
|
|
115
|
+
return @provider.chat(@history, **effective_opts) if @provider.respond_to?(:chat)
|
|
133
116
|
|
|
134
|
-
|
|
117
|
+
raise "Provider #{@provider.class} does not support complete or chat"
|
|
118
|
+
ensure
|
|
119
|
+
@operations.finish(OperationTracker::MODEL)
|
|
120
|
+
end
|
|
135
121
|
end
|
|
136
122
|
|
|
137
123
|
def drain_inbox_into_history
|
|
@@ -155,10 +141,8 @@ module Rubik
|
|
|
155
141
|
end
|
|
156
142
|
|
|
157
143
|
# Single-call subagent dispatch (the TurnExecutor's per-call seam):
|
|
158
|
-
# pre-execution auto-apply, timed execution,
|
|
159
|
-
#
|
|
160
|
-
# other channel (the row-superset delta of the agent-turn-executor
|
|
161
|
-
# plan; no wire-side formatter reads them, observability only).
|
|
144
|
+
# pre-execution auto-apply, timed execution, the canonical ToolHistory
|
|
145
|
+
# row (status + duration_ms; observability only), and the #124 journal.
|
|
162
146
|
def execute_subagent_tool_call(tool_call)
|
|
163
147
|
name = tool_call["tool"]
|
|
164
148
|
args = tool_call["args"] || {}
|
|
@@ -166,15 +150,39 @@ module Rubik
|
|
|
166
150
|
canonical_name = tool ? tool.name : name.to_s.downcase
|
|
167
151
|
|
|
168
152
|
auto_apply_buffer!(:pre_execution, tool: canonical_name)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
153
|
+
# Journal the call BEFORE dispatch (issue #124): a crash inside the
|
|
154
|
+
# tool still leaves the attempted call correlated by its id. The
|
|
155
|
+
# operation snapshot (issue #126) shows the tool + redacted detail
|
|
156
|
+
# while it runs; the duration lands in the tool bucket either way.
|
|
157
|
+
@operations.start(OperationTracker::TOOL, name: canonical_name, detail: tool_detail(args))
|
|
158
|
+
@journal&.record_tool_call(run: @run_seq, call_id: tool_call["id"], tool: canonical_name, args: args)
|
|
159
|
+
|
|
160
|
+
result = nil
|
|
161
|
+
begin
|
|
162
|
+
result = dispatch_subagent_tool(tool, name, args)
|
|
163
|
+
ensure
|
|
164
|
+
elapsed = @operations.finish(OperationTracker::TOOL)
|
|
165
|
+
end
|
|
166
|
+
duration_ms = result.duration_ms.positive? ? result.duration_ms : ((elapsed || 0.0) * 1000).round(1)
|
|
167
|
+
@journal&.record_tool_result(run: @run_seq, call_id: tool_call["id"], tool: canonical_name,
|
|
168
|
+
status: result.status, duration_ms: duration_ms, output: result.output,
|
|
169
|
+
exit_code: result.exit_code, truncated: result.truncated,
|
|
170
|
+
artifact_ref: Rubik::Uris.subagent_transcript(@storage_id))
|
|
173
171
|
Rubik::Orchestrator::ToolHistory.write(@history, tool_call_id: tool_call["id"], tool_name: name,
|
|
174
|
-
status: status, duration_ms: duration_ms,
|
|
172
|
+
status: result.status, duration_ms: duration_ms,
|
|
173
|
+
content: result.output, exit_code: result.exit_code,
|
|
174
|
+
metadata: result.metadata)
|
|
175
175
|
nil
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
+
# Redacted, clipped argument preview for the model-visible operation
|
|
179
|
+
# snapshot (issue #126): credentials are masked before they can reach
|
|
180
|
+
# status/wait output, and long payloads are bounded.
|
|
181
|
+
def tool_detail(args)
|
|
182
|
+
json = Rubik::Platform.safe_json_generate(Rubik::Security::Redactor.mask_value(args))
|
|
183
|
+
json.length > 120 ? "#{json[0, 120]}... [truncated]" : json
|
|
184
|
+
end
|
|
185
|
+
|
|
178
186
|
# The subagent's own spelling of the shared auto-apply policy (AutoApply
|
|
179
187
|
# owns the decision): decide, apply+sync through the coeffect shell,
|
|
180
188
|
# and REPORT failures instead of swallowing them — a failed pre-test
|
|
@@ -208,30 +216,15 @@ module Rubik
|
|
|
208
216
|
Rubik::Coeffects.get(@context, Rubik::Coeffects::SHELL)
|
|
209
217
|
end
|
|
210
218
|
|
|
211
|
-
# Returns [truncated_output, status] so the dispatch site can build
|
|
212
|
-
# the canonical tool row. Unknown tools and execution errors carry
|
|
213
|
-
# :error; plain results are :ok (Result-backed tools carry their own).
|
|
214
|
-
def dispatch_subagent_tool(tool, name, args)
|
|
215
|
-
return ["Tool '#{name}' is not available in subagent toolset.", :error] unless tool
|
|
216
|
-
|
|
217
|
-
raw = tool.execute(args)
|
|
218
|
-
out_str = raw.respond_to?(:output) ? raw.output.to_s : raw.to_s
|
|
219
|
-
max_chars = (@config.respond_to?(:fetch) ? @config.fetch("tools.max_output_chars", 12_000) : 12_000) || 12_000
|
|
220
|
-
max_lines = (@config.respond_to?(:fetch) ? @config.fetch("tools.max_output_lines", 300) : 300) || 300
|
|
221
|
-
trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars.to_i, max_lines: max_lines.to_i)
|
|
222
|
-
status = raw.respond_to?(:status) ? raw.status : :ok
|
|
223
|
-
[trunc[:text], status]
|
|
224
|
-
rescue StandardError => e
|
|
225
|
-
["Tool execution error: #{e.class}: #{e.message}", :error]
|
|
226
|
-
end
|
|
227
|
-
|
|
228
219
|
private
|
|
229
220
|
|
|
230
221
|
# Plan 109 Faz A: per-step budget transparency replaces the 75%
|
|
231
222
|
# warning latch — Budget owns the marker string under its lock; the
|
|
232
223
|
# step loop owns only the history mutation (a conversation fact),
|
|
233
|
-
# the same split the warning used.
|
|
224
|
+
# the same split the warning used. The step boundary is also an
|
|
225
|
+
# observable progress point for the operation telemetry (#126).
|
|
234
226
|
def inject_step_marker
|
|
227
|
+
@operations.progress!
|
|
235
228
|
@history << { role: "user", content: @budget.step_marker }
|
|
236
229
|
end
|
|
237
230
|
end
|