Class: Rubino::Commands::Handlers::Agents

Inherits:
Object
  • Object
show all
Includes:
UI::ProbeWaitIndicator
Defined in:
lib/rubino/commands/handlers/agents.rb

Overview

The /agents (alias /tasks) drill-in surface, 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, and steers/probes/stops a running one.

/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

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 , /stop , steer, probe).

"(background tasks reset when rubino restarts)"

Instance Method Summary collapse

Methods included from UI::ProbeWaitIndicator

#probe_thinking_finished, #probe_thinking_started

Constructor Details

#initialize(ui:) ⇒ Agents

Returns a new instance of Agents.



37
38
39
# File 'lib/rubino/commands/handlers/agents.rb', line 37

def initialize(ui:)
  @ui = ui
end

Instance Method Details

#auto_resolve_pendingObject

Auto-open the EXISTING interactive approval 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>. 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. 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.



56
57
58
59
60
61
62
63
64
65
66
# File 'lib/rubino/commands/handlers/agents.rb', line 56

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
  # Oldest-first (FIFO), skipping any the user dismissed with "Decide
  # later" (#586): those stay parked cards the user re-opens deliberately,
  # so the auto-modal doesn't re-pop them at every idle tick.
  if (entry = registry.awaiting_approval.find { |e| !e.approval_snoozed })
    resolve_agent_approval(entry)
    return true
  end
  false
end

#handle_agents(arguments) ⇒ Object



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/rubino/commands/handlers/agents.rb', line 68

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



96
97
98
99
100
101
102
103
104
105
# File 'lib/rubino/commands/handlers/agents.rb', line 96

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.



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
# File 'lib/rubino/commands/handlers/agents.rb', line 134

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 background task. #{RESET_HINT}")
    return
  end

  @ui.info(pastel.dim("┄┄ probe → #{id} ┄┄  (ephemeral · not saved · trajectory unchanged)"))
  hint = entry.peek_hint
  @ui.info(pastel.dim("   #{hint}")) if hint
  @ui.info("?  #{question}")
  # The peek is polymorphic: a subagent runs a synchronous LLM side-inference
  # (seconds of model wait — show the thinking row so the gap doesn't look
  # frozen, #58/#146), while a shell returns an instant output snapshot with
  # no model call. Either way #peek lives on the entry, not here.
  probe_thinking_started(@ui)
  answer = begin
    entry.peek(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.



114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/rubino/commands/handlers/agents.rb', line 114

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)}")
    @ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
  else
    @ui.error("cannot steer #{id} — no such running background task. #{RESET_HINT}")
  end
end