rubino-agent 0.3.0 → 0.5.0
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/.rubocop_todo.yml +11 -2
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +172 -5
- data/CONTRIBUTING.md +10 -1
- data/README.md +14 -5
- data/Rakefile +31 -0
- data/docs/agents.md +42 -23
- data/docs/architecture.md +2 -2
- data/docs/commands.md +35 -3
- data/docs/configuration.md +20 -23
- data/docs/getting-started.md +5 -3
- data/docs/security.md +16 -5
- data/docs/skills.md +31 -0
- data/docs/troubleshooting.md +1 -1
- data/exe/rubino +16 -2
- data/install.sh +721 -59
- data/lib/rubino/active_agent.rb +73 -0
- data/lib/rubino/agent/action_claim_guard.rb +881 -0
- data/lib/rubino/agent/agent_registry.rb +5 -2
- data/lib/rubino/agent/definition.rb +1 -9
- data/lib/rubino/agent/fallback_chain.rb +0 -6
- data/lib/rubino/agent/iteration_budget.rb +109 -3
- data/lib/rubino/agent/loop.rb +476 -20
- data/lib/rubino/agent/model_call_runner.rb +81 -3
- data/lib/rubino/agent/prompts/build.txt +22 -5
- data/lib/rubino/agent/response_validator.rb +8 -0
- data/lib/rubino/agent/runner.rb +133 -8
- data/lib/rubino/agent/tool_executor.rb +166 -14
- data/lib/rubino/agent/truncation_continuation.rb +4 -1
- data/lib/rubino/api/server.rb +19 -0
- data/lib/rubino/attachments/classify.rb +35 -17
- data/lib/rubino/boot/config_guard.rb +71 -0
- data/lib/rubino/cli/chat/completion_builder.rb +42 -6
- data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
- data/lib/rubino/cli/chat/session_resolver.rb +87 -21
- data/lib/rubino/cli/chat_command.rb +1189 -50
- data/lib/rubino/cli/commands.rb +282 -2
- data/lib/rubino/cli/config_command.rb +68 -8
- data/lib/rubino/cli/doctor_command.rb +204 -12
- data/lib/rubino/cli/jobs_command.rb +12 -0
- data/lib/rubino/cli/memory_command.rb +53 -20
- data/lib/rubino/cli/onboarding_wizard.rb +79 -6
- data/lib/rubino/cli/session_command.rb +172 -18
- data/lib/rubino/cli/setup_command.rb +131 -8
- data/lib/rubino/cli/skills_command.rb +183 -9
- data/lib/rubino/cli/trust_gate.rb +16 -7
- data/lib/rubino/commands/built_ins.rb +2 -0
- data/lib/rubino/commands/command.rb +12 -2
- data/lib/rubino/commands/executor.rb +149 -12
- data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
- data/lib/rubino/commands/handlers/agents.rb +156 -41
- data/lib/rubino/commands/handlers/config.rb +4 -1
- data/lib/rubino/commands/handlers/help.rb +113 -14
- data/lib/rubino/commands/handlers/memory.rb +15 -5
- data/lib/rubino/commands/handlers/sessions.rb +26 -3
- data/lib/rubino/commands/handlers/status.rb +9 -4
- data/lib/rubino/commands/loader.rb +12 -0
- data/lib/rubino/config/configuration.rb +86 -24
- data/lib/rubino/config/defaults.rb +140 -33
- data/lib/rubino/config/loader.rb +62 -12
- data/lib/rubino/config/validator.rb +341 -0
- data/lib/rubino/config/writer.rb +123 -31
- data/lib/rubino/context/compressor.rb +184 -22
- data/lib/rubino/context/environment_inspector.rb +2 -2
- data/lib/rubino/context/file_discovery.rb +2 -2
- data/lib/rubino/context/message_boundary.rb +27 -1
- data/lib/rubino/context/project_languages.rb +90 -0
- data/lib/rubino/context/prompt_assembler.rb +105 -22
- data/lib/rubino/context/summary_builder.rb +45 -4
- data/lib/rubino/context/token_budget.rb +36 -11
- data/lib/rubino/context/token_estimate.rb +45 -0
- data/lib/rubino/context/tool_result_pruner.rb +81 -0
- data/lib/rubino/database/connection.rb +154 -3
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
- data/lib/rubino/database/migrator.rb +98 -5
- data/lib/rubino/documents/cap_exceeded.rb +13 -0
- data/lib/rubino/documents/converters/csv.rb +4 -3
- data/lib/rubino/documents/converters/docx.rb +29 -5
- data/lib/rubino/documents/converters/html.rb +5 -1
- data/lib/rubino/documents/converters/json.rb +2 -1
- data/lib/rubino/documents/converters/pdf.rb +11 -2
- data/lib/rubino/documents/converters/plain.rb +2 -1
- data/lib/rubino/documents/converters/pptx.rb +11 -2
- data/lib/rubino/documents/converters/xlsx.rb +35 -4
- data/lib/rubino/documents/converters/xml.rb +2 -1
- data/lib/rubino/documents/limits.rb +210 -0
- data/lib/rubino/documents.rb +10 -3
- data/lib/rubino/errors.rb +36 -5
- data/lib/rubino/interaction/cancel_token.rb +19 -3
- data/lib/rubino/interaction/events.rb +13 -0
- data/lib/rubino/interaction/lifecycle.rb +99 -13
- data/lib/rubino/interaction/polishing.rb +176 -0
- data/lib/rubino/jobs/cron_job_repository.rb +5 -8
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
- data/lib/rubino/jobs/queue.rb +63 -8
- data/lib/rubino/jobs/runner.rb +24 -6
- data/lib/rubino/jobs/worker.rb +0 -4
- data/lib/rubino/llm/adapter_response.rb +47 -4
- data/lib/rubino/llm/credential_check.rb +15 -16
- data/lib/rubino/llm/error_classifier.rb +89 -1
- data/lib/rubino/llm/inline_think_filter.rb +69 -12
- data/lib/rubino/llm/request.rb +30 -3
- data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
- data/lib/rubino/llm/tool_bridge.rb +113 -9
- data/lib/rubino/mcp/manager.rb +18 -1
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
- data/lib/rubino/memory/aux_retry.rb +107 -0
- data/lib/rubino/memory/backends/sqlite.rb +73 -44
- data/lib/rubino/memory/backends.rb +23 -7
- data/lib/rubino/memory/salience_gate.rb +103 -0
- data/lib/rubino/memory/sqlite_extraction.rb +70 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
- data/lib/rubino/memory/store.rb +33 -5
- data/lib/rubino/memory/threat_scanner.rb +52 -0
- data/lib/rubino/output/cost.rb +52 -0
- data/lib/rubino/output/headless_block_latch.rb +53 -0
- data/lib/rubino/output/result_serializer.rb +222 -0
- data/lib/rubino/output/turn_recorder.rb +77 -0
- data/lib/rubino/security/approval_policy.rb +227 -32
- data/lib/rubino/security/command_allowlist.rb +79 -4
- data/lib/rubino/security/doom_loop_detector.rb +21 -2
- data/lib/rubino/security/hardline_guard.rb +189 -16
- data/lib/rubino/security/pattern_matcher.rb +28 -5
- data/lib/rubino/security/prefix_deriver.rb +25 -6
- data/lib/rubino/security/readonly_commands.rb +145 -5
- data/lib/rubino/security/secret_path.rb +134 -0
- data/lib/rubino/security/url_safety.rb +255 -0
- data/lib/rubino/session/repository.rb +212 -11
- data/lib/rubino/session/store.rb +139 -14
- data/lib/rubino/skills/installer.rb +230 -0
- data/lib/rubino/skills/prompt_index.rb +2 -2
- data/lib/rubino/skills/registry.rb +52 -1
- data/lib/rubino/skills/skill.rb +64 -3
- data/lib/rubino/skills/skill_tool.rb +16 -5
- data/lib/rubino/tools/background_tasks.rb +157 -13
- data/lib/rubino/tools/base.rb +204 -3
- data/lib/rubino/tools/edit_tool.rb +73 -18
- data/lib/rubino/tools/glob_tool.rb +48 -9
- data/lib/rubino/tools/grep_tool.rb +103 -9
- data/lib/rubino/tools/multi_edit_tool.rb +64 -9
- data/lib/rubino/tools/patch_tool.rb +5 -0
- data/lib/rubino/tools/read_attachment_tool.rb +3 -1
- data/lib/rubino/tools/read_tool.rb +33 -15
- data/lib/rubino/tools/read_tracker.rb +153 -35
- data/lib/rubino/tools/registry.rb +113 -12
- data/lib/rubino/tools/result.rb +9 -1
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/shell_registry.rb +70 -0
- data/lib/rubino/tools/shell_tool.rb +40 -1
- data/lib/rubino/tools/summarize_file_tool.rb +6 -0
- data/lib/rubino/tools/task_stop_tool.rb +10 -16
- data/lib/rubino/tools/task_tool.rb +36 -8
- data/lib/rubino/tools/vision_tool.rb +5 -0
- data/lib/rubino/tools/webfetch_tool.rb +39 -7
- data/lib/rubino/tools/websearch_tool.rb +92 -30
- data/lib/rubino/tools/write_tool.rb +23 -4
- data/lib/rubino/ui/api.rb +10 -1
- data/lib/rubino/ui/base.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +382 -74
- data/lib/rubino/ui/cli.rb +515 -83
- data/lib/rubino/ui/completion_menu.rb +11 -7
- data/lib/rubino/ui/headless_trace.rb +63 -0
- data/lib/rubino/ui/live_region.rb +70 -7
- data/lib/rubino/ui/markdown_renderer.rb +142 -7
- data/lib/rubino/ui/notifier.rb +0 -2
- data/lib/rubino/ui/null.rb +52 -5
- data/lib/rubino/ui/paste_store.rb +16 -2
- data/lib/rubino/ui/queued_indicators.rb +6 -1
- data/lib/rubino/ui/status_bar.rb +61 -7
- data/lib/rubino/ui/streaming_markdown.rb +59 -6
- data/lib/rubino/ui/subagent_view.rb +29 -4
- data/lib/rubino/ui/tool_label.rb +52 -0
- data/lib/rubino/update_check.rb +39 -4
- data/lib/rubino/util/atomic_file.rb +117 -0
- data/lib/rubino/util/ignore_rules.rb +120 -0
- data/lib/rubino/util/output.rb +229 -12
- data/lib/rubino/util/secrets_mask.rb +70 -7
- data/lib/rubino/util/spill_store.rb +153 -0
- data/lib/rubino/version.rb +1 -1
- data/lib/rubino/workspace.rb +9 -1
- data/lib/rubino.rb +191 -7
- data/rubino-agent.gemspec +1 -0
- data/skills/ruby-expert/SKILL.md +1 -0
- metadata +42 -12
- data/lib/rubino/agent/router.rb +0 -65
- data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
- data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
|
@@ -73,6 +73,12 @@ module Rubino
|
|
|
73
73
|
# error can't bleed into the empty-retry count.
|
|
74
74
|
error_attempts = 0
|
|
75
75
|
|
|
76
|
+
# Cumulative error-path backoff already spent on THIS call (seconds), the
|
|
77
|
+
# denominator of the TOTAL-wall-time cap (#dead-host). Reset per call! so
|
|
78
|
+
# a fresh turn gets the full retry budget. A fallback rotation also zeroes
|
|
79
|
+
# it (handle_error! → activate_fallback!) so the new adapter starts clean.
|
|
80
|
+
@error_retry_spent = 0.0
|
|
81
|
+
|
|
76
82
|
# The degenerate-response recovery ladder (Slice 5). Fresh per call! so
|
|
77
83
|
# its per-turn counters (prefill ≤2, empty ≤3) reset exactly where the
|
|
78
84
|
# reference zeroes them on a successful content turn.
|
|
@@ -237,7 +243,10 @@ module Rubino
|
|
|
237
243
|
thinking: request.thinking,
|
|
238
244
|
prefill: seed,
|
|
239
245
|
image_paths: request.image_paths,
|
|
240
|
-
stream: request.stream
|
|
246
|
+
stream: request.stream?,
|
|
247
|
+
on_intermediate_message: request.on_intermediate_message,
|
|
248
|
+
on_round_trip: request.on_round_trip,
|
|
249
|
+
budget_exhausted: request.budget_exhausted
|
|
241
250
|
)
|
|
242
251
|
end
|
|
243
252
|
|
|
@@ -281,13 +290,29 @@ module Rubino
|
|
|
281
290
|
classified = LLM::ErrorClassifier.classify(error)
|
|
282
291
|
|
|
283
292
|
unless classified.retryable && attempts < api_max_retries
|
|
284
|
-
return
|
|
293
|
+
return reset_error_budget! if activate_fallback!(iteration)
|
|
285
294
|
|
|
286
295
|
raise_with_auth_hint(error, classified)
|
|
287
296
|
end
|
|
288
297
|
|
|
289
298
|
attempts += 1
|
|
290
299
|
wait = error_backoff(attempts, classified, error)
|
|
300
|
+
|
|
301
|
+
# TOTAL wall-time cap (#dead-host). A permanently-unreachable host fails
|
|
302
|
+
# with a RETRYABLE connection timeout every attempt, so the count budget
|
|
303
|
+
# alone lets backoff stack to ~75-110s before giving up. Once the backoff
|
|
304
|
+
# already spent PLUS this next planned wait would cross the budget, stop
|
|
305
|
+
# retrying: try a fallback first (resets the budget for the new adapter),
|
|
306
|
+
# otherwise fail fast with a clear "gave up after ~Ns" message. This
|
|
307
|
+
# bounds the dead-host wall WITHOUT cutting genuine recovery inside the
|
|
308
|
+
# window — a transient blip that clears before the budget still retries.
|
|
309
|
+
if exceeds_total_budget?(wait)
|
|
310
|
+
return reset_error_budget! if activate_fallback!(iteration)
|
|
311
|
+
|
|
312
|
+
raise_retry_budget_exhausted!(error, attempts)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
@error_retry_spent += wait
|
|
291
316
|
@event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
|
|
292
317
|
iteration: iteration, error_retry: attempts)
|
|
293
318
|
log_safely(event: "llm.retry", attempt: attempts, sleep: wait, error: error.message)
|
|
@@ -295,6 +320,37 @@ module Rubino
|
|
|
295
320
|
attempts
|
|
296
321
|
end
|
|
297
322
|
|
|
323
|
+
# A fallback rotation gives the NEW adapter a fresh count budget AND a fresh
|
|
324
|
+
# wall-time budget — the time spent on the dead primary shouldn't penalise a
|
|
325
|
+
# healthy fallback. Zero the spent-clock and return 0 (the loop's reset
|
|
326
|
+
# sentinel for error_attempts).
|
|
327
|
+
def reset_error_budget!
|
|
328
|
+
@error_retry_spent = 0.0
|
|
329
|
+
0
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# True when the cumulative error-path backoff already spent plus the next
|
|
333
|
+
# planned wait would cross the total wall-time budget. nil budget ⇒ no total
|
|
334
|
+
# cap (count-based retries only — the pre-cap behaviour).
|
|
335
|
+
def exceeds_total_budget?(next_wait)
|
|
336
|
+
budget = retry_total_timeout
|
|
337
|
+
return false if budget.nil?
|
|
338
|
+
|
|
339
|
+
(@error_retry_spent + next_wait) > budget
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Fail fast on the dead-host path: the host keeps timing out and the
|
|
343
|
+
# wall-time budget is spent, so surface a clear, actionable message instead
|
|
344
|
+
# of stalling for another full backoff. Preserves the original error and
|
|
345
|
+
# the auth-hint upgrade for the (rare) auth-shaped retryable case.
|
|
346
|
+
def raise_retry_budget_exhausted!(error, attempts)
|
|
347
|
+
spent = @error_retry_spent.round(1)
|
|
348
|
+
raise Rubino::Error,
|
|
349
|
+
"Gave up after ~#{spent}s and #{attempts - 1} retries: the provider host is " \
|
|
350
|
+
"unreachable or persistently failing (#{error.message}). Check the model's " \
|
|
351
|
+
"base_url / network, or configure a fallback model."
|
|
352
|
+
end
|
|
353
|
+
|
|
298
354
|
# Jittered backoff for an invalid/empty response — 5s base, 120s cap,
|
|
299
355
|
# via the INVALID_RESPONSE preset.
|
|
300
356
|
def empty_backoff(attempt)
|
|
@@ -311,9 +367,14 @@ module Rubino
|
|
|
311
367
|
retry_after: retry_after_for(classified, error))
|
|
312
368
|
end
|
|
313
369
|
|
|
370
|
+
# The per-retry backoff CEILING for this error. The non-overload path now
|
|
371
|
+
# honours the (previously dead) api_retry_backoff_cap_seconds knob (16s)
|
|
372
|
+
# instead of the hardcoded 60s ERROR_PATH ceiling — capping the worst
|
|
373
|
+
# single wait to ~24s. Overload/unknown still ride the higher overload cap
|
|
374
|
+
# so a 529 backs off long enough to clear the hot window.
|
|
314
375
|
def error_backoff_cap(classified)
|
|
315
376
|
overload = [LLM::FailoverReason::OVERLOADED, LLM::FailoverReason::UNKNOWN]
|
|
316
|
-
base =
|
|
377
|
+
base = backoff_cap
|
|
317
378
|
overload.include?(classified.reason) ? [base, overload_backoff_cap].max : base
|
|
318
379
|
end
|
|
319
380
|
|
|
@@ -373,6 +434,23 @@ module Rubino
|
|
|
373
434
|
@config.dig("agent", "api_retry_backoff_overload_cap_seconds") || 60
|
|
374
435
|
end
|
|
375
436
|
|
|
437
|
+
# Per-retry backoff ceiling for the ordinary error path (non-overload). The
|
|
438
|
+
# ERROR_PATH preset max is the fallback when the knob is unset.
|
|
439
|
+
def backoff_cap
|
|
440
|
+
@config.dig("agent", "api_retry_backoff_cap_seconds") || BackoffPolicy::ERROR_PATH[:max]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Total error-path retry wall-time budget (seconds), or nil for no total
|
|
444
|
+
# cap. A non-positive value is treated as nil (no cap) rather than an
|
|
445
|
+
# instant give-up — a 0 here is almost certainly a misconfig.
|
|
446
|
+
def retry_total_timeout
|
|
447
|
+
raw = @config.dig("agent", "api_retry_total_timeout_seconds")
|
|
448
|
+
return nil if raw.nil?
|
|
449
|
+
|
|
450
|
+
n = Float(raw, exception: false)
|
|
451
|
+
n if n&.positive?
|
|
452
|
+
end
|
|
453
|
+
|
|
376
454
|
def log_safely(**fields)
|
|
377
455
|
Rubino.logger.warn(**fields)
|
|
378
456
|
rescue StandardError
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
[Identity]
|
|
2
|
-
You are rubino, a software engineering assistant running in
|
|
3
|
-
real environment. You read, edit, and run code with actual tool
|
|
4
|
-
treat the file system, git, and the shell as production by default.
|
|
2
|
+
You are rubino, a general-purpose software engineering assistant running in
|
|
3
|
+
the user's real environment. You read, edit, and run code with actual tool
|
|
4
|
+
access — treat the file system, git, and the shell as production by default.
|
|
5
|
+
You are not tied to any one language or framework: work in whatever language
|
|
6
|
+
and stack the project in front of you uses (Python, JavaScript, Go, Ruby,
|
|
7
|
+
Rust, …). Detect the project's language from its files and conventions; never
|
|
8
|
+
assume or default to one.
|
|
5
9
|
|
|
6
10
|
[Principles]
|
|
7
11
|
- Smallest change that solves the task. No speculative refactors, no
|
|
@@ -27,8 +31,10 @@ treat the file system, git, and the shell as production by default.
|
|
|
27
31
|
map-reduces the file in a separate context and returns only the summary,
|
|
28
32
|
so the raw text never fills this conversation. Reach for `read` (with
|
|
29
33
|
offset/limit) or `grep` only when you need exact lines, not an overview.
|
|
30
|
-
-
|
|
31
|
-
|
|
34
|
+
- The `ruby` tool runs sandboxed Ruby for quick computation/scripting —
|
|
35
|
+
reach for it when Ruby fits the project. Otherwise use `shell` for the
|
|
36
|
+
host's binaries and the project's own toolchain (its interpreter, package
|
|
37
|
+
manager, test runner). Match the project's language; don't default to Ruby.
|
|
32
38
|
- When multiple tool calls are independent (no data dependency), issue
|
|
33
39
|
them in parallel — one message with several tool uses.
|
|
34
40
|
- Cite files as `path/to/file.rb:42` so the user can jump straight to the
|
|
@@ -43,6 +49,17 @@ treat the file system, git, and the shell as production by default.
|
|
|
43
49
|
several background subagents at once; check one early with `task_result` or
|
|
44
50
|
stop it with `task_stop`. Pass `background: false` only when the very next
|
|
45
51
|
step depends on the subagent's output.
|
|
52
|
+
- A BACKGROUND subagent runs ASYNCHRONOUSLY: there is nothing to "wait" for and
|
|
53
|
+
no result to poll for now — the user is notified and you receive the
|
|
54
|
+
`[background-task]` result automatically when it finishes. So do not narrate
|
|
55
|
+
waiting (no "I'll wait for it", no "task_result isn't available yet, nothing I
|
|
56
|
+
can do"); just finish your current reply or move on to other work. If you
|
|
57
|
+
genuinely cannot proceed without the answer this step, use `background: false`
|
|
58
|
+
instead of waiting.
|
|
59
|
+
- Subagents are FULLY capable: the general subagent has every tool available,
|
|
60
|
+
including `task` (it can spawn its own subagents) and `ask_parent` (it can ask
|
|
61
|
+
YOU a clarifying question mid-task). Never decline to delegate by claiming a
|
|
62
|
+
subagent lacks `task`/`ask_parent` or can't reach the parent — that is false.
|
|
46
63
|
|
|
47
64
|
[Safety]
|
|
48
65
|
- Destructive shell commands (`rm -rf`, `git push --force`, `git reset
|
|
@@ -27,6 +27,13 @@ module Rubino
|
|
|
27
27
|
def valid?(response)
|
|
28
28
|
return [false, :nil_response] if response.nil?
|
|
29
29
|
return [false, :interrupted] if response.interrupted?
|
|
30
|
+
# A budget-Halt response (#355a) is a deliberate turn-ending signal — the
|
|
31
|
+
# streaming round-trip loop was cut short because the iteration/time
|
|
32
|
+
# budget ran out, not a model failure. It may legitimately carry no
|
|
33
|
+
# content (no preamble streamed), so it must NOT be judged empty and sent
|
|
34
|
+
# through the recovery ladder; the Loop reads #halted? and runs its
|
|
35
|
+
# budget-exhausted summary.
|
|
36
|
+
return [true, nil] if response.respond_to?(:halted?) && response.halted?
|
|
30
37
|
return [true, nil] if response.has_tool_calls?
|
|
31
38
|
return [false, :empty_response] if response.content.to_s.strip.empty?
|
|
32
39
|
|
|
@@ -41,6 +48,7 @@ module Rubino
|
|
|
41
48
|
# Tool-call responses are never degenerate — the tool call IS the answer.
|
|
42
49
|
def degenerate?(response)
|
|
43
50
|
return false if response.nil? || response.interrupted?
|
|
51
|
+
return false if response.respond_to?(:halted?) && response.halted?
|
|
44
52
|
return false if response.has_tool_calls?
|
|
45
53
|
|
|
46
54
|
!content_after_think_block?(response.content)
|
data/lib/rubino/agent/runner.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Rubino
|
|
|
13
13
|
|
|
14
14
|
def initialize(session_id: nil, model_override: nil, provider_override: nil,
|
|
15
15
|
max_turns: nil, ignore_rules: false, ui: nil, agent_definition: nil,
|
|
16
|
-
event_bus: nil, announce_session: true)
|
|
16
|
+
event_bus: nil, announce_session: true, session_source: "cli")
|
|
17
17
|
@ui = ui || Rubino.ui
|
|
18
18
|
# An in-chat rewind/fork builds a runner on the child session but has its
|
|
19
19
|
# own purpose-built "┄ rewound to message N — editing ┄" marker, so the
|
|
@@ -33,14 +33,32 @@ module Rubino
|
|
|
33
33
|
@max_turns = max_turns
|
|
34
34
|
@ignore_rules = ignore_rules
|
|
35
35
|
@agent_definition = agent_definition
|
|
36
|
+
# The `source` stamped on a freshly-created session row. Defaults to
|
|
37
|
+
# "cli" (a user-driven REPL/one-shot session); the `task` tool passes
|
|
38
|
+
# "subagent" so internal subagent prompt-sessions can be filtered out of
|
|
39
|
+
# the user-facing /sessions picker + `sessions list` (they're machinery,
|
|
40
|
+
# not the user's own conversations) while staying resumable by explicit
|
|
41
|
+
# id. Like Claude Code hiding its Task subagent sessions from the picker.
|
|
42
|
+
@session_source = session_source
|
|
36
43
|
# Pre-instantiate so cancel! is meaningful between turns and during the
|
|
37
44
|
# window between Signal.trap install and run() — a too-early Ctrl+C
|
|
38
45
|
# used to land on a nil token and silently no-op, then the next run
|
|
39
46
|
# started fresh and the user's cancel was lost.
|
|
40
47
|
@cancel_token = Interaction::CancelToken.new
|
|
48
|
+
# Detached post-turn polishing worker (#319): owns the background thread
|
|
49
|
+
# that drains memory-extract / skill-distill / summarize OFF the live
|
|
50
|
+
# turn so the next prompt is never gated, and is cancellable via Esc.
|
|
51
|
+
# Reused across this runner's turns so #running? / #cancel! address the
|
|
52
|
+
# CURRENT polishing run (coalescing rapid turns).
|
|
53
|
+
@polishing = Interaction::Polishing.new(config: @config)
|
|
41
54
|
@session = load_or_create_session(session_id)
|
|
42
55
|
end
|
|
43
56
|
|
|
57
|
+
# The detached post-turn polishing worker, so the CLI can show the
|
|
58
|
+
# non-blocking "polishing… (Esc to skip)" indicator while it runs and
|
|
59
|
+
# extend the single Esc/cancel path to it (#319).
|
|
60
|
+
attr_reader :polishing
|
|
61
|
+
|
|
44
62
|
# Executes a full interaction turn, swallowing failures so CLI callers
|
|
45
63
|
# can stay in the REPL after a model/tool error. The friendly UI
|
|
46
64
|
# message is emitted, but the bus event INTERACTION_FAILED is NOT
|
|
@@ -89,18 +107,68 @@ module Rubino
|
|
|
89
107
|
cancel_token: @cancel_token,
|
|
90
108
|
model_override: @explicit_model_override,
|
|
91
109
|
provider_override: @provider_override,
|
|
92
|
-
max_tool_iterations: @max_turns
|
|
110
|
+
max_tool_iterations: @max_turns,
|
|
111
|
+
polishing: @polishing
|
|
93
112
|
)
|
|
94
113
|
|
|
95
|
-
lifecycle.execute(input, image_paths: image_paths, input_queue: input_queue,
|
|
96
|
-
|
|
114
|
+
response = lifecycle.execute(input, image_paths: image_paths, input_queue: input_queue,
|
|
115
|
+
paste_expansions: paste_expansions)
|
|
116
|
+
|
|
117
|
+
# Adopt an automatic-compaction swap so the NEXT turn runs on the (small)
|
|
118
|
+
# compaction child, not the dead parent (P3 F1). When #check_and_compact
|
|
119
|
+
# fires, it reassigns the lifecycle's session to the child; without
|
|
120
|
+
# picking that up here the Runner would rebuild every subsequent turn's
|
|
121
|
+
# Lifecycle on the un-shrunk parent → re-compact every turn (superlinear
|
|
122
|
+
# DB/context bloat + ~2.9x slowdown). This is the automatic-path
|
|
123
|
+
# counterpart to the manual /compact swap (chat_command rebuilds the
|
|
124
|
+
# runner on result[:compact_into]).
|
|
125
|
+
@session = lifecycle.active_session
|
|
126
|
+
|
|
127
|
+
response
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Pins the agent Definition this runner threads into every subsequent turn
|
|
131
|
+
# (the sticky `/agent <name>` / Tab-cycle switch). Lifecycle reads
|
|
132
|
+
# @agent_definition fresh on each #run!, so swapping it here takes effect
|
|
133
|
+
# from the NEXT turn — the agent's system prompt and tool scope come along.
|
|
134
|
+
# nil restores the default (build) persona. The reader feeds the CLI
|
|
135
|
+
# status bar and a one-shot route that wants to restore it afterwards.
|
|
136
|
+
attr_accessor :agent_definition
|
|
137
|
+
|
|
138
|
+
# Runs ONE turn under +definition+ (a one-shot `/<name> <message>` route)
|
|
139
|
+
# without disturbing the runner's sticky agent. The override is swapped in
|
|
140
|
+
# for the single #run and restored in the ensure, so the next idle prompt
|
|
141
|
+
# is back on whatever the user had pinned.
|
|
142
|
+
def run_with_agent(definition, input, **)
|
|
143
|
+
sticky = @agent_definition
|
|
144
|
+
@agent_definition = definition
|
|
145
|
+
run(input, **)
|
|
146
|
+
ensure
|
|
147
|
+
@agent_definition = sticky
|
|
97
148
|
end
|
|
98
149
|
|
|
99
150
|
# Flips the current turn's cancel token. Called from the UI thread when
|
|
100
151
|
# the user hits Esc or a second Ctrl+C while the worker is mid-stream.
|
|
101
152
|
# No-op when no turn is in flight.
|
|
102
|
-
|
|
103
|
-
|
|
153
|
+
#
|
|
154
|
+
# ONE Esc cancels whatever is in flight (#319): the FOREGROUND turn OR the
|
|
155
|
+
# DETACHED post-turn polishing. Flipping both tokens is safe — a token is
|
|
156
|
+
# one-shot and idle-when-untouched, so cancelling the not-running side is a
|
|
157
|
+
# harmless no-op. The polishing worker stops between jobs and its aux
|
|
158
|
+
# retry/backoff aborts mid-wait, leaving partial work in place.
|
|
159
|
+
# +reason+ records WHY the turn was cancelled so the result label stays
|
|
160
|
+
# truthful: :user (Esc/Ctrl+C, default) vs :external (SIGTERM/SIGHUP
|
|
161
|
+
# teardown). Plumbed through to the CancelToken / Interrupted (#361b).
|
|
162
|
+
def cancel!(reason: :user)
|
|
163
|
+
@cancel_token&.cancel!(reason: reason)
|
|
164
|
+
@polishing&.cancel!
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# True while the detached post-turn polishing is still draining — drives
|
|
168
|
+
# the non-blocking "polishing… (Esc to skip)" indicator the CLI shows
|
|
169
|
+
# without owning the input.
|
|
170
|
+
def polishing?
|
|
171
|
+
@polishing&.running? || false
|
|
104
172
|
end
|
|
105
173
|
|
|
106
174
|
# Switches the LIVE model for this runner (the in-chat `/model <name>`).
|
|
@@ -136,6 +204,11 @@ module Rubino
|
|
|
136
204
|
@session_repo.end_session!(@session[:id])
|
|
137
205
|
rescue StandardError
|
|
138
206
|
nil
|
|
207
|
+
ensure
|
|
208
|
+
# Let any in-flight detached polishing settle (bounded) so a clean
|
|
209
|
+
# teardown doesn't abandon a half-written extraction (#319). Best-effort:
|
|
210
|
+
# the cursor re-feeds anything unfinished next session anyway.
|
|
211
|
+
@polishing&.wait(3)
|
|
139
212
|
end
|
|
140
213
|
|
|
141
214
|
private
|
|
@@ -170,9 +243,31 @@ module Rubino
|
|
|
170
243
|
"Try `rubino sessions list`, or resume by id prefix."
|
|
171
244
|
end
|
|
172
245
|
|
|
246
|
+
# Owner-guard on EXPLICIT resume (#347): auto-resume already skips a
|
|
247
|
+
# session a DIFFERENT live process is actively writing, but explicit
|
|
248
|
+
# `--resume <id>` / `-s <id>` had NO guard — N processes could latch
|
|
249
|
+
# the same "active" row and interleave writes into one malformed
|
|
250
|
+
# transcript (user user user … assistant), poisoning the next resume's
|
|
251
|
+
# history. When the target is live-owned by another process, fork a
|
|
252
|
+
# fresh child that inherits the full history instead of stomping the
|
|
253
|
+
# live session; the user keeps their context and the two writers never
|
|
254
|
+
# interleave.
|
|
255
|
+
# ATOMICALLY claim the row for THIS process (#390/residual #376).
|
|
256
|
+
# The old code checked `owned_by_other_live_process?` then later
|
|
257
|
+
# stamped owner_pid — a TOCTOU window where two concurrent
|
|
258
|
+
# `--resume <id>` both read the same dead owner_pid, both passed the
|
|
259
|
+
# check, and both stamped+wrote the live row (user,user … interleave).
|
|
260
|
+
# claim_for_resume! folds the check and stamp into one compare-and-swap
|
|
261
|
+
# (same idiom as Jobs::Queue#claim!): exactly one racer wins, the
|
|
262
|
+
# loser gets false and forks a fresh child off the busy parent.
|
|
263
|
+
return fork_busy_session(session) unless @session_repo.claim_for_resume!(session)
|
|
264
|
+
|
|
173
265
|
# An existing row is already in the DB; mark it so the lazy-persist
|
|
174
|
-
# path (#144) treats it as persisted and never re-inserts.
|
|
266
|
+
# path (#144) treats it as persisted and never re-inserts. We now own
|
|
267
|
+
# owner_pid (stamped atomically above) so a later concurrent resume
|
|
268
|
+
# sees us as the live owner and forks rather than interleaving.
|
|
175
269
|
session[:persisted] = true
|
|
270
|
+
session[:owner_pid] = Process.pid
|
|
176
271
|
@ui.status("Resuming session: #{session[:id][0..7]}...") if @announce_session
|
|
177
272
|
session
|
|
178
273
|
else
|
|
@@ -182,7 +277,7 @@ module Rubino
|
|
|
182
277
|
# record carries a real id so the whole turn pipeline works unchanged;
|
|
183
278
|
# Lifecycle#persist_user_message flips it to a real row on demand.
|
|
184
279
|
session = @session_repo.build(
|
|
185
|
-
source:
|
|
280
|
+
source: @session_source,
|
|
186
281
|
model: @model_id,
|
|
187
282
|
provider: @provider_override || LLM::ProviderResolver.resolve(@model_id)
|
|
188
283
|
)
|
|
@@ -190,6 +285,36 @@ module Rubino
|
|
|
190
285
|
session
|
|
191
286
|
end
|
|
192
287
|
end
|
|
288
|
+
|
|
289
|
+
# Forks a child session off a parent another live process is still writing
|
|
290
|
+
# (#347), copying the parent's full history so the explicit-resume user
|
|
291
|
+
# keeps their context, while writing to a SEPARATE row so the two writers
|
|
292
|
+
# never interleave into one malformed transcript. The child is owned by
|
|
293
|
+
# THIS process. Mirrors the /branch copy (history + extraction watermark +
|
|
294
|
+
# message_count sync) without a probe seed.
|
|
295
|
+
def fork_busy_session(parent)
|
|
296
|
+
store = @message_store
|
|
297
|
+
child = @session_repo.create(
|
|
298
|
+
source: "cli",
|
|
299
|
+
model: parent[:model] || @model_id,
|
|
300
|
+
provider: parent[:provider] || @provider_override,
|
|
301
|
+
title: parent[:title],
|
|
302
|
+
parent_session_id: parent[:id],
|
|
303
|
+
cwd: parent[:cwd]
|
|
304
|
+
)
|
|
305
|
+
store.copy_into(child[:id], store.for_session(parent[:id]))
|
|
306
|
+
store.seed_extraction_cursor(child[:id])
|
|
307
|
+
@session_repo.update(child[:id], message_count: store.count(child[:id]))
|
|
308
|
+
|
|
309
|
+
if @announce_session
|
|
310
|
+
@ui.status(
|
|
311
|
+
"Session #{parent[:id][0..7]} is in use by another rubino — " \
|
|
312
|
+
"forked a copy: #{child[:id][0..7]}"
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
child[:persisted] = true
|
|
316
|
+
child
|
|
317
|
+
end
|
|
193
318
|
end
|
|
194
319
|
end
|
|
195
320
|
end
|