Class: Rubino::Commands::Handlers::Agents
- Inherits:
-
Object
- Object
- Rubino::Commands::Handlers::Agents
- Includes:
- UI::ProbeWaitIndicator
- Defined in:
- lib/rubino/commands/handlers/agents.rb
Overview
The ‘/agents` (alias `/tasks`) drill-in surface and the `/reply` answer path, extracted from Commands::Executor (batch B).
The “see what other agents do” surface. Lists background subagents from the BackgroundTasks registry (the async ‘task` substrate), drills into a single one’s result/error, steers/probes/stops a running one, and routes a human /reply back down to a blocked child.
/agents → list
/agents <id> → drill-in (result / error / status)
/agents <id> --stop → cancel a running subagent
/agents <id> steer "…" → fire-and-forget note into the child's context
/agents <id> probe "…" → ephemeral read-only peek
/reply <id> <answer> → answer a child blocked on a human/parent ask
Constant Summary collapse
- APPROVAL_ASK_ATTEMPTS =
How many times the parked-child approval prompt re-renders after an empty/aborted read (#144) before giving up and leaving the child parked.
3- RESET_HINT =
Appended to every “no such subagent id” error (item 5). Subagent ids (sa_*) live ONLY in the current process — the BackgroundTasks registry is in-memory, never persisted — so a prior session’s id is genuinely gone after a REPL restart. The bare “no such id” left the user thinking they’d mistyped; this names the real reason so they don’t hunt for a typo. Surfaced from EVERY not-found path (/agents <id>, /reply <id>, /stop <id>, steer, probe).
"(subagents reset when rubino restarts)"
Instance Method Summary collapse
-
#answer_all_human ⇒ Object
FIFO drain of the children blocked on the human, used by the MID-TURN auto-open: deliver the head, then RE-READ awaiting_human (a 2nd child may have asked while the dropdown was open, or the head may have been delivered/timed-out) and surface the next head, until the queue is empty.
-
#answer_one_human(entry) ⇒ Object
The ONE shared “surface the answer affordance for a child blocked on the human, read the human’s answer, deliver it down the SAME wire” step — used by BOTH the idle poll (#auto_resolve_pending) and the mid-turn auto-open (BottomComposer#request_takeover, triggered by the child’s ask_parent the instant it blocks).
-
#auto_resolve_pending ⇒ Object
Auto-open the EXISTING interactive prompt for ONE pending subagent request the human must act on — the REPL idle loop calls this at every idle tick so the affordance presents ITSELF instead of forcing the user to guess ‘/agents <id>` or `/reply <id>` (the maintainer’s “auto-open the existing dropdown” ask).
-
#deliver_reply(entry, answer) ⇒ Object
Routes the answer back DOWN to the child: decide the gate (unblocks a blocking ask with the answer as its tool result) and push it onto the steer queue (a non-blocking ask folds it in next turn).
- #handle_agents(arguments) ⇒ Object
-
#handle_reply(arguments) ⇒ Object
child->parent ASK_PARENT answer: /reply <id> <answer>.
-
#handle_stop_alias(arguments) ⇒ Object
‘/stop <id>` is the discoverable alias for the unguessable `/agents <id> –stop` cancel syntax (FRICTION-4).
-
#initialize(ui:) ⇒ Agents
constructor
A new instance of Agents.
-
#probe_agent(id, question) ⇒ Object
parent->child PROBE: an EPHEMERAL read-only peek.
-
#steer_agent(id, text) ⇒ Object
parent->child STEER: a fire-and-forget note that enters the child’s context at its next turn boundary (Loop#inject_steered_input).
Methods included from UI::ProbeWaitIndicator
#probe_thinking_finished, #probe_thinking_started
Constructor Details
#initialize(ui:) ⇒ Agents
Returns a new instance of Agents.
39 40 41 |
# File 'lib/rubino/commands/handlers/agents.rb', line 39 def initialize(ui:) @ui = ui end |
Instance Method Details
#answer_all_human ⇒ Object
FIFO drain of the children blocked on the human, used by the MID-TURN auto-open: deliver the head, then RE-READ awaiting_human (a 2nd child may have asked while the dropdown was open, or the head may have been delivered/timed-out) and surface the next head, until the queue is empty. Each #answer_one_human runs its own dropdown takeover; a child that arrives mid-open simply appends and is picked up on the re-read. Bounded by the live awaiting_human snapshot shrinking each pass, so it always terminates. Runs on the INPUT thread (it owns the keyboard).
114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/rubino/commands/handlers/agents.rb', line 114 def answer_all_human loop do entry = Tools::BackgroundTasks.instance.awaiting_human.first break unless entry answer_one_human(entry) # A cancelled (still-blocked) head would otherwise re-surface forever: # stop once the head is no longer awaiting an answer it just got, OR # the human declined it. We break when the FIRST awaiting_human entry # is unchanged after the attempt (cancelled), so an Esc doesn't loop. still = Tools::BackgroundTasks.instance.awaiting_human.first break if still && still.id == entry.id end end |
#answer_one_human(entry) ⇒ Object
The ONE shared “surface the answer affordance for a child blocked on the human, read the human’s answer, deliver it down the SAME wire” step —used by BOTH the idle poll (#auto_resolve_pending) and the mid-turn auto-open (BottomComposer#request_takeover, triggered by the child’s ask_parent the instant it blocks). Keeping it in one method means the delivery semantics (free-text or pick-an-option → #deliver_reply →BackgroundTasks#deliver_answer) are identical on both paths and the parent turn’s state is NEVER touched (deliver_answer only decides the child’s gate + pushes its steer note under the registry mutex).
An empty answer (the human cancelled — Esc in the dropdown / blank free-text) leaves the child PARKED and reports it: the affordance/hint stays so it can re-open. Returns true once it surfaced the request (the caller re-polls / re-reads awaiting_human for the next head).
96 97 98 99 100 101 102 103 104 |
# File 'lib/rubino/commands/handlers/agents.rb', line 96 def answer_one_human(entry) # rubocop:disable Naming/PredicateMethod -- a prompt-presenting mutator that reports it surfaced a request answer = prompt_reply_answer(entry) if answer.to_s.strip.empty? @ui.info("No answer given — #{entry.id} is still waiting.") else deliver_reply(entry, answer) end true end |
#auto_resolve_pending ⇒ Object
Auto-open the EXISTING interactive prompt for ONE pending subagent request the human must act on — the REPL idle loop calls this at every idle tick so the affordance presents ITSELF instead of forcing the user to guess ‘/agents <id>` or `/reply <id>` (the maintainer’s “auto-open the existing dropdown” ask). A request that arrives mid-turn, or survives a turn that is interrupted/aborted, is re-detected here the next time the REPL returns to idle, so it is never lost.
Reuses the SAME primitives the manual slash paths use:
:needs_approval → #resolve_agent_approval (the approve/deny/always
prompt, identical to /agents <id>)
:blocked_on_human → #prompt_reply_answer + #deliver_reply (the ◆ ask
takeover, identical to /reply <id> with no inline
answer)
The manual slash commands stay as a fallback; this is just the primary, zero-typing surface. Approval is offered FIRST (a parked tool holds a concurrency slot and a possibly dangerous side effect, so it is the more urgent gate). Resolves at most ONE request per call so the loop repaints and re-checks between each. Returns true when it presented a request (the caller re-polls), false when nothing was pending.
SECURITY: this changes WHEN the existing approval prompt appears (now it auto-presents), never WHAT requires approval — the gate semantics, the policy that flips a child to :needs_approval, and the approve/deny/always persistence are untouched. The human still makes the same explicit decision through the same gate.
69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/rubino/commands/handlers/agents.rb', line 69 def auto_resolve_pending # rubocop:disable Naming/PredicateMethod -- a prompt-presenting mutator that reports whether it surfaced a request, not a pure query registry = Tools::BackgroundTasks.instance if (entry = registry.awaiting_approval.first) resolve_agent_approval(entry) return true end if (entry = registry.awaiting_human.first) answer_one_human(entry) return true end false end |
#deliver_reply(entry, answer) ⇒ Object
Routes the answer back DOWN to the child: decide the gate (unblocks a blocking ask with the answer as its tool result) and push it onto the steer queue (a non-blocking ask folds it in next turn). Then clear the blocked state and repaint so the ⛔ marker clears.
347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 |
# File 'lib/rubino/commands/handlers/agents.rb', line 347 def deliver_reply(entry, answer) # The ONE shared answer wire (also used by the model-callable # answer_child tool): decide the gate + push the steer note + clear the # blocked state, all in BackgroundTasks#deliver_answer. # H5 — deliver_answer reports HONESTLY now: false when the child has # already finished and neither delivery path landed. Say so instead of # the false "resumes at its next turn" — there is no next turn. delivered = Tools::BackgroundTasks.instance.deliver_answer(entry.id, answer) if delivered @ui.info("↳ answered #{entry.id}: #{truncate(answer, 80)}") @ui.info("✓ tree unblocked · #{entry.id} resumes at its next turn") else @ui.info("↳ answer to #{entry.id}: #{truncate(answer, 80)}") @ui.error("⚠ not delivered — #{entry.id} already finished; it never saw your answer.") end @ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards) end |
#handle_agents(arguments) ⇒ Object
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/rubino/commands/handlers/agents.rb', line 129 def handle_agents(arguments) args = arguments.to_s.strip return show_agents_list if args.empty? tokens = args.split(/\s+/) stop, snapshot, attach = %w[--stop --snapshot --attach].map { |flag| tokens.delete(flag) } id = tokens.shift return show_agents_list if id.nil? || id.empty? return stop_agent(id) if stop # `--attach` is the menu's Enter action: hand the id back to the REPL, # which switches the whole timeline to that agent's (clear + replay) and # scopes the input to it. Internal — not a typed grammar candidate. return { attach_agent: id } if attach return show_agent_detail(id, snapshot: true) if snapshot if tokens.first == "steer" steer_agent(id, dequote(tokens[1..].join(" "))) elsif tokens.first == "probe" probe_agent(id, dequote(tokens[1..].join(" "))) else show_agent_detail(id) end end |
#handle_reply(arguments) ⇒ Object
child->parent ASK_PARENT answer: /reply <id> <answer>. Resolves the child’s ask gate (Run::ApprovalGate#decide) so a BLOCKING ask unwinds with the answer as its tool result, and ALSO pushes the answer onto the child’s steer queue so a NON-BLOCKING ask folds it in at its next turn boundary. Either way the answer PERSISTS in the child’s context. With no inline answer, falls back to an interactive prompt (the ◆ takeover, like the approval menu). Clears the blocked state and unblocks the tree.
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/rubino/commands/handlers/agents.rb', line 175 def handle_reply(arguments) tokens = arguments.to_s.strip.split(/\s+/) id = tokens.shift if id.nil? || id.empty? show_blocked_agents return end # /reply is UNSCOPED: the human is the ultimate supervisor and may answer # ANY blocked node — one waiting on the human (:blocked_on_human) OR one # waiting on its agent-parent (:blocked_on_parent), if the human chooses # to step in. entry = Tools::BackgroundTasks.instance.find(id) if entry.nil? @ui.error("no background subagent with id #{id}. #{RESET_HINT}") return end unless %i[blocked_on_human blocked_on_parent].include?(entry.status) @ui.error("#{id} is not waiting on you.") return end answer = dequote(tokens.join(" ")) answer = prompt_reply_answer(entry) if answer.to_s.strip.empty? if answer.to_s.strip.empty? @ui.info("No answer given — #{id} is still waiting.") return end deliver_reply(entry, answer) end |
#handle_stop_alias(arguments) ⇒ Object
‘/stop <id>` is the discoverable alias for the unguessable `/agents <id> –stop` cancel syntax (FRICTION-4). A bare `/stop` teaches the syntax and lists running subagents rather than erroring.
157 158 159 160 161 162 163 164 165 166 |
# File 'lib/rubino/commands/handlers/agents.rb', line 157 def handle_stop_alias(arguments) id = arguments.to_s.strip.split(/\s+/).first if id.nil? || id.empty? @ui.info("Stop a running subagent: /stop <id> (same as /agents <id> --stop).") handle_agents("") else handle_agents("#{id} --stop") end :handled end |
#probe_agent(id, question) ⇒ Object
parent->child PROBE: an EPHEMERAL read-only peek. Snapshots the child’s current messages, runs ONE side-inference ([child messages] + question) on the child’s own model, prints the answer in a dashed “ephemeral · not saved” aside, and DISCARDS it — nothing is appended to the child’s history, nothing enters the timeline. The absence of any saved/timeline entry is itself the signal that the peek changed nothing.
234 235 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 |
# File 'lib/rubino/commands/handlers/agents.rb', line 234 def probe_agent(id, question) if question.to_s.strip.empty? @ui.error(%(usage: /agents #{id} probe "your question")) return end entry = Tools::BackgroundTasks.instance.find(id) unless entry @ui.error("cannot probe #{id} — no such subagent. #{RESET_HINT}") return end @ui.info(pastel.dim("┄┄ probe → #{id} ┄┄ (ephemeral · not saved · child trajectory unchanged)")) # A probe answers from the child's context AT THIS INSTANT; right after # spawn that context is still empty and the child honestly says it isn't # working on anything yet — hint so that doesn't read as broken (#112). if entry.tool_count.to_i.zero? @ui.info(pastel.dim(" (snapshot at this instant — the child just started and its " \ "context is still empty; probe again in a moment)")) end @ui.info("? #{question}") # The peek is a synchronous side-inference (seconds of model wait) with # nothing streaming — show the same thinking row /probe got in #58 so # the gap before the ⟵ answer never looks frozen (#146). TTY only; # Null/API adapters and pipes stay silent. probe_thinking_started(@ui) answer = begin Tools::SubagentProbe.new.peek(entry: entry, question: question) ensure probe_thinking_finished(@ui) end @ui.info("⟵ #{answer}") @ui.info(pastel.dim("┄┄ end probe (nothing was saved to #{id}) ┄┄")) end |
#steer_agent(id, text) ⇒ Object
parent->child STEER: a fire-and-forget note that enters the child’s context at its next turn boundary (Loop#inject_steered_input). Pushes onto the child’s steering queue via BackgroundTasks#steer — the SAME wire the human uses to steer the parent. Echoed with the existing steer vocabulary (▸, “enters child context”) + a card repaint so the parked note shows.
214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/rubino/commands/handlers/agents.rb', line 214 def steer_agent(id, text) if text.to_s.strip.empty? @ui.error(%(usage: /agents #{id} steer "your note")) return end if Tools::BackgroundTasks.instance.steer(id, text) @ui.info("steer ▸ #{id} ← #{truncate(text, 80)} (parked · enters child context next turn)") @ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards) else @ui.error("cannot steer #{id} — no such running subagent. #{RESET_HINT}") end end |