Class: Rubino::Tools::AskParentTool

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/tools/ask_parent_tool.rb

Overview

ask_parent — the child->parent escalation channel (the third mechanism of the parent<->subagent comm design). A subagent calls this when it hits a fork it cannot resolve from its sealed prompt (“sqlite or postgres?”).

Answerer is MIXED: the parent answers from its own context if it can, else it escalates to the HUMAN. This tool implements the wire; the escalation itself reuses Run::ApprovalGate verbatim (the SAME blocking cross-thread hand-off the Option-2 background-approval path already uses):

1. The tool finds the child\'s own BackgroundTasks entry (via the
   thread-local Rubino.current_subagent_id set by TaskTool around the
   child run). No entry ⇒ this run has no parent (top-level / foreground
   sync) and the tool refuses gracefully — never hangs.
2. It registers a Run::ApprovalGate on the entry (BackgroundTasks#begin_ask),
   flipping the entry to :blocked_on_human so the parent CLI surfaces the
   ⛔ blocked banner + the persistent "N subagent waiting on you" marker,
   and informs the parent loop by pushing a note onto the parent\'s
   InputQueue (so the parent MODEL sees the question at its next turn and
   MAY answer it — the "parent answers if it can" half; the parent\'s
   answer routes back through the SAME gate via /reply or a parent path).
3. blocking:true  → the tool BLOCKS on gate.await(timeout: nil) — wait
   INDEFINITELY, no auto-default (the owner constraint). The human answers
   via /reply <id>, which decides the gate; the answer is the tool result
   and enters the child\'s context as the tool message.
   blocking:false → the tool returns IMMEDIATELY ("asked, keep working");
   the answer is delivered later as a steer note on the child\'s queue
   (Loop#inject_steered_input), so the child keeps making progress.

SUSPEND/RESUME (the W1/#54 lesson): on the CLI a background subagent runs on its OWN dedicated Thread — NOT a pooled Puma/Solid-Queue worker. Parking that dedicated thread on the gate (blocking:true) therefore holds only the child's own thread, never a shared pool, so it cannot freeze the REPL the way a parked Puma worker froze the server (W1). This is exactly how the existing Option-2 approval handler parks the child thread today. A full persist-and-resume suspend (free the thread entirely, rehydrate on answer) is only required for the POOLED web path, which is OUT OF SCOPE here and tracked as a follow-up. A stop (/agents <id> –stop) cancels the gate so a blocking ask unwinds at once instead of waiting forever.

Constant Summary collapse

NONBLOCKING_ACK =

Sentinel head used when a non-blocking ask returns to the child: the child keeps working and the real answer arrives later as a steer note.

"Question sent to your parent. Keep working with your best " + "judgement; the answer will be delivered to you as a note " + "at your next turn if/when it arrives."
DEFAULT_ASK_TIMEOUT =

Fallback bound (seconds) for a blocking ask when no configuration is reachable (a bare tool in a unit test). The live value comes from tasks.ask_parent_timeout; this matches the approvals wait-timeout default.

900

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk

Instance Method Summary collapse

Methods inherited from Base

#cancellation_requested?, #emit_chunk, #risky?, #to_tool_definition, workspace_root, workspace_roots

Instance Method Details

#call(arguments) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/rubino/tools/ask_parent_tool.rb', line 86

def call(arguments)
  question = (arguments["question"] || arguments[:question]).to_s.strip
  blocking = blocking_arg(arguments)
  return "Error: question is required" if question.empty?

  id = Rubino.current_subagent_id
  entry = id && BackgroundTasks.instance.find(id)
  unless entry
    return "Error: ask_parent is only available to a background subagent " + "(no parent to ask). Resolve this from your task instead."
  end

  escalate(entry, question, blocking)
rescue Rubino::Interrupted
  # A /agents <id> --stop (or teardown) cancelled the gate while we were
  # parked. Unwind cleanly: report it as denied/cancelled so the child can
  # finish rather than hang.
  BackgroundTasks.instance.end_ask(entry&.id) if defined?(entry) && entry
  "Your parent question was cancelled (the run is being stopped)."
end

#config_keyObject

Gated by the same ‘tools.task` delegation key — it is meaningless without the delegation substrate (BackgroundTasks/registry). Disabling delegation disables ask_parent too.



60
61
62
# File 'lib/rubino/tools/ask_parent_tool.rb', line 60

def config_key
  "task"
end

#descriptionObject



64
65
66
# File 'lib/rubino/tools/ask_parent_tool.rb', line 64

def description
  "Ask YOUR PARENT agent a question when you hit a decision you cannot " + "resolve from the task you were given (e.g. a missing preference, an " + "ambiguous requirement, sqlite-vs-postgres). Your parent answers from " + "its own context if it can, otherwise it asks the human. Use " + "blocking:true when you CANNOT proceed without the answer (you will " + "pause until it arrives); blocking:false (default) when you can keep " + "working and fold the answer in later. Only available to subagents."
end

#input_schemaObject



68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/rubino/tools/ask_parent_tool.rb', line 68

def input_schema
  {
    type: "object",
    properties: {
      question: { type: "string", description: "The question for your parent. Be specific and self-contained." },
      blocking: {
        type: "boolean",
        description: "true = pause until answered (you cannot proceed without it). " + "false (default) = keep working; the answer is delivered later as a note."
      }
    },
    required: %w[question]
  }
end

#nameObject



53
54
55
# File 'lib/rubino/tools/ask_parent_tool.rb', line 53

def name
  "ask_parent"
end

#risk_levelObject



82
83
84
# File 'lib/rubino/tools/ask_parent_tool.rb', line 82

def risk_level
  :low
end