Class: Rubino::UI::API
Overview
Bridge between Agent::Runner and the HTTP API.
Streaming output (info/success/stream/...) is appended to an in-memory event buffer that the API server drains over SSE.
Interactive prompts cross threads through an ApprovalGate:
- #confirm emits
approval.requiredon the recorder and blocks on the gate until an HTTP client posts a decision. - #ask emits
clarify.requiredand blocks the same way.
When no gate/recorder is wired (CLI or test contexts), both calls fall back to auto-approve (#confirm -> true, #ask -> nil).
APPROVE_DECISIONS lists the decision strings that count as approve;
anything else yields a false from #confirm. The two deny forms differ
only in persistence: "deny" denies this call ONCE (nothing remembered,
re-prompts next session); "deny_always" additionally persists a
permissions:deny rule so ApprovalPolicy#decide auto-denies the pattern
across sessions. The set is kept in sync with Schemas::DecideApproval so
every value the HTTP boundary accepts is either an approve or an explicit
deny — no unreachable values, no silent denies from typos. always is a
back-compat alias for always_command (existing web clients post it).
Constant Summary collapse
- APPROVE_DECISIONS =
%w[once session always always_prefix always_command].freeze
- ALWAYS_ALIAS =
alwaysfrom older web clients means the narrow "always this command" form (== always_command); normalized away before decision handling. { "always" => "always_command" }.freeze
Instance Attribute Summary collapse
-
#events ⇒ Object
readonly
Returns the value of attribute events.
Instance Method Summary collapse
-
#ask(prompt) ⇒ String?
Emits
clarify.requiredand blocks on the ApprovalGate until an HTTP client posts a clarification response for the generated clarify_id. - #assistant_text(text) ⇒ Object
- #blank_line ⇒ Object
-
#blocking_human_input? ⇒ Boolean
The API adapter parks the run on the ApprovalGate for approvals (#confirm) and clarifications (#ask) — but only when a gate AND recorder are actually wired.
- #compression_finished(metadata, at: nil) ⇒ Object
- #compression_started(at: nil) ⇒ Object
-
#confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil) ⇒ Boolean
Emits
approval.requiredand blocks on the ApprovalGate until an HTTP client posts a decision for the generated approval_id. - #error(message) ⇒ Object
- #info(message) ⇒ Object
-
#initialize(gate: nil, recorder: nil, session_id: nil, approval_cache: nil) ⇒ API
constructor
A new instance of API.
-
#interactive? ⇒ Boolean
ToolExecutor reads this to decide whether a tool needing approval can be put in front of a human (#260).
- #job_enqueued(type) ⇒ Object
- #job_finished(type) ⇒ Object
- #job_started(type) ⇒ Object
- #mode_changed(name, previous: nil) ⇒ Object
- #note(text) ⇒ Object
- #reasoning_changed(mode, previous: nil) ⇒ Object
- #reasoning_status(mode) ⇒ Object
- #separator ⇒ Object
- #status(message) ⇒ Object
-
#stream(chunk) ⇒ Object
The adapter no longer drops :thinking deltas in hidden mode (the CLI retains them unrendered for the Ctrl-O reveal, #76); the HTTP wire keeps the old contract — hidden means no reasoning deltas reach API consumers, so the gate lives here now.
- #stream_end ⇒ Object
- #success(message) ⇒ Object
- #table(headers:, rows:) ⇒ Object
- #think_changed(effort, previous: nil) ⇒ Object
- #think_status(effort) ⇒ Object
- #thinking_started ⇒ Object
- #tool_body(text, kind: :plain) ⇒ Object
- #tool_chunk(name, chunk, kind: :plain) ⇒ Object
- #tool_finished(name, result: nil) ⇒ Object
- #tool_started(name, arguments: nil, at: nil, call_id: nil) ⇒ Object
- #warning(message) ⇒ Object
Methods inherited from Base
#body, #box_close, #box_open, #confirm_destructive, #hint_row, #input_injected, #panel_line, #queued, #replay_user_input, #select, #turn_interrupted
Constructor Details
#initialize(gate: nil, recorder: nil, session_id: nil, approval_cache: nil) ⇒ API
Returns a new instance of API.
39 40 41 42 43 44 45 |
# File 'lib/rubino/ui/api.rb', line 39 def initialize(gate: nil, recorder: nil, session_id: nil, approval_cache: nil) @gate = gate @recorder = recorder @session_id = session_id @approval_cache = approval_cache || Rubino::Run::SessionApprovalCache.instance @events = [] end |
Instance Attribute Details
#events ⇒ Object (readonly)
Returns the value of attribute events.
37 38 39 |
# File 'lib/rubino/ui/api.rb', line 37 def events @events end |
Instance Method Details
#ask(prompt) ⇒ String?
Emits clarify.required and blocks on the ApprovalGate until an
HTTP client posts a clarification response for the generated
clarify_id. Returns nil when no gate/recorder is wired.
186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/rubino/ui/api.rb', line 186 def ask(prompt) return nil unless @gate && @recorder clarify_id = SecureRandom.uuid @gate.register(clarify_id, recorder: @recorder) @recorder.emit("clarify.required", { clarify_id: clarify_id, question: prompt.to_s }) answer = @gate.await(clarify_id) # Deadline elapsed with no answer: the gate emitted approval.expired; # treat an abandoned clarification as "no response". return nil if answer.equal?(Run::ApprovalGate::EXPIRED) answer end |
#assistant_text(text) ⇒ Object
71 |
# File 'lib/rubino/ui/api.rb', line 71 def assistant_text(text) = emit_event(:assistant_text, text: text) |
#blank_line ⇒ Object
116 |
# File 'lib/rubino/ui/api.rb', line 116 def blank_line = emit_event(:blank_line) |
#blocking_human_input? ⇒ Boolean
The API adapter parks the run on the ApprovalGate for approvals (#confirm) and clarifications (#ask) — but only when a gate AND recorder are actually wired. Without them both calls auto-resolve and never block, so the loop can keep streaming. Drives Loop#interactive_turn?.
51 52 53 |
# File 'lib/rubino/ui/api.rb', line 51 def blocking_human_input? !@gate.nil? && !@recorder.nil? end |
#compression_finished(metadata, at: nil) ⇒ Object
108 109 110 |
# File 'lib/rubino/ui/api.rb', line 108 def compression_finished(, at: nil) emit_event(:compression_finished, metadata: , at: at) end |
#compression_started(at: nil) ⇒ Object
106 |
# File 'lib/rubino/ui/api.rb', line 106 def compression_started(at: nil) = emit_event(:compression_started, at: at) |
#confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil) ⇒ Boolean
Emits approval.required and blocks on the ApprovalGate until an
HTTP client posts a decision for the generated approval_id.
Auto-approves (returns true) when no gate/recorder is wired.
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 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/rubino/ui/api.rb', line 139 def confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil) return true unless @gate && @recorder # Session-scope short-circuit: a prior "session" / "always_*" # decision (or a persisted prefix) for this scope means we must NOT # prompt again in the same session. return true if scope && @session_id && @approval_cache.allowed?(@session_id, scope) rule = derive_rule(tool, command, pattern_key) approval_id = SecureRandom.uuid # Register before emitting: a fast HTTP client could POST a decision # the moment it sees approval.required, racing past #await; the gate # must already know the id is valid by then. @gate.register(approval_id, recorder: @recorder) @recorder.emit( "approval.required", approval_payload(approval_id, question, tool: tool, command: command, pattern_key: pattern_key, description: description, rule: rule) ) decision = @gate.await(approval_id) # Wait deadline elapsed with no human answer (abandoned run): the gate # already emitted approval.expired. Resolve to a safe DENY — NEVER an # auto-approve — so the gated command does not run. return false if decision.equal?(Run::ApprovalGate::EXPIRED) normalized = normalize_decision(decision) approved = APPROVE_DECISIONS.include?(normalized) if approved apply_decision(normalized, scope: scope, command: command, rule: rule) elsif normalized == "deny_always" # Not an approve, but PERSIST the deny so ApprovalPolicy#decide # auto-denies this pattern across sessions (it checks permissions:deny # first). Plain "deny" stays a one-off — nothing persisted, re-prompts. persist_deny(tool, command, rule) end approved end |
#error(message) ⇒ Object
68 |
# File 'lib/rubino/ui/api.rb', line 68 def error() = emit_event(:error, message: ) |
#info(message) ⇒ Object
64 |
# File 'lib/rubino/ui/api.rb', line 64 def info() = emit_event(:info, message: ) |
#interactive? ⇒ Boolean
ToolExecutor reads this to decide whether a tool needing approval can be put in front of a human (#260). The API adapter CAN — via the HTTP ApprovalGate — but only when a gate + recorder are wired; a gate-less embed/test run has no one to answer, so it fails closed too instead of silently auto-approving a write/shell command.
60 61 62 |
# File 'lib/rubino/ui/api.rb', line 60 def interactive? blocking_human_input? end |
#job_enqueued(type) ⇒ Object
112 |
# File 'lib/rubino/ui/api.rb', line 112 def job_enqueued(type) = emit_event(:job_enqueued, type: type) |
#job_finished(type) ⇒ Object
114 |
# File 'lib/rubino/ui/api.rb', line 114 def job_finished(type) = emit_event(:job_finished, type: type) |
#job_started(type) ⇒ Object
113 |
# File 'lib/rubino/ui/api.rb', line 113 def job_started(type) = emit_event(:job_started, type: type) |
#mode_changed(name, previous: nil) ⇒ Object
117 |
# File 'lib/rubino/ui/api.rb', line 117 def mode_changed(name, previous: nil) = emit_event(:mode_changed, mode: name, previous: previous) |
#note(text) ⇒ Object
70 |
# File 'lib/rubino/ui/api.rb', line 70 def note(text) = emit_event(:note, text: text) |
#reasoning_changed(mode, previous: nil) ⇒ Object
119 |
# File 'lib/rubino/ui/api.rb', line 119 def reasoning_changed(mode, previous: nil) = emit_event(:reasoning_changed, mode: mode, previous: previous) |
#reasoning_status(mode) ⇒ Object
118 |
# File 'lib/rubino/ui/api.rb', line 118 def reasoning_status(mode) = emit_event(:reasoning_status, mode: mode) |
#separator ⇒ Object
115 |
# File 'lib/rubino/ui/api.rb', line 115 def separator = emit_event(:separator) |
#status(message) ⇒ Object
69 |
# File 'lib/rubino/ui/api.rb', line 69 def status() = emit_event(:status, message: ) |
#stream(chunk) ⇒ Object
The adapter no longer drops :thinking deltas in hidden mode (the CLI retains them unrendered for the Ctrl-O reveal, #76); the HTTP wire keeps the old contract — hidden means no reasoning deltas reach API consumers, so the gate lives here now.
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/rubino/ui/api.rb', line 77 def stream(chunk) # A tool-preparing hint (the tool name while its args stream, #608) is a # transient progress signal, NOT answer text — route it to its own event # so an API consumer can surface "preparing <tool>" without mistaking it # for streamed content. (The real call arrives later as :tool_started.) return emit_event(:tool_preparing, name: chunk[:text]) if chunk.is_a?(Hash) && chunk[:type] == :tool_preparing # A streaming argument fragment of the in-flight call (#608): its own # event so a consumer can show the params composing, distinct from # answer content. The decoded/rendered form is the CLI's concern. return emit_event(:tool_args, text: chunk[:text]) if chunk.is_a?(Hash) && chunk[:type] == :tool_args return if chunk.is_a?(Hash) && chunk[:type] == :thinking && Config::ReasoningPrefs.effective_mode(Rubino.configuration) == :hidden emit_event(:stream, chunk: chunk) end |
#stream_end ⇒ Object
95 |
# File 'lib/rubino/ui/api.rb', line 95 def stream_end = emit_event(:stream_end) |
#success(message) ⇒ Object
66 |
# File 'lib/rubino/ui/api.rb', line 66 def success() = emit_event(:success, message: ) |
#table(headers:, rows:) ⇒ Object
97 |
# File 'lib/rubino/ui/api.rb', line 97 def table(headers:, rows:) = emit_event(:table, headers: headers, rows: rows) |
#think_changed(effort, previous: nil) ⇒ Object
121 |
# File 'lib/rubino/ui/api.rb', line 121 def think_changed(effort, previous: nil) = emit_event(:think_changed, effort: effort, previous: previous) |
#think_status(effort) ⇒ Object
120 |
# File 'lib/rubino/ui/api.rb', line 120 def think_status(effort) = emit_event(:think_status, effort: effort) |
#thinking_started ⇒ Object
96 |
# File 'lib/rubino/ui/api.rb', line 96 def thinking_started = emit_event(:thinking_started) |
#tool_body(text, kind: :plain) ⇒ Object
103 |
# File 'lib/rubino/ui/api.rb', line 103 def tool_body(text, kind: :plain) = emit_event(:tool_body, text: text, kind: kind) |
#tool_chunk(name, chunk, kind: :plain) ⇒ Object
104 |
# File 'lib/rubino/ui/api.rb', line 104 def tool_chunk(name, chunk, kind: :plain) = emit_event(:tool_chunk, name: name, chunk: chunk, kind: kind) |
#tool_finished(name, result: nil) ⇒ Object
105 |
# File 'lib/rubino/ui/api.rb', line 105 def tool_finished(name, result: nil) = emit_event(:tool_finished, name: name) |
#tool_started(name, arguments: nil, at: nil, call_id: nil) ⇒ Object
99 100 101 |
# File 'lib/rubino/ui/api.rb', line 99 def tool_started(name, arguments: nil, at: nil, call_id: nil) emit_event(:tool_started, name: name, arguments: arguments, at: at) end |
#warning(message) ⇒ Object
67 |
# File 'lib/rubino/ui/api.rb', line 67 def warning() = emit_event(:warning, message: ) |