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.
-
#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) ⇒ 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.
175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/rubino/ui/api.rb', line 175 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
105 |
# File 'lib/rubino/ui/api.rb', line 105 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
97 98 99 |
# File 'lib/rubino/ui/api.rb', line 97 def compression_finished(, at: nil) emit_event(:compression_finished, metadata: , at: at) end |
#compression_started(at: nil) ⇒ Object
95 |
# File 'lib/rubino/ui/api.rb', line 95 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.
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 166 |
# File 'lib/rubino/ui/api.rb', line 128 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
101 |
# File 'lib/rubino/ui/api.rb', line 101 def job_enqueued(type) = emit_event(:job_enqueued, type: type) |
#job_finished(type) ⇒ Object
103 |
# File 'lib/rubino/ui/api.rb', line 103 def job_finished(type) = emit_event(:job_finished, type: type) |
#job_started(type) ⇒ Object
102 |
# File 'lib/rubino/ui/api.rb', line 102 def job_started(type) = emit_event(:job_started, type: type) |
#mode_changed(name, previous: nil) ⇒ Object
106 |
# File 'lib/rubino/ui/api.rb', line 106 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
108 |
# File 'lib/rubino/ui/api.rb', line 108 def reasoning_changed(mode, previous: nil) = emit_event(:reasoning_changed, mode: mode, previous: previous) |
#reasoning_status(mode) ⇒ Object
107 |
# File 'lib/rubino/ui/api.rb', line 107 def reasoning_status(mode) = emit_event(:reasoning_status, mode: mode) |
#separator ⇒ Object
104 |
# File 'lib/rubino/ui/api.rb', line 104 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 |
# File 'lib/rubino/ui/api.rb', line 77 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
84 |
# File 'lib/rubino/ui/api.rb', line 84 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
86 |
# File 'lib/rubino/ui/api.rb', line 86 def table(headers:, rows:) = emit_event(:table, headers: headers, rows: rows) |
#think_changed(effort, previous: nil) ⇒ Object
110 |
# File 'lib/rubino/ui/api.rb', line 110 def think_changed(effort, previous: nil) = emit_event(:think_changed, effort: effort, previous: previous) |
#think_status(effort) ⇒ Object
109 |
# File 'lib/rubino/ui/api.rb', line 109 def think_status(effort) = emit_event(:think_status, effort: effort) |
#thinking_started ⇒ Object
85 |
# File 'lib/rubino/ui/api.rb', line 85 def thinking_started = emit_event(:thinking_started) |
#tool_body(text, kind: :plain) ⇒ Object
92 |
# File 'lib/rubino/ui/api.rb', line 92 def tool_body(text, kind: :plain) = emit_event(:tool_body, text: text, kind: kind) |
#tool_chunk(name, chunk, kind: :plain) ⇒ Object
93 |
# File 'lib/rubino/ui/api.rb', line 93 def tool_chunk(name, chunk, kind: :plain) = emit_event(:tool_chunk, name: name, chunk: chunk, kind: kind) |
#tool_finished(name, result: nil) ⇒ Object
94 |
# File 'lib/rubino/ui/api.rb', line 94 def tool_finished(name, result: nil) = emit_event(:tool_finished, name: name) |
#tool_started(name, arguments: nil, at: nil) ⇒ Object
88 89 90 |
# File 'lib/rubino/ui/api.rb', line 88 def tool_started(name, arguments: nil, at: 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: ) |