rubino-agent 0.5.2.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 +56 -0
- data/Dockerfile +56 -0
- data/agent.md +112 -0
- 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/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 +3 -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/cli/chat/idle_card_host.rb +6 -1
- data/lib/rubino/cli/chat_command.rb +119 -17
- data/lib/rubino/cli/commands.rb +5 -0
- data/lib/rubino/commands/handlers/agents.rb +27 -18
- data/lib/rubino/commands/handlers/status.rb +6 -3
- data/lib/rubino/config/configuration.rb +25 -8
- data/lib/rubino/config/defaults.rb +15 -13
- data/lib/rubino/context/prompt_assembler.rb +89 -1
- data/lib/rubino/context/summary_builder.rb +0 -22
- data/lib/rubino/interaction/events.rb +2 -2
- data/lib/rubino/interaction/lifecycle.rb +54 -20
- data/lib/rubino/llm/ruby_llm_adapter.rb +178 -20
- data/lib/rubino/security/redactor.rb +1 -1
- data/lib/rubino/session/message.rb +12 -0
- data/lib/rubino/tools/background_tasks.rb +107 -12
- data/lib/rubino/tools/base.rb +1 -1
- 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/task_result_tool.rb +4 -1
- data/lib/rubino/tools/task_tool.rb +74 -11
- data/lib/rubino/tools/vision_tool.rb +1 -1
- data/lib/rubino/ui/agent_menu.rb +8 -2
- data/lib/rubino/ui/api.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +24 -11
- data/lib/rubino/ui/cli.rb +254 -15
- data/lib/rubino/ui/markdown_renderer.rb +4 -1
- data/lib/rubino/ui/stdout_proxy.rb +25 -10
- data/lib/rubino/ui/streaming_markdown.rb +67 -12
- data/lib/rubino/ui/subagent_cards.rb +8 -7
- data/lib/rubino/ui/tool_args_stream.rb +143 -0
- data/lib/rubino/update_check.rb +10 -2
- data/lib/rubino/version.rb +1 -1
- metadata +14 -6
- data/AGENTS.md +0 -97
- data/docs/agents.md +0 -216
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
- data/lib/rubino/tools/summarize_file_tool.rb +0 -194
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "ruby_llm"
|
|
4
4
|
require "faraday"
|
|
5
5
|
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "ipaddr"
|
|
6
8
|
require_relative "tool_bridge"
|
|
7
9
|
require_relative "cache_breakpoint_middleware"
|
|
8
10
|
require_relative "inline_think_filter"
|
|
@@ -198,7 +200,7 @@ module Rubino
|
|
|
198
200
|
think_filter = InlineThinkFilter.new
|
|
199
201
|
buffered = +""
|
|
200
202
|
last_chunk_at = monotonic_now
|
|
201
|
-
stale_after = stale_chunk_timeout
|
|
203
|
+
stale_after = stale_chunk_timeout(messages)
|
|
202
204
|
chunks_seen = 0
|
|
203
205
|
# #488: a tool that ruby_llm runs MID-STREAM (e.g. a blocking
|
|
204
206
|
# `question`/clarify parked on a human answer for up to the clarify
|
|
@@ -228,6 +230,9 @@ module Rubino
|
|
|
228
230
|
# glued on. The headless one-shot `result` surfaces this, not `buffered`.
|
|
229
231
|
last_block = +""
|
|
230
232
|
last_block_seen = message_block_id
|
|
233
|
+
# Tool-call ids already announced as "preparing" this stream, so the
|
|
234
|
+
# per-chunk arg deltas of one call surface the name exactly once (#608).
|
|
235
|
+
announced_tools = {}
|
|
231
236
|
|
|
232
237
|
emit = lambda do |type, text|
|
|
233
238
|
next if text.nil? || text.empty?
|
|
@@ -313,9 +318,10 @@ module Rubino
|
|
|
313
318
|
# stalled SSE / a 200 that never sends an event), nothing inside the
|
|
314
319
|
# callback ever runs and the only backstop is the 600s socket
|
|
315
320
|
# read-timeout. Bound the idle gap INDEPENDENTLY of chunk arrival with a
|
|
316
|
-
# watchdog thread that wakes on `stale_after` (
|
|
317
|
-
#
|
|
318
|
-
#
|
|
321
|
+
# watchdog thread that wakes on `stale_after` (90s remote default,
|
|
322
|
+
# DISABLED for local endpoints, configurable via
|
|
323
|
+
# providers.<name>.stale_timeout_seconds — see #stale_chunk_timeout) and,
|
|
324
|
+
# on observing an idle past the deadline, raises StreamStaleError INTO this
|
|
319
325
|
# streaming thread to break it out of the blocking socket read. The
|
|
320
326
|
# rescue below then surfaces a clear "stream stalled" and lets the retry
|
|
321
327
|
# ladder run. The closure reads `last_chunk_at`/`chunks_seen` live (they
|
|
@@ -344,6 +350,13 @@ module Rubino
|
|
|
344
350
|
emit.call(:thinking, thinking_text)
|
|
345
351
|
end
|
|
346
352
|
think_filter.feed(chunk.content, &emit) if chunk.content.is_a?(String) && !chunk.content.empty?
|
|
353
|
+
# A tool-call delta (the streaming arguments of a long `write`, etc.)
|
|
354
|
+
# carries neither thinking nor textual content. Surface BOTH the tool
|
|
355
|
+
# NAME once per call (:tool_preparing, so the timeline can open the
|
|
356
|
+
# tool card the moment the call starts) AND every argument FRAGMENT
|
|
357
|
+
# (:tool_args, so the UI streams the params live and the token meter
|
|
358
|
+
# keeps climbing instead of freezing while a big file streams). #608.
|
|
359
|
+
announce_tool_stream(chunk, announced_tools, &emit)
|
|
347
360
|
end
|
|
348
361
|
rescue Rubino::Interrupted
|
|
349
362
|
# Flush whatever the filter has buffered, then re-raise. Loop will
|
|
@@ -363,13 +376,13 @@ module Rubino
|
|
|
363
376
|
end
|
|
364
377
|
log_safely(event: "llm.stream.partial", error: e.message, buffered_bytes: buffered.bytesize)
|
|
365
378
|
flush_filter(think_filter, &emit)
|
|
366
|
-
return partial_response(buffered)
|
|
379
|
+
return partial_response(buffered, usage)
|
|
367
380
|
rescue JSON::ParserError => e
|
|
368
381
|
# Preserve whatever we've buffered so far so the user sees partial
|
|
369
382
|
# output instead of a blank failure. (issues #12, #22)
|
|
370
383
|
log_safely(event: "llm.stream.partial", error: e.message, buffered_bytes: buffered.bytesize)
|
|
371
384
|
flush_filter(think_filter, &emit)
|
|
372
|
-
return partial_response(buffered)
|
|
385
|
+
return partial_response(buffered, usage)
|
|
373
386
|
rescue *STREAM_DROP_ERRORS => e
|
|
374
387
|
# A genuine transport drop (the observed M3 EOF, a connection reset, a
|
|
375
388
|
# read timeout, …). If NOTHING was emitted yet, re-raise so the runner
|
|
@@ -382,7 +395,7 @@ module Rubino
|
|
|
382
395
|
log_safely(event: "llm.stream.partial_interrupted", error: e.message,
|
|
383
396
|
buffered_bytes: buffered.bytesize)
|
|
384
397
|
flush_filter(think_filter, &emit)
|
|
385
|
-
return partial_response(buffered)
|
|
398
|
+
return partial_response(buffered, usage)
|
|
386
399
|
ensure
|
|
387
400
|
# Always tear the watchdog down — on success, on partial-return, and on
|
|
388
401
|
# a raised StreamStaleError/transport drop — so it never leaks a thread
|
|
@@ -453,11 +466,27 @@ module Rubino
|
|
|
453
466
|
{
|
|
454
467
|
content: msg.respond_to?(:content) ? msg.content : nil,
|
|
455
468
|
tool_calls: normalize_message_tool_calls(msg),
|
|
469
|
+
# Carry the reasoning forward so the Loop persists it and later turns
|
|
470
|
+
# replay it (KV-cache prefix stability, #608b — see load_history).
|
|
471
|
+
reasoning: message_reasoning_text(msg),
|
|
456
472
|
input_tokens: msg.respond_to?(:input_tokens) ? msg.input_tokens.to_i : 0,
|
|
457
473
|
output_tokens: msg.respond_to?(:output_tokens) ? msg.output_tokens.to_i : 0
|
|
458
474
|
}
|
|
459
475
|
end
|
|
460
476
|
|
|
477
|
+
# The reasoning text on a ruby_llm Message (its `thinking` is a
|
|
478
|
+
# RubyLLM::Thinking with `#text`), or nil. Best-effort: a chunk-shape quirk
|
|
479
|
+
# must never break the intermediate-persist path.
|
|
480
|
+
def message_reasoning_text(msg)
|
|
481
|
+
return nil unless msg.respond_to?(:thinking) && msg.thinking
|
|
482
|
+
|
|
483
|
+
t = msg.thinking
|
|
484
|
+
text = t.respond_to?(:text) ? t.text : t.to_s
|
|
485
|
+
text.to_s.empty? ? nil : text
|
|
486
|
+
rescue StandardError
|
|
487
|
+
nil
|
|
488
|
+
end
|
|
489
|
+
|
|
461
490
|
def normalize_message_tool_calls(msg)
|
|
462
491
|
return [] unless msg.respond_to?(:tool_calls) && msg.tool_calls
|
|
463
492
|
|
|
@@ -475,14 +504,56 @@ module Rubino
|
|
|
475
504
|
log_safely(event: event, error: e.message)
|
|
476
505
|
end
|
|
477
506
|
|
|
507
|
+
# Surface a streaming tool call to the UI as two signals (#608):
|
|
508
|
+
# * `:tool_preparing` — the tool NAME, emitted ONCE per call (deduped via
|
|
509
|
+
# +announced+, keyed by id), the instant the call first appears. The
|
|
510
|
+
# timeline uses it to open the tool card at the START of the call.
|
|
511
|
+
# * `:tool_args` — every argument FRAGMENT as it streams. ruby_llm yields
|
|
512
|
+
# the per-delta chunk (not the accumulator), so `tc.arguments` here is
|
|
513
|
+
# the NEW fragment, not the cumulative string — forward it as-is. The UI
|
|
514
|
+
# streams these as the live params and counts them toward the token
|
|
515
|
+
# meter, so a long `write` shows progress instead of a frozen footer.
|
|
516
|
+
# Both ride the stream's (type, text) lambda, so they share the same
|
|
517
|
+
# error-handled path as content/thinking. Best-effort: never let a
|
|
518
|
+
# chunk-shape quirk break the stream.
|
|
519
|
+
def announce_tool_stream(chunk, announced, &emit)
|
|
520
|
+
calls = chunk.respond_to?(:tool_calls) ? chunk.tool_calls : nil
|
|
521
|
+
return unless calls.respond_to?(:each_value)
|
|
522
|
+
|
|
523
|
+
calls.each_value do |tc|
|
|
524
|
+
name = tc.respond_to?(:name) ? tc.name : nil
|
|
525
|
+
if name && !name.empty?
|
|
526
|
+
id = (tc.respond_to?(:id) && tc.id) || name
|
|
527
|
+
unless announced[id]
|
|
528
|
+
announced[id] = true
|
|
529
|
+
emit.call(:tool_preparing, name.to_s)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
args = tc.respond_to?(:arguments) ? tc.arguments : nil
|
|
534
|
+
emit.call(:tool_args, args) if args.is_a?(String) && !args.empty?
|
|
535
|
+
end
|
|
536
|
+
rescue StandardError => e
|
|
537
|
+
log_safely(event: "llm.stream.tool_stream_error", error: e.message)
|
|
538
|
+
end
|
|
539
|
+
|
|
478
540
|
# Buffered-partial AdapterResponse returned when a stream is cut after at
|
|
479
541
|
# least one chunk (parse error, stale, or post-first-chunk transport drop).
|
|
480
542
|
# Flagged +interrupted+ so the Loop fails the turn (run.failed) instead of
|
|
481
543
|
# mistaking the truncated buffer for a finished answer (the silent
|
|
482
544
|
# "completed-but-empty" bug — see Rubino::StreamInterruptedError).
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
545
|
+
#
|
|
546
|
+
# Token accounting (#token-loss): a cut stream still SPENT the tokens of
|
|
547
|
+
# every round-trip ruby_llm already completed inside this one ask() — the
|
|
548
|
+
# `usage` accumulator (wire_round_trip_callbacks) holds their summed spend
|
|
549
|
+
# at the moment of the drop. A multi-round-trip TOOL turn against a flaky
|
|
550
|
+
# transport is exactly where this fires, so zeroing the tokens here made
|
|
551
|
+
# tool/file-writing turns report no token spend at all. Carry the
|
|
552
|
+
# accumulated usage through so the turn summary still counts what was spent.
|
|
553
|
+
def partial_response(buffered, usage = nil)
|
|
554
|
+
summed_in, summed_out = usage ? [usage[:input].to_i, usage[:output].to_i] : [0, 0]
|
|
555
|
+
AdapterResponse.new(content: buffered, tool_calls: [], input_tokens: summed_in,
|
|
556
|
+
output_tokens: summed_out, model_id: @model_id, interrupted: true)
|
|
486
557
|
end
|
|
487
558
|
|
|
488
559
|
def configure_ruby_llm!
|
|
@@ -971,15 +1042,86 @@ module Rubino
|
|
|
971
1042
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
972
1043
|
end
|
|
973
1044
|
|
|
974
|
-
|
|
1045
|
+
# The stale-stream watchdog deadline, ported from Hermes'
|
|
1046
|
+
# _compute_non_stream_stale_timeout (run_agent.py). Priority:
|
|
1047
|
+
# 1. providers.<id>.stale_timeout_seconds — explicit always wins.
|
|
1048
|
+
# 2. LOCAL endpoint (localhost / 127.x / host.docker.internal / RFC-1918
|
|
1049
|
+
# / Tailscale CGNAT) with NO explicit config ⇒ DISABLED (0): a large
|
|
1050
|
+
# local model can legitimately PREFILL for minutes before the first
|
|
1051
|
+
# token, and a local box isn't a flaky remote provider — the 600s
|
|
1052
|
+
# socket read-timeout still backstops a genuinely dead connection.
|
|
1053
|
+
# This is the fix for "no chunk received for 30s" on local models.
|
|
1054
|
+
# 3. Remote default 90s (Hermes parity; was 30/300), scaled UP for large
|
|
1055
|
+
# contexts whose prefill is inherently slower (>50k→150, >100k→240).
|
|
1056
|
+
# 0 disables the watchdog (start_stale_watchdog / check_stream_stale! both
|
|
1057
|
+
# early-return on a non-positive deadline).
|
|
1058
|
+
def stale_chunk_timeout(messages = nil)
|
|
975
1059
|
explicit = @config.dig("providers", @provider, "stale_timeout_seconds")
|
|
976
1060
|
return explicit if explicit
|
|
977
1061
|
|
|
978
|
-
return
|
|
1062
|
+
return 0 if local_endpoint?
|
|
979
1063
|
|
|
980
|
-
|
|
1064
|
+
scale_stale_for_context(default_remote_stale_timeout, messages)
|
|
981
1065
|
end
|
|
982
1066
|
|
|
1067
|
+
DEFAULT_REMOTE_STALE_SECONDS = 90
|
|
1068
|
+
|
|
1069
|
+
def default_remote_stale_timeout
|
|
1070
|
+
@config.dig("providers", "openai", "stale_timeout_seconds") || DEFAULT_REMOTE_STALE_SECONDS
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
# Bump the base deadline for large requests — prefill (time-to-first-token)
|
|
1074
|
+
# grows with context, so a flat timeout false-positives on big prompts.
|
|
1075
|
+
def scale_stale_for_context(base, messages)
|
|
1076
|
+
est = estimate_context_tokens(messages)
|
|
1077
|
+
return [base, 240].max if est > 100_000
|
|
1078
|
+
return [base, 150].max if est > 50_000
|
|
1079
|
+
|
|
1080
|
+
base
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
# Cheap context-size estimate (~4 chars/token) over the request messages,
|
|
1084
|
+
# only used to pick a timeout tier — never for billing/truncation. Tolerant
|
|
1085
|
+
# of either symbol/string content keys and any odd shape (→ 0).
|
|
1086
|
+
def estimate_context_tokens(messages)
|
|
1087
|
+
return 0 unless messages.respond_to?(:sum)
|
|
1088
|
+
|
|
1089
|
+
chars = messages.sum do |m|
|
|
1090
|
+
content = m[:content] || m["content"] if m.respond_to?(:[])
|
|
1091
|
+
content.to_s.length
|
|
1092
|
+
end
|
|
1093
|
+
chars / 4
|
|
1094
|
+
rescue StandardError
|
|
1095
|
+
0
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# True when the configured base_url points at the local machine / private
|
|
1099
|
+
# network — a faithful port of Hermes' agent.model_metadata.is_local_endpoint.
|
|
1100
|
+
# Such endpoints auto-disable the stale watchdog (slow local prefill is
|
|
1101
|
+
# normal, not a stall). Any parse failure ⇒ treat as remote (false).
|
|
1102
|
+
def local_endpoint?
|
|
1103
|
+
url = present_base_url(provider_cfg).to_s
|
|
1104
|
+
return false if url.empty?
|
|
1105
|
+
|
|
1106
|
+
host = (URI.parse(url).host || URI.parse("http://#{url}").host).to_s.downcase
|
|
1107
|
+
return false if host.empty?
|
|
1108
|
+
return true if LOCAL_HOSTS.include?(host)
|
|
1109
|
+
return true if CONTAINER_LOCAL_SUFFIXES.any? { |s| host.end_with?(s) }
|
|
1110
|
+
|
|
1111
|
+
ip = IPAddr.new(host)
|
|
1112
|
+
ip.loopback? || ip.private? || ip.link_local? || TAILSCALE_CGNAT.include?(ip)
|
|
1113
|
+
rescue URI::InvalidURIError, IPAddr::Error
|
|
1114
|
+
false
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 0.0.0.0].freeze
|
|
1118
|
+
# Container/VM internal DNS (host.docker.internal, *.lima.internal, …) and
|
|
1119
|
+
# the mDNS .local suffix all resolve to the host loopback in practice.
|
|
1120
|
+
CONTAINER_LOCAL_SUFFIXES = %w[.internal .local .localhost].freeze
|
|
1121
|
+
# Tailscale CGNAT 100.64.0.0/10 — a remote-but-trusted local box reached
|
|
1122
|
+
# over a Tailscale mesh gets the same treatment as localhost.
|
|
1123
|
+
TAILSCALE_CGNAT = IPAddr.new("100.64.0.0/10")
|
|
1124
|
+
|
|
983
1125
|
def check_stream_stale!(last_chunk_at, stale_after)
|
|
984
1126
|
return if stale_after.to_f <= 0
|
|
985
1127
|
return if (monotonic_now - last_chunk_at) <= stale_after
|
|
@@ -1095,7 +1237,13 @@ module Rubino
|
|
|
1095
1237
|
chat_instance.messages << RubyLLM::Message.new(
|
|
1096
1238
|
role: role,
|
|
1097
1239
|
content: content,
|
|
1098
|
-
tool_calls: tool_calls
|
|
1240
|
+
tool_calls: tool_calls,
|
|
1241
|
+
# Replay the stored reasoning as the wire `reasoning_content`
|
|
1242
|
+
# (openai/chat.rb#format_thinking) so the prompt prefix byte-matches
|
|
1243
|
+
# the server's KV cache, which holds this turn's reasoning tokens
|
|
1244
|
+
# (#608b). Omitting it diverged the prefix at the reasoning boundary
|
|
1245
|
+
# and forced a full re-prefill every turn.
|
|
1246
|
+
thinking: rebuild_thinking(msg[:reasoning] || msg["reasoning"])
|
|
1099
1247
|
)
|
|
1100
1248
|
when :tool
|
|
1101
1249
|
chat_instance.messages << build_tool_message(
|
|
@@ -1175,6 +1323,16 @@ module Rubino
|
|
|
1175
1323
|
end
|
|
1176
1324
|
end
|
|
1177
1325
|
|
|
1326
|
+
# Reconstructs the RubyLLM::Thinking carrying a replayed assistant turn's
|
|
1327
|
+
# reasoning (#608b), or nil when there is none. RubyLLM::Thinking.build
|
|
1328
|
+
# already drops empty text, so a blank/absent reasoning yields nil and the
|
|
1329
|
+
# message replays without a reasoning_content field (unchanged behaviour).
|
|
1330
|
+
def rebuild_thinking(reasoning)
|
|
1331
|
+
return nil if reasoning.nil? || reasoning.to_s.empty?
|
|
1332
|
+
|
|
1333
|
+
RubyLLM::Thinking.build(text: reasoning.to_s)
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1178
1336
|
# +buffered+ (streaming path) is every assistant TEXT block of the turn
|
|
1179
1337
|
# concatenated, not just the final one. ruby_llm runs tools mid-stream and
|
|
1180
1338
|
# returns a response whose #content is only the LAST block, so any text the
|
|
@@ -1330,13 +1488,13 @@ module Rubino
|
|
|
1330
1488
|
|
|
1331
1489
|
# Reasoning text/summary if ruby_llm surfaced it on the message; nil
|
|
1332
1490
|
# otherwise. Kept defensive — older builds carry no reasoning field.
|
|
1491
|
+
# ruby_llm exposes a completed message's reasoning as #thinking (a
|
|
1492
|
+
# RubyLLM::Thinking with #text) — there is no #reasoning method, so the old
|
|
1493
|
+
# probe (response.reasoning) always rescued to nil and the final turn's
|
|
1494
|
+
# reasoning was never persisted; its replay then busted the KV-cache prefix
|
|
1495
|
+
# (#608b). Same extraction the intermediate path uses (#message_reasoning_text).
|
|
1333
1496
|
def extract_thinking(response)
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
r = response.reasoning
|
|
1337
|
-
r.respond_to?(:text) ? r.text : r.to_s
|
|
1338
|
-
rescue StandardError
|
|
1339
|
-
nil
|
|
1497
|
+
message_reasoning_text(response)
|
|
1340
1498
|
end
|
|
1341
1499
|
|
|
1342
1500
|
def extract_tool_calls(response)
|
|
@@ -11,7 +11,7 @@ module Rubino
|
|
|
11
11
|
# `grep` match content (both with code_file:true to skip the ENV/JSON
|
|
12
12
|
# assignment patterns that false-positive on source), `shell`/background
|
|
13
13
|
# shell output (full patterns — `cat .env` / `printenv` leak keys), and
|
|
14
|
-
#
|
|
14
|
+
# converted-document content from `read_attachment` before it enters context.
|
|
15
15
|
#
|
|
16
16
|
# Short tokens (< 18 chars) are fully masked; longer ones preserve the
|
|
17
17
|
# first 6 and last 4 characters for debuggability — matching Hermes'
|
|
@@ -65,6 +65,18 @@ module Rubino
|
|
|
65
65
|
# Surface assistant tool_calls (persisted as metadata) so the adapter
|
|
66
66
|
# can rebuild the toolUse block expected by strict providers on resume.
|
|
67
67
|
msg[:tool_calls] = @metadata[:tool_calls] if @metadata.is_a?(Hash) && @metadata[:tool_calls]
|
|
68
|
+
# Replay the assistant's reasoning on every later turn (Hermes
|
|
69
|
+
# conversation_loop.py:940 "pass reasoning back to the API for ALL
|
|
70
|
+
# assistant messages"). The local server's KV cache, after generating a
|
|
71
|
+
# turn, holds the reasoning tokens; a replay that OMITS them diverges
|
|
72
|
+
# from that cache at the point the reasoning was generated, forcing a
|
|
73
|
+
# full re-prefill of the whole context every turn. Re-emitting the stored
|
|
74
|
+
# reasoning keeps the prompt prefix byte-stable so the server reuses the
|
|
75
|
+
# cache (verified: same assistant row got a KV hit WITH reasoning, a miss
|
|
76
|
+
# WITHOUT). The adapter rebuilds it into the wire `reasoning_content`.
|
|
77
|
+
if @role == "assistant" && @metadata.is_a?(Hash) && (reasoning = @metadata[:reasoning])
|
|
78
|
+
msg[:reasoning] = reasoning
|
|
79
|
+
end
|
|
68
80
|
# #583: re-derive the error flag from the persisted outcome so a
|
|
69
81
|
# denied/errored tool result replays to the model marked as an error
|
|
70
82
|
# (is_error) on the next turn, exactly as it was sent live — never as a
|
|
@@ -59,10 +59,24 @@ module Rubino
|
|
|
59
59
|
# to remember); the parking/wake/stop-cancel plumbing is identical.
|
|
60
60
|
Entry = Struct.new(
|
|
61
61
|
:id, :subagent, :prompt, :status, :result, :error,
|
|
62
|
+
# How the child's turn TERMINATED (Agent::Loop#stop_reason). :completed on
|
|
63
|
+
# a real answer; :max_time / :max_iterations / :stream_incomplete when the
|
|
64
|
+
# run was force-summarized/truncated. Lets the completion notice, the card,
|
|
65
|
+
# and task_result all report a truncated run as PARTIAL, not "completed".
|
|
66
|
+
:stop_reason,
|
|
62
67
|
:thread, :runner, :started_at, :finished_at,
|
|
63
68
|
:last_activity, :tool_count, :activity_log, :output_tail,
|
|
64
69
|
:approval_gate, :approval_id, :approval_question, :approval_command,
|
|
65
70
|
:budget_request,
|
|
71
|
+
# "Decide later" (#586): the user dismissed this request's AUTO-modal
|
|
72
|
+
# without deciding it. The child stays parked on its gate and the
|
|
73
|
+
# `wants +budget` card stays visible, but auto_resolve stops re-popping
|
|
74
|
+
# the modal at idle — the user re-engages it deliberately via the picker /
|
|
75
|
+
# `/agents <id>` (the manual path presents regardless). This is what makes
|
|
76
|
+
# the picker's ↓+Enter gesture non-destructive: a stray gesture lands on
|
|
77
|
+
# "Decide later" (defers) instead of "Summarize now". Cleared when the
|
|
78
|
+
# approval is (re)opened or decided. nil/false ⇒ auto-pops normally.
|
|
79
|
+
:approval_snoozed,
|
|
66
80
|
# Monotonic stamp of the instant this child blocked on its approval gate
|
|
67
81
|
# (begin_approval), used to order the approval MODAL QUEUE FIFO: only one
|
|
68
82
|
# approval modal is presented at a time (awaiting_approval.first), and a
|
|
@@ -98,6 +112,36 @@ module Rubino
|
|
|
98
112
|
session_id = runner&.session&.dig(:id)
|
|
99
113
|
session_id ? ::Rubino::Session::Store.new.for_session(session_id) : []
|
|
100
114
|
end
|
|
115
|
+
|
|
116
|
+
# A subagent IS NOT a shell — the discriminator the shared /stop, steer,
|
|
117
|
+
# probe and attach paths dispatch on (ShellEntryAdapter#shell? ⇒ true).
|
|
118
|
+
def shell? = false
|
|
119
|
+
|
|
120
|
+
# Parent→child steer: park a turn-boundary note on the child's queue
|
|
121
|
+
# (folded in at its next iteration). The shell analogue is a stdin write.
|
|
122
|
+
def steer(text)
|
|
123
|
+
return false unless steer_queue
|
|
124
|
+
|
|
125
|
+
steer_queue.push(text)
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Ephemeral read-only peek: a synchronous LLM side-inference over the
|
|
130
|
+
# child's current context. The shell analogue is an output snapshot.
|
|
131
|
+
def peek(question)
|
|
132
|
+
::Rubino::Tools::SubagentProbe.new.peek(entry: self, question: question)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A dim hint shown ABOVE a probe answer, or nil. A just-spawned subagent has
|
|
136
|
+
# an empty context, so its honest "I'm not doing anything yet" reply would
|
|
137
|
+
# read as broken (#112) without this; a shell has no such notion (its peek
|
|
138
|
+
# IS its output), so the adapter returns nil.
|
|
139
|
+
def peek_hint
|
|
140
|
+
return unless tool_count.to_i.zero?
|
|
141
|
+
|
|
142
|
+
"(snapshot at this instant — the child just started and its context is " \
|
|
143
|
+
"still empty; probe again in a moment)"
|
|
144
|
+
end
|
|
101
145
|
end
|
|
102
146
|
|
|
103
147
|
# How many recent activity lines the drill-in shows (the live `recent:` ring).
|
|
@@ -254,12 +298,13 @@ module Rubino
|
|
|
254
298
|
# omitted from `undelivered`, yet reported delivered. Returns the notes
|
|
255
299
|
# that were still queued at finalize time (never delivered to the child),
|
|
256
300
|
# so the caller can surface them as undelivered.
|
|
257
|
-
def complete(entry, status:, result: nil, error: nil)
|
|
301
|
+
def complete(entry, status:, result: nil, error: nil, stop_reason: nil)
|
|
258
302
|
@mutex.synchronize do
|
|
259
303
|
status = :stopped if entry.status == :stopping && status == :failed
|
|
260
304
|
entry.status = status
|
|
261
305
|
entry.result = result
|
|
262
306
|
entry.error = error
|
|
307
|
+
entry.stop_reason = stop_reason
|
|
263
308
|
entry.finished_at = Time.now
|
|
264
309
|
# Drain UNDER the mutex: anything still here is undelivered (the child
|
|
265
310
|
# has no further turn to fold it in), and once status is terminal no
|
|
@@ -334,11 +379,26 @@ module Rubino
|
|
|
334
379
|
entry.approval_question = question.to_s
|
|
335
380
|
entry.approval_command = command.to_s
|
|
336
381
|
entry.budget_request = budget ? true : false
|
|
382
|
+
entry.approval_snoozed = false
|
|
337
383
|
entry.approval_seq = (@approval_seq += 1)
|
|
338
384
|
entry.status = :needs_approval
|
|
339
385
|
end
|
|
340
386
|
end
|
|
341
387
|
|
|
388
|
+
# "Decide later" (#586): stop auto-popping THIS request's modal at idle
|
|
389
|
+
# without deciding its gate — the child stays parked and the card stays
|
|
390
|
+
# visible; the user re-engages via the picker / `/agents <id>`. Mirrors
|
|
391
|
+
# begin/end_approval (one mutex-guarded mutation of the entry's approval
|
|
392
|
+
# state); a no-op if the entry is gone.
|
|
393
|
+
def snooze_approval(id)
|
|
394
|
+
@mutex.synchronize do
|
|
395
|
+
entry = @entries[id]
|
|
396
|
+
return unless entry
|
|
397
|
+
|
|
398
|
+
entry.approval_snoozed = true
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
342
402
|
# Clears the approval state and returns the entry to :running once a
|
|
343
403
|
# decision has been delivered (or the child unwinds).
|
|
344
404
|
def end_approval(id)
|
|
@@ -351,6 +411,7 @@ module Rubino
|
|
|
351
411
|
entry.approval_question = nil
|
|
352
412
|
entry.approval_command = nil
|
|
353
413
|
entry.budget_request = false
|
|
414
|
+
entry.approval_snoozed = false
|
|
354
415
|
entry.approval_seq = nil
|
|
355
416
|
entry.status = :running if entry.status == :needs_approval
|
|
356
417
|
end
|
|
@@ -373,15 +434,15 @@ module Rubino
|
|
|
373
434
|
# is no window in which a note is pushed onto a queue nobody will drain yet
|
|
374
435
|
# reported delivered. Pushing inside the mutex is safe: InputQueue#push has
|
|
375
436
|
# its own lock and never calls back into the registry, so no lock cycle.
|
|
437
|
+
# Send input to a background worker. The registry owns the liveness guard
|
|
438
|
+
# (one place); the ACTION is polymorphic on the entry — a subagent parks a
|
|
439
|
+
# turn-boundary steer note, a shell writes straight to its stdin.
|
|
376
440
|
def steer(id, text)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return false if terminal_status?(entry.status)
|
|
441
|
+
entry = find(id)
|
|
442
|
+
return false unless entry
|
|
443
|
+
return false if terminal_status?(entry.status)
|
|
381
444
|
|
|
382
|
-
|
|
383
|
-
true
|
|
384
|
-
end
|
|
445
|
+
entry.steer(text)
|
|
385
446
|
end
|
|
386
447
|
|
|
387
448
|
# Records a BILLED live probe against a child (S3): bumps probe_count
|
|
@@ -424,12 +485,29 @@ module Rubino
|
|
|
424
485
|
end
|
|
425
486
|
|
|
426
487
|
def find(id)
|
|
427
|
-
@mutex.synchronize { @entries[id] }
|
|
488
|
+
@mutex.synchronize { @entries[id] } || shell_adapter_for(id)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# A read-time adapter for a background SHELL by id (bg_*), or nil. Lets the
|
|
492
|
+
# shared /stop and attach paths resolve a shell exactly like a subagent
|
|
493
|
+
# without the shell living in @entries (no cap/steer/sync coupling).
|
|
494
|
+
def shell_adapter_for(id)
|
|
495
|
+
shell = ShellRegistry.instance.find(id)
|
|
496
|
+
shell ? ShellEntryAdapter.new(shell) : nil
|
|
428
497
|
end
|
|
429
498
|
|
|
430
|
-
# All entries, newest first — for a `task` listing (the /tasks analogue)
|
|
499
|
+
# All entries, newest first — for a `task` listing (the /tasks analogue) and
|
|
500
|
+
# the /agents list + /status count. Includes background shells (same unified
|
|
501
|
+
# set as #running) so a running shell is never visible in the picker/cards
|
|
502
|
+
# yet absent from the list/count.
|
|
431
503
|
def list
|
|
432
|
-
@mutex.synchronize { @entries.values
|
|
504
|
+
subs = @mutex.synchronize { @entries.values }
|
|
505
|
+
# The list (/agents table, /status count) keeps FINISHED subagents, so it
|
|
506
|
+
# keeps finished-but-retained shells too (running + retired) for symmetry —
|
|
507
|
+
# otherwise a just-finished shell vanished from /agents while a finished
|
|
508
|
+
# subagent lingered. #running stays running-only (the live picker/cards).
|
|
509
|
+
shells = ShellRegistry.instance.listable_entries.map { |e| ShellEntryAdapter.new(e) }
|
|
510
|
+
(subs + shells).sort_by(&:started_at).reverse
|
|
433
511
|
end
|
|
434
512
|
|
|
435
513
|
# Live (still-running) children — used by the parent stop path to cancel
|
|
@@ -437,7 +515,18 @@ module Rubino
|
|
|
437
515
|
# approval (:needs_approval) is STILL live (its thread is alive, holding a
|
|
438
516
|
# slot), so it counts as running here.
|
|
439
517
|
def running
|
|
440
|
-
@mutex.synchronize { @entries.values.select { |e| live_status?(e.status) } }
|
|
518
|
+
subs = @mutex.synchronize { @entries.values.select { |e| live_status?(e.status) } }
|
|
519
|
+
subs + shell_adapters
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Background SHELLS, presented as read-time adapters that duck-type a
|
|
523
|
+
# subagent entry — the ONE place shells join the unified live set, so every
|
|
524
|
+
# surface that lists "background work" (cards, picker, /agents list, /status
|
|
525
|
+
# count) includes them with zero shell-specific branches. No second registry
|
|
526
|
+
# entry ⇒ no status sync / double completion notice / concurrency-cap
|
|
527
|
+
# pollution / dead steer_queue.
|
|
528
|
+
def shell_adapters
|
|
529
|
+
ShellRegistry.instance.running_entries.map { |e| ShellEntryAdapter.new(e) }
|
|
441
530
|
end
|
|
442
531
|
|
|
443
532
|
def remove(id)
|
|
@@ -455,10 +544,16 @@ module Rubino
|
|
|
455
544
|
# non-live status), so #cancel_all can call it across the whole registry.
|
|
456
545
|
def stop_entry(entry)
|
|
457
546
|
return unless entry
|
|
547
|
+
# A shell stops by killing its process group (polymorphic #stop on the
|
|
548
|
+
# adapter), not by the cooperative subagent cancel.
|
|
549
|
+
return entry.stop if entry.respond_to?(:shell?) && entry.shell?
|
|
458
550
|
|
|
459
551
|
request_stop(entry.id)
|
|
460
552
|
entry.approval_gate&.cancel!
|
|
461
553
|
entry.runner&.cancel!
|
|
554
|
+
# Cascade-kill the child background shells this subagent opened — stopping
|
|
555
|
+
# a subagent stops its resources (Hermes kill_all(task_id)).
|
|
556
|
+
ShellRegistry.instance.terminate_owned_by(entry.id)
|
|
462
557
|
end
|
|
463
558
|
|
|
464
559
|
# Structured-concurrency teardown seam: cancel EVERY live subagent so the
|
data/lib/rubino/tools/base.rb
CHANGED
|
@@ -295,7 +295,7 @@ module Rubino
|
|
|
295
295
|
end
|
|
296
296
|
|
|
297
297
|
# Typed "outside workspace" error gate, retained for the AUX-LLM read
|
|
298
|
-
# tools (
|
|
298
|
+
# tools (vision) ONLY. Those route the raw file bytes
|
|
299
299
|
# through a third-party auxiliary model, so an out-of-workspace read would
|
|
300
300
|
# EXFILTRATE a sibling-repo secret / ~/.ssh file — a stronger threat than
|
|
301
301
|
# the in-process read/grep/glob, which were relaxed to broad in #406. A
|