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

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

#auto_resolve_pendingObject

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
81
82
83
84
85
# 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 = 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
    return true
  end
  false
end

#handle_agents(arguments) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/rubino/commands/handlers/agents.rb', line 87

def handle_agents(arguments)
  args = arguments.to_s.strip

  if args.empty?
    show_agents_list
    return
  end

  tokens = args.split(/\s+/)
  stop   = tokens.delete("--stop") ? true : false
  id     = tokens.shift

  if id.nil? || id.empty?
    show_agents_list
  elsif stop
    stop_agent(id)
  elsif 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.



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

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.



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

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