Class: Rubino::Agent::Runner

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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") ⇒ 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
# 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")
  @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
  # 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_definitionObject

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.



163
164
165
# File 'lib/rubino/agent/runner.rb', line 163

def agent_definition
  @agent_definition
end

#model_idObject (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

#polishingObject (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).



60
61
62
# File 'lib/rubino/agent/runner.rb', line 60

def polishing
  @polishing
end

#sessionObject (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.

Returns:

  • (Boolean)


98
99
100
# File 'lib/rubino/agent/runner.rb', line 98

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).



189
190
191
192
# File 'lib/rubino/agent/runner.rb', line 189

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).



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/rubino/agent/runner.rb', line 236

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.

Returns:

  • (Boolean)


197
198
199
# File 'lib/rubino/agent/runner.rb', line 197

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.



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/rubino/agent/runner.rb', line 69

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(friendly_error_message(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.



107
108
109
110
111
112
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
# File 'lib/rubino/agent/runner.rb', line 107

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,
    # The SOFT iteration ceiling (where the budget-extension prompt fires)
    # vs the HARD max_turns outer rail. For the main agent @max_turns is the
    # `--max-turns N` override, which intentionally sets the soft ceiling.
    # A SUBAGENT, though, gets @max_turns = definition.max_turns (= config
    # agent.max_turns, 90) — passing THAT as the soft ceiling made soft ==
    # hard, so #extendable? was always false and a subagent could NEVER
    # surface a budget request (#571) — it just force-summarized. Subagents
    # therefore pass nil so the soft ceiling falls back to config
    # agent.max_tool_iterations (25) < the 90 hard rail, exactly like the
    # main agent — so a subagent at 25 iterations parks and asks for budget
    # via the dropdown (#574), extendable up to the 90 outer rail.
    max_tool_iterations: @session_source == "subagent" ? nil : @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

  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.



169
170
171
172
173
174
175
# File 'lib/rubino/agent/runner.rb', line 169

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`, 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.



209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/rubino/agent/runner.rb', line 209

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