Class: Rubino::UI::API

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/ui/api.rb

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

Instance Method Summary collapse

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

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

Parameters:

  • prompt (String)

    question to ask the user

Returns:

  • (String, nil)

    the response text, or nil in non-API contexts or when the wait deadline elapsed with no answer (abandoned run)



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_lineObject



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

Returns:

  • (Boolean)


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.

Parameters:

  • question (String)

    human-readable approval prompt

  • scope (String, nil) (defaults to: nil)

    cache key for "session"/"always" decisions; pass "<tool>:<args>" so a second call with the same shape bypasses the user prompt entirely. Nil opts out.

  • tool (String, nil) (defaults to: nil)

    tool name, for the enriched event.

  • command (String, nil) (defaults to: nil)

    literal command/args, for the event + prefix derivation when a decision persists.

  • pattern_key (String, nil) (defaults to: nil)

    matched dangerous-pattern key, if any.

  • description (String, nil) (defaults to: nil)

    dangerous-pattern description, if any.

Returns:

  • (Boolean)

    true when the decision is in APPROVE_DECISIONS; false on an explicit deny OR when the gate's wait deadline elapses with no answer (abandoned run) — the safe auto-DENY default.



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(message) = emit_event(:error, message: message)

#info(message) ⇒ Object



64
# File 'lib/rubino/ui/api.rb', line 64

def info(message) = emit_event(:info, message: 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.

Returns:

  • (Boolean)


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)

#separatorObject



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(message) = emit_event(:status, message: 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_endObject



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(message) = emit_event(:success, message: 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_startedObject



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(message) = emit_event(:warning, message: message)