rubino-agent 0.5.1 → 0.5.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.dockerignore +15 -0
- data/CHANGELOG.md +127 -0
- data/Dockerfile +56 -0
- data/agent.md +112 -0
- data/docs/api/v1.md +2 -0
- data/docs/commands.md +3 -6
- data/docs/configuration.md +13 -6
- data/docs/design/bg-shell-pty-port.md +88 -0
- data/docs/design/bg-shell-review-refinements.md +65 -0
- data/docs/design/bg-shell-ux.md +130 -0
- data/docs/oauth-providers.md +21 -0
- data/docs/tools.md +3 -12
- data/lib/rubino/agent/iteration_budget.rb +13 -0
- data/lib/rubino/agent/loop.rb +43 -5
- data/lib/rubino/agent/prompts/build.txt +10 -5
- data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
- data/lib/rubino/agent/runner.rb +55 -12
- data/lib/rubino/agent/tool_executor.rb +1 -1
- data/lib/rubino/api/operations/tasks/stop_operation.rb +0 -3
- data/lib/rubino/attachments/classify.rb +0 -1
- data/lib/rubino/cli/chat/completion_builder.rb +0 -8
- data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
- data/lib/rubino/cli/chat_command.rb +324 -171
- data/lib/rubino/cli/commands.rb +5 -0
- data/lib/rubino/commands/built_ins.rb +0 -1
- data/lib/rubino/commands/executor.rb +1 -7
- data/lib/rubino/commands/handlers/agents.rb +55 -265
- data/lib/rubino/commands/handlers/status.rb +6 -3
- data/lib/rubino/compression/line_skeleton.rb +1 -1
- data/lib/rubino/compression/python_code_skeleton.rb +1 -1
- data/lib/rubino/compression/ruby_code_skeleton.rb +1 -1
- data/lib/rubino/compression/tree_sitter_code_skeleton.rb +1 -1
- data/lib/rubino/config/configuration.rb +47 -18
- data/lib/rubino/config/defaults.rb +57 -33
- data/lib/rubino/context/prompt_assembler.rb +89 -1
- data/lib/rubino/context/summary_builder.rb +0 -22
- data/lib/rubino/context/token_budget.rb +0 -5
- data/lib/rubino/errors.rb +2 -2
- data/lib/rubino/interaction/events.rb +2 -2
- data/lib/rubino/interaction/lifecycle.rb +54 -20
- data/lib/rubino/llm/anthropic_role_merge.rb +75 -0
- data/lib/rubino/llm/error_classifier.rb +34 -1
- data/lib/rubino/llm/fake_provider.rb +0 -4
- data/lib/rubino/llm/ruby_llm_adapter.rb +222 -59
- data/lib/rubino/llm/stream_tool_call_recovery.rb +91 -0
- data/lib/rubino/llm/tool_call_recovery.rb +177 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +0 -2
- data/lib/rubino/memory/store.rb +0 -19
- data/lib/rubino/security/pattern_matcher.rb +0 -2
- data/lib/rubino/security/redactor.rb +1 -1
- data/lib/rubino/security/secret_path.rb +16 -4
- data/lib/rubino/session/message.rb +12 -0
- data/lib/rubino/skills/registry.rb +16 -2
- data/lib/rubino/tools/background_tasks.rb +132 -228
- data/lib/rubino/tools/base.rb +1 -17
- data/lib/rubino/tools/grep_tool.rb +13 -1
- data/lib/rubino/tools/question_tool.rb +3 -4
- data/lib/rubino/tools/read_attachment_tool.rb +52 -54
- data/lib/rubino/tools/registry.rb +21 -72
- data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
- data/lib/rubino/tools/shell_input_tool.rb +1 -1
- data/lib/rubino/tools/shell_kill_tool.rb +4 -4
- data/lib/rubino/tools/shell_registry.rb +178 -38
- data/lib/rubino/tools/shell_tool.rb +45 -5
- data/lib/rubino/tools/steer_tool.rb +3 -4
- data/lib/rubino/tools/task_result_tool.rb +4 -1
- data/lib/rubino/tools/task_stop_tool.rb +5 -7
- data/lib/rubino/tools/task_tool.rb +81 -35
- data/lib/rubino/tools/vision_tool.rb +1 -1
- data/lib/rubino/tools/write_tool.rb +22 -2
- data/lib/rubino/ui/agent_menu.rb +8 -4
- data/lib/rubino/ui/api.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +240 -374
- data/lib/rubino/ui/cli.rb +381 -155
- data/lib/rubino/ui/input_history.rb +0 -5
- data/lib/rubino/ui/live_region.rb +18 -1
- data/lib/rubino/ui/markdown_renderer.rb +51 -4
- data/lib/rubino/ui/markdown_repair.rb +114 -0
- data/lib/rubino/ui/notifier.rb +4 -10
- data/lib/rubino/ui/stdout_proxy.rb +25 -10
- data/lib/rubino/ui/streaming_markdown.rb +79 -12
- data/lib/rubino/ui/subagent_cards.rb +18 -44
- data/lib/rubino/ui/tool_args_stream.rb +143 -0
- data/lib/rubino/update_check.rb +10 -2
- data/lib/rubino/util/ignore_rules.rb +18 -2
- data/lib/rubino/util/secrets_mask.rb +0 -9
- data/lib/rubino/version.rb +1 -1
- data/lib/rubino.rb +33 -7
- data/rubino-agent.gemspec +1 -0
- metadata +31 -5
- data/AGENTS.md +0 -97
- data/docs/agents.md +0 -224
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
- data/lib/rubino/tools/summarize_file_tool.rb +0 -194
data/lib/rubino/cli/commands.rb
CHANGED
|
@@ -601,6 +601,11 @@ module Rubino
|
|
|
601
601
|
ui.warning("gem update failed. If this is a permission error, re-run the installer or try `gem update --user-install #{Rubino::UpdateCheck::GEM_NAME}`.")
|
|
602
602
|
return
|
|
603
603
|
end
|
|
604
|
+
# The subprocess installed the new gem into this process's gem paths,
|
|
605
|
+
# but our in-memory spec list predates it — refresh so the version
|
|
606
|
+
# query below sees what `gem update` just wrote (else we'd report the
|
|
607
|
+
# pre-update version and claim "already up to date").
|
|
608
|
+
Gem.refresh
|
|
604
609
|
new_v = Rubino::UpdateCheck.installed_gem_version(Rubino::UpdateCheck::GEM_NAME)
|
|
605
610
|
if new_v && Gem::Version.new(new_v) > Gem::Version.new(current)
|
|
606
611
|
ui.info("rubino is now on v#{new_v} (was v#{current}).")
|
|
@@ -22,7 +22,6 @@ module Rubino
|
|
|
22
22
|
"/agent" => "Switch the primary agent (/agent <name>; a bare /<name> or Tab cycles)",
|
|
23
23
|
"/agents" => "List background subagents; ↓+Enter to attach & steer one live, or steer/probe/view by id",
|
|
24
24
|
"/tasks" => "Alias for /agents",
|
|
25
|
-
"/reply" => "Answer a subagent that is blocked waiting on you (e.g. an approval)",
|
|
26
25
|
"/stop" => "Stop a running subagent (/stop <id>; alias for /agents <id> --stop)",
|
|
27
26
|
"/jobs" => "List the background job queue (status counts); /jobs <id> for detail",
|
|
28
27
|
"/skills" => "List skills; activate one ('none' clears), or enable/disable NAME",
|
|
@@ -20,10 +20,7 @@ module Rubino
|
|
|
20
20
|
# conversation / config / turn state, so they run on the composer's reader
|
|
21
21
|
# thread concurrently with the turn thread without a race (output routes
|
|
22
22
|
# through the SAME render-mutex-serialized UI). /stop reuses the cancel
|
|
23
|
-
# machinery Esc / `--stop` use (already concurrent-safe).
|
|
24
|
-
# BLOCKED: its interactive form `/reply <id>` (-> @ui.ask) can't be told
|
|
25
|
-
# apart by NAME from the safe inline form, and would steal the reader's
|
|
26
|
-
# stdin (default-to-blocked on a concurrency hazard). Single source of
|
|
23
|
+
# machinery Esc / `--stop` use (already concurrent-safe). Single source of
|
|
27
24
|
# truth for the busy-time classification — #busy_disposition reads it.
|
|
28
25
|
IMMEDIATE_WHILE_BUSY = %w[agents tasks stop status jobs help commands dirs].freeze
|
|
29
26
|
|
|
@@ -210,9 +207,6 @@ module Rubino
|
|
|
210
207
|
result.is_a?(Hash) ? result : :handled
|
|
211
208
|
when "stop" # `/stop <id>` → `/agents <id> --stop` alias (FRICTION-4)
|
|
212
209
|
agents_handler.handle_stop_alias(arguments) # returns :handled
|
|
213
|
-
when "reply"
|
|
214
|
-
agents_handler.handle_reply(arguments)
|
|
215
|
-
:handled
|
|
216
210
|
when "sessions"
|
|
217
211
|
sessions_handler.handle_sessions(arguments)
|
|
218
212
|
when "probe"
|
|
@@ -6,21 +6,19 @@ require "time"
|
|
|
6
6
|
module Rubino
|
|
7
7
|
module Commands
|
|
8
8
|
module Handlers
|
|
9
|
-
# The `/agents` (alias `/tasks`) drill-in surface
|
|
10
|
-
#
|
|
9
|
+
# The `/agents` (alias `/tasks`) drill-in surface, extracted from
|
|
10
|
+
# Commands::Executor (batch B).
|
|
11
11
|
#
|
|
12
12
|
# The "see what other agents do" surface. Lists background subagents from
|
|
13
13
|
# the BackgroundTasks registry (the async `task` substrate), drills into a
|
|
14
|
-
# single one's result/error, steers/probes/stops a running one
|
|
15
|
-
# a human /reply back down to a blocked child.
|
|
14
|
+
# single one's result/error, and steers/probes/stops a running one.
|
|
16
15
|
#
|
|
17
16
|
# /agents → list
|
|
18
17
|
# /agents <id> → drill-in (result / error / status)
|
|
19
18
|
# /agents <id> --stop → cancel a running subagent
|
|
20
19
|
# /agents <id> steer "…" → fire-and-forget note into the child's context
|
|
21
20
|
# /agents <id> probe "…" → ephemeral read-only peek
|
|
22
|
-
|
|
23
|
-
class Agents # rubocop:disable Metrics/ClassLength -- one cohesive /agents command surface (list/attach/steer/probe/reply/approval/budget); splitting would scatter the routing
|
|
21
|
+
class Agents
|
|
24
22
|
include Rubino::UI::ProbeWaitIndicator
|
|
25
23
|
|
|
26
24
|
# How many times the parked-child approval prompt re-renders after an
|
|
@@ -32,34 +30,23 @@ module Rubino
|
|
|
32
30
|
# is in-memory, never persisted — so a prior session's id is genuinely
|
|
33
31
|
# gone after a REPL restart. The bare "no such id" left the user thinking
|
|
34
32
|
# they'd mistyped; this names the real reason so they don't hunt for a
|
|
35
|
-
# typo. Surfaced from EVERY not-found path (/agents <id>, /
|
|
36
|
-
#
|
|
37
|
-
RESET_HINT = "(
|
|
33
|
+
# typo. Surfaced from EVERY not-found path (/agents <id>, /stop <id>,
|
|
34
|
+
# steer, probe).
|
|
35
|
+
RESET_HINT = "(background tasks reset when rubino restarts)"
|
|
38
36
|
|
|
39
37
|
def initialize(ui:)
|
|
40
38
|
@ui = ui
|
|
41
39
|
end
|
|
42
40
|
|
|
43
|
-
# Auto-open the EXISTING interactive prompt for ONE pending
|
|
44
|
-
# request the human must act on — the REPL idle loop calls this at
|
|
45
|
-
# idle tick so the affordance presents ITSELF instead of forcing the
|
|
46
|
-
# to guess `/agents <id
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# :needs_approval → #resolve_agent_approval (the approve/deny/always
|
|
53
|
-
# prompt, identical to /agents <id>)
|
|
54
|
-
# :blocked_on_human → #prompt_reply_answer + #deliver_reply (the ◆ ask
|
|
55
|
-
# takeover, identical to /reply <id> with no inline
|
|
56
|
-
# answer)
|
|
57
|
-
# The manual slash commands stay as a fallback; this is just the primary,
|
|
58
|
-
# zero-typing surface. Approval is offered FIRST (a parked tool holds a
|
|
59
|
-
# concurrency slot and a possibly dangerous side effect, so it is the more
|
|
60
|
-
# urgent gate). Resolves at most ONE request per call so the loop repaints
|
|
61
|
-
# and re-checks between each. Returns true when it presented a request
|
|
62
|
-
# (the caller re-polls), false when nothing was pending.
|
|
41
|
+
# Auto-open the EXISTING interactive approval prompt for ONE pending
|
|
42
|
+
# subagent request the human must act on — the REPL idle loop calls this at
|
|
43
|
+
# every idle tick so the affordance presents ITSELF instead of forcing the
|
|
44
|
+
# user to guess `/agents <id>`. A request that arrives mid-turn, or
|
|
45
|
+
# survives a turn that is interrupted/aborted, is re-detected here the next
|
|
46
|
+
# time the REPL returns to idle, so it is never lost. Resolves at most ONE
|
|
47
|
+
# request per call so the loop repaints and re-checks between each. Returns
|
|
48
|
+
# true when it presented a request (the caller re-polls), false when
|
|
49
|
+
# nothing was pending.
|
|
63
50
|
#
|
|
64
51
|
# SECURITY: this changes WHEN the existing approval prompt appears (now it
|
|
65
52
|
# auto-presents), never WHAT requires approval — the gate semantics, the
|
|
@@ -68,64 +55,16 @@ module Rubino
|
|
|
68
55
|
# decision through the same gate.
|
|
69
56
|
def auto_resolve_pending # rubocop:disable Naming/PredicateMethod -- a prompt-presenting mutator that reports whether it surfaced a request, not a pure query
|
|
70
57
|
registry = Tools::BackgroundTasks.instance
|
|
71
|
-
|
|
58
|
+
# Oldest-first (FIFO), skipping any the user dismissed with "Decide
|
|
59
|
+
# later" (#586): those stay parked cards the user re-opens deliberately,
|
|
60
|
+
# so the auto-modal doesn't re-pop them at every idle tick.
|
|
61
|
+
if (entry = registry.awaiting_approval.find { |e| !e.approval_snoozed })
|
|
72
62
|
resolve_agent_approval(entry)
|
|
73
63
|
return true
|
|
74
64
|
end
|
|
75
|
-
if (entry = registry.awaiting_human.first)
|
|
76
|
-
answer_one_human(entry)
|
|
77
|
-
return true
|
|
78
|
-
end
|
|
79
65
|
false
|
|
80
66
|
end
|
|
81
67
|
|
|
82
|
-
# The ONE shared "surface the answer affordance for a child blocked on the
|
|
83
|
-
# human, read the human's answer, deliver it down the SAME wire" step —
|
|
84
|
-
# used by BOTH the idle poll (#auto_resolve_pending) and the mid-turn
|
|
85
|
-
# auto-open (BottomComposer#request_takeover, triggered by the child's
|
|
86
|
-
# ask_parent the instant it blocks). Keeping it in one method means the
|
|
87
|
-
# delivery semantics (free-text or pick-an-option → #deliver_reply →
|
|
88
|
-
# BackgroundTasks#deliver_answer) are identical on both paths and the
|
|
89
|
-
# parent turn's state is NEVER touched (deliver_answer only decides the
|
|
90
|
-
# child's gate + pushes its steer note under the registry mutex).
|
|
91
|
-
#
|
|
92
|
-
# An empty answer (the human cancelled — Esc in the dropdown / blank
|
|
93
|
-
# free-text) leaves the child PARKED and reports it: the affordance/hint
|
|
94
|
-
# stays so it can re-open. Returns true once it surfaced the request
|
|
95
|
-
# (the caller re-polls / re-reads awaiting_human for the next head).
|
|
96
|
-
def answer_one_human(entry) # rubocop:disable Naming/PredicateMethod -- a prompt-presenting mutator that reports it surfaced a request
|
|
97
|
-
answer = prompt_reply_answer(entry)
|
|
98
|
-
if answer.to_s.strip.empty?
|
|
99
|
-
@ui.info("No answer given — #{entry.id} is still waiting.")
|
|
100
|
-
else
|
|
101
|
-
deliver_reply(entry, answer)
|
|
102
|
-
end
|
|
103
|
-
true
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# FIFO drain of the children blocked on the human, used by the MID-TURN
|
|
107
|
-
# auto-open: deliver the head, then RE-READ awaiting_human (a 2nd child
|
|
108
|
-
# may have asked while the dropdown was open, or the head may have been
|
|
109
|
-
# delivered/timed-out) and surface the next head, until the queue is
|
|
110
|
-
# empty. Each #answer_one_human runs its own dropdown takeover; a child
|
|
111
|
-
# that arrives mid-open simply appends and is picked up on the re-read.
|
|
112
|
-
# Bounded by the live awaiting_human snapshot shrinking each pass, so it
|
|
113
|
-
# always terminates. Runs on the INPUT thread (it owns the keyboard).
|
|
114
|
-
def answer_all_human
|
|
115
|
-
loop do
|
|
116
|
-
entry = Tools::BackgroundTasks.instance.awaiting_human.first
|
|
117
|
-
break unless entry
|
|
118
|
-
|
|
119
|
-
answer_one_human(entry)
|
|
120
|
-
# A cancelled (still-blocked) head would otherwise re-surface forever:
|
|
121
|
-
# stop once the head is no longer awaiting an answer it just got, OR
|
|
122
|
-
# the human declined it. We break when the FIRST awaiting_human entry
|
|
123
|
-
# is unchanged after the attempt (cancelled), so an Esc doesn't loop.
|
|
124
|
-
still = Tools::BackgroundTasks.instance.awaiting_human.first
|
|
125
|
-
break if still && still.id == entry.id
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
68
|
def handle_agents(arguments)
|
|
130
69
|
args = arguments.to_s.strip
|
|
131
70
|
return show_agents_list if args.empty?
|
|
@@ -165,45 +104,6 @@ module Rubino
|
|
|
165
104
|
:handled
|
|
166
105
|
end
|
|
167
106
|
|
|
168
|
-
# child->parent ASK_PARENT answer: /reply <id> <answer>. Resolves the
|
|
169
|
-
# child's ask gate (Run::ApprovalGate#decide) so a BLOCKING ask unwinds with
|
|
170
|
-
# the answer as its tool result, and ALSO pushes the answer onto the child's
|
|
171
|
-
# steer queue so a NON-BLOCKING ask folds it in at its next turn boundary.
|
|
172
|
-
# Either way the answer PERSISTS in the child's context. With no inline
|
|
173
|
-
# answer, falls back to an interactive prompt (the ◆ takeover, like the
|
|
174
|
-
# approval menu). Clears the blocked state and unblocks the tree.
|
|
175
|
-
def handle_reply(arguments)
|
|
176
|
-
tokens = arguments.to_s.strip.split(/\s+/)
|
|
177
|
-
id = tokens.shift
|
|
178
|
-
if id.nil? || id.empty?
|
|
179
|
-
show_blocked_agents
|
|
180
|
-
return
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# /reply is UNSCOPED: the human is the ultimate supervisor and may answer
|
|
184
|
-
# ANY blocked node — one waiting on the human (:blocked_on_human) OR one
|
|
185
|
-
# waiting on its agent-parent (:blocked_on_parent), if the human chooses
|
|
186
|
-
# to step in.
|
|
187
|
-
entry = Tools::BackgroundTasks.instance.find(id)
|
|
188
|
-
if entry.nil?
|
|
189
|
-
@ui.error("no background subagent with id #{id}. #{RESET_HINT}")
|
|
190
|
-
return
|
|
191
|
-
end
|
|
192
|
-
unless %i[blocked_on_human blocked_on_parent].include?(entry.status)
|
|
193
|
-
@ui.error("#{id} is not waiting on you.")
|
|
194
|
-
return
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
answer = dequote(tokens.join(" "))
|
|
198
|
-
answer = prompt_reply_answer(entry) if answer.to_s.strip.empty?
|
|
199
|
-
if answer.to_s.strip.empty?
|
|
200
|
-
@ui.info("No answer given — #{id} is still waiting.")
|
|
201
|
-
return
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
deliver_reply(entry, answer)
|
|
205
|
-
end
|
|
206
|
-
|
|
207
107
|
private
|
|
208
108
|
|
|
209
109
|
# parent->child STEER: a fire-and-forget note that enters the child's
|
|
@@ -218,10 +118,10 @@ module Rubino
|
|
|
218
118
|
end
|
|
219
119
|
|
|
220
120
|
if Tools::BackgroundTasks.instance.steer(id, text)
|
|
221
|
-
@ui.info("steer ▸ #{id} ← #{truncate(text, 80)}
|
|
121
|
+
@ui.info("steer ▸ #{id} ← #{truncate(text, 80)}")
|
|
222
122
|
@ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
|
|
223
123
|
else
|
|
224
|
-
@ui.error("cannot steer #{id} — no such running
|
|
124
|
+
@ui.error("cannot steer #{id} — no such running background task. #{RESET_HINT}")
|
|
225
125
|
end
|
|
226
126
|
end
|
|
227
127
|
|
|
@@ -239,26 +139,21 @@ module Rubino
|
|
|
239
139
|
|
|
240
140
|
entry = Tools::BackgroundTasks.instance.find(id)
|
|
241
141
|
unless entry
|
|
242
|
-
@ui.error("cannot probe #{id} — no such
|
|
142
|
+
@ui.error("cannot probe #{id} — no such background task. #{RESET_HINT}")
|
|
243
143
|
return
|
|
244
144
|
end
|
|
245
145
|
|
|
246
|
-
@ui.info(pastel.dim("┄┄ probe → #{id} ┄┄ (ephemeral · not saved ·
|
|
247
|
-
|
|
248
|
-
#
|
|
249
|
-
# working on anything yet — hint so that doesn't read as broken (#112).
|
|
250
|
-
if entry.tool_count.to_i.zero?
|
|
251
|
-
@ui.info(pastel.dim(" (snapshot at this instant — the child just started and its " \
|
|
252
|
-
"context is still empty; probe again in a moment)"))
|
|
253
|
-
end
|
|
146
|
+
@ui.info(pastel.dim("┄┄ probe → #{id} ┄┄ (ephemeral · not saved · trajectory unchanged)"))
|
|
147
|
+
hint = entry.peek_hint
|
|
148
|
+
@ui.info(pastel.dim(" #{hint}")) if hint
|
|
254
149
|
@ui.info("? #{question}")
|
|
255
|
-
# The peek is a synchronous side-inference
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
150
|
+
# The peek is polymorphic: a subagent runs a synchronous LLM side-inference
|
|
151
|
+
# (seconds of model wait — show the thinking row so the gap doesn't look
|
|
152
|
+
# frozen, #58/#146), while a shell returns an instant output snapshot with
|
|
153
|
+
# no model call. Either way #peek lives on the entry, not here.
|
|
259
154
|
probe_thinking_started(@ui)
|
|
260
155
|
answer = begin
|
|
261
|
-
|
|
156
|
+
entry.peek(question)
|
|
262
157
|
ensure
|
|
263
158
|
probe_thinking_finished(@ui)
|
|
264
159
|
end
|
|
@@ -266,118 +161,6 @@ module Rubino
|
|
|
266
161
|
@ui.info(pastel.dim("┄┄ end probe (nothing was saved to #{id}) ┄┄"))
|
|
267
162
|
end
|
|
268
163
|
|
|
269
|
-
# The interactive ◆ takeover for /reply with no inline answer — mirrors the
|
|
270
|
-
# approval menu (composer-suspend, ◆ glyph) so answering an ask_parent feels
|
|
271
|
-
# exactly like answering an approval, a pattern the user already knows.
|
|
272
|
-
#
|
|
273
|
-
# Content of the affordance (LOCKED "options if present, else free text"):
|
|
274
|
-
# * the asking child supplied `options:` → an arrow-SELECT of those
|
|
275
|
-
# concrete options PLUS a trailing "✎ Answer (type)…" entry that opens
|
|
276
|
-
# the free-text field. Reuses @ui.select (the same TTY::Prompt picker
|
|
277
|
-
# /sessions resume uses) under run_in_terminal.
|
|
278
|
-
# * no options → the original free-text @ui.ask, but offered as a
|
|
279
|
-
# [Answer / Dismiss] choice first so Esc/Dismiss cleanly CANCELS this
|
|
280
|
-
# answer (the child stays blocked) instead of forcing a blank line.
|
|
281
|
-
# Returns the chosen/typed answer, or "" when the human cancels (Esc /
|
|
282
|
-
# Dismiss / blank) — answer_one_human then leaves the child parked.
|
|
283
|
-
def prompt_reply_answer(entry)
|
|
284
|
-
@ui.info("")
|
|
285
|
-
@ui.info("◆ #{entry.id} (#{entry.subagent}) asks — everything is waiting on this")
|
|
286
|
-
@ui.info(" ❓ #{entry.ask_question}")
|
|
287
|
-
options = Array(entry.ask_options)
|
|
288
|
-
options.empty? ? prompt_free_or_dismiss : prompt_pick_option(options)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# No options: [Answer / Dismiss]. "Answer" opens the free-text field;
|
|
292
|
-
# "Dismiss" (or Esc, which #select returns as nil) cancels — child stays
|
|
293
|
-
# blocked. A UI without #select (scripted/legacy) falls straight through
|
|
294
|
-
# to the free-text @ui.ask so the existing behaviour is preserved.
|
|
295
|
-
def prompt_free_or_dismiss
|
|
296
|
-
return @ui.ask("✎ your answer › ").to_s unless @ui.respond_to?(:select)
|
|
297
|
-
|
|
298
|
-
choice = @ui.select("Answer this subagent?",
|
|
299
|
-
[["✎ Answer (type)…", :answer], ["Dismiss (leave blocked)", :dismiss]])
|
|
300
|
-
return "" unless choice == :answer
|
|
301
|
-
|
|
302
|
-
@ui.ask("✎ your answer › ").to_s
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# Options present: arrow-select one of them, or the trailing free-text
|
|
306
|
-
# entry. Esc (nil from #select) cancels. Reuses @ui.select; a UI without
|
|
307
|
-
# it answers free-text so scripted callers keep working.
|
|
308
|
-
def prompt_pick_option(options)
|
|
309
|
-
return @ui.ask("✎ your answer › ").to_s unless @ui.respond_to?(:select)
|
|
310
|
-
|
|
311
|
-
# An option is either a plain string (label==value) or a
|
|
312
|
-
# {label, description} map. Show the clean LABEL (with the description
|
|
313
|
-
# as a dim hint when present) and deliver the label STRING as the
|
|
314
|
-
# answer — never a raw hash literal (#475-3).
|
|
315
|
-
choices = options.map { |o| [option_label(o), option_value(o)] }
|
|
316
|
-
choices << ["✎ Answer (type)…", :__free__]
|
|
317
|
-
choice = @ui.select("Pick an answer for the subagent:", choices)
|
|
318
|
-
return "" if choice.nil? # Esc / cancelled
|
|
319
|
-
return @ui.ask("✎ your answer › ").to_s if choice == :__free__
|
|
320
|
-
|
|
321
|
-
choice
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
# The display label for a picker option: the bare label for a string,
|
|
325
|
-
# or "label — description" (description dimmed) for a {label, description}
|
|
326
|
-
# map. The description rides the label since @ui.select takes only
|
|
327
|
-
# [label, value] pairs (no separate hint slot).
|
|
328
|
-
def option_label(opt)
|
|
329
|
-
return opt.to_s unless opt.is_a?(Hash)
|
|
330
|
-
|
|
331
|
-
label = opt["label"].to_s
|
|
332
|
-
desc = opt["description"].to_s
|
|
333
|
-
desc.empty? ? label : "#{label} #{pastel.dim("— #{desc}")}"
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
# The value DELIVERED to the child for a picker option: always the label
|
|
337
|
-
# STRING (the description is presentational only), so the child's answer
|
|
338
|
-
# is clean text, never a hash literal.
|
|
339
|
-
def option_value(opt)
|
|
340
|
-
opt.is_a?(Hash) ? opt["label"].to_s : opt.to_s
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
# Routes the answer back DOWN to the child: decide the gate (unblocks a
|
|
344
|
-
# blocking ask with the answer as its tool result) and push it onto the
|
|
345
|
-
# steer queue (a non-blocking ask folds it in next turn). Then clear the
|
|
346
|
-
# blocked state and repaint so the ⛔ marker clears.
|
|
347
|
-
def deliver_reply(entry, answer)
|
|
348
|
-
# The ONE shared answer wire (also used by the model-callable
|
|
349
|
-
# answer_child tool): decide the gate + push the steer note + clear the
|
|
350
|
-
# blocked state, all in BackgroundTasks#deliver_answer.
|
|
351
|
-
# H5 — deliver_answer reports HONESTLY now: false when the child has
|
|
352
|
-
# already finished and neither delivery path landed. Say so instead of
|
|
353
|
-
# the false "resumes at its next turn" — there is no next turn.
|
|
354
|
-
delivered = Tools::BackgroundTasks.instance.deliver_answer(entry.id, answer)
|
|
355
|
-
if delivered
|
|
356
|
-
@ui.info("↳ answered #{entry.id}: #{truncate(answer, 80)}")
|
|
357
|
-
@ui.info("✓ tree unblocked · #{entry.id} resumes at its next turn")
|
|
358
|
-
else
|
|
359
|
-
@ui.info("↳ answer to #{entry.id}: #{truncate(answer, 80)}")
|
|
360
|
-
@ui.error("⚠ not delivered — #{entry.id} already finished; it never saw your answer.")
|
|
361
|
-
end
|
|
362
|
-
@ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
|
|
363
|
-
end
|
|
364
|
-
|
|
365
|
-
# Lists the children currently blocked on the human (the /reply with no id
|
|
366
|
-
# case) so the user can see who is waiting and on what.
|
|
367
|
-
def show_blocked_agents
|
|
368
|
-
blocked = Tools::BackgroundTasks.instance.awaiting_human
|
|
369
|
-
if blocked.empty?
|
|
370
|
-
@ui.info("No subagent is waiting on you.")
|
|
371
|
-
return
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
@ui.info(pastel.red("⛔ #{blocked.size} subagent waiting on you:"))
|
|
375
|
-
blocked.each do |e|
|
|
376
|
-
@ui.info(" #{e.id} · #{e.subagent}: #{truncate(e.ask_question, 80)}")
|
|
377
|
-
end
|
|
378
|
-
@ui.info("/reply <id> <answer> to answer")
|
|
379
|
-
end
|
|
380
|
-
|
|
381
164
|
# Strips a single pair of wrapping double/single quotes from a steer/probe
|
|
382
165
|
# argument so `steer "be terse"` lands as `be terse`, not `"be terse"`.
|
|
383
166
|
def dequote(text)
|
|
@@ -426,7 +209,7 @@ module Rubino
|
|
|
426
209
|
|
|
427
210
|
def show_agent_snapshot(entry)
|
|
428
211
|
return render_agent_watch(entry) if %i[
|
|
429
|
-
running stopping
|
|
212
|
+
running stopping needs_approval
|
|
430
213
|
].include?(entry.status)
|
|
431
214
|
|
|
432
215
|
show_agent_result(entry)
|
|
@@ -597,6 +380,17 @@ module Rubino
|
|
|
597
380
|
choice = ask_budget_answer(entry)
|
|
598
381
|
return if choice.nil?
|
|
599
382
|
|
|
383
|
+
if choice == :later
|
|
384
|
+
# "Decide later" (#586): don't decide the gate — leave the child parked
|
|
385
|
+
# and SNOOZE its auto-modal so it stops re-popping at idle. It stays a
|
|
386
|
+
# `wants +budget` card the user resolves deliberately via the picker /
|
|
387
|
+
# `/agents <id>`. This is the safe ↓-target that keeps a mis-aimed
|
|
388
|
+
# picker ↓+Enter from force-summarizing a child.
|
|
389
|
+
Tools::BackgroundTasks.instance.snooze_approval(entry.id)
|
|
390
|
+
@ui.info("#{entry.id} left waiting — /agents #{entry.id} to grant or summarize.")
|
|
391
|
+
return
|
|
392
|
+
end
|
|
393
|
+
|
|
600
394
|
grant = choice == :grant
|
|
601
395
|
gate.decide(entry.approval_id, grant)
|
|
602
396
|
@ui.info(grant ? "Granted more budget to #{entry.id}." : "#{entry.id} will summarize now.")
|
|
@@ -700,20 +494,18 @@ module Rubino
|
|
|
700
494
|
return
|
|
701
495
|
end
|
|
702
496
|
|
|
703
|
-
unless %i[running needs_approval
|
|
497
|
+
unless %i[running needs_approval stopping].include?(entry.status)
|
|
704
498
|
@ui.info("#{id} already #{entry.status} — nothing to stop.")
|
|
705
499
|
return
|
|
706
500
|
end
|
|
707
501
|
|
|
708
|
-
# A child parked on a human approval
|
|
709
|
-
#
|
|
710
|
-
#
|
|
711
|
-
#
|
|
712
|
-
#
|
|
713
|
-
#
|
|
714
|
-
#
|
|
715
|
-
# unwind as :stopped, not ✗ failed (#13), then flips the runner token. The
|
|
716
|
-
# SAME body the parent-teardown #cancel_all uses — one implementation.
|
|
502
|
+
# A child parked on a human approval is blocked in its gate's wait; the
|
|
503
|
+
# shared #stop_entry cancels the gate so it wakes (Interrupted →
|
|
504
|
+
# deny/cancel) and unwinds instead of holding its thread, marks the stop
|
|
505
|
+
# FIRST so the very next /agents list shows ◌ stopping instead of a stale
|
|
506
|
+
# ● running (#108) and the worker's terminal write records the unwind as
|
|
507
|
+
# :stopped, not ✗ failed (#13), then flips the runner token. The SAME
|
|
508
|
+
# body the parent-teardown #cancel_all uses — one implementation.
|
|
717
509
|
registry.stop_entry(entry)
|
|
718
510
|
@ui.success("Stop requested for #{id} (#{entry.subagent}); it unwinds at its next checkpoint.")
|
|
719
511
|
end
|
|
@@ -728,8 +520,6 @@ module Rubino
|
|
|
728
520
|
when :stopping then ["◌", "stopping", :yellow]
|
|
729
521
|
when :stopped then ["⊘", "stopped", :yellow]
|
|
730
522
|
when :needs_approval then ["●", "approval", :yellow]
|
|
731
|
-
when :blocked_on_human then ["⛔", "waiting on you", :red]
|
|
732
|
-
when :blocked_on_parent then ["◷", "waiting on parent", :cyan]
|
|
733
523
|
when :failed then ["✗", "failed", :red]
|
|
734
524
|
else ["✓", "done", :green]
|
|
735
525
|
end
|
|
@@ -784,10 +574,10 @@ module Rubino
|
|
|
784
574
|
end
|
|
785
575
|
|
|
786
576
|
# Direct entry points for the REPL's agent-attach view: it calls these with
|
|
787
|
-
# the user's RAW text, so a steer/probe
|
|
788
|
-
#
|
|
789
|
-
#
|
|
790
|
-
public :steer_agent, :probe_agent
|
|
577
|
+
# the user's RAW text, so a steer/probe note keeps embedded quotes intact
|
|
578
|
+
# instead of being serialized into a "steer \"…\"" command string and
|
|
579
|
+
# mangled by the executor's whitespace-split + single-pair dequote.
|
|
580
|
+
public :steer_agent, :probe_agent
|
|
791
581
|
end
|
|
792
582
|
end
|
|
793
583
|
end
|
|
@@ -232,9 +232,12 @@ module Rubino
|
|
|
232
232
|
|
|
233
233
|
def status_background_line
|
|
234
234
|
entries = Tools::BackgroundTasks.instance.list
|
|
235
|
-
running = entries.
|
|
236
|
-
ids
|
|
237
|
-
|
|
235
|
+
running = entries.select { |e| e.status == :running }
|
|
236
|
+
# The parenthetical names the RUNNING ids (capped), matching the count
|
|
237
|
+
# and the picker — not the first rows of the total table, which could
|
|
238
|
+
# name a finished task while hiding a running one (subagents + shells).
|
|
239
|
+
ids = running.first(3).map(&:id).join(", ")
|
|
240
|
+
line = "#{running.size} running · #{entries.size} total"
|
|
238
241
|
ids.empty? ? line : "#{line} (#{ids})"
|
|
239
242
|
rescue StandardError
|
|
240
243
|
"(unavailable)"
|
|
@@ -23,7 +23,7 @@ module Rubino
|
|
|
23
23
|
# leaves behind. `first_line`/`line_count` are the 1-based read window into
|
|
24
24
|
# the ORIGINAL file (so a `read offset=first_line limit=line_count` returns
|
|
25
25
|
# exactly these bytes — the drill-in invariant).
|
|
26
|
-
Elision = Struct.new(:first_line, :line_count,
|
|
26
|
+
Elision = Struct.new(:first_line, :line_count, keyword_init: true)
|
|
27
27
|
|
|
28
28
|
def initialize(keep_method_body_max_lines:)
|
|
29
29
|
@keep_max = keep_method_body_max_lines.to_i
|
|
@@ -111,7 +111,7 @@ module Rubino
|
|
|
111
111
|
line_count = inner_last - inner_first + 1
|
|
112
112
|
return nil unless line_count > @keep_max
|
|
113
113
|
|
|
114
|
-
Elision.new(first_line: inner_first, line_count: line_count
|
|
114
|
+
Elision.new(first_line: inner_first, line_count: line_count)
|
|
115
115
|
end
|
|
116
116
|
end
|
|
117
117
|
end
|
|
@@ -60,6 +60,26 @@ module Rubino
|
|
|
60
60
|
value.positive? ? value : UI::BottomComposer::MAX_INPUT_ROWS
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Render the in-flight streamed block as formatted markdown in the live
|
|
64
|
+
# region (display.live_markdown). Default true; only an explicit false
|
|
65
|
+
# falls back to the legacy raw live tail.
|
|
66
|
+
def display_live_markdown?
|
|
67
|
+
dig("display", "live_markdown") != false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wrap each live-region frame in DEC-2026 synchronized output
|
|
71
|
+
# (display.synchronized_output). Default true; only an explicit false
|
|
72
|
+
# falls back to the legacy per-write frames.
|
|
73
|
+
def display_synchronized_output?
|
|
74
|
+
dig("display", "synchronized_output") != false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Syntax-highlight committed code blocks (display.code_highlight). Default
|
|
78
|
+
# true; only an explicit false falls back to plain (uncoloured) code.
|
|
79
|
+
def display_code_highlight?
|
|
80
|
+
dig("display", "code_highlight") != false
|
|
81
|
+
end
|
|
82
|
+
|
|
63
83
|
# -- Paste section (UI::PasteStore: the file-backed paste pipeline) --
|
|
64
84
|
# A paste with MORE than this many lines collapses to a
|
|
65
85
|
# "[Pasted text #N +M lines]" placeholder in the composer (expanded to
|
|
@@ -192,18 +212,10 @@ module Rubino
|
|
|
192
212
|
dig("tasks", "max_live_probes_per_child") || Defaults.dig("tasks", "max_live_probes_per_child")
|
|
193
213
|
end
|
|
194
214
|
|
|
195
|
-
# Bound (seconds) a BLOCKING ask_parent waits for an answer before the child
|
|
196
|
-
# self-heals and proceeds with its best judgement (S5a). Reuses the
|
|
197
|
-
# approval-gate timeout convention — a sane upper bound, never "forever" —
|
|
198
|
-
# so an abandoned ask never parks the child's thread indefinitely. Default 900.
|
|
199
|
-
def tasks_ask_parent_timeout
|
|
200
|
-
dig("tasks", "ask_parent_timeout") || Defaults.dig("tasks", "ask_parent_timeout")
|
|
201
|
-
end
|
|
202
|
-
|
|
203
215
|
# Bound (seconds) an interactive `question`/clarify waits for the human to
|
|
204
216
|
# answer before it EXPIRES CLEANLY and the agent proceeds with its best
|
|
205
|
-
# judgement (#552). Mirrors the
|
|
206
|
-
#
|
|
217
|
+
# judgement (#552). Mirrors the Hermes clarify_timeout convention — a
|
|
218
|
+
# generous upper bound (default 600s = 10 min, well above
|
|
207
219
|
# human reading/deliberation time), never the 30s stale-chunk window and
|
|
208
220
|
# never "forever". An abandoned clarify self-heals into the NO_ANSWER
|
|
209
221
|
# outcome instead of hanging the run or being killed by the stale watchdog.
|
|
@@ -272,14 +284,6 @@ module Rubino
|
|
|
272
284
|
dig("memory", "auto_extract") == true
|
|
273
285
|
end
|
|
274
286
|
|
|
275
|
-
# Background session-summary aux-LLM job (SummarizeSessionJob). Default ON
|
|
276
|
-
# (absent ⇒ true), so existing behaviour is unchanged; an explicit false
|
|
277
|
-
# turns it off — letting the whole background aux-LLM surface
|
|
278
|
-
# (extract/distill/summarize) be disabled together.
|
|
279
|
-
def memory_auto_summarize?
|
|
280
|
-
dig("memory", "auto_summarize") != false
|
|
281
|
-
end
|
|
282
|
-
|
|
283
287
|
# Throttle interval (in turns) for memory.auto_extract (#412). Returns a
|
|
284
288
|
# positive Integer; nil/<=1 (or absent) ⇒ 1 = every turn. The lifecycle
|
|
285
289
|
# only enqueues ExtractMemoryJob when turns-since-last >= this.
|
|
@@ -437,6 +441,31 @@ module Rubino
|
|
|
437
441
|
dig("auxiliary", task.to_s) || {}
|
|
438
442
|
end
|
|
439
443
|
|
|
444
|
+
# True when the auxiliary +task+ resolves to the SAME server ENDPOINT as the
|
|
445
|
+
# main model — i.e. its LLM calls land on the main model server's KV slot.
|
|
446
|
+
# Slot-sharing is about the endpoint (provider + base_url), NOT the model: a
|
|
447
|
+
# different model on the SAME server still shares the single slot. At the
|
|
448
|
+
# defaults (auxiliary.<task>.provider:"main", empty base_url) this is true.
|
|
449
|
+
#
|
|
450
|
+
# It matters for local single-slot servers: an aux call sharing the slot
|
|
451
|
+
# OVERWRITES the live conversation's KV-cache prefix, so the next user turn
|
|
452
|
+
# re-prefills the whole context (the "freeze after N turns"). The post-turn
|
|
453
|
+
# extraction/distill gates use this to stay OFF the interactive slot,
|
|
454
|
+
# mirroring how Hermes/Claude Code keep automatic memory work off the live
|
|
455
|
+
# conversation (extract at session end instead). A DISTINCT aux endpoint
|
|
456
|
+
# (its own server/slot) does not evict, so inter-turn extraction stays on.
|
|
457
|
+
def auxiliary_on_main_endpoint?(task)
|
|
458
|
+
cfg = auxiliary_config(task)
|
|
459
|
+
provider = cfg["provider"].to_s.strip
|
|
460
|
+
aux_provider = provider.empty? || provider == "main" ? dig("model", "provider").to_s : provider
|
|
461
|
+
|
|
462
|
+
aux_base = cfg["base_url"].to_s.strip
|
|
463
|
+
aux_base = provider_config(aux_provider)["base_url"].to_s.strip if aux_base.empty?
|
|
464
|
+
main_base = provider_config(dig("model", "provider").to_s)["base_url"].to_s.strip
|
|
465
|
+
|
|
466
|
+
aux_provider == dig("model", "provider").to_s && aux_base == main_base
|
|
467
|
+
end
|
|
468
|
+
|
|
440
469
|
# Returns true when the primary model can ingest images directly. Honours
|
|
441
470
|
# an explicit `model.supports_vision` override; otherwise falls back to
|
|
442
471
|
# ContentBuilder's name-pattern heuristic. Used by VisionTool to decide
|