samagotchi 0.58.0 → 0.60.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/CHANGELOG.md +86 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +6 -4
- data/docs/cli.md +14 -7
- data/docs/configuration.md +96 -31
- data/docs/hooks.md +12 -6
- data/docs/internals/background-tasks.md +7 -0
- data/docs/internals/engine.md +20 -12
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +4 -3
- data/docs/internals/tool-guardrails.md +2 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +75 -6
- data/docs/sessions.md +2 -2
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +6 -2
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +44 -10
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +89 -15
- data/lib/samagotchi/host_config.rb +3 -1
- data/lib/samagotchi/host_registry.rb +2 -1
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +35 -9
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +11 -6
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/model_settings.rb +4 -2
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/process_group.rb +76 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +124 -0
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +15 -3
- data/lib/samagotchi/session_commands.rb +15 -3
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/steer_cut_after.rb +69 -0
- data/lib/samagotchi/system_prompt.rb +74 -18
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +23 -5
- data/lib/samagotchi/terminal_ui.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +18 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/execute.rb +318 -70
- data/lib/samagotchi/tools/task_get.rb +2 -0
- data/lib/samagotchi/tools/task_runtime.rb +96 -13
- data/lib/samagotchi/tools/task_wait.rb +23 -5
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/utf8_default.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/read_view.js +3 -1
- data/lib/samagotchi/web/public/refs.js +107 -15
- data/lib/samagotchi/web/public/task_stop.js +25 -7
- data/lib/samagotchi/worker.rb +1 -0
- metadata +7 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -105,17 +105,20 @@ module Samagotchi
|
|
|
105
105
|
# @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
|
|
106
106
|
# @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
|
|
107
107
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
108
|
+
# @param appended_prompt_files [Array<String>] --append-system-prompt's files (absolute paths; a session's
|
|
109
|
+
# appended_prompt_files): their text goes into the system prompt after config.yml's append_system_prompt:
|
|
108
110
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
109
111
|
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
110
112
|
# delegate (a child would outlive it) nor plugin fork
|
|
111
113
|
def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
|
|
112
|
-
plugins: true, scratch: false, model_typed: nil)
|
|
114
|
+
plugins: true, scratch: false, model_typed: nil, appended_prompt_files: [])
|
|
113
115
|
# The turn as other threads see it (flag, cancel controller, sink,
|
|
114
116
|
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
115
117
|
# may steer or ask whether a turn runs while it loads.
|
|
116
118
|
@turn_state = TurnState.new(clock: -> { monotonic_now })
|
|
117
119
|
# A message for the running turn cutting its generation.
|
|
118
|
-
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller }
|
|
120
|
+
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
|
|
121
|
+
cut_after: -> { steer_cut_after })
|
|
119
122
|
@scratch = scratch
|
|
120
123
|
@no_interrupt = no_interrupt
|
|
121
124
|
@chat_backend = nil
|
|
@@ -204,7 +207,7 @@ module Samagotchi
|
|
|
204
207
|
@prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
|
|
205
208
|
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
206
209
|
model_notes: -> { model_notes }, memories: memories,
|
|
207
|
-
muted_memory_names: @muted_memory_names)
|
|
210
|
+
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
|
|
208
211
|
@session = nil
|
|
209
212
|
@session_observer = SessionObserver.new
|
|
210
213
|
@metrics = SessionMetrics.new
|
|
@@ -1012,7 +1015,8 @@ module Samagotchi
|
|
|
1012
1015
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
1013
1016
|
served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
|
|
1014
1017
|
thinking: thinking_explained&.summary,
|
|
1015
|
-
reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
|
|
1018
|
+
reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
|
|
1019
|
+
prompt_files: @prompt_builder.prompt_files, price_host: target.entry.name)
|
|
1016
1020
|
unless snapshot.dig(:context, :window_tokens)
|
|
1017
1021
|
window = current_context_window(target)
|
|
1018
1022
|
if window
|
|
@@ -1094,6 +1098,29 @@ module Samagotchi
|
|
|
1094
1098
|
explained&.source ? explained.label : nil
|
|
1095
1099
|
end
|
|
1096
1100
|
|
|
1101
|
+
# The seconds a cut waits for the effective model to stream only
|
|
1102
|
+
# thinking (SteerCutAfter.resolve), resolved on every call, as
|
|
1103
|
+
# turn_sampling; on any error, the global and its name.
|
|
1104
|
+
# @return [Array(Integer, String|nil)]
|
|
1105
|
+
def steer_cut_after_explained
|
|
1106
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1107
|
+
SteerCutAfter.resolve(target, names: model_lookup_names(target))
|
|
1108
|
+
rescue StandardError
|
|
1109
|
+
[SteerCut.cut_after, "steer.cut_after"]
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
# #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
|
|
1113
|
+
# reads, per host and model.
|
|
1114
|
+
def steer_cut_after = steer_cut_after_explained.first
|
|
1115
|
+
|
|
1116
|
+
# "after 60 s (models: qwen)", "never (hosts.splash)" for /model: the
|
|
1117
|
+
# effective model's cut wait and where it came from, nil for the
|
|
1118
|
+
# default.
|
|
1119
|
+
def steer_cut_summary
|
|
1120
|
+
seconds, source = steer_cut_after_explained
|
|
1121
|
+
source ? SteerCutAfter.label(seconds, source) : nil
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1097
1124
|
# The effective model's thinking level with where it came from and the
|
|
1098
1125
|
# session's own (SettingExplained), as the next turn resolves it;
|
|
1099
1126
|
# nil when it can't be resolved.
|
|
@@ -1494,7 +1521,7 @@ module Samagotchi
|
|
|
1494
1521
|
private :hook_source
|
|
1495
1522
|
|
|
1496
1523
|
# What a notice may stand in for (hook_notify's fallback_for:).
|
|
1497
|
-
NOTICE_FALLBACKS = %i[display].freeze
|
|
1524
|
+
NOTICE_FALLBACKS = %i[display editor].freeze
|
|
1498
1525
|
|
|
1499
1526
|
# One line to the user (:hook_notice). During a turn it is a turn
|
|
1500
1527
|
# event: the turn's sink (the REPL) and the observers (bridge, log).
|
|
@@ -1503,9 +1530,10 @@ module Samagotchi
|
|
|
1503
1530
|
#
|
|
1504
1531
|
# +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
|
|
1505
1532
|
# :display, the answer's display (AnswerDisplay), for a UI that doesn't
|
|
1506
|
-
# render its links
|
|
1507
|
-
#
|
|
1508
|
-
#
|
|
1533
|
+
# render its links; :editor, a ref the viewer could open in its editor
|
|
1534
|
+
# (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
|
|
1535
|
+
# does leaves the line out; the others, the log among them, show it. An
|
|
1536
|
+
# unknown value is dropped, so the line shows everywhere.
|
|
1509
1537
|
def hook_notify(text, level, hook, fallback_for: nil)
|
|
1510
1538
|
level = (level || :info).to_sym
|
|
1511
1539
|
level = :info unless %i[info warn].include?(level)
|
|
@@ -1777,8 +1805,14 @@ module Samagotchi
|
|
|
1777
1805
|
# +id+: names the turn on :turn_started (its metrics record's id) and on
|
|
1778
1806
|
# its saved prompt, so a UI pairs the two whatever left the conversation.
|
|
1779
1807
|
# +limit+: the iteration limit #generate ran it with.
|
|
1808
|
+
# +resumed+: a step-limit Continue (run_turn's resumed:).
|
|
1809
|
+
# +resume_from+: where its reasoning starts, the run it resumes
|
|
1810
|
+
# (run_turn's resume_from:); nil for every other turn, and for a
|
|
1811
|
+
# Continue that starts cold (after a model change).
|
|
1780
1812
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1781
|
-
:settings, :id, :limit) do
|
|
1813
|
+
:settings, :id, :limit, :resumed, :resume_from) do
|
|
1814
|
+
def resumed? = resumed ? true : false
|
|
1815
|
+
|
|
1782
1816
|
# The turn's boundary events carry the origin only when there is one,
|
|
1783
1817
|
# so payloads stay unchanged for callers that don't pass it.
|
|
1784
1818
|
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
@@ -1808,6 +1842,14 @@ module Samagotchi
|
|
|
1808
1842
|
# @param continue [Boolean] resume the conversation without appending a
|
|
1809
1843
|
# user prompt (continue after the iteration limit, reminder turns);
|
|
1810
1844
|
# +prompt+ is ignored and :turn_started carries `continue: true`
|
|
1845
|
+
# @param resumed [Boolean] a step-limit Continue (with continue:):
|
|
1846
|
+
# :turn_started carries `resumed: true`, so do the before_turn payload
|
|
1847
|
+
# and the turn's analytics record. false for every other continue (a
|
|
1848
|
+
# reminder, a wake).
|
|
1849
|
+
# @param resume_from [Integer, nil] a step-limit Continue's: where the
|
|
1850
|
+
# run it resumes started (ModelResult#run_start, kept in the offer by
|
|
1851
|
+
# TurnFlow), so it keeps that run's reasoning; nil starts cold (a
|
|
1852
|
+
# model change while the offer stood, every other turn).
|
|
1811
1853
|
# @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:});
|
|
1812
1854
|
# when given, the turn's boundary events (:turn_started, :turn_completed,
|
|
1813
1855
|
# :turn_canceled, :turn_failed) carry it as `origin:`
|
|
@@ -1830,9 +1872,13 @@ module Samagotchi
|
|
|
1830
1872
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1831
1873
|
# one-line summary:.
|
|
1832
1874
|
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
|
|
1833
|
-
images: [], id: nil)
|
|
1875
|
+
images: [], id: nil, resumed: false, resume_from: nil)
|
|
1834
1876
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
1835
1877
|
continue: continue, id: id)
|
|
1878
|
+
if continue
|
|
1879
|
+
turn.resumed = resumed
|
|
1880
|
+
turn.resume_from = resume_from
|
|
1881
|
+
end
|
|
1836
1882
|
begin
|
|
1837
1883
|
# A Stop cuts the /props probes this thread makes for the turn
|
|
1838
1884
|
# (window, served model, vision) instead of waiting their timeout.
|
|
@@ -1915,7 +1961,9 @@ module Samagotchi
|
|
|
1915
1961
|
bind_metrics(session)
|
|
1916
1962
|
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
|
|
1917
1963
|
turn_started[:continue] = true if turn.continue
|
|
1964
|
+
turn_started[:resumed] = true if turn.resumed?
|
|
1918
1965
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1966
|
+
turn_started[:cut_after] = steer_cut_after
|
|
1919
1967
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
1920
1968
|
raise image_error if image_error
|
|
1921
1969
|
|
|
@@ -1955,9 +2003,9 @@ module Samagotchi
|
|
|
1955
2003
|
end
|
|
1956
2004
|
|
|
1957
2005
|
# Fire :before_turn hook, with a read-only copy of the history so far
|
|
1958
|
-
# and the prompt (nil on a continue).
|
|
2006
|
+
# and the prompt (nil on a continue); resumed: a step-limit Continue.
|
|
1959
2007
|
@hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
|
|
1960
|
-
messages: hook_messages(session.messages) })
|
|
2008
|
+
resumed: turn.resumed?, messages: hook_messages(session.messages) })
|
|
1961
2009
|
|
|
1962
2010
|
turn_messages(turn, image_refs)
|
|
1963
2011
|
true
|
|
@@ -2093,18 +2141,39 @@ module Samagotchi
|
|
|
2093
2141
|
log_llm_context(explained)
|
|
2094
2142
|
|
|
2095
2143
|
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
2096
|
-
|
|
2144
|
+
# Where this run starts (ChatLoop::Run's start): an offer made at its
|
|
2145
|
+
# end keeps it, so a Continue resumes this run.
|
|
2146
|
+
run_start = turn.messages.length
|
|
2147
|
+
reasoning_since = resumed_run_start(turn, run_start)
|
|
2148
|
+
result = backend.complete(
|
|
2097
2149
|
messages: turn.messages,
|
|
2098
2150
|
max_iterations: turn.limit,
|
|
2099
2151
|
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
2100
2152
|
cancel_controller: turn.controller,
|
|
2101
2153
|
model_name: bare_for_backend,
|
|
2102
2154
|
max_tool_output_chars: max_tool_output_chars,
|
|
2103
|
-
pending_input: turn_drain(pending_input)
|
|
2155
|
+
pending_input: turn_drain(pending_input),
|
|
2156
|
+
reasoning_since: reasoning_since
|
|
2104
2157
|
)
|
|
2158
|
+
result.run_start = run_start
|
|
2159
|
+
result
|
|
2105
2160
|
end
|
|
2106
2161
|
private :generate
|
|
2107
2162
|
|
|
2163
|
+
# A step-limit Continue's resume_from, when it is inside the messages the
|
|
2164
|
+
# run gets (a wrong one only drops reasoning: it is ignored, logged).
|
|
2165
|
+
# Entries before it never move between the turns (#turn_messages adds
|
|
2166
|
+
# at the tail, and the system head is replaced in place).
|
|
2167
|
+
def resumed_run_start(turn, run_start)
|
|
2168
|
+
start = turn.resume_from
|
|
2169
|
+
return nil if start.nil?
|
|
2170
|
+
return start if start.is_a?(Integer) && start.between?(0, run_start)
|
|
2171
|
+
|
|
2172
|
+
Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
|
|
2173
|
+
nil
|
|
2174
|
+
end
|
|
2175
|
+
private :resumed_run_start
|
|
2176
|
+
|
|
2108
2177
|
# The turn's LLM context strategy and where it came from, in the turn
|
|
2109
2178
|
# log: when it runs a layer, or the session set it (none too).
|
|
2110
2179
|
def log_llm_context(explained)
|
|
@@ -2178,7 +2247,8 @@ module Samagotchi
|
|
|
2178
2247
|
# replace). Runs under the event lock: it also adds the turn's note to
|
|
2179
2248
|
# result.conversation, which the REPL keeps as-is.
|
|
2180
2249
|
# A turn that ran out of iterations ends at its tool results, so a
|
|
2181
|
-
# continue resumes from them rather than after a made-up reply
|
|
2250
|
+
# continue resumes from them rather than after a made-up reply (and a
|
|
2251
|
+
# step-limit Continue from its run's start: ModelResult#run_start).
|
|
2182
2252
|
def kept_messages(turn, result, seconds)
|
|
2183
2253
|
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
2184
2254
|
# Nothing visible (no text) is a turn the model should know ended
|
|
@@ -2599,6 +2669,10 @@ module Samagotchi
|
|
|
2599
2669
|
state_dir: -> { session_state_dir },
|
|
2600
2670
|
scratch: -> { @scratch },
|
|
2601
2671
|
setup: -> { SessionSetup.of(@session) },
|
|
2672
|
+
# The files whose text the built system prompt holds; [] until a
|
|
2673
|
+
# build and after a rebuild that drops them (SystemPrompt#prompt_files
|
|
2674
|
+
# returns [] itself: the || keeps a nil builder safe).
|
|
2675
|
+
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
2602
2676
|
frontend: -> { interface }
|
|
2603
2677
|
)
|
|
2604
2678
|
end
|
|
@@ -19,7 +19,7 @@ module Samagotchi
|
|
|
19
19
|
# describes them, nil when unset; models: {downcased id => HostModel}.
|
|
20
20
|
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
|
|
21
21
|
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
22
|
-
:window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
22
|
+
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
23
23
|
:models) do
|
|
24
24
|
# Every field but name may be left out: nil, models {}.
|
|
25
25
|
def initialize(**fields)
|
|
@@ -50,6 +50,7 @@ module Samagotchi
|
|
|
50
50
|
vision: ConfigFile.vision_flag(value.call(:vision), where),
|
|
51
51
|
remote: ConfigFile.bool_flag(value.call(:remote), where, "remote"),
|
|
52
52
|
window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
|
|
53
|
+
steer_cut_after: SteerCutAfter.seconds(value.call(:steer_cut_after), where),
|
|
53
54
|
sampling: ConfigFile.sampling_map(value.call(:sampling), where),
|
|
54
55
|
thinking: Thinking.level(value.call(:thinking), where),
|
|
55
56
|
reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
|
|
@@ -170,6 +171,7 @@ module Samagotchi
|
|
|
170
171
|
"profile" => profile, "first_token_timeout" => first_token_timeout,
|
|
171
172
|
"vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
|
|
172
173
|
"reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
|
|
174
|
+
"steer_cut_after" => steer_cut_after,
|
|
173
175
|
"llm_context_strategy" => llm_context_strategy&.map(&:to_s),
|
|
174
176
|
"llm_context_apply" => llm_context_apply&.to_s,
|
|
175
177
|
"llm_context_budget_tokens" => llm_context_budget_tokens,
|
|
@@ -38,13 +38,14 @@ module Samagotchi
|
|
|
38
38
|
# reasoning_history: the configured level (ReasoningHistory), nil when unset;
|
|
39
39
|
# remote: the configured true/false (#remote?), nil when unset;
|
|
40
40
|
# window_tokens: the configured context window (ContextWindow.setting), nil when unset;
|
|
41
|
+
# steer_cut_after: the seconds a cut waits for its thinking (SteerCutAfter), nil when unset;
|
|
41
42
|
# llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
|
|
42
43
|
# llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
|
|
43
44
|
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
|
|
44
45
|
# models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
|
|
45
46
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
46
47
|
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
47
|
-
:window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
48
|
+
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
48
49
|
:models, keyword_init: true) do
|
|
49
50
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
50
51
|
# raw-prompt loop.
|
|
@@ -10,8 +10,9 @@ module Samagotchi
|
|
|
10
10
|
module IterationLimit
|
|
11
11
|
KEY = "turn.max_iterations"
|
|
12
12
|
# A turn's limit unless turn.max_iterations says otherwise, and the
|
|
13
|
-
# loops' own default.
|
|
14
|
-
|
|
13
|
+
# loops' own default. Loops are caught by the loop-guard bundle and a
|
|
14
|
+
# long turn shows check-in's card well before it; this catches the rest.
|
|
15
|
+
DEFAULT = 300
|
|
15
16
|
# A --no-interrupt / --non-interactive turn's, at least: nobody is there
|
|
16
17
|
# to answer a continue offer.
|
|
17
18
|
NO_INTERRUPT = 1000
|
|
@@ -204,12 +204,18 @@ module Samagotchi
|
|
|
204
204
|
# steering mid-stream); drained lines merge into ONE user message appended
|
|
205
205
|
# at the conversation tail (prefix KV cache preserved) and a
|
|
206
206
|
# :pending_input_merged stream event is emitted.
|
|
207
|
+
# @param reasoning_since [Integer, nil] a step-limit Continue's: where the
|
|
208
|
+
# run it resumes started in +messages+; that run's model messages keep
|
|
209
|
+
# their thought blocks (#prepare_conversation), as within one run. nil:
|
|
210
|
+
# every earlier thought is stripped.
|
|
207
211
|
# @return [LLM::ModelResult] final visible response with continuation metadata
|
|
208
|
-
def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil
|
|
212
|
+
def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil,
|
|
213
|
+
reasoning_since: nil)
|
|
209
214
|
@turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
|
|
210
215
|
@last_slot = nil
|
|
211
216
|
turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
|
|
212
|
-
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars)
|
|
217
|
+
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars),
|
|
218
|
+
reasoning_since: reasoning_since)
|
|
213
219
|
|
|
214
220
|
max_iterations.times do |iteration_index|
|
|
215
221
|
turn.iteration = iteration_index + 1
|
|
@@ -284,8 +290,8 @@ module Samagotchi
|
|
|
284
290
|
:buffer_mark, keyword_init: true)
|
|
285
291
|
private_constant :Turn, :Request, :Generation
|
|
286
292
|
|
|
287
|
-
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
288
|
-
conversation = prepare_conversation(messages)
|
|
293
|
+
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
|
|
294
|
+
conversation = prepare_conversation(messages, keep_thought_from: thought_floor(messages, reasoning_since))
|
|
289
295
|
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
|
|
290
296
|
emit = ->(event) { emit_stream_event(on_stream_event, event) }
|
|
291
297
|
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
|
|
@@ -992,9 +998,13 @@ module Samagotchi
|
|
|
992
998
|
@profile.uses_role_prefixes? ? @profile.user_prefix : "#{@profile.turn_start}user\n"
|
|
993
999
|
end
|
|
994
1000
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1001
|
+
# +keep_thought_from+: model messages from this index on keep their
|
|
1002
|
+
# thought blocks (a resumed run's); nil strips every one.
|
|
1003
|
+
def sanitize_history(messages, keep_thought_from: nil)
|
|
1004
|
+
messages.each_with_index.map do |m, index|
|
|
1005
|
+
if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
|
|
1006
|
+
{ role: m[:role], content: m[:content].to_s, display: m[:display] }.compact
|
|
1007
|
+
elsif m[:role] == "model"
|
|
998
1008
|
# `display` rides along so the stored conversation keeps it; the
|
|
999
1009
|
# prompt formatter never reads it (AnswerDisplay).
|
|
1000
1010
|
{ role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display] }.compact
|
|
@@ -1004,8 +1014,24 @@ module Samagotchi
|
|
|
1004
1014
|
end
|
|
1005
1015
|
end
|
|
1006
1016
|
|
|
1007
|
-
# Standard multi-turn compliance: never pass prior raw thought blocks
|
|
1008
|
-
|
|
1017
|
+
# Standard multi-turn compliance: never pass prior raw thought blocks,
|
|
1018
|
+
# but a resumed run's (+keep_thought_from+, #thought_floor): the same
|
|
1019
|
+
# run going on after a step-limit Continue.
|
|
1020
|
+
def prepare_conversation(messages, keep_thought_from: nil) = sanitize_history(messages, keep_thought_from: keep_thought_from)
|
|
1021
|
+
|
|
1022
|
+
# Where a resumed run's thought blocks start: its start (+since+). Inside
|
|
1023
|
+
# one native run nothing re-sanitizes, so a user entry the run merged (a
|
|
1024
|
+
# steer, a delegate report, the Qwen recovery prompt) left the thoughts
|
|
1025
|
+
# before it in the prompt: they stay. Only a user or turn_start entry
|
|
1026
|
+
# added after the run ended (after its last model message: a !cmd's
|
|
1027
|
+
# output) starts them again past itself. nil without +since+.
|
|
1028
|
+
def thought_floor(messages, since)
|
|
1029
|
+
return nil if since.nil?
|
|
1030
|
+
|
|
1031
|
+
last_reply = messages.rindex { |m| m[:role].to_s == "model" } || -1
|
|
1032
|
+
query = messages.rindex { |m| m[:role].to_s == "user" || m[:turn_start] == true }
|
|
1033
|
+
query && query > last_reply ? [query + 1, since].max : since
|
|
1034
|
+
end
|
|
1009
1035
|
|
|
1010
1036
|
def duplicate_conversation(messages)
|
|
1011
1037
|
messages.map(&:dup)
|
|
@@ -14,9 +14,14 @@ module Samagotchi
|
|
|
14
14
|
# extra surface lives on backend-specific subclasses, never on this interface.
|
|
15
15
|
#
|
|
16
16
|
# The raw-prompt KernelLoop is NativeBackend; the chat loop is ChatLoop.
|
|
17
|
+
#
|
|
18
|
+
# +reasoning_since+: where the run a step-limit Continue resumes started
|
|
19
|
+
# in +messages+ (Engine#run_turn resume_from:); the replies from there on
|
|
20
|
+
# keep the model's reasoning (thought blocks), as within one run. nil:
|
|
21
|
+
# the run starts here, as every other turn's does.
|
|
17
22
|
class ModelBackend
|
|
18
23
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
19
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
24
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
20
25
|
raise NotImplementedError, "#{self.class}#complete must be implemented"
|
|
21
26
|
end
|
|
22
27
|
end
|
|
@@ -68,12 +68,14 @@ module Samagotchi
|
|
|
68
68
|
|
|
69
69
|
def provider = :chat
|
|
70
70
|
|
|
71
|
+
# +reasoning_since+ (ModelBackend#complete): the resumed run's start,
|
|
72
|
+
# the Run's own start then (#reasoning_boundary).
|
|
71
73
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
72
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
74
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
73
75
|
raise ArgumentError, "ChatLoop has no adapter" unless @adapter
|
|
74
76
|
|
|
75
77
|
conversation = Array(messages).map(&:dup)
|
|
76
|
-
Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input)
|
|
78
|
+
Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: reasoning_since)
|
|
77
79
|
.call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
|
|
78
80
|
rescue StandardError => e
|
|
79
81
|
FailedTurn.attach(e, conversation && plain(conversation))
|
|
@@ -195,7 +197,8 @@ module Samagotchi
|
|
|
195
197
|
# user message: a prompt, a steer, merged input, the chat template's
|
|
196
198
|
# last query; or an entry that starts a turn, turn_start) and +since+
|
|
197
199
|
# (the Run's start, so a continue turn -- a reminder, a context wake,
|
|
198
|
-
# a
|
|
200
|
+
# a delegate report's wake -- sends none of the run before it; a
|
|
201
|
+
# step-limit Continue's Run starts where the run it resumes did).
|
|
199
202
|
# @return [Integer, nil]
|
|
200
203
|
def reasoning_boundary(conversation, since: nil, level: reasoning_history)
|
|
201
204
|
case level
|
|
@@ -402,7 +405,9 @@ module Samagotchi
|
|
|
402
405
|
# One turn's state: the conversation, the stream sink and tool
|
|
403
406
|
# activity.
|
|
404
407
|
class Run
|
|
405
|
-
|
|
408
|
+
# +start+: where the run a step-limit Continue resumes started (nil:
|
|
409
|
+
# this run starts at the conversation's end).
|
|
410
|
+
def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: nil)
|
|
406
411
|
@loop = loop
|
|
407
412
|
@conversation = conversation
|
|
408
413
|
@on_stream_event = on_stream_event
|
|
@@ -410,8 +415,8 @@ module Samagotchi
|
|
|
410
415
|
@model_name = model_name
|
|
411
416
|
@pending_input = pending_input
|
|
412
417
|
# Where this run's entries start: the reasoning it sends back
|
|
413
|
-
# (ChatLoop#reasoning_boundary) is its own.
|
|
414
|
-
@start = conversation.length
|
|
418
|
+
# (ChatLoop#reasoning_boundary) is its own, a resumed run's too.
|
|
419
|
+
@start = start || conversation.length
|
|
415
420
|
@tool_activity = []
|
|
416
421
|
@empty_retry = EmptyAnswerRetry.new
|
|
417
422
|
# The empty generations, never saved: the UIs draw the turn's
|
|
@@ -14,9 +14,15 @@ module Samagotchi
|
|
|
14
14
|
# generations as model messages, which the loops keep out of the
|
|
15
15
|
# conversation) and .empty_retries (the retries spent): the Engine saves
|
|
16
16
|
# them on the turn's note for the UIs (TurnNote.empty).
|
|
17
|
+
#
|
|
18
|
+
# .run_start is where the turn's run started in the conversation (the
|
|
19
|
+
# length of the messages the loop got), set by the Engine after the loop
|
|
20
|
+
# returns; nil from a loop called directly. A step-limit offer keeps it
|
|
21
|
+
# (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
|
|
17
22
|
class ModelResult
|
|
18
23
|
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
19
24
|
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
|
|
25
|
+
attr_accessor :run_start
|
|
20
26
|
|
|
21
27
|
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
22
28
|
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
|
|
@@ -57,8 +63,11 @@ module Samagotchi
|
|
|
57
63
|
!!pending_tool_calls
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
#
|
|
61
|
-
#
|
|
66
|
+
# A run that ran out of iterations can be continued (the step-limit
|
|
67
|
+
# offer). KernelLoop marks one exhausted only when it stopped on pending
|
|
68
|
+
# tool calls; ChatLoop also leaves it exhausted when its last iteration
|
|
69
|
+
# ended in a retry (an empty answer's nudge, a cut) or merged input
|
|
70
|
+
# after an answer, which a Continue then picks up.
|
|
62
71
|
def resumable?
|
|
63
72
|
exhausted?
|
|
64
73
|
end
|
|
@@ -17,7 +17,7 @@ module Samagotchi
|
|
|
17
17
|
def provider = :native
|
|
18
18
|
|
|
19
19
|
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
20
|
-
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
20
|
+
model_name: nil, max_tool_output_chars: nil, pending_input: nil, reasoning_since: nil)
|
|
21
21
|
@kernel.run(
|
|
22
22
|
messages,
|
|
23
23
|
max_iterations: max_iterations,
|
|
@@ -25,7 +25,8 @@ module Samagotchi
|
|
|
25
25
|
cancel_controller: cancel_controller,
|
|
26
26
|
model_name: model_name,
|
|
27
27
|
max_tool_output_chars: max_tool_output_chars,
|
|
28
|
-
pending_input: pending_input
|
|
28
|
+
pending_input: pending_input,
|
|
29
|
+
reasoning_since: reasoning_since
|
|
29
30
|
)
|
|
30
31
|
end
|
|
31
32
|
end
|
|
@@ -63,7 +63,8 @@ module Samagotchi
|
|
|
63
63
|
@generation_started_at.clear
|
|
64
64
|
@tool_started_at.clear
|
|
65
65
|
log(:info, :turn_started, session: event[:session_id], prompt_chars: event[:prompt].to_s.length,
|
|
66
|
-
continue: event[:continue] || nil,
|
|
66
|
+
continue: event[:continue] || nil, resumed: event[:resumed] || nil, **items(event),
|
|
67
|
+
**origin(event))
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def on_turn_completed(event)
|
|
@@ -10,10 +10,11 @@ module Samagotchi
|
|
|
10
10
|
# downcased key. Each field is nil when unset: profile kept as written
|
|
11
11
|
# (downcased; ModelProfile.resolve validates it), vision true/false,
|
|
12
12
|
# sampling a request-field map, thinking a level, reasoning_history a
|
|
13
|
-
# ReasoningHistory level, window_tokens,
|
|
13
|
+
# ReasoningHistory level, window_tokens, steer_cut_after whole seconds
|
|
14
|
+
# (0 = never, SteerCutAfter), and the
|
|
14
15
|
# LLM context strategy, apply rule and budget (LLMContextStrategy). The
|
|
15
16
|
# same per-model fields a hosts: entry carries (HostConfig).
|
|
16
|
-
ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens,
|
|
17
|
+
ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens, :steer_cut_after,
|
|
17
18
|
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens) do
|
|
18
19
|
# Every field may be left out (nil).
|
|
19
20
|
def initialize(**fields)
|
|
@@ -37,6 +38,7 @@ module Samagotchi
|
|
|
37
38
|
thinking: Thinking.level(value.call(:thinking), where),
|
|
38
39
|
reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
|
|
39
40
|
window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
|
|
41
|
+
steer_cut_after: SteerCutAfter.seconds(value.call(:steer_cut_after), where),
|
|
40
42
|
llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
|
|
41
43
|
llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
|
|
42
44
|
llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where))
|
|
@@ -19,7 +19,9 @@ module Samagotchi
|
|
|
19
19
|
# (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
|
|
20
20
|
# the answer text.
|
|
21
21
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
22
|
-
# +
|
|
22
|
+
# +prompt_files+ returns the files whose text the built system prompt
|
|
23
|
+
# holds (SystemPrompt#prompt_files, [] before a build); +model_name+
|
|
24
|
+
# (the session's resolved model ref, live after /model) and
|
|
23
25
|
# +state_dir+ are what ctx.sessions forks with, and +setup+ (the
|
|
24
26
|
# session's own SessionSetup), which a fork copies; +model_key+ is the
|
|
25
27
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
@@ -28,8 +30,8 @@ module Samagotchi
|
|
|
28
30
|
# +frontend+ returns the Engine's interface (:repl, :worker,
|
|
29
31
|
# :non_interactive), which ctx.frontend names for plugins.
|
|
30
32
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
31
|
-
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :
|
|
32
|
-
:frontend, keyword_init: true)
|
|
33
|
+
:ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
|
|
34
|
+
:steer, :stop_turn, :stop_generation, :frontend, keyword_init: true)
|
|
33
35
|
|
|
34
36
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
35
37
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -159,6 +161,17 @@ module Samagotchi
|
|
|
159
161
|
# a plugin can say what its answer is about.
|
|
160
162
|
def messages_partial? = !!@host.messages_partial&.call
|
|
161
163
|
|
|
164
|
+
# The files whose text the session's system prompt holds now, as
|
|
165
|
+
# absolute paths in prompt order (SystemPrompt#prompt_files): the
|
|
166
|
+
# AGENTS.md chain (the work tree top down to the cwd), then the
|
|
167
|
+
# appended files (append_system_prompt:, --append-system-prompt).
|
|
168
|
+
# [] before the first build and after one that drops them (a model
|
|
169
|
+
# or tools change). A Host without the member (a spec's) reads [].
|
|
170
|
+
# @return [Array<String>] frozen
|
|
171
|
+
def prompt_files
|
|
172
|
+
Array(@host.prompt_files&.call || []).freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
162
175
|
# Run +block+ as this plugin's handler for +event+: until it returns,
|
|
163
176
|
# the helpers called on this thread act as the event's own. Nests (a
|
|
164
177
|
# fire inside a handler gets its own event; the outer one is back
|
|
@@ -109,23 +109,96 @@ module Samagotchi
|
|
|
109
109
|
# TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
|
|
110
110
|
# waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
|
|
111
111
|
# done, when kill returns).
|
|
112
|
+
#
|
|
113
|
+
# The groups the group's processes started are stopped with it: a
|
|
114
|
+
# descendant that moved to a group of its own (GNU `timeout N cmd`
|
|
115
|
+
# without --foreground calls setpgid) is out of reach of a signal to
|
|
116
|
+
# +pgid+. They are found (#descendant_groups) before the TERM, while the
|
|
117
|
+
# group's processes still run, and again before the KILL; each is
|
|
118
|
+
# signalled too and must be gone as well. One that already lost its
|
|
119
|
+
# parent (reparented to init: a setsid'd `cmd &` whose shell exited) is
|
|
120
|
+
# not found, and so left running.
|
|
112
121
|
# @param stopped [#call] true once it has stopped (default: the leader
|
|
113
122
|
# is gone)
|
|
114
123
|
# @param wait [#call] (seconds) between two checks (default: a sleep)
|
|
124
|
+
# @param walk [Boolean] also stop the descendants' groups (one ps each
|
|
125
|
+
# time, ~40 ms on macOS with ~850 processes)
|
|
115
126
|
# @param signal_options [Hash] for #signal (leader:, quiet:)
|
|
116
127
|
# @return [Boolean] true when it stopped within the grace (no KILL sent)
|
|
117
128
|
# @raise [ArgumentError] unless #signalable?
|
|
118
129
|
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
|
|
119
|
-
kill_wait: KILL_WAIT_SEC, **signal_options)
|
|
130
|
+
kill_wait: KILL_WAIT_SEC, walk: true, **signal_options)
|
|
120
131
|
check!(pgid)
|
|
132
|
+
# The group's members only: a leader reaped by now may be another process.
|
|
133
|
+
extra = walk ? descendant_groups(pgid) : []
|
|
121
134
|
signal(pgid, "TERM", **signal_options)
|
|
122
|
-
|
|
135
|
+
extra.each { |group| signal(group, "TERM") }
|
|
136
|
+
# +stopped+ is asked until it says true once (it may reap: a second
|
|
137
|
+
# wait on a reaped pid raises ECHILD), then only the extra groups.
|
|
138
|
+
done = false
|
|
139
|
+
all_stopped = -> { (done ||= stopped.call ? true : false) && extra.none? { |group| group_alive?(group) } }
|
|
140
|
+
return true if wait_for(all_stopped, grace, poll, wait)
|
|
123
141
|
|
|
142
|
+
extra |= descendant_groups(pgid) if walk
|
|
124
143
|
signal(pgid, "KILL", **signal_options)
|
|
125
|
-
|
|
144
|
+
extra.each { |group| signal(group, "KILL") }
|
|
145
|
+
wait_for(all_stopped, kill_wait, poll, wait)
|
|
126
146
|
false
|
|
127
147
|
end
|
|
128
148
|
|
|
149
|
+
# Whether any process is still in the group +pgid+.
|
|
150
|
+
def group_alive?(pgid)
|
|
151
|
+
check!(pgid)
|
|
152
|
+
Process.kill(0, -pgid)
|
|
153
|
+
true
|
|
154
|
+
rescue Errno::ESRCH
|
|
155
|
+
false
|
|
156
|
+
rescue Errno::EPERM
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The process groups, other than +pgid+ and chi's own, of the
|
|
161
|
+
# descendants of the processes in the group +pgid+ (and of the process
|
|
162
|
+
# +pgid+ itself with +leader+: a leader chi started that left its
|
|
163
|
+
# group). Read from one `ps -A -o pid=,ppid=,pgid=`; [] when ps fails.
|
|
164
|
+
# Only members of the group are roots, never the bare pid +pgid+ without
|
|
165
|
+
# +leader+: a reaped leader's pid may name some other process by now.
|
|
166
|
+
# @return [Array<Integer>]
|
|
167
|
+
def descendant_groups(pgid, leader: false)
|
|
168
|
+
rows = process_table
|
|
169
|
+
children = rows.group_by { |_pid, ppid, _group| ppid }
|
|
170
|
+
queue = rows.select { |_pid, _ppid, group| group == pgid }.map(&:first)
|
|
171
|
+
queue << pgid if leader
|
|
172
|
+
seen = {}
|
|
173
|
+
groups = []
|
|
174
|
+
until queue.empty?
|
|
175
|
+
pid = queue.shift
|
|
176
|
+
next if seen[pid]
|
|
177
|
+
|
|
178
|
+
seen[pid] = true
|
|
179
|
+
Array(children[pid]).each do |child, _ppid, group|
|
|
180
|
+
groups << group unless group == pgid
|
|
181
|
+
queue << child
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
own = Process.getpgrp
|
|
185
|
+
groups.uniq.select { |group| signalable?(group) && group != own }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Every process as [pid, ppid, pgid].
|
|
189
|
+
# @return [Array<Array(Integer, Integer, Integer)>]
|
|
190
|
+
def process_table
|
|
191
|
+
out, status = Open3.capture2("ps", "-A", "-o", "pid=,ppid=,pgid=", err: File::NULL)
|
|
192
|
+
return [] unless status.success?
|
|
193
|
+
|
|
194
|
+
out.lines.filter_map do |line|
|
|
195
|
+
fields = line.split.map { |field| Integer(field, exception: false) }
|
|
196
|
+
fields if fields.size == 3 && fields.all?
|
|
197
|
+
end
|
|
198
|
+
rescue SystemCallError, IOError
|
|
199
|
+
[]
|
|
200
|
+
end
|
|
201
|
+
|
|
129
202
|
# Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
|
|
130
203
|
# @return [Boolean] whether it stopped
|
|
131
204
|
def wait_for(stopped, seconds, poll, wait)
|