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.required` on the recorder and blocks on the gate until an HTTP client posts a decision.
-
#ask emits ‘clarify.required` and 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 =
‘always` from 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.required` and 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.required` and 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.
- #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) ⇒ Object
- #tool_finished(name, result: nil) ⇒ Object
- #tool_started(name, arguments: nil, at: 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.
166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/rubino/ui/api.rb', line 166 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
62 |
# File 'lib/rubino/ui/api.rb', line 62 def assistant_text(text) = emit_event(:assistant_text, text: text) |
#blank_line ⇒ Object
96 |
# File 'lib/rubino/ui/api.rb', line 96 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
88 89 90 |
# File 'lib/rubino/ui/api.rb', line 88 def compression_finished(, at: nil) emit_event(:compression_finished, metadata: , at: at) end |
#compression_started(at: nil) ⇒ Object
86 |
# File 'lib/rubino/ui/api.rb', line 86 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.
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 |
# File 'lib/rubino/ui/api.rb', line 119 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
59 |
# File 'lib/rubino/ui/api.rb', line 59 def error() = emit_event(:error, message: ) |
#info(message) ⇒ Object
55 |
# File 'lib/rubino/ui/api.rb', line 55 def info() = emit_event(:info, message: ) |
#job_enqueued(type) ⇒ Object
92 |
# File 'lib/rubino/ui/api.rb', line 92 def job_enqueued(type) = emit_event(:job_enqueued, type: type) |
#job_finished(type) ⇒ Object
94 |
# File 'lib/rubino/ui/api.rb', line 94 def job_finished(type) = emit_event(:job_finished, type: type) |
#job_started(type) ⇒ Object
93 |
# File 'lib/rubino/ui/api.rb', line 93 def job_started(type) = emit_event(:job_started, type: type) |
#mode_changed(name, previous: nil) ⇒ Object
97 |
# File 'lib/rubino/ui/api.rb', line 97 def mode_changed(name, previous: nil) = emit_event(:mode_changed, mode: name, previous: previous) |
#note(text) ⇒ Object
61 |
# File 'lib/rubino/ui/api.rb', line 61 def note(text) = emit_event(:note, text: text) |
#reasoning_changed(mode, previous: nil) ⇒ Object
99 |
# File 'lib/rubino/ui/api.rb', line 99 def reasoning_changed(mode, previous: nil) = emit_event(:reasoning_changed, mode: mode, previous: previous) |
#reasoning_status(mode) ⇒ Object
98 |
# File 'lib/rubino/ui/api.rb', line 98 def reasoning_status(mode) = emit_event(:reasoning_status, mode: mode) |
#separator ⇒ Object
95 |
# File 'lib/rubino/ui/api.rb', line 95 def separator = emit_event(:separator) |
#status(message) ⇒ Object
60 |
# File 'lib/rubino/ui/api.rb', line 60 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.
68 69 70 71 72 73 |
# File 'lib/rubino/ui/api.rb', line 68 def stream(chunk) return if chunk.is_a?(Hash) && chunk[:type] == :thinking && Config::ReasoningPrefs.mode(Rubino.configuration) == :hidden emit_event(:stream, chunk: chunk) end |
#stream_end ⇒ Object
75 |
# File 'lib/rubino/ui/api.rb', line 75 def stream_end = emit_event(:stream_end) |
#success(message) ⇒ Object
57 |
# File 'lib/rubino/ui/api.rb', line 57 def success() = emit_event(:success, message: ) |
#table(headers:, rows:) ⇒ Object
77 |
# File 'lib/rubino/ui/api.rb', line 77 def table(headers:, rows:) = emit_event(:table, headers: headers, rows: rows) |
#think_changed(effort, previous: nil) ⇒ Object
101 |
# File 'lib/rubino/ui/api.rb', line 101 def think_changed(effort, previous: nil) = emit_event(:think_changed, effort: effort, previous: previous) |
#think_status(effort) ⇒ Object
100 |
# File 'lib/rubino/ui/api.rb', line 100 def think_status(effort) = emit_event(:think_status, effort: effort) |
#thinking_started ⇒ Object
76 |
# File 'lib/rubino/ui/api.rb', line 76 def thinking_started = emit_event(:thinking_started) |
#tool_body(text, kind: :plain) ⇒ Object
83 |
# File 'lib/rubino/ui/api.rb', line 83 def tool_body(text, kind: :plain) = emit_event(:tool_body, text: text, kind: kind) |
#tool_chunk(name, chunk) ⇒ Object
84 |
# File 'lib/rubino/ui/api.rb', line 84 def tool_chunk(name, chunk) = emit_event(:tool_chunk, name: name, chunk: chunk) |
#tool_finished(name, result: nil) ⇒ Object
85 |
# File 'lib/rubino/ui/api.rb', line 85 def tool_finished(name, result: nil) = emit_event(:tool_finished, name: name) |
#tool_started(name, arguments: nil, at: nil) ⇒ Object
79 80 81 |
# File 'lib/rubino/ui/api.rb', line 79 def tool_started(name, arguments: nil, at: nil) emit_event(:tool_started, name: name, arguments: arguments, at: at) end |
#warning(message) ⇒ Object
58 |
# File 'lib/rubino/ui/api.rb', line 58 def warning() = emit_event(:warning, message: ) |