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
|
@@ -32,7 +32,7 @@ module Rubino
|
|
|
32
32
|
# they'd mistyped; this names the real reason so they don't hunt for a
|
|
33
33
|
# typo. Surfaced from EVERY not-found path (/agents <id>, /stop <id>,
|
|
34
34
|
# steer, probe).
|
|
35
|
-
RESET_HINT = "(
|
|
35
|
+
RESET_HINT = "(background tasks reset when rubino restarts)"
|
|
36
36
|
|
|
37
37
|
def initialize(ui:)
|
|
38
38
|
@ui = ui
|
|
@@ -55,7 +55,10 @@ module Rubino
|
|
|
55
55
|
# decision through the same gate.
|
|
56
56
|
def auto_resolve_pending # rubocop:disable Naming/PredicateMethod -- a prompt-presenting mutator that reports whether it surfaced a request, not a pure query
|
|
57
57
|
registry = Tools::BackgroundTasks.instance
|
|
58
|
-
|
|
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 })
|
|
59
62
|
resolve_agent_approval(entry)
|
|
60
63
|
return true
|
|
61
64
|
end
|
|
@@ -115,10 +118,10 @@ module Rubino
|
|
|
115
118
|
end
|
|
116
119
|
|
|
117
120
|
if Tools::BackgroundTasks.instance.steer(id, text)
|
|
118
|
-
@ui.info("steer ▸ #{id} ← #{truncate(text, 80)}
|
|
121
|
+
@ui.info("steer ▸ #{id} ← #{truncate(text, 80)}")
|
|
119
122
|
@ui.set_subagent_cards if @ui.respond_to?(:set_subagent_cards)
|
|
120
123
|
else
|
|
121
|
-
@ui.error("cannot steer #{id} — no such running
|
|
124
|
+
@ui.error("cannot steer #{id} — no such running background task. #{RESET_HINT}")
|
|
122
125
|
end
|
|
123
126
|
end
|
|
124
127
|
|
|
@@ -136,26 +139,21 @@ module Rubino
|
|
|
136
139
|
|
|
137
140
|
entry = Tools::BackgroundTasks.instance.find(id)
|
|
138
141
|
unless entry
|
|
139
|
-
@ui.error("cannot probe #{id} — no such
|
|
142
|
+
@ui.error("cannot probe #{id} — no such background task. #{RESET_HINT}")
|
|
140
143
|
return
|
|
141
144
|
end
|
|
142
145
|
|
|
143
|
-
@ui.info(pastel.dim("┄┄ probe → #{id} ┄┄ (ephemeral · not saved ·
|
|
144
|
-
|
|
145
|
-
#
|
|
146
|
-
# working on anything yet — hint so that doesn't read as broken (#112).
|
|
147
|
-
if entry.tool_count.to_i.zero?
|
|
148
|
-
@ui.info(pastel.dim(" (snapshot at this instant — the child just started and its " \
|
|
149
|
-
"context is still empty; probe again in a moment)"))
|
|
150
|
-
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
|
|
151
149
|
@ui.info("? #{question}")
|
|
152
|
-
# The peek is a synchronous side-inference
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
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.
|
|
156
154
|
probe_thinking_started(@ui)
|
|
157
155
|
answer = begin
|
|
158
|
-
|
|
156
|
+
entry.peek(question)
|
|
159
157
|
ensure
|
|
160
158
|
probe_thinking_finished(@ui)
|
|
161
159
|
end
|
|
@@ -382,6 +380,17 @@ module Rubino
|
|
|
382
380
|
choice = ask_budget_answer(entry)
|
|
383
381
|
return if choice.nil?
|
|
384
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
|
+
|
|
385
394
|
grant = choice == :grant
|
|
386
395
|
gate.decide(entry.approval_id, grant)
|
|
387
396
|
@ui.info(grant ? "Granted more budget to #{entry.id}." : "#{entry.id} will summarize now.")
|
|
@@ -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)"
|
|
@@ -284,14 +284,6 @@ module Rubino
|
|
|
284
284
|
dig("memory", "auto_extract") == true
|
|
285
285
|
end
|
|
286
286
|
|
|
287
|
-
# Background session-summary aux-LLM job (SummarizeSessionJob). Default ON
|
|
288
|
-
# (absent ⇒ true), so existing behaviour is unchanged; an explicit false
|
|
289
|
-
# turns it off — letting the whole background aux-LLM surface
|
|
290
|
-
# (extract/distill/summarize) be disabled together.
|
|
291
|
-
def memory_auto_summarize?
|
|
292
|
-
dig("memory", "auto_summarize") != false
|
|
293
|
-
end
|
|
294
|
-
|
|
295
287
|
# Throttle interval (in turns) for memory.auto_extract (#412). Returns a
|
|
296
288
|
# positive Integer; nil/<=1 (or absent) ⇒ 1 = every turn. The lifecycle
|
|
297
289
|
# only enqueues ExtractMemoryJob when turns-since-last >= this.
|
|
@@ -449,6 +441,31 @@ module Rubino
|
|
|
449
441
|
dig("auxiliary", task.to_s) || {}
|
|
450
442
|
end
|
|
451
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
|
+
|
|
452
469
|
# Returns true when the primary model can ingest images directly. Honours
|
|
453
470
|
# an explicit `model.supports_vision` override; otherwise falls back to
|
|
454
471
|
# ContentBuilder's name-pattern heuristic. Used by VisionTool to decide
|
|
@@ -134,9 +134,10 @@ module Rubino
|
|
|
134
134
|
"base_url" => nil,
|
|
135
135
|
"timeout" => 120
|
|
136
136
|
},
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
137
|
+
# Summarization aux task. Used by skill distillation
|
|
138
|
+
# (jobs/handlers/distill_skill_job.rb, task: "summarize") to condense
|
|
139
|
+
# captured transcripts into a reusable skill; routed through this aux
|
|
140
|
+
# backend so it never blocks the live turn.
|
|
140
141
|
# `provider: "main"` reuses the primary's provider/model.
|
|
141
142
|
"summarize" => {
|
|
142
143
|
"provider" => "main",
|
|
@@ -186,9 +187,16 @@ module Rubino
|
|
|
186
187
|
# Pure SAFETY-NET wall clock on a single turn, NOT a working-time cap
|
|
187
188
|
# (#408). Hermes' IterationBudget has no clock at all; the old 120s
|
|
188
189
|
# KILLED slow-but-legitimate test/build turns mid-work (and was the
|
|
189
|
-
# root that made the #403 budget-extension loop possible).
|
|
190
|
-
#
|
|
191
|
-
"
|
|
190
|
+
# root that made the #403 budget-extension loop possible). Even the
|
|
191
|
+
# raised 600s still guillotines legitimate work: a genuine multi-file
|
|
192
|
+
# "compare docs vs code" audit on a LOCAL model runs ~40 tool calls in
|
|
193
|
+
# ~13 min, blowing 600s and force-summarizing the turn into a confused
|
|
194
|
+
# non-answer — the exact failure #408 warned about. So the wall clock
|
|
195
|
+
# is DISABLED by default (nil): the tool-iteration budget
|
|
196
|
+
# (max_tool_iterations, 90) is the real runaway guard, and individual
|
|
197
|
+
# hung tools are bounded by their own per-tool timeouts. Hermes parity.
|
|
198
|
+
# Set a positive number to re-arm the clock as an explicit backstop.
|
|
199
|
+
"max_turn_seconds" => nil,
|
|
192
200
|
# 5 retries with exponential backoff = 1+2+4+8+16 = 31s total wait.
|
|
193
201
|
# Sized to absorb common provider blips (MiniMax intl in particular
|
|
194
202
|
# has been observed returning "API server error - please try again"
|
|
@@ -377,12 +385,6 @@ module Rubino
|
|
|
377
385
|
"enabled" => true,
|
|
378
386
|
"backend" => "sqlite",
|
|
379
387
|
"auto_extract" => true,
|
|
380
|
-
# Background session-summary aux-LLM job (SummarizeSessionJob), enqueued
|
|
381
|
-
# once a session passes the message threshold. Gateable like
|
|
382
|
-
# auto_extract / skills.auto_distill so the whole background aux-LLM
|
|
383
|
-
# surface can be turned off together (e.g. an engine-vs-engine
|
|
384
|
-
# benchmark that wants ONLY the task, no side-work). Default on.
|
|
385
|
-
"auto_summarize" => true,
|
|
386
388
|
# Throttle the background aux-LLM memory extraction to ~every N turns
|
|
387
389
|
# instead of EVERY turn (#412), mirroring Hermes' nudge_interval (10):
|
|
388
390
|
# extraction enqueues only when turns-since-last-extract >= this. 10x
|
|
@@ -743,7 +745,7 @@ module Rubino
|
|
|
743
745
|
# passwords, JWTs…) from tool output before it enters context, the
|
|
744
746
|
# transcript, or the aux model. ON by default (secure default,
|
|
745
747
|
# Hermes #17691). Applied to read / grep / shell / shell_output /
|
|
746
|
-
# shell_tail /
|
|
748
|
+
# shell_tail / read_attachment content via Security::Redactor. Set
|
|
747
749
|
# false ONLY when you need raw credential values in tool output
|
|
748
750
|
# (e.g. working on the redactor itself). NOT a security boundary —
|
|
749
751
|
# the shell runs as the same OS user; this is defense-in-depth.
|
|
@@ -43,6 +43,14 @@ module Rubino
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Model-name substrings that trigger tool-use-enforcement steering under the
|
|
47
|
+
# "auto" default. Ported from Hermes' TOOL_USE_ENFORCEMENT_MODELS, plus
|
|
48
|
+
# "minimax" — rubino's default targets it and its field notes document the
|
|
49
|
+
# same text-leaked-tool-call / narrate-instead-of-act failures.
|
|
50
|
+
TOOL_USE_ENFORCEMENT_MODELS = %w[
|
|
51
|
+
gpt codex gemini gemma grok glm qwen deepseek minimax
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
46
54
|
def initialize(session:, memory_context:, config:, agent_definition: nil,
|
|
47
55
|
ignore_rules: false)
|
|
48
56
|
@session = session
|
|
@@ -77,7 +85,21 @@ module Rubino
|
|
|
77
85
|
# compaction/fork (those rows exist in prod). Mirrors Claude Code's
|
|
78
86
|
# pre-call sanitization: never emit an orphan tool block that 400s a
|
|
79
87
|
# strict provider. Conservative by design — when in doubt, keep.
|
|
80
|
-
|
|
88
|
+
#
|
|
89
|
+
# Drop `[harness control]` messages FIRST: they are ephemeral within-turn
|
|
90
|
+
# runtime control (Loop's iteration-cap summary nudge, the resume /
|
|
91
|
+
# truncation / blocked-tool reminders, #75), written on the user's behalf
|
|
92
|
+
# for ONE model call — not durable conversation. They stay in the DB for
|
|
93
|
+
# audit/scrollback (and are already excluded from the rewind picker,
|
|
94
|
+
# chat_command#rewindable_message?), but must never re-enter the model's
|
|
95
|
+
# context on a LATER turn. Re-feeding the synthetic "you've reached the
|
|
96
|
+
# maximum tool-calling iterations" user turn made the model believe it
|
|
97
|
+
# was perpetually capped and parrot a "start a new session" refusal on
|
|
98
|
+
# every subsequent turn instead of acting. This is the single chokepoint
|
|
99
|
+
# every surface (CLI + web/API) rebuilds context through, so filtering
|
|
100
|
+
# here fixes them all.
|
|
101
|
+
durable = @message_store.for_session(@session[:id]).reject { |m| harness_control?(m) }
|
|
102
|
+
history = repair_tool_pairs(durable)
|
|
81
103
|
history.each do |msg|
|
|
82
104
|
messages << msg.to_context
|
|
83
105
|
end
|
|
@@ -87,6 +109,17 @@ module Rubino
|
|
|
87
109
|
|
|
88
110
|
private
|
|
89
111
|
|
|
112
|
+
# A `[harness control]`-marked message is runtime control the agent wrote
|
|
113
|
+
# on the user's behalf for a single model call (#75), never durable
|
|
114
|
+
# conversation — see #build for why re-feeding it poisons later turns. The
|
|
115
|
+
# marker only ever rides the user role (the iteration-cap nudge, resume /
|
|
116
|
+
# truncation / blocked-tool reminders), so scope the check to user rows to
|
|
117
|
+
# avoid dropping a genuine assistant message that merely quotes the marker.
|
|
118
|
+
def harness_control?(msg)
|
|
119
|
+
msg.role == "user" &&
|
|
120
|
+
msg.content.to_s.start_with?(Agent::Loop::HARNESS_CONTROL_MARKER)
|
|
121
|
+
end
|
|
122
|
+
|
|
90
123
|
# Final pairing repair over the full history (a list of Message objects).
|
|
91
124
|
# Two orphan shapes 400 strict providers; we fix both, conservatively:
|
|
92
125
|
#
|
|
@@ -209,6 +242,15 @@ module Rubino
|
|
|
209
242
|
|
|
210
243
|
parts = []
|
|
211
244
|
parts << agent_identity
|
|
245
|
+
# Behavioral steering grouped with identity (and inside the cached stable
|
|
246
|
+
# prefix — model-stable). Ported from Hermes (prompt_builder.py): the
|
|
247
|
+
# open-weight models rubino targets (MiniMax/Qwen/GLM/DeepSeek) narrate-
|
|
248
|
+
# instead-of-act and leak tool-calls as text without this — the config
|
|
249
|
+
# key existed but its injection was dropped in the port (#588 dead key).
|
|
250
|
+
enforcement = tool_use_enforcement_block
|
|
251
|
+
parts << enforcement if enforcement
|
|
252
|
+
memory_guide = memory_guidance_block
|
|
253
|
+
parts << memory_guide if memory_guide
|
|
212
254
|
product = product_preamble
|
|
213
255
|
parts << "[Product]\n#{product}" if product
|
|
214
256
|
env = environment_block
|
|
@@ -394,6 +436,52 @@ module Rubino
|
|
|
394
436
|
FALLBACK
|
|
395
437
|
end
|
|
396
438
|
|
|
439
|
+
# The tool-use-enforcement block (+ per-family operational guidance), or nil
|
|
440
|
+
# when not applicable. Gated by `agent.tool_use_enforcement`:
|
|
441
|
+
# "auto" (default) — inject when the model id matches a known weak-FC family
|
|
442
|
+
# true / false — always / never
|
|
443
|
+
# Array of substrings — custom match list
|
|
444
|
+
# Hermes-aligned (system_prompt.py): the open-weight models rubino runs need
|
|
445
|
+
# explicit steering to call tools instead of describing intended actions.
|
|
446
|
+
def tool_use_enforcement_block
|
|
447
|
+
return nil unless inject_tool_use_enforcement?
|
|
448
|
+
|
|
449
|
+
parts = [load_builtin_prompt("tool_use_enforcement")].compact
|
|
450
|
+
model = model_id_lower
|
|
451
|
+
if model.include?("gemini") || model.include?("gemma")
|
|
452
|
+
parts << load_builtin_prompt("tool_use_enforcement_google")
|
|
453
|
+
end
|
|
454
|
+
if model.include?("gpt") || model.include?("codex") || model.include?("grok")
|
|
455
|
+
parts << load_builtin_prompt("tool_use_enforcement_openai")
|
|
456
|
+
end
|
|
457
|
+
parts.compact!
|
|
458
|
+
parts.empty? ? nil : parts.join("\n\n")
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def inject_tool_use_enforcement?
|
|
462
|
+
setting = @config.dig("agent", "tool_use_enforcement")
|
|
463
|
+
case setting
|
|
464
|
+
when true then true
|
|
465
|
+
when false then false
|
|
466
|
+
when Array then setting.any? { |p| model_id_lower.include?(p.to_s.downcase) }
|
|
467
|
+
else # "auto" or any unrecognised value — the default model-family list
|
|
468
|
+
TOOL_USE_ENFORCEMENT_MODELS.any? { |p| model_id_lower.include?(p) }
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# The memory-write discipline block (HOW to curate memory), or nil when the
|
|
473
|
+
# memory subsystem is off. Hermes injects this when the memory tool is
|
|
474
|
+
# present; rubino gates on `memory.enabled` (default on), the same signal.
|
|
475
|
+
def memory_guidance_block
|
|
476
|
+
return nil if @config.dig("memory", "enabled") == false
|
|
477
|
+
|
|
478
|
+
load_builtin_prompt("memory_guidance")
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def model_id_lower
|
|
482
|
+
@config.dig("model", "default").to_s.downcase
|
|
483
|
+
end
|
|
484
|
+
|
|
397
485
|
def product_preamble
|
|
398
486
|
return nil unless @config.respond_to?(:prompts_preamble)
|
|
399
487
|
|
|
@@ -116,16 +116,6 @@ module Rubino
|
|
|
116
116
|
text
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
# Builds and saves the summary to the database
|
|
120
|
-
def build_and_save!
|
|
121
|
-
message_store = Session::Store.new
|
|
122
|
-
messages = message_store.for_session(@session_id)
|
|
123
|
-
return if messages.size < 10
|
|
124
|
-
|
|
125
|
-
summary = build(messages: messages, previous_summary: load_previous_summary)
|
|
126
|
-
save!(summary)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
119
|
private
|
|
130
120
|
|
|
131
121
|
def summary_system_prompt
|
|
@@ -172,18 +162,6 @@ module Rubino
|
|
|
172
162
|
end.join("\n\n")
|
|
173
163
|
end
|
|
174
164
|
|
|
175
|
-
def summary_store
|
|
176
|
-
@summary_store ||= Session::SummaryStore.new
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def load_previous_summary
|
|
180
|
-
summary_store.latest_content(@session_id)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def save!(content)
|
|
184
|
-
summary_store.insert(session_id: @session_id, content: content)
|
|
185
|
-
end
|
|
186
|
-
|
|
187
165
|
def fallback_summary(messages, previous_summary)
|
|
188
166
|
parts = []
|
|
189
167
|
parts << "## Previous Context\n#{previous_summary}" if previous_summary
|
|
@@ -51,8 +51,8 @@ module Rubino
|
|
|
51
51
|
|
|
52
52
|
# Tool events
|
|
53
53
|
TOOL_STARTED = :tool_started
|
|
54
|
-
# Incremental progress from a long-running tool (e.g.
|
|
55
|
-
#
|
|
54
|
+
# Incremental progress from a long-running tool (e.g. shell stdout lines
|
|
55
|
+
# streamed as they arrive). Emitted from
|
|
56
56
|
# the tool's stream_chunk callback so a tool that runs for minutes
|
|
57
57
|
# without finishing keeps the API event stream alive — the SSE idle
|
|
58
58
|
# watchdog only fires when NOTHING flows, so a genuinely hung run is
|
|
@@ -12,8 +12,8 @@ module Rubino
|
|
|
12
12
|
|
|
13
13
|
# Queue priority for the user-visible memory save (#79). Lower = drained
|
|
14
14
|
# first (the queue orders by `priority, run_at`). Below the default 100 the
|
|
15
|
-
# other post-turn jobs use, so an ExtractMemoryJob jumps ahead of
|
|
16
|
-
#
|
|
15
|
+
# other post-turn jobs use, so an ExtractMemoryJob jumps ahead of any
|
|
16
|
+
# default-priority backlog and the "remember X" → recall is prompt.
|
|
17
17
|
PRIORITY_EXTRACT_MEMORY = 50
|
|
18
18
|
# The session this lifecycle is currently bound to. Starts as the session
|
|
19
19
|
# passed in, but an automatic budget-triggered compaction swaps it to the
|
|
@@ -25,10 +25,16 @@ module Rubino
|
|
|
25
25
|
@session
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# How the turn just run by #execute terminated (Agent::Loop#stop_reason),
|
|
29
|
+
# read back by the owning Runner so the subagent-completion path can report
|
|
30
|
+
# a force-summarized/truncated run as PARTIAL rather than "completed".
|
|
31
|
+
# nil until a turn has run.
|
|
32
|
+
attr_reader :last_stop_reason
|
|
33
|
+
|
|
28
34
|
def initialize(session:, event_bus:, ui:, config:, ignore_rules: false,
|
|
29
35
|
agent_definition: nil, cancel_token: nil,
|
|
30
36
|
model_override: nil, provider_override: nil,
|
|
31
|
-
max_tool_iterations: nil, polishing: nil)
|
|
37
|
+
max_tool_iterations: nil, polishing: nil, interactive: false)
|
|
32
38
|
@session = session
|
|
33
39
|
@event_bus = event_bus
|
|
34
40
|
@ui = ui
|
|
@@ -44,6 +50,9 @@ module Rubino
|
|
|
44
50
|
# API/server path and nested subagent runs, which keep the original
|
|
45
51
|
# synchronous inline drain (no interactive prompt to free up).
|
|
46
52
|
@polishing = polishing
|
|
53
|
+
# True only on the interactive REPL (more in-process turns follow). Gates
|
|
54
|
+
# automatic memory extraction OFF the live KV slot between turns (#608c).
|
|
55
|
+
@interactive = interactive
|
|
47
56
|
# Explicit per-run cap from `--max-turns` (Runner → here → IterationBudget).
|
|
48
57
|
# nil ⇒ use the configured agent_max_tool_iterations (#141).
|
|
49
58
|
@max_tool_iterations = max_tool_iterations
|
|
@@ -393,7 +402,11 @@ module Rubino
|
|
|
393
402
|
# (API/server) ⇒ no sink; the result stays reachable via `task_result`.
|
|
394
403
|
Rubino.with_background_sink(input_queue) do
|
|
395
404
|
Rubino.with_event_bus(@event_bus) do
|
|
396
|
-
loop_runner.run(messages: messages, tools: tools)
|
|
405
|
+
content = loop_runner.run(messages: messages, tools: tools)
|
|
406
|
+
# Post-turn state, captured like #active_session: the caller reads it
|
|
407
|
+
# off #last_stop_reason after #execute returns.
|
|
408
|
+
@last_stop_reason = loop_runner.stop_reason
|
|
409
|
+
content
|
|
397
410
|
end
|
|
398
411
|
end
|
|
399
412
|
end
|
|
@@ -426,14 +439,36 @@ module Rubino
|
|
|
426
439
|
# extract off the interactive path AND cuts its cadence ~10x.
|
|
427
440
|
enqueued = false
|
|
428
441
|
|
|
429
|
-
|
|
442
|
+
# KV-cache coherence (#608c): when the aux model shares the MAIN model's
|
|
443
|
+
# server slot (the default — see Configuration#auxiliary_on_main_endpoint?),
|
|
444
|
+
# an inter-turn extraction OVERWRITES the live conversation's prefix cache,
|
|
445
|
+
# so the NEXT user turn re-prefills the whole context (the "freeze after N
|
|
446
|
+
# turns"). On a single-slot local server there is no way to run a divergent
|
|
447
|
+
# aux prompt between turns without evicting — so we DON'T: extraction is
|
|
448
|
+
# deferred to the session-end flush (Memory::Flusher#flush_on_session_end!,
|
|
449
|
+
# runner.rb) and to compaction, exactly like Hermes/Claude Code keep
|
|
450
|
+
# automatic memory work off the live conversation. No recall is lost: the
|
|
451
|
+
# per-session memory snapshot is FROZEN at session start (PromptAssembler
|
|
452
|
+
# @snapshots), so a mid-session extract is never recalled THIS session
|
|
453
|
+
# anyway — only the next one, which the end-of-session flush already feeds.
|
|
454
|
+
#
|
|
455
|
+
# Scoped to the INTERACTIVE REPL (@interactive), where more in-process
|
|
456
|
+
# turns follow this one and would reuse the live KV prefix. A headless
|
|
457
|
+
# one-shot / API run exits after its single turn, so there is no live
|
|
458
|
+
# cache to protect and the extraction must still run there (it is how the
|
|
459
|
+
# fact gets stored before exit, #358). A DISTINCT aux endpoint (its own
|
|
460
|
+
# slot) never evicts, so it keeps the inter-turn cadence too.
|
|
461
|
+
extract_evicts_live_slot = @interactive && @config.auxiliary_on_main_endpoint?("compression")
|
|
462
|
+
distill_evicts_live_slot = @interactive && @config.auxiliary_on_main_endpoint?("summarize")
|
|
463
|
+
|
|
464
|
+
if @config.memory_auto_extract? && !extract_evicts_live_slot &&
|
|
465
|
+
interval_due?(turn_no, @config.memory_auto_extract_interval)
|
|
430
466
|
# ExtractMemoryJob is the user-visible save ("remember X" → recall):
|
|
431
|
-
# it must drain AHEAD of
|
|
432
|
-
#
|
|
433
|
-
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
436
|
-
# is about to recall waits minutes behind a FIFO backlog of summaries.
|
|
467
|
+
# it must drain AHEAD of any default-priority post-turn jobs already in
|
|
468
|
+
# the queue (#79). The drain orders by `priority, run_at` (lower =
|
|
469
|
+
# first), so a higher-priority (smaller number) extract jumps slower,
|
|
470
|
+
# less time-sensitive jobs enqueued before it — otherwise the save the
|
|
471
|
+
# user is about to recall waits behind a FIFO backlog.
|
|
437
472
|
queue.enqueue("ExtractMemoryJob", { session_id: @session[:id] },
|
|
438
473
|
priority: PRIORITY_EXTRACT_MEMORY, drain_inline: drain_inline)
|
|
439
474
|
@event_bus.emit(Events::JOB_ENQUEUED, type: "ExtractMemoryJob")
|
|
@@ -448,20 +483,19 @@ module Rubino
|
|
|
448
483
|
# already covered) before spending one aux-model call. Handler lookup
|
|
449
484
|
# is load-order independent: Jobs::Registry resolves the class from
|
|
450
485
|
# the Handlers namespace on demand (#81).
|
|
451
|
-
if @config.skills_auto_distill? &&
|
|
486
|
+
if @config.skills_auto_distill? && !distill_evicts_live_slot &&
|
|
487
|
+
interval_due?(turn_no, @config.skills_auto_distill_interval)
|
|
452
488
|
queue.enqueue("DistillSkillJob", { session_id: @session[:id] }, drain_inline: drain_inline)
|
|
453
489
|
@event_bus.emit(Events::JOB_ENQUEUED, type: "DistillSkillJob")
|
|
454
490
|
enqueued = true
|
|
455
491
|
end
|
|
456
492
|
|
|
457
|
-
#
|
|
458
|
-
#
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
enqueued = true
|
|
464
|
-
end
|
|
493
|
+
# NB: there is no per-turn session-summary job. The running summary that
|
|
494
|
+
# PromptAssembler injects is produced by the THRESHOLD-GATED compaction
|
|
495
|
+
# (Context::Compressor → SummaryStore), exactly as Hermes / Claude Code /
|
|
496
|
+
# Codex do it — summarize INLINE only when the context approaches its
|
|
497
|
+
# limit, never as a background job after every turn (which on a slow local
|
|
498
|
+
# gateway would pile up faster than it drains and starve the live turns).
|
|
465
499
|
|
|
466
500
|
# Detach: kick the polishing worker so it drains the rows just enqueued
|
|
467
501
|
# off this thread. Returns immediately — the next prompt is never gated.
|