Class: Rubino::Agent::Runner
- Inherits:
-
Object
- Object
- Rubino::Agent::Runner
- Defined in:
- lib/rubino/agent/runner.rb
Overview
Top-level orchestrator for a single user interaction. Coordinates session management, the agent loop, and post-turn jobs.
Instance Attribute Summary collapse
-
#agent_definition ⇒ Object
Pins the agent Definition this runner threads into every subsequent turn (the sticky
/agent <name>/ Tab-cycle switch). -
#last_stop_reason ⇒ Object
readonly
How this runner's LAST turn terminated (Agent::Loop#stop_reason), threaded up via Lifecycle.
-
#model_id ⇒ Object
readonly
The resolved model id this runner runs against.
-
#polishing ⇒ Object
readonly
The detached post-turn polishing worker, so the CLI can show the non-blocking "polishing… (Esc to skip)" indicator while it runs and extend the single Esc/cancel path to it (#319).
-
#session ⇒ Object
readonly
Returns the value of attribute session.
Instance Method Summary collapse
-
#auth_error? ⇒ Boolean
True when an AUTH/credential error was surfaced during this runner's lifetime (read by the interactive REPL to exit non-zero on teardown).
-
#cancel!(reason: :user) ⇒ Object
Flips the current turn's cancel token.
-
#end_session!(handoff: false) ⇒ Object
Marks the current session ended (#100).
-
#initialize(session_id: nil, model_override: nil, provider_override: nil, max_turns: nil, ignore_rules: false, ui: nil, agent_definition: nil, event_bus: nil, announce_session: true, session_source: "cli", interactive: false) ⇒ Runner
constructor
A new instance of Runner.
-
#polishing? ⇒ Boolean
True while the detached post-turn polishing is still draining — drives the non-blocking "polishing… (Esc to skip)" indicator the CLI shows without owning the input.
-
#run(input, image_paths: [], input_queue: nil, paste_expansions: []) ⇒ Object
Executes a full interaction turn, swallowing failures so CLI callers can stay in the REPL after a model/tool error.
-
#run!(input, image_paths: [], input_queue: nil, paste_expansions: []) ⇒ Object
Like
runbut propagates exceptions to the caller. -
#run_with_agent(definition, input) ⇒ Object
Runs ONE turn under
definition(a one-shot/<name> <message>route) without disturbing the runner's sticky agent. -
#switch_model!(model_id) ⇒ Object
Switches the LIVE model for this runner (the in-chat
/model <name>). -
#sync_resumed_session_model!(session) ⇒ Object
Aligns a RESUMED session's stored model with the model the adapter will actually use this run (#model-resume).
Constructor Details
#initialize(session_id: nil, model_override: nil, provider_override: nil, max_turns: nil, ignore_rules: false, ui: nil, agent_definition: nil, event_bus: nil, announce_session: true, session_source: "cli", interactive: false) ⇒ Runner
Returns a new instance of Runner.
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/rubino/agent/runner.rb', line 14 def initialize(session_id: nil, model_override: nil, provider_override: nil, max_turns: nil, ignore_rules: false, ui: nil, agent_definition: nil, event_bus: nil, announce_session: true, session_source: "cli", interactive: false) @ui = ui || Rubino.ui # An in-chat rewind/fork builds a runner on the child session but has its # own purpose-built "┄ rewound to message N — editing ┄" marker, so the # generic "Resuming session: <id>…" plumbing line must not also leak into # the transcript (#220). Off-rewind callers keep the announcement. @announce_session = announce_session # Defaults to the process-global bus for the single-run CLI path; the # HTTP Executor injects a fresh per-run bus so concurrent runs don't # cross-contaminate each other's events/output (architecture audit A1). @event_bus = event_bus || Rubino.event_bus @config = Rubino.configuration @session_repo = Session::Repository.new @message_store = Session::Store.new @explicit_model_override = model_override @model_id = model_override || @config.dig("model", "default") @provider_override = provider_override @max_turns = max_turns @ignore_rules = ignore_rules @agent_definition = agent_definition # The `source` stamped on a freshly-created session row. Defaults to # "cli" (a user-driven REPL/one-shot session); the `task` tool passes # "subagent" so internal subagent prompt-sessions can be filtered out of # the user-facing /sessions picker + `sessions list` (they're machinery, # not the user's own conversations) while staying resumable by explicit # id. Like Claude Code hiding its Task subagent sessions from the picker. @session_source = session_source # True only for the interactive REPL, where more in-process turns follow # this one. Lifecycle uses it to keep automatic memory extraction OFF the # live KV-cache slot between turns (#608c) — a headless one-shot, which # exits after its single turn, leaves it false and extracts normally. @interactive = interactive # Pre-instantiate so cancel! is meaningful between turns and during the # window between Signal.trap install and run() — a too-early Ctrl+C # used to land on a nil token and silently no-op, then the next run # started fresh and the user's cancel was lost. @cancel_token = Interaction::CancelToken.new # Detached post-turn polishing worker (#319): owns the background thread # that drains memory-extract / skill-distill / summarize OFF the live # turn so the next prompt is never gated, and is cancellable via Esc. # Reused across this runner's turns so #running? / #cancel! address the # CURRENT polishing run (coalescing rapid turns). @polishing = Interaction::Polishing.new(config: @config) @session = load_or_create_session(session_id) end |
Instance Attribute Details
#agent_definition ⇒ Object
Pins the agent Definition this runner threads into every subsequent turn
(the sticky /agent <name> / Tab-cycle switch). Lifecycle reads
from the NEXT turn — the agent's system prompt and tool scope come along.
nil restores the default (build) persona. The reader feeds the CLI
status bar and a one-shot route that wants to restore it afterwards.
179 180 181 |
# File 'lib/rubino/agent/runner.rb', line 179 def agent_definition @agent_definition end |
#last_stop_reason ⇒ Object (readonly)
How this runner's LAST turn terminated (Agent::Loop#stop_reason),
threaded up via Lifecycle. nil until a turn has run. Read by the task
tool after a subagent's #run! to distinguish a real completion from a
budget-/time-truncated partial.
171 172 173 |
# File 'lib/rubino/agent/runner.rb', line 171 def last_stop_reason @last_stop_reason end |
#model_id ⇒ Object (readonly)
The resolved model id this runner runs against. Read by SubagentProbe so an ephemeral peek uses the child's OWN model, not the global default.
12 13 14 |
# File 'lib/rubino/agent/runner.rb', line 12 def model_id @model_id end |
#polishing ⇒ Object (readonly)
The detached post-turn polishing worker, so the CLI can show the non-blocking "polishing… (Esc to skip)" indicator while it runs and extend the single Esc/cancel path to it (#319).
66 67 68 |
# File 'lib/rubino/agent/runner.rb', line 66 def polishing @polishing end |
#session ⇒ Object (readonly)
Returns the value of attribute session.
8 9 10 |
# File 'lib/rubino/agent/runner.rb', line 8 def session @session end |
Instance Method Details
#auth_error? ⇒ Boolean
True when an AUTH/credential error was surfaced during this runner's lifetime (read by the interactive REPL to exit non-zero on teardown). Latched by #run; never reset.
104 105 106 |
# File 'lib/rubino/agent/runner.rb', line 104 def auth_error? @auth_error == true end |
#cancel!(reason: :user) ⇒ Object
Flips the current turn's cancel token. Called from the UI thread when the user hits Esc or a second Ctrl+C while the worker is mid-stream. No-op when no turn is in flight.
ONE Esc cancels whatever is in flight (#319): the FOREGROUND turn OR the
DETACHED post-turn polishing. Flipping both tokens is safe — a token is
one-shot and idle-when-untouched, so cancelling the not-running side is a
harmless no-op. The polishing worker stops between jobs and its aux
retry/backoff aborts mid-wait, leaving partial work in place.
reason records WHY the turn was cancelled so the result label stays
truthful: :user (Esc/Ctrl+C, default) vs :external (SIGTERM/SIGHUP
teardown). Plumbed through to the CancelToken / Interrupted (#361b).
205 206 207 208 |
# File 'lib/rubino/agent/runner.rb', line 205 def cancel!(reason: :user) @cancel_token&.cancel!(reason: reason) @polishing&.cancel! end |
#end_session!(handoff: false) ⇒ Object
Marks the current session ended (#100). Called from the CLI on a clean
REPL teardown (and best-effort on terminal close) so a session stops
showing as "active" forever and cleanup/list/--continue can tell a
finished session from a live one. Best-effort: a failure here must never
crash the exit path.
handoff marks an IN-SESSION switch (the in-chat /new) where the REPL
immediately builds a fresh runner and stays interactive — as opposed to a
teardown/headless close where the process is about to exit. On a handoff
the end-of-session memory flush is ENQUEUED detached (so the prompt is
never blocked 2-3s on the catch-all extract's aux LLM call) and the
in-flight-polishing wait is skipped — the still-running process's worker
drains the same process-global queue. Teardown/headless keep the
synchronous flush + bounded wait so the row's facts are mined before the
process dies (a detached job would never drain after exit).
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 |
# File 'lib/rubino/agent/runner.rb', line 278 def end_session!(handoff: false) # Nothing to end for a session that was never persisted (the user opened # chat and left without sending a message, #144) — there's no row. return if @session.nil? || (@session[:persisted] == false && !@session_repo.persisted?(@session[:id])) # End-of-session memory flush (#554): the turn-based auto-extract gate # only fires when the turn counter lands on memory.auto_extract_interval # (default 10), so a session that ends with FEWER turns than the interval # — and never compacted — never extracted its facts. This is the # catch-all that mines any un-extracted turns once on a clean close, # bounded by the same per-session extraction watermark (so it never # double-extracts what the interval/compaction flush already mined) and # gated on memory.enabled + memory.auto_extract. Mirrors Hermes' # MemoryProvider#on_session_end. Best-effort: never breaks the exit. flush_memory_on_session_end!(handoff: handoff) @session_repo.end_session!(@session[:id]) rescue StandardError nil ensure # Let any in-flight detached polishing settle (bounded) so a clean # teardown doesn't abandon a half-written extraction (#319). Best-effort: # the cursor re-feeds anything unfinished next session anyway. On a # handoff we DON'T wait — the prompt must stay instant and the new # runner's worker drains the same queue. @polishing&.wait(3) unless handoff # Release the per-session advisory lock (#543) so a subsequent # `--continue`/`--resume` of this id in another live process can claim it # cleanly. The kernel also drops the flock on process exit/crash, so this # is just the prompt clean-teardown release. @session_lock&.release @session_lock = nil end |
#polishing? ⇒ Boolean
True while the detached post-turn polishing is still draining — drives the non-blocking "polishing… (Esc to skip)" indicator the CLI shows without owning the input.
213 214 215 |
# File 'lib/rubino/agent/runner.rb', line 213 def polishing? @polishing&.running? || false end |
#run(input, image_paths: [], input_queue: nil, paste_expansions: []) ⇒ Object
Executes a full interaction turn, swallowing failures so CLI callers
can stay in the REPL after a model/tool error. The friendly UI
message is emitted, but the bus event INTERACTION_FAILED is NOT
re-emitted here — Interaction::Lifecycle is the single source of
truth for that, and it already emitted before re-raising. Use
run! from non-CLI callers (HTTP executor) that need the
exception to propagate so the run row can be marked failed.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/rubino/agent/runner.rb', line 75 def run(input, image_paths: [], input_queue: nil, paste_expansions: []) run!(input, image_paths: image_paths, input_queue: input_queue, paste_expansions: paste_expansions) rescue Interrupted # Standardized single interrupt notice: a dim `⎿ interrupted` marker # right after the partial answer the Loop already committed via # #stream_end. Replaces the old "⚠ interrupted by user" warning so the # Ctrl+C path and the interrupt-by-default type-ahead path read the same. @ui.turn_interrupted nil rescue SystemExit, Interrupt, SignalException raise rescue Exception => e # rubocop:disable Lint/RescueException # Record an AUTH/credential failure so the interactive REPL can exit # NON-ZERO on teardown (field standard: a CLI that surfaced an auth error # must not report success — git/gh/Claude Code/Codex all exit non-zero). # We do NOT exit here: the swallow-and-stay-in-the-REPL contract above is # deliberate (the user can fix their key and retry without relaunching), # so the failure is LATCHED and the process exits 1 at clean teardown # instead of mid-turn. A subsequent successful turn does NOT clear it — # the run as a whole still hit a credential error the caller should see. @auth_error = true if auth_credential_error?(e) @ui.error((e)) nil end |
#run!(input, image_paths: [], input_queue: nil, paste_expansions: []) ⇒ Object
Like run but propagates exceptions to the caller. The HTTP
Executor uses this so it can transition the run row to "failed"
(instead of mark_completed!) when the lifecycle raises. The
ScriptError / Exception net is kept here too so the Executor sees
LoadError etc. as a real failure rather than nil-and-completed.
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/rubino/agent/runner.rb', line 113 def run!(input, image_paths: [], input_queue: nil, paste_expansions: []) # Each turn gets a fresh token. A CancelToken is one-shot, so reusing a # cancelled one would poison every subsequent turn (it would raise # Interrupted immediately at the first poll point). The per-turn SIGINT # trap (CLI) / stop-watcher (HTTP) is wired to #cancel! against this new # token before any LLM/tool work runs, so an in-flight interrupt still # cancels the current turn. @cancel_token = Interaction::CancelToken.new lifecycle = Interaction::Lifecycle.new( session: @session, event_bus: @event_bus, ui: @ui, config: @config, ignore_rules: @ignore_rules, agent_definition: @agent_definition, cancel_token: @cancel_token, model_override: @explicit_model_override, provider_override: @provider_override, interactive: @interactive, # The SOFT iteration ceiling (where the budget-extension prompt fires) # vs the HARD max_turns outer rail (config agent.max_turns, applied # inside IterationBudget). @max_turns carries the per-run soft cap on # BOTH paths: # - MAIN agent: the `--max-turns N` override (nil ⇒ config default). # - SUBAGENT: definition.max_turns — e.g. explore=20, general=50, # BELOW the 90 hard rail — so the child both HONORS its per-agent # cap (#571: it used to be dropped entirely) AND can surface the # #574 budget-park at that cap, extendable up to the 90 outer rail. # A subagent that sets no max_turns falls back to config agent.max_turns # (soft == hard) and simply hard-stops there, like the main agent. max_tool_iterations: @max_turns, polishing: @polishing ) response = lifecycle.execute(input, image_paths: image_paths, input_queue: input_queue, paste_expansions: paste_expansions) # Adopt an automatic-compaction swap so the NEXT turn runs on the (small) # compaction child, not the dead parent (P3 F1). When #check_and_compact # fires, it reassigns the lifecycle's session to the child; without # picking that up here the Runner would rebuild every subsequent turn's # Lifecycle on the un-shrunk parent → re-compact every turn (superlinear # DB/context bloat + ~2.9x slowdown). This is the automatic-path # counterpart to the manual /compact swap (chat_command rebuilds the # runner on result[:compact_into]). @session = lifecycle.active_session # Post-turn state, read by the subagent-completion path (task_tool) so a # force-summarized/truncated child is reported PARTIAL, not "completed". @last_stop_reason = lifecycle.last_stop_reason response end |
#run_with_agent(definition, input) ⇒ Object
Runs ONE turn under definition (a one-shot /<name> <message> route)
without disturbing the runner's sticky agent. The override is swapped in
for the single #run and restored in the ensure, so the next idle prompt
is back on whatever the user had pinned.
185 186 187 188 189 190 191 |
# File 'lib/rubino/agent/runner.rb', line 185 def run_with_agent(definition, input, **) sticky = @agent_definition @agent_definition = definition run(input, **) ensure @agent_definition = sticky end |
#switch_model!(model_id) ⇒ Object
Switches the LIVE model for this runner (the in-chat /model <name>).
Lifecycle builds the adapter per turn from
@explicit_model_override || @session[:model], and the CLI always
passes a model_override at boot — so both fields must move for the
NEXT turn to actually hit the new model. The session hash is mutated
in place (statusbar and /status read it) and the persisted row is
updated so resume/--continue agree; an unpersisted lazy session gets
the new value via Repository#persist! on its first message instead.
225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/rubino/agent/runner.rb', line 225 def switch_model!(model_id) @explicit_model_override = model_id @model_id = model_id @session[:model] = model_id @session[:provider] = @provider_override || LLM::ProviderResolver.resolve(model_id, explicit_provider: @config.dig("model", "provider")) if @session_repo.persisted?(@session[:id]) @session_repo.update(@session[:id], model: model_id, provider: @session[:provider]) end model_id end |
#sync_resumed_session_model!(session) ⇒ Object
Aligns a RESUMED session's stored model with the model the adapter will
actually use this run (#model-resume). Lifecycle builds the adapter from
@explicit_model_override || @session[:model], and the CLI ALWAYS passes
a boot override (explicit -m, else model.default from config) — so on
resume the override, NOT the model this session happened to last use, is
what generates. The session row, the footer/statusbar, the token-budget
context window and /status all read session[:model], so without this
they showed the STALE pinned model (e.g. the old default) while the agent
was really running the new one: changing model.default looked ignored
even though generation honored it. Re-point the row to the effective
model so every surface tells the truth and a config change takes visible
effect. No-op when there is no explicit override (then the session model
IS what the adapter uses) or it already matches.
251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/rubino/agent/runner.rb', line 251 def sync_resumed_session_model!(session) return unless @explicit_model_override return if session[:model] == @explicit_model_override session[:model] = @explicit_model_override session[:provider] = @provider_override || LLM::ProviderResolver.resolve(@explicit_model_override, explicit_provider: @config.dig("model", "provider")) return unless @session_repo.persisted?(session[:id]) @session_repo.update(session[:id], model: session[:model], provider: session[:provider]) end |