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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +15 -0
  3. data/CHANGELOG.md +56 -0
  4. data/Dockerfile +56 -0
  5. data/agent.md +112 -0
  6. data/docs/design/bg-shell-pty-port.md +88 -0
  7. data/docs/design/bg-shell-review-refinements.md +65 -0
  8. data/docs/design/bg-shell-ux.md +130 -0
  9. data/docs/tools.md +3 -12
  10. data/lib/rubino/agent/iteration_budget.rb +13 -0
  11. data/lib/rubino/agent/loop.rb +43 -5
  12. data/lib/rubino/agent/prompts/build.txt +3 -5
  13. data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
  14. data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
  15. data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
  16. data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
  17. data/lib/rubino/agent/runner.rb +55 -12
  18. data/lib/rubino/agent/tool_executor.rb +1 -1
  19. data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
  20. data/lib/rubino/cli/chat_command.rb +119 -17
  21. data/lib/rubino/cli/commands.rb +5 -0
  22. data/lib/rubino/commands/handlers/agents.rb +27 -18
  23. data/lib/rubino/commands/handlers/status.rb +6 -3
  24. data/lib/rubino/config/configuration.rb +25 -8
  25. data/lib/rubino/config/defaults.rb +15 -13
  26. data/lib/rubino/context/prompt_assembler.rb +89 -1
  27. data/lib/rubino/context/summary_builder.rb +0 -22
  28. data/lib/rubino/interaction/events.rb +2 -2
  29. data/lib/rubino/interaction/lifecycle.rb +54 -20
  30. data/lib/rubino/llm/ruby_llm_adapter.rb +178 -20
  31. data/lib/rubino/security/redactor.rb +1 -1
  32. data/lib/rubino/session/message.rb +12 -0
  33. data/lib/rubino/tools/background_tasks.rb +107 -12
  34. data/lib/rubino/tools/base.rb +1 -1
  35. data/lib/rubino/tools/read_attachment_tool.rb +52 -54
  36. data/lib/rubino/tools/registry.rb +21 -72
  37. data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
  38. data/lib/rubino/tools/shell_input_tool.rb +1 -1
  39. data/lib/rubino/tools/shell_kill_tool.rb +4 -4
  40. data/lib/rubino/tools/shell_registry.rb +178 -38
  41. data/lib/rubino/tools/shell_tool.rb +45 -5
  42. data/lib/rubino/tools/task_result_tool.rb +4 -1
  43. data/lib/rubino/tools/task_tool.rb +74 -11
  44. data/lib/rubino/tools/vision_tool.rb +1 -1
  45. data/lib/rubino/ui/agent_menu.rb +8 -2
  46. data/lib/rubino/ui/api.rb +11 -0
  47. data/lib/rubino/ui/bottom_composer.rb +24 -11
  48. data/lib/rubino/ui/cli.rb +254 -15
  49. data/lib/rubino/ui/markdown_renderer.rb +4 -1
  50. data/lib/rubino/ui/stdout_proxy.rb +25 -10
  51. data/lib/rubino/ui/streaming_markdown.rb +67 -12
  52. data/lib/rubino/ui/subagent_cards.rb +8 -7
  53. data/lib/rubino/ui/tool_args_stream.rb +143 -0
  54. data/lib/rubino/update_check.rb +10 -2
  55. data/lib/rubino/version.rb +1 -1
  56. metadata +14 -6
  57. data/AGENTS.md +0 -97
  58. data/docs/agents.md +0 -216
  59. data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
  60. 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 = "(subagents reset when rubino restarts)"
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
- if (entry = registry.awaiting_approval.first)
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)} (parked · enters child context next turn)")
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 subagent. #{RESET_HINT}")
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 subagent. #{RESET_HINT}")
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 · child trajectory unchanged)"))
144
- # A probe answers from the child's context AT THIS INSTANT; right after
145
- # spawn that context is still empty and the child honestly says it isn't
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 (seconds of model wait) with
153
- # nothing streaming — show the same thinking row /probe got in #58 so
154
- # the gap before the answer never looks frozen (#146). TTY only;
155
- # Null/API adapters and pipes stay silent.
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
- Tools::SubagentProbe.new.peek(entry: entry, question: question)
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.count { |e| e.status == :running }
236
- ids = entries.first(3).map(&:id).join(", ")
237
- line = "#{running} running · #{entries.size} total"
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
- # Document summarization. The `summarize_file` tool delegates here so
138
- # the raw bytes of a huge file are map-reduced in these aux calls and
139
- # never enter the main agent context (only the final summary returns).
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). Raised to a
190
- # backstop only a genuinely runaway turn should ever hit. nil disables.
191
- "max_turn_seconds" => 600,
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 / summarize_file content via Security::Redactor. Set
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
- history = repair_tool_pairs(@message_store.for_session(@session[:id]))
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. summarize_file's
55
- # per-chunk "summarizing chunk N/M" or shell stdout lines). Emitted from
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 the
16
- # SummarizeSessionJob backlog and the "remember X" → recall is prompt.
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
- if @config.memory_auto_extract? && interval_due?(turn_no, @config.memory_auto_extract_interval)
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 the SummarizeSessionJobs that pile up one per
432
- # turn once a session passes 20 messages (#79). The drain orders by
433
- # `priority, run_at` (lower = first), so a higher-priority (smaller
434
- # number) extract jumps the queue of slower, less time-sensitive
435
- # summaries that were enqueued before it otherwise the save the user
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? && interval_due?(turn_no, @config.skills_auto_distill_interval)
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
- # Summarize if session is getting long (gateable like the extract/distill
458
- # background jobs, so the whole aux-LLM surface can be turned off together).
459
- message_count = @message_store.count(@session[:id])
460
- if @config.memory_auto_summarize? && message_count > 20
461
- queue.enqueue("SummarizeSessionJob", { session_id: @session[:id] }, drain_inline: drain_inline)
462
- @event_bus.emit(Events::JOB_ENQUEUED, type: "SummarizeSessionJob")
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.