samagotchi 0.153.0 → 0.154.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 +28 -1
- data/README.md +2 -3
- data/bin/chi +23 -24
- data/docs/architecture.md +30 -33
- data/docs/broadcast.md +5 -5
- data/docs/bundles.md +9 -12
- data/docs/cli.md +80 -96
- data/docs/configuration.md +27 -26
- data/docs/context.md +3 -3
- data/docs/desktop.md +1 -1
- data/docs/guardrails.md +8 -10
- data/docs/hooks.md +9 -9
- data/docs/internals/attached-context.md +2 -2
- data/docs/internals/background-tasks.md +3 -4
- data/docs/internals/block-renderers.md +4 -0
- data/docs/internals/bridge.md +3 -3
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +33 -7
- data/docs/internals/objectives.md +6 -5
- data/docs/internals/participants.md +7 -7
- data/docs/internals/prompt-caching.md +1 -1
- data/docs/internals/remote-sessions.md +7 -7
- data/docs/internals/web-push.md +1 -1
- data/docs/memory.md +2 -2
- data/docs/plugins.md +23 -28
- data/docs/sessions.md +24 -24
- data/docs/sub-agent.md +2 -2
- data/docs/system-one.md +1 -1
- data/docs/testing.md +16 -6
- data/lib/samagotchi/answer_text.rb +34 -0
- data/lib/samagotchi/bridge.rb +43 -32
- data/lib/samagotchi/bridge_client.rb +10 -9
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/broadcast/triage.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +5 -5
- data/lib/samagotchi/bundle_command/try/command.rb +1 -1
- data/lib/samagotchi/bundle_command/try/run.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
- data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/child_move.rb +3 -3
- data/lib/samagotchi/commands/registry.rb +6 -6
- data/lib/samagotchi/composer_route.rb +4 -5
- data/lib/samagotchi/config.rb +14 -12
- data/lib/samagotchi/context_candidate_choice.rb +1 -1
- data/lib/samagotchi/context_candidates.rb +4 -3
- data/lib/samagotchi/continue_offer.rb +1 -1
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/degrade.rb +75 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
- data/lib/samagotchi/engine/cards.rb +87 -0
- data/lib/samagotchi/engine/context_judge.rb +138 -0
- data/lib/samagotchi/engine/events.rb +131 -0
- data/lib/samagotchi/engine/guardrails.rb +87 -0
- data/lib/samagotchi/engine/hooks.rb +119 -0
- data/lib/samagotchi/engine/messages.rb +124 -0
- data/lib/samagotchi/engine/model_settings.rb +127 -0
- data/lib/samagotchi/engine/models.rb +261 -0
- data/lib/samagotchi/engine/objectives.rb +116 -0
- data/lib/samagotchi/engine/participants.rb +86 -0
- data/lib/samagotchi/engine/plugins.rb +208 -0
- data/lib/samagotchi/engine/questions.rb +98 -0
- data/lib/samagotchi/engine/recap.rb +130 -0
- data/lib/samagotchi/engine/session.rb +131 -0
- data/lib/samagotchi/engine/side_requests.rb +135 -0
- data/lib/samagotchi/engine/steering.rb +134 -0
- data/lib/samagotchi/engine/toolkit.rb +269 -0
- data/lib/samagotchi/engine/turn_end.rb +180 -0
- data/lib/samagotchi/engine/turn_helpers.rb +160 -0
- data/lib/samagotchi/engine/verify.rb +153 -0
- data/lib/samagotchi/engine.rb +62 -2646
- data/lib/samagotchi/grants.rb +575 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -2
- data/lib/samagotchi/guardrails/context.rb +2 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
- data/lib/samagotchi/hook_routes.rb +3 -3
- data/lib/samagotchi/idle_recap.rb +4 -5
- data/lib/samagotchi/idle_reminders.rb +3 -4
- data/lib/samagotchi/iteration_limit.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +10 -11
- data/lib/samagotchi/launch_mode.rb +13 -32
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/llm_context_command.rb +3 -2
- data/lib/samagotchi/log.rb +7 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_path.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +18 -8
- data/lib/samagotchi/objectives.rb +24 -3
- data/lib/samagotchi/objectives_check.rb +8 -27
- data/lib/samagotchi/objectives_command.rb +4 -69
- data/lib/samagotchi/objectives_edit.rb +87 -0
- data/lib/samagotchi/output_formatter.rb +7 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/parent_answers_command.rb +5 -4
- data/lib/samagotchi/participant_exposure.rb +42 -0
- data/lib/samagotchi/participant_mode.rb +1 -1
- data/lib/samagotchi/participant_name.rb +1 -1
- data/lib/samagotchi/participants.rb +17 -17
- data/lib/samagotchi/participants_watch.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/context.rb +21 -7
- data/lib/samagotchi/plugin/peer.rb +2 -1
- data/lib/samagotchi/plugin/sessions.rb +6 -4
- data/lib/samagotchi/plugin_tasks.rb +1 -2
- data/lib/samagotchi/private_files.rb +29 -0
- data/lib/samagotchi/project_env.rb +2 -2
- data/lib/samagotchi/question_desk.rb +8 -7
- data/lib/samagotchi/recap_command.rb +2 -1
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/recap_store.rb +1 -1
- data/lib/samagotchi/redact.rb +86 -0
- data/lib/samagotchi/reminder_queue.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +2 -2
- data/lib/samagotchi/review_start.rb +1 -1
- data/lib/samagotchi/run_options.rb +6 -6
- data/lib/samagotchi/self_report.rb +42 -39
- data/lib/samagotchi/send_command/busy.rb +1 -1
- data/lib/samagotchi/send_command.rb +4 -1
- data/lib/samagotchi/session.rb +11 -2
- data/lib/samagotchi/session_access/contract.rb +3 -3
- data/lib/samagotchi/session_access/local.rb +9 -9
- data/lib/samagotchi/session_access/remote.rb +17 -7
- data/lib/samagotchi/session_access.rb +4 -4
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +8 -6
- data/lib/samagotchi/session_commands.rb +45 -48
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +4 -4
- data/lib/samagotchi/session_manager.rb +59 -36
- data/lib/samagotchi/session_metrics.rb +2 -2
- data/lib/samagotchi/session_model_settings.rb +3 -2
- data/lib/samagotchi/session_participants.rb +1 -1
- data/lib/samagotchi/session_retention.rb +3 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +10 -10
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/system_prompt.rb +102 -109
- data/lib/samagotchi/task_watch.rb +1 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
- data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
- data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
- data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
- data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
- data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
- data/lib/samagotchi/terminal_ui.rb +142 -1387
- data/lib/samagotchi/title_command.rb +2 -2
- data/lib/samagotchi/tool_runner.rb +14 -16
- data/lib/samagotchi/toolkit_command.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +3 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/objectives_tool.rb +2 -2
- data/lib/samagotchi/tools/send_note.rb +2 -1
- data/lib/samagotchi/turn_endings.rb +2 -2
- data/lib/samagotchi/turn_flow.rb +4 -5
- data/lib/samagotchi/update_hint.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +39 -50
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_review.rb +5 -5
- data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
- data/lib/samagotchi/web/participant_view.rb +2 -82
- data/lib/samagotchi/web/participants_admin.rb +1 -1
- data/lib/samagotchi/web/public/block_host.js +35 -8
- data/lib/samagotchi/web/public/block_renderers.css +9 -5
- data/lib/samagotchi/web/public/chains.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +3 -3
- data/lib/samagotchi/web/push/store.rb +1 -1
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +12 -8
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +2 -2
- data/lib/samagotchi/web/session_show.rb +2 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +100 -9
- data/lib/samagotchi/worker_link.rb +3 -3
- data/lib/samagotchi/working_set_command.rb +3 -2
- metadata +29 -6
- data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
- data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
- data/lib/samagotchi/web/grants.rb +0 -575
- data/lib/samagotchi/web/participant_exposure.rb +0 -44
- data/lib/samagotchi/web/private_files.rb +0 -31
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# Ask-user-question: the model's question (its :question_asked hooks),
|
|
6
|
+
# and opening, posting, answering and cancelling questions
|
|
7
|
+
# (QuestionDesk, forwarded). Reads @question_desk, @hooks, @kernel,
|
|
8
|
+
# @session.
|
|
9
|
+
module QuestionsPart
|
|
10
|
+
# ── Ask-user-question (structured qualification) ──────────────────────────
|
|
11
|
+
|
|
12
|
+
# @return [PendingQuestion, nil] the question pending now
|
|
13
|
+
def pending_question
|
|
14
|
+
@question_desk.pending
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Ask the model's question (ask_user_question): the kernel's
|
|
18
|
+
# question_handler, called on the turn thread with the payload
|
|
19
|
+
# Tools::AskUserQuestion.validate made (QuestionDesk#request), after
|
|
20
|
+
# the :question_asked hooks (QuestionAsked) gave the step its refs; a
|
|
21
|
+
# --non-interactive run has no one to ask, so they don't fire there.
|
|
22
|
+
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
23
|
+
# @return [String] normalized answer JSON
|
|
24
|
+
def request_question(payload)
|
|
25
|
+
payload = question_asked(QuestionAsked::KIND_QUESTION, payload) unless interface == :non_interactive
|
|
26
|
+
@question_desk.request(payload)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The :question_asked hooks for a question of the model's about to
|
|
30
|
+
# open: the fields with the asking step's refs (QuestionAsked.fire).
|
|
31
|
+
# @return [Hash]
|
|
32
|
+
def question_asked(kind, fields)
|
|
33
|
+
QuestionAsked.fire(hooks: @hooks, conversation: live_conversation, kind: kind, fields: fields,
|
|
34
|
+
cwd: @session&.working_directory, synchronize: ->(&block) { synchronize_events(&block) },
|
|
35
|
+
visible: ->(content) { question_step_text(content) })
|
|
36
|
+
end
|
|
37
|
+
private :question_asked
|
|
38
|
+
|
|
39
|
+
# A step's text as the asking slot shows it: the native loop's raw
|
|
40
|
+
# response without its thoughts and tool-call bodies (a chat step has
|
|
41
|
+
# neither), then the markup's tokens (OutputFormatter.strip_markup).
|
|
42
|
+
def question_step_text(content)
|
|
43
|
+
parser = @kernel.parser
|
|
44
|
+
OutputFormatter.strip_markup(parser.strip_tool_calls(parser.strip_thought(content)))
|
|
45
|
+
end
|
|
46
|
+
private :question_step_text
|
|
47
|
+
|
|
48
|
+
# Open a question for the UIs and wait for its answer
|
|
49
|
+
# (QuestionDesk#open_question: BLOCKS until answered or cancelled).
|
|
50
|
+
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
51
|
+
# (as a Hash or as keywords)
|
|
52
|
+
# @param watch [#call, nil] closes the question with the reason it returns
|
|
53
|
+
# @return [QuestionAnswer, Hash, String] the answer, or {error:, …}, or a
|
|
54
|
+
# sync handler's text
|
|
55
|
+
def open_question(fields = nil, watch: nil, **more)
|
|
56
|
+
@question_desk.open_question({ **fields.to_h, **more }, watch: watch)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Publish a standing question that blocks nobody (QuestionDesk#post: a
|
|
60
|
+
# worker's step-limit question).
|
|
61
|
+
# @return [PendingQuestion, nil] the pending question, nil while another is open
|
|
62
|
+
def post_question(fields, on_answer:, on_superseded_close: nil)
|
|
63
|
+
@question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Withdraw the standing question (QuestionDesk#withdraw).
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def withdraw_question(reason, id: nil)
|
|
69
|
+
@question_desk.withdraw(reason, id: id)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
73
|
+
# @return [QuestionAnswer]
|
|
74
|
+
def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
75
|
+
@question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
|
|
76
|
+
parent_agent: parent_agent)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Mark the pending question as relayed, or clear the mark
|
|
80
|
+
# (QuestionDesk#annotate).
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
def annotate_question(id, relayed_to:, reason: nil)
|
|
83
|
+
@question_desk.annotate(id, relayed_to: relayed_to, reason: reason)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def set_question_sync_handler(&block)
|
|
87
|
+
@question_desk.sync_handler = block
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Cancel the pending question (QuestionDesk#cancel).
|
|
91
|
+
# @return [Boolean] whether it was cancelled
|
|
92
|
+
# @raise [QuestionDesk::NotDismissable] +id+ names a standing question
|
|
93
|
+
def cancel_question(reason = "user", id: nil)
|
|
94
|
+
@question_desk.cancel(reason, id: id)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# The idle recap (IdleRecap) and the shared idle scheduler: the recap job
|
|
6
|
+
# rebuilt under the bound session's own recap values, /recap's override,
|
|
7
|
+
# the saved recap. Writes @recap, @recap_awaiting_continue; reads
|
|
8
|
+
# @recap_built_for, @idle_scheduler, @session.
|
|
9
|
+
module RecapPart
|
|
10
|
+
# Snapshot the current session messages as a JSON string for the idle
|
|
11
|
+
# recap. The array is never mutated in place (a turn or a note replaces
|
|
12
|
+
# it), so the reference read here is a consistent snapshot; a dup'd
|
|
13
|
+
# copy is serialized. Never mutates session.messages.
|
|
14
|
+
# @return [String] JSON array of the messages
|
|
15
|
+
def messages_json_for_recap
|
|
16
|
+
snapshot = @session&.messages
|
|
17
|
+
return "[]" if snapshot.nil?
|
|
18
|
+
|
|
19
|
+
JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Emit a :recap_ready event (additive slot) carrying the generated recap
|
|
23
|
+
# and the generation id an observer uses to reject an invalidated recap.
|
|
24
|
+
# +covered+ counts the session messages it summarizes; +created_at+ is
|
|
25
|
+
# the saved recap's (UTC ISO; the UIs show its local time).
|
|
26
|
+
def emit_recap(recap:, generation:, covered: nil, created_at: nil)
|
|
27
|
+
event = { type: :recap_ready, recap: recap, generation: generation, covered: covered }
|
|
28
|
+
event[:created_at] = created_at if created_at
|
|
29
|
+
@session_observer.notify(event)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
33
|
+
attr_reader :recap
|
|
34
|
+
|
|
35
|
+
# Whether a continue offer waits (the Worker's or the one-shot's TurnFlow),
|
|
36
|
+
# for the recap job: this one and any rebuilt one (IdleRecap#awaiting_continue=).
|
|
37
|
+
def recap_awaiting_continue=(callable)
|
|
38
|
+
@recap_awaiting_continue = callable
|
|
39
|
+
@recap&.awaiting_continue = callable
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The bound session's own recap.* values ({} for none).
|
|
43
|
+
def session_recap_values
|
|
44
|
+
(@session&.settings&.core || {}).select { |key, _| key.start_with?("recap.") }
|
|
45
|
+
end
|
|
46
|
+
private :session_recap_values
|
|
47
|
+
|
|
48
|
+
# A new recap job when the bound session's own recap values differ from
|
|
49
|
+
# the ones the current job was built under (none at start): the old one
|
|
50
|
+
# is dropped (an attempt in flight goes stale) and the scheduler swaps
|
|
51
|
+
# it, keeping its one thread. Binding the same session again changes
|
|
52
|
+
# nothing.
|
|
53
|
+
def rebuild_recap!
|
|
54
|
+
return if session_recap_values == @recap_built_for
|
|
55
|
+
|
|
56
|
+
old = @recap
|
|
57
|
+
old&.invalidate!
|
|
58
|
+
@recap = build_recap
|
|
59
|
+
@idle_scheduler&.replace_job(old, @recap)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# /recap on|off|default: the bound session's own recap.enabled (true,
|
|
63
|
+
# false; nil removes it, so config.yml's applies again), and the recap
|
|
64
|
+
# job rebuilt under it at once. It doesn't save: the command says
|
|
65
|
+
# changed: [:recap] and its host saves (Worker#run_command).
|
|
66
|
+
# @param enabled [Boolean, nil]
|
|
67
|
+
# @raise [ArgumentError] no session is bound
|
|
68
|
+
def recap_override=(enabled)
|
|
69
|
+
raise ArgumentError, "no session" unless @session
|
|
70
|
+
|
|
71
|
+
core = @session.settings.core.dup
|
|
72
|
+
enabled.nil? ? core.delete(RECAP_ENABLED_KEY) : core[RECAP_ENABLED_KEY] = enabled
|
|
73
|
+
@session.settings = @session.settings.with(core: core)
|
|
74
|
+
rebuild_recap!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The bound session's own recap.enabled (/recap on|off, --set), or nil.
|
|
78
|
+
# @return [Boolean, nil]
|
|
79
|
+
def recap_override = @session&.settings&.core&.fetch(RECAP_ENABLED_KEY, nil)
|
|
80
|
+
|
|
81
|
+
# Write the recap now, before the session is left (IdleRecap#write_now).
|
|
82
|
+
# @return [String, nil] the recap written, nil when none was (recap off,
|
|
83
|
+
# nothing new, an error, the timeout)
|
|
84
|
+
def write_recap_now(on_start: nil)
|
|
85
|
+
@recap&.write_now(on_start: on_start)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The longest #write_recap_now waits (recap.timeout), nil when no recap
|
|
89
|
+
# is set up.
|
|
90
|
+
# @return [Float, nil]
|
|
91
|
+
def recap_timeout = @recap&.timeout
|
|
92
|
+
|
|
93
|
+
# Ask for a recap now (/recap), without waiting for the idle window.
|
|
94
|
+
# @return [Symbol] IdleRecap#request_now's answer, or :off
|
|
95
|
+
def request_recap
|
|
96
|
+
@recap ? @recap.request_now : :off
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The recap saved with the current session (recap.json), and how many
|
|
100
|
+
# user turns came after it (0: it is current).
|
|
101
|
+
# @return [Hash, nil] {text:, covered:, turns_since:, created_at:}
|
|
102
|
+
def saved_recap
|
|
103
|
+
state = @recap&.state
|
|
104
|
+
return nil unless state
|
|
105
|
+
|
|
106
|
+
messages = @session&.messages || []
|
|
107
|
+
covered = state[:covered].to_i
|
|
108
|
+
turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
|
|
109
|
+
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Start the shared idle scheduler (reminders + optional recap). The recap
|
|
113
|
+
# job is only registered when recap is configured; the reminders job is
|
|
114
|
+
# always present. SessionManager workers call it so reminders can
|
|
115
|
+
# trigger turns; a one-shot never starts it and polls nothing.
|
|
116
|
+
def start_idle
|
|
117
|
+
@idle_scheduler&.start
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Stop the shared idle scheduler (a worker as it leaves). +join+: wait for a tick under way (IdleScheduler#stop); a
|
|
122
|
+
# caller holding #synchronize_events passes false, since a recap's emit
|
|
123
|
+
# in that tick waits for the same lock.
|
|
124
|
+
def stop_idle(join: true)
|
|
125
|
+
@idle_scheduler&.stop(join: join)
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# The bound session and its prompt: #session= (its recap, metrics and
|
|
6
|
+
# memories), its state dir, the relay's view, and the system prompt with
|
|
7
|
+
# the memory names it holds. Writes @session, @session_state_dir,
|
|
8
|
+
# @running_turn_messages; reads @prompt_builder, @metrics, @used_memories,
|
|
9
|
+
# @question_desk, @state_dir.
|
|
10
|
+
module SessionPart
|
|
11
|
+
# @return [Array<String>] deduped used memory names (thread-safe copy)
|
|
12
|
+
def used_memory_names
|
|
13
|
+
@used_memories.names
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<String>] the memories hidden from this session (normalized names)
|
|
17
|
+
def muted_memory_names
|
|
18
|
+
@muted_memory_names.dup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Array<String>] the names the session preloads (config baseline
|
|
22
|
+
# + --memory, minus mutes), known before the prompt is built, unlike
|
|
23
|
+
# #activated_memory_names
|
|
24
|
+
def preloaded_memory_names
|
|
25
|
+
@prompt_builder.preloaded_memory_names
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Where sessions live (a session's images/ are under it); a worker sets
|
|
29
|
+
# its own.
|
|
30
|
+
attr_writer :session_state_dir
|
|
31
|
+
|
|
32
|
+
# The metrics load the session's saved records from its state dir, once.
|
|
33
|
+
def bind_metrics(session)
|
|
34
|
+
return unless session&.id
|
|
35
|
+
|
|
36
|
+
@metrics.state_dir = session_state_dir
|
|
37
|
+
@metrics.session_id = session.id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
41
|
+
|
|
42
|
+
# The running turn's metrics so far, saved as a turn a worker died in
|
|
43
|
+
# would leave them (SessionMetrics#persist's running_turn): a worker's
|
|
44
|
+
# turn checkpoint (TurnCheckpoint) calls it when it writes.
|
|
45
|
+
# @return [Boolean] whether they were saved
|
|
46
|
+
def checkpoint_metrics = @metrics.persist(state_dir: session_state_dir, running_turn: true)
|
|
47
|
+
|
|
48
|
+
# What a Bridge adds to a plugin's ctx.messages while a turn runs (the
|
|
49
|
+
# turn so far, as messages); nil without a Bridge (a one-shot).
|
|
50
|
+
attr_writer :running_turn_messages
|
|
51
|
+
|
|
52
|
+
# The system prompt for a target's loop (default: the effective model's):
|
|
53
|
+
# the chat loop's leaves out the raw-prompt tool text, since its tools go
|
|
54
|
+
# as schemas. Built once per loop (and again after a model switch) so the
|
|
55
|
+
# prompt prefix, and the server's KV cache for it, stay stable.
|
|
56
|
+
# @param model [EffectiveModel, nil]
|
|
57
|
+
# @return [String]
|
|
58
|
+
# The native prompt depends on the thinking level too (Gemma's token):
|
|
59
|
+
# a level change builds it again. A build that
|
|
60
|
+
# misses the cache names the model and the level through its own
|
|
61
|
+
# lambdas (#model_identity, #turn_thinking), which resolve again.
|
|
62
|
+
def system_prompt(model = nil)
|
|
63
|
+
model ||= effective_model
|
|
64
|
+
chat = model.chat?
|
|
65
|
+
level = chat ? nil : @model_settings.thinking_level(model)
|
|
66
|
+
prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(@model_settings.llm_context_session_args))
|
|
67
|
+
# The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
|
|
68
|
+
@metrics.memory_index = @prompt_builder.memory_index
|
|
69
|
+
# The model notes it carries: the session records them (/stats, /model, the web).
|
|
70
|
+
record_prompt_notes(@prompt_builder.prompt_notes)
|
|
71
|
+
prompt
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The base prompt (specs, plugins' declarations).
|
|
75
|
+
def assist_system_prompt(chat: false)
|
|
76
|
+
@prompt_builder.base(chat: chat)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The --memory names activated while building the prompt; the TerminalUI
|
|
80
|
+
# mirrors them into its status line.
|
|
81
|
+
def activated_memory_names
|
|
82
|
+
@prompt_builder.activated_memory_names
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Session] current session (Engine owns create/resume)
|
|
86
|
+
attr_reader :session
|
|
87
|
+
|
|
88
|
+
# This worker's relays of its delegates' approvals (memory only).
|
|
89
|
+
# @return [RelayDesk]
|
|
90
|
+
attr_reader :relay_desk
|
|
91
|
+
|
|
92
|
+
# The relay's view of this Engine, or nil when it can't host a relay: a
|
|
93
|
+
# worker's Engine only (its Bridge is what a child verifies against;
|
|
94
|
+
# --non-interactive has no one to ask), never with a sync handler (no
|
|
95
|
+
# wait loop, no watch).
|
|
96
|
+
# @return [RelayPeer, nil]
|
|
97
|
+
def relay_peer
|
|
98
|
+
return nil unless interface == :worker && !@question_desk.sync_handler?
|
|
99
|
+
|
|
100
|
+
RelayPeer.new(self)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [String] the state dir holding this Engine's sessions
|
|
104
|
+
def peer_state_dir = @state_dir
|
|
105
|
+
|
|
106
|
+
# Set the current session outside a turn (the worker and the one-shot
|
|
107
|
+
# do, before the first turn, so the messages API and recap see it)
|
|
108
|
+
def session=(session)
|
|
109
|
+
@session = session
|
|
110
|
+
# One session per worker or one-shot process: its records carry this sid.
|
|
111
|
+
Log.session_id = session.id if session&.id
|
|
112
|
+
# Its own recap values (chi --set recap.enabled=false) rebuild the job.
|
|
113
|
+
rebuild_recap!
|
|
114
|
+
# A woken worker's /stats and status line count the turns before it.
|
|
115
|
+
bind_metrics(session)
|
|
116
|
+
@used_memories.absorb(session)
|
|
117
|
+
# Loaded on another model than its last turn ran on (a --resume
|
|
118
|
+
# one-shot's --model, a worker woken after a /model with no turn since):
|
|
119
|
+
# the saved ctx % was that model's window's. Drop it and count it as
|
|
120
|
+
# /model does, on a configured window only: this runs before a
|
|
121
|
+
# worker's Bridge serves, so no /props GET or model list here (a slow
|
|
122
|
+
# or hung host would hold up the worker's start). No configured
|
|
123
|
+
# window: no % (ctx ?) until the first generation reports one.
|
|
124
|
+
return unless model_switched_since_last_turn?
|
|
125
|
+
|
|
126
|
+
@metrics.forget_model_reports!
|
|
127
|
+
announce_switched_context!(configured_only: true)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# Side requests: a plugin's ctx.ask_model and Check this on their own
|
|
6
|
+
# IdleClient, their usage in the session's side line, and System 1's
|
|
7
|
+
# recording of judged asks. Writes @side_client/@side_client_key (under
|
|
8
|
+
# @side_client_mutex); reads @metrics, @session, @host_registry.
|
|
9
|
+
module SideRequestsPart
|
|
10
|
+
# Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
|
|
11
|
+
# on_usage) in the session's side line, apart from its turns' totals.
|
|
12
|
+
# Folded and announced as :side_usage under the event lock, so the side
|
|
13
|
+
# blocks reach the UIs in fold order (the lock order event lock →
|
|
14
|
+
# metrics mutex is the existing one); then, between turns, saved to
|
|
15
|
+
# analytics.json (a running turn's end or checkpoint saves it), unless
|
|
16
|
+
# the session is gone (no session file: a late recap thread must not
|
|
17
|
+
# recreate a deleted session's folder; the folder itself may not exist
|
|
18
|
+
# yet before a first turn). Safe from any thread: the recap's, a
|
|
19
|
+
# plugin's, the turn's.
|
|
20
|
+
# A side request counts against the participant who caused it
|
|
21
|
+
# (#side_author), its usage's own author when it names one.
|
|
22
|
+
# @param usage [SideUsage]
|
|
23
|
+
# @return [void]
|
|
24
|
+
def note_side_usage(usage)
|
|
25
|
+
usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
|
|
26
|
+
synchronize_events do
|
|
27
|
+
block = @metrics.add_side(usage)
|
|
28
|
+
@session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
|
|
29
|
+
end
|
|
30
|
+
sid = @metrics.session_id
|
|
31
|
+
return if sid.nil? || turn_running?
|
|
32
|
+
return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
|
|
33
|
+
|
|
34
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Who caused a side request of +kind+ now: the running turn's author (a
|
|
38
|
+
# plugin's ctx.ask_model in it), the last turn's for the idle recap
|
|
39
|
+
# (it recaps that turn); nil, the owner's, for anything else (an owner
|
|
40
|
+
# command's ask_model between turns).
|
|
41
|
+
# @return [String, nil]
|
|
42
|
+
def side_author(kind)
|
|
43
|
+
# Check this is only ever the owner's, whichever turn runs as it ends.
|
|
44
|
+
return nil if kind.to_s == ObjectivesCheck::SOURCE
|
|
45
|
+
return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
|
|
46
|
+
|
|
47
|
+
@guardrail_wiring.turn_author
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# +client+ (nil for none) saving its asks as +judgment+'s runs. Anything
|
|
53
|
+
# going wrong here leaves the client as it was: a run is never worth a
|
|
54
|
+
# judgment.
|
|
55
|
+
#
|
|
56
|
+
# A scratch session's asks aren't saved; a participant's turn is never
|
|
57
|
+
# judged, so it never comes here.
|
|
58
|
+
# @param mode [String, nil] the use's mode, for its runs (Recording.wrap)
|
|
59
|
+
def system_one_recording(client, judgment, session, mode: nil)
|
|
60
|
+
return client if client.nil? || session&.scratch
|
|
61
|
+
|
|
62
|
+
context = SystemOne::Recording::Context.of(session, session_model: session && effective_model_name)
|
|
63
|
+
SystemOne::Recording.wrap(client, judgment: judgment, runs: system_one_runs, context: context, recorded_mode: mode)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
Log.warn(:system_one, "system_one_recording_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
66
|
+
client
|
|
67
|
+
end
|
|
68
|
+
private :system_one_recording
|
|
69
|
+
|
|
70
|
+
# Where this Engine's runs are saved; read each time, as the settings are.
|
|
71
|
+
def system_one_runs = SystemOne::Runs.for_sessions_dir(session_state_dir)
|
|
72
|
+
private :system_one_runs
|
|
73
|
+
|
|
74
|
+
# ctx.ask_model's request: the session's current model on its host, as
|
|
75
|
+
# a turn resolves them (a /model switch counts), or a configured +model+
|
|
76
|
+
# (a host:id ref, an alias, a bare id one of a host's models), through its own
|
|
77
|
+
# IdleClient, so it shares nothing with the turn's backend. The client
|
|
78
|
+
# is kept while the target and timeout stay (what it learned, e.g. a
|
|
79
|
+
# refused thinking-off, holds for the next request); the session's id
|
|
80
|
+
# goes with the request. Its usage counts in the session's side line
|
|
81
|
+
# (#note_side_usage) under +source+, the asking bundle's name.
|
|
82
|
+
# @return [String] the answer
|
|
83
|
+
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
|
|
84
|
+
target = model.nil? ? session_model_recap_target : configured_side_target(model)
|
|
85
|
+
client = side_model_client(target, timeout)
|
|
86
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
|
|
88
|
+
kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
|
|
89
|
+
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
90
|
+
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
91
|
+
answer.text
|
|
92
|
+
end
|
|
93
|
+
private :ask_side_model
|
|
94
|
+
|
|
95
|
+
# The target of ctx.ask_model(model:): +model+ as a turn resolves it,
|
|
96
|
+
# once it is known to name something configured. IdleTarget.of_model
|
|
97
|
+
# never raises (an unknown host falls to the default host, an unknown id
|
|
98
|
+
# to the default entry), so a typo would silently ask another model.
|
|
99
|
+
# @raise [ArgumentError] +model+ names no configured host and is no
|
|
100
|
+
# host's model (cached, declared or saved lists; nothing is asked)
|
|
101
|
+
def configured_side_target(model)
|
|
102
|
+
ref = @host_registry.model_ref(model)
|
|
103
|
+
unless ref.host_name || ref.alias_name || @host_registry.hosts_with_model(ref.id).any?
|
|
104
|
+
raise ArgumentError, "ask_model: unknown model #{model.to_s.inspect} (use a configured host:id, an alias, " \
|
|
105
|
+
"or a model id one of your hosts lists)"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
IdleTarget.of_model(@host_registry, model)
|
|
109
|
+
end
|
|
110
|
+
private :configured_side_target
|
|
111
|
+
|
|
112
|
+
# The IdleClient for +target+ at +timeout+: the last one when both are
|
|
113
|
+
# the same, else a new one.
|
|
114
|
+
def side_model_client(target, timeout)
|
|
115
|
+
@side_client_mutex.synchronize do
|
|
116
|
+
key = [target, timeout]
|
|
117
|
+
unless @side_client && @side_client_key == key
|
|
118
|
+
@side_client = IdleClient.for(target, timeout: timeout)
|
|
119
|
+
@side_client_key = key
|
|
120
|
+
end
|
|
121
|
+
@side_client
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
private :side_model_client
|
|
125
|
+
|
|
126
|
+
# The session's current model as a recap target: its host's OpenAI API
|
|
127
|
+
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
128
|
+
# and bare model name, as a turn resolves them.
|
|
129
|
+
# @return [IdleTarget]
|
|
130
|
+
def session_model_recap_target
|
|
131
|
+
IdleTarget.of_model(@host_registry, effective_model_name)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class Engine
|
|
5
|
+
# The running turn as other threads see it: steering into it (now or
|
|
6
|
+
# at the next turn), the drains, the cut, the activity clock and the
|
|
7
|
+
# cancel. Reads @turn_state and @steer_cut; writes nothing of its own.
|
|
8
|
+
module SteeringPart
|
|
9
|
+
# Record that activity happened (user input or a completed turn): the
|
|
10
|
+
# idle jobs' seam (TurnState#record_activity). Each call advances both
|
|
11
|
+
# the last-activity timestamp and the activity sequence.
|
|
12
|
+
# @param now [Float, nil] injectable monotonic time (defaults to now)
|
|
13
|
+
def record_activity(now = nil)
|
|
14
|
+
@turn_state.record_activity(now)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Float] monotonic seconds of the last recorded activity
|
|
18
|
+
def last_activity_at
|
|
19
|
+
@turn_state.last_activity_at
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Integer] monotonically-increasing activity counter (advanced by
|
|
23
|
+
# #record_activity; lets the idle detector summarize once per idle window)
|
|
24
|
+
def activity_seq
|
|
25
|
+
@turn_state.activity_seq
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] true while a turn is in flight (the idle jobs never
|
|
29
|
+
# fire, nor render, while the model is generating)
|
|
30
|
+
def turn_running?
|
|
31
|
+
@turn_state.running?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Put +text+ into the running turn, as a UI's steering does: it joins the
|
|
35
|
+
# conversation at the loop's next iteration boundary as its own user
|
|
36
|
+
# message (kind: "steer", source:). Callable from any thread; never
|
|
37
|
+
# blocks. With no turn running it does nothing (it never starts one).
|
|
38
|
+
# True means queued, not merged: at the after-answer boundary, and when
|
|
39
|
+
# the turn ends first, it is dropped (logged).
|
|
40
|
+
# @return [Boolean] whether it was queued
|
|
41
|
+
def steer(text, source:)
|
|
42
|
+
@turn_state.steer(text, source: source)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A message for the running turn from +source+ cuts the generation
|
|
46
|
+
# streaming only thinking (SteerCut#cut_for_steer). Call it after the
|
|
47
|
+
# message is queued, with the #input_epoch read before queueing it.
|
|
48
|
+
# @return [Boolean] whether a generation was cut now
|
|
49
|
+
def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
|
|
50
|
+
|
|
51
|
+
# The drains that took input so far: read it before queueing a message
|
|
52
|
+
# for #cut_for_steer.
|
|
53
|
+
def input_epoch = @steer_cut.input_epoch
|
|
54
|
+
|
|
55
|
+
# The running generation's phase (SteerCut#generation_snapshot): what
|
|
56
|
+
# Bridge#snapshot puts in current_turn.generation.
|
|
57
|
+
def generation_snapshot = @steer_cut.generation_snapshot
|
|
58
|
+
|
|
59
|
+
# Like #steer, for the turn that begins next (a continue turn answered
|
|
60
|
+
# with a text): it joins that turn at its first boundary. Callable from
|
|
61
|
+
# any thread; a turn that never begins keeps it for the next one, unless
|
|
62
|
+
# #drop_carried_steers drops it.
|
|
63
|
+
# @return [Boolean] whether it was queued (false: blank)
|
|
64
|
+
def steer_next_turn(text, source:)
|
|
65
|
+
@turn_state.steer_next_turn(text, source: source)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Drop the #steer_next_turn steers no turn began with (the continue turn
|
|
69
|
+
# failed before it began), logged; a no-op once a turn took them.
|
|
70
|
+
def drop_carried_steers
|
|
71
|
+
log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The drain a turn's loop gets: the caller's lines (a UI's steering; nil
|
|
75
|
+
# in a --non-interactive run) and the plugin steers. At the after-answer
|
|
76
|
+
# boundary the steers are dropped: the model answered, and a nudge would
|
|
77
|
+
# only restart the turn. The steer part fails on its own, never taking
|
|
78
|
+
# the user's lines with it. Items are a String (a user line), a
|
|
79
|
+
# Steer::Line (a worker's input line with its sender) or a steer Hash.
|
|
80
|
+
def turn_drain(pending_input)
|
|
81
|
+
lambda do |at_answer: false|
|
|
82
|
+
lines = pending_input ? Array(pending_input.call) : []
|
|
83
|
+
# The input went to the model: a message that waited to cut is in.
|
|
84
|
+
@steer_cut.delivered! unless lines.empty?
|
|
85
|
+
lines + take_steers(at_answer)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
private :turn_drain
|
|
89
|
+
|
|
90
|
+
def take_steers(drop)
|
|
91
|
+
steers = @turn_state.take_steers
|
|
92
|
+
return steers unless drop
|
|
93
|
+
|
|
94
|
+
log_dropped_steers(steers, "answered")
|
|
95
|
+
[]
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
private :take_steers
|
|
101
|
+
|
|
102
|
+
def log_dropped_steers(steers, why)
|
|
103
|
+
steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
|
|
104
|
+
end
|
|
105
|
+
private :log_dropped_steers
|
|
106
|
+
|
|
107
|
+
# @return [CancellationController, nil] active turn's cancellation controller
|
|
108
|
+
def active_cancel_controller
|
|
109
|
+
@turn_state.controller
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether a message a person sent waits for the running turn's next
|
|
113
|
+
# boundary (run_turn's input_waiting: probe): a task_wait or delegate wait
|
|
114
|
+
# returns on it, an execute hands its command over early
|
|
115
|
+
# (PeerView#input_waiting?). False outside a turn, for a turn given no
|
|
116
|
+
# probe, and when the probe raises (warned once a turn; the tool never
|
|
117
|
+
# fails for it).
|
|
118
|
+
def input_waiting?
|
|
119
|
+
probe = @turn_state.input_waiting
|
|
120
|
+
!!probe&.call
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Cancel the currently running turn, if any.
|
|
127
|
+
# @param reason [Symbol] cancellation reason
|
|
128
|
+
# @return [Boolean] whether a cancellation was triggered
|
|
129
|
+
def cancel_current_turn!(reason = :manual)
|
|
130
|
+
@turn_state.cancel!(reason)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|