samagotchi 0.154.0 → 0.155.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.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +10 -8
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/bridge.md +18 -5
  12. data/docs/internals/engine.md +221 -100
  13. data/docs/internals/inline-widgets.md +136 -0
  14. data/docs/internals/objectives.md +8 -6
  15. data/docs/internals/participants.md +5 -5
  16. data/docs/internals/prompt-caching.md +12 -6
  17. data/docs/internals/web-frontend.md +2 -2
  18. data/docs/internals/working-set.md +2 -1
  19. data/docs/plugins.md +2 -0
  20. data/docs/releasing.md +1 -1
  21. data/docs/sessions.md +3 -3
  22. data/docs/testing.md +16 -1
  23. data/lib/samagotchi/answer_text.rb +1 -2
  24. data/lib/samagotchi/block_renderers.rb +5 -0
  25. data/lib/samagotchi/bridge/card_places.rb +56 -7
  26. data/lib/samagotchi/bridge/card_store.rb +24 -9
  27. data/lib/samagotchi/bridge.rb +9 -0
  28. data/lib/samagotchi/bridge_client.rb +9 -5
  29. data/lib/samagotchi/bundle_command/check.rb +1 -1
  30. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  33. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  35. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  36. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  38. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  39. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  40. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  41. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  42. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  43. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +19 -125
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/objectives.rb +22 -90
  48. data/lib/samagotchi/engine/participants.rb +15 -10
  49. data/lib/samagotchi/engine/plugins.rb +3 -1
  50. data/lib/samagotchi/engine/questions.rb +1 -1
  51. data/lib/samagotchi/engine/side_requests.rb +5 -2
  52. data/lib/samagotchi/engine/toolkit.rb +33 -190
  53. data/lib/samagotchi/engine/turn_end.rb +1 -1
  54. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  55. data/lib/samagotchi/engine/verify.rb +7 -143
  56. data/lib/samagotchi/engine.rb +162 -99
  57. data/lib/samagotchi/engine_peers.rb +1 -1
  58. data/lib/samagotchi/engine_snapshots.rb +9 -8
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +6 -4
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/idle_recap.rb +3 -1
  67. data/lib/samagotchi/inline_widgets.rb +325 -0
  68. data/lib/samagotchi/kernel_loop.rb +194 -216
  69. data/lib/samagotchi/llm/backend.rb +3 -1
  70. data/lib/samagotchi/llm/chat_loop.rb +66 -42
  71. data/lib/samagotchi/llm/native_backend.rb +3 -2
  72. data/lib/samagotchi/llm_context_forget.rb +5 -3
  73. data/lib/samagotchi/model_binding.rb +3 -3
  74. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  75. data/lib/samagotchi/question_desk.rb +1 -1
  76. data/lib/samagotchi/revise_anchor.rb +1 -1
  77. data/lib/samagotchi/session_manager.rb +8 -13
  78. data/lib/samagotchi/session_retention.rb +24 -4
  79. data/lib/samagotchi/steer.rb +12 -1
  80. data/lib/samagotchi/stream_event.rb +22 -0
  81. data/lib/samagotchi/system_prompt.rb +84 -61
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  83. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  84. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  85. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  86. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  87. data/lib/samagotchi/testing/boundary.rb +7 -0
  88. data/lib/samagotchi/testing/bundle.rb +7 -5
  89. data/lib/samagotchi/testing/install.rb +3 -2
  90. data/lib/samagotchi/tool_activity.rb +8 -6
  91. data/lib/samagotchi/tool_declarations.rb +4 -4
  92. data/lib/samagotchi/tool_response.rb +4 -3
  93. data/lib/samagotchi/tool_runner.rb +93 -101
  94. data/lib/samagotchi/toolkit_binding.rb +40 -14
  95. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  96. data/lib/samagotchi/tools/registry.rb +43 -40
  97. data/lib/samagotchi/tools/tool_index.rb +135 -88
  98. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  99. data/lib/samagotchi/turn_context.rb +67 -0
  100. data/lib/samagotchi/turn_flow.rb +7 -2
  101. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  102. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  103. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  104. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  105. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  106. data/lib/samagotchi/turn_state.rb +46 -4
  107. data/lib/samagotchi/version.rb +1 -1
  108. data/lib/samagotchi/web/app.rb +3 -1
  109. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  110. data/lib/samagotchi/web/message_display.rb +8 -1
  111. data/lib/samagotchi/web/public/app.js +5 -1
  112. data/lib/samagotchi/web/public/block_host.js +74 -199
  113. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  114. data/lib/samagotchi/web/public/block_source.js +7 -4
  115. data/lib/samagotchi/web/public/block_watch.js +256 -0
  116. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  117. data/lib/samagotchi/web/public/cards_place.js +5 -3
  118. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  119. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  120. data/lib/samagotchi/web/public/format.js +3 -2
  121. data/lib/samagotchi/web/public/index.html +28 -9
  122. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  123. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  124. data/lib/samagotchi/web/public/participant.js +1 -1
  125. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  126. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  127. data/lib/samagotchi/web/public/text_roots.js +1 -1
  128. data/lib/samagotchi/web/public/turn_html.js +8 -4
  129. data/lib/samagotchi/web/public/verify_target.js +1 -1
  130. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  131. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  132. data/lib/samagotchi/web/public/widgets.css +70 -0
  133. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  134. data/lib/samagotchi/web/session_show.rb +4 -0
  135. data/lib/samagotchi/worker.rb +8 -5
  136. data/lib/samagotchi.rb +0 -1
  137. metadata +19 -2
  138. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -2,30 +2,15 @@
2
2
 
3
3
  module Samagotchi
4
4
  class Engine
5
- # The session's objectives: their view announced when it changed, Check
6
- # this's side request, the model's proposals kept as cards until the turn
7
- # is over, System 1's audit of them (ObjectivesAudit). Writes
8
- # @objectives_announced, @objectives_audit; reads @objective_proposals
9
- # under @objective_proposals_mutex, @session, @side_judgments.
5
+ # The session's objectives (TurnStages::Objectives, forwarded): the view
6
+ # announced when it changed, the model's proposals kept as cards until
7
+ # the turn is over, System 1's audit of them (ObjectivesAudit); and
8
+ # Check this's side request. Reads @objectives_stage, @side_judgments,
9
+ # @host_registry, @session.
10
10
  module ObjectivesPart
11
- # The session's objectives as the page draws them (Objectives.view), when
12
- # that differs from the last announced: after an edit (its file written,
13
- # pending), once a list note is in, at a turn's end (its moves) and after
14
- # a rollback. A session with no list announces nothing. It only informs:
15
- # a failure is logged, never the turn's.
16
- def announce_objectives(session = @session)
17
- return unless session
18
-
19
- synchronize_events do
20
- view = Objectives.view(session.id, session.messages, state_dir: session_state_dir, running: live_conversation&.dup)
21
- next if view.nil? || view == @objectives_announced
22
-
23
- @objectives_announced = view
24
- announce({ type: :objectives_updated, session_id: session.id, **view })
25
- end
26
- rescue StandardError => e
27
- Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
28
- end
11
+ # The session's objectives as the page draws them, when that differs
12
+ # from the last announced (TurnStages::Objectives#announce).
13
+ def announce_objectives(session = @session) = @objectives_stage.announce(session)
29
14
 
30
15
  # Check this (ObjectivesCheck)'s one side request. Its usage is in the
31
16
  # session's side line. Raises as ctx.ask_model's request does.
@@ -38,79 +23,26 @@ module Samagotchi
38
23
  end
39
24
 
40
25
  # A change the model proposes (the objectives tool), kept until the turn
41
- # is over, as #context_choice's cards are: a card shown then is a quiet one, between turns, with its Apply kept
42
- # over a restart (CardStore), not a running turn's that says it waits on the
43
- # user. The same change twice in a turn is one card.
26
+ # is over (TurnStages::Objectives#propose).
44
27
  # @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
45
- def propose_objective(proposal)
46
- @objective_proposals_mutex.synchronize do
47
- next true if @objective_proposals.any? { |p| p.line == proposal.line }
48
- next false if @objective_proposals.size >= Objectives::PROPOSALS_PER_TURN
49
-
50
- @objective_proposals << proposal
51
- true
52
- end
53
- end
54
-
55
- def show_objective_proposals
56
- drain_objective_proposals.each do |proposal|
57
- id = "objectives-#{SecureRandom.hex(4)}"
58
- show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
59
- actions: [{ label: "Apply", command: proposal.action_line(id) },
60
- { label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
61
- end
62
- rescue StandardError => e
63
- Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
64
- end
65
- private :show_objective_proposals
66
-
67
- # A turn that did not complete (canceled, failed) takes its proposals
68
- # off the buffer without showing them: their Apply card would sit after a
69
- # turn whose `objectives propose` call is not in the history (a rollback
70
- # after Ctrl-C drops it), and leaving them in the buffer would show them
71
- # at the end of the next turn.
72
- def discard_objective_proposals(turn)
73
- dropped = drain_objective_proposals
74
- Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
75
- end
76
- private :discard_objective_proposals
77
-
78
- def drain_objective_proposals
79
- @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
80
- end
81
- private :drain_objective_proposals
28
+ def propose_objective(proposal) = @objectives_stage.propose(proposal)
82
29
 
83
30
  private
84
31
 
85
- # System 1 on the objectives, in audit (ObjectivesAudit): the owner's turns
86
- # only, and for a message a person's: a reminder's or a delegate's prompt isn't one.
87
- def audit_objectives_incoming(turn)
88
- return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
89
-
90
- objectives_audit.incoming(turn.session, turn.prompt)
91
- end
92
- private :audit_objectives_incoming
93
-
94
- def audit_objectives_outgoing(turn, result)
95
- return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
96
-
97
- objectives_audit.outgoing(turn.session, result.text)
98
- end
99
- private :audit_objectives_outgoing
100
-
101
- # With saving off nothing would come of an ask, so none is made.
102
- def objectives_audit
103
- @objectives_audit ||= ObjectivesAudit.new(
104
- side: @side_judgments, state_dir: -> { session_state_dir },
105
- client_for: lambda do |judgment, session|
106
- next nil unless system_one_runs.on?
107
-
108
- system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
109
- judgment, session)
110
- end
32
+ # The objectives' stage (TurnStages::Objectives).
33
+ attr_reader :objectives_stage
34
+
35
+ # The stage over this Engine's side asks and its event lock (built in
36
+ # #initialize, once the host registry is).
37
+ def build_objectives_stage
38
+ TurnStages::Objectives.new(
39
+ side_judgments: @side_judgments, host_registry: @host_registry, state_dir: -> { session_state_dir },
40
+ recording: ->(client, judgment, session, mode:) { system_one_recording(client, judgment, session, mode: mode) },
41
+ runs: -> { system_one_runs }, synchronize: ->(&block) { synchronize_events(&block) },
42
+ announce: ->(event) { announce(event) }, running: -> { live_conversation },
43
+ show_card: ->(**card) { show_card(**card) }
111
44
  )
112
45
  end
113
- private :objectives_audit
114
46
  end
115
47
  end
116
48
  end
@@ -5,13 +5,16 @@ module Samagotchi
5
5
  # The session's participants: the running turn's author, a pair turn's
6
6
  # stop check, who came or went, the system prompt's participants line and
7
7
  # a participant's author fields. Writes @participant_stop,
8
- # @participants_line; reads @participants, @participants_watch,
9
- # @guardrail_wiring.
8
+ # @participants_line; reads @participants, @participants_watch and the
9
+ # turn's TurnContext.
10
10
  module ParticipantsPart
11
11
  # The running turn's author when a participant wrote it, else nil
12
- # (ctx.turn_author, the hooks' author).
12
+ # (ctx.turn_author, the hooks' author, the judgments' participant:,
13
+ # ObjectivesCheck): one read of the published TurnContext, so any
14
+ # thread sees the running turn's author from its first moment, never
15
+ # the last turn's.
13
16
  # @return [String, nil]
14
- def turn_author = turn_running? ? @guardrail_wiring.turn_author : nil
17
+ def turn_author = turn_context&.author
15
18
 
16
19
  # Whether a participant's pair turn must stop, read before each of its
17
20
  # generations (#guard_participant_spend): author ("p:<id>") → why (their
@@ -20,7 +23,7 @@ module Samagotchi
20
23
  # nothing is checked mid-turn.
21
24
  attr_writer :participant_stop
22
25
 
23
- # Before each generation (KernelLoop#generation_guard): a pair turn whose
26
+ # Before each generation (KernelLoop::Services#generation_guard): a pair turn whose
24
27
  # participant's link ended, or whose spend (Engine#metrics: their
25
28
  # finished turns, this one's finished generations, their side requests)
26
29
  # passed their cap, stops on the plugins' stop path (cost-cap's,
@@ -28,10 +31,11 @@ module Samagotchi
28
31
  # once it ends, so a turn can pass the cap by its last generation. The
29
32
  # turn-start check is the Worker's (Worker#participant_refusal); a
30
33
  # review turn only reads, and keeps to it.
31
- def guard_participant_spend
32
- author = turn_author
34
+ # @param context [TurnContext] the running turn's (the loop's)
35
+ def guard_participant_spend(context)
36
+ author = context&.author
33
37
  return if author.nil? || @participant_stop.nil?
34
- return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
38
+ return unless context.participant_mode == ParticipantMode::PAIR
35
39
 
36
40
  reason = @participant_stop.call(author) or return
37
41
  hook_stop_turn(reason, PARTICIPANTS_HOOK)
@@ -77,8 +81,9 @@ module Samagotchi
77
81
  # A participant's opening message keeps the rules it was delivered under
78
82
  # (ParticipantMode), stamped before any request carries it, so its header
79
83
  # never changes when the session's mode does.
80
- def participant_mode_fields(author)
81
- mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
84
+ # @param context [TurnContext] the turn's
85
+ def participant_mode_fields(context)
86
+ mode = context.participant? && context.participant_mode
82
87
  mode ? { participant_mode: mode.to_s } : {}
83
88
  end
84
89
  end
@@ -101,11 +101,13 @@ module Samagotchi
101
101
  # Apply the staged tool sets, on the turn thread, before the turn's
102
102
  # system prompt is built (PluginTasks#apply_staged_tools!). A changed
103
103
  # set may change the chi group the search tool's description counts
104
- # (a plugin tool the toolkit leaves out).
104
+ # (a plugin tool the toolkit leaves out). Publishes the Registry it
105
+ # leaves (#publish_tools!).
105
106
  # @return [Boolean] whether the tools changed
106
107
  def apply_staged_tools!
107
108
  changed = @plugin_tasks.apply_staged_tools!
108
109
  @tool_index.refresh! if changed
110
+ publish_tools!
109
111
  changed
110
112
  end
111
113
  public :apply_staged_tools!
@@ -15,7 +15,7 @@ module Samagotchi
15
15
  end
16
16
 
17
17
  # Ask the model's question (ask_user_question): the kernel's
18
- # question_handler, called on the turn thread with the payload
18
+ # Services#question_handler, called on the turn thread with the payload
19
19
  # Tools::AskUserQuestion.validate made (QuestionDesk#request), after
20
20
  # the :question_asked hooks (QuestionAsked) gave the step its refs; a
21
21
  # --non-interactive run has no one to ask, so they don't fire there.
@@ -42,9 +42,12 @@ module Samagotchi
42
42
  def side_author(kind)
43
43
  # Check this is only ever the owner's, whichever turn runs as it ends.
44
44
  return nil if kind.to_s == ObjectivesCheck::SOURCE
45
- return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
46
45
 
47
- @guardrail_wiring.turn_author
46
+ # One read each of the published contexts (TurnState): any thread.
47
+ running = turn_context
48
+ return running.author if running
49
+
50
+ kind.to_s == RECAP_SIDE_KIND ? last_turn_context&.author : nil
48
51
  end
49
52
 
50
53
  private
@@ -4,9 +4,9 @@ module Samagotchi
4
4
  class Engine
5
5
  # The session's toolkit: the kits and what a turn declares
6
6
  # (ToolkitBinding, forwarded), ctx.load_tools and the staged loads a turn
7
- # appends, and System 1's toolkit suggestion (start, settle, warm-up).
8
- # Writes @toolkit_warmup_thread; reads @toolkit_binding, @tools,
9
- # @tool_index, @tool_loads, @side_judgments, @session.
7
+ # appends, and System 1's toolkit suggestion (TurnStages::ToolkitSuggestion,
8
+ # forwarded). Reads @toolkit_binding, @tools, @tool_index, @tool_loads,
9
+ # @toolkit_suggestion, @session.
10
10
  module ToolkitPart
11
11
  private
12
12
 
@@ -17,22 +17,29 @@ module Samagotchi
17
17
  def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
18
18
  def toolkit_summary = @toolkit_binding.summary
19
19
 
20
+ # The Registry published for readers off the turn thread
21
+ # (ToolkitBinding#publish!): at the end of the Engine's build, after a
22
+ # turn's staged tool sets and its toolkit apply.
23
+ def publish_tools! = @toolkit_binding.publish!
24
+
20
25
  # The tools this session registered (Tools::Registry), declared or not.
21
26
  attr_reader :tools
22
27
 
23
28
  public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
24
29
 
25
30
  # A plugin's ctx.load_tools: stage the reachable tools +names+ names
26
- # (Tools::ToolIndex#deferred_name) that the session neither declares
27
- # nor loaded with an earlier tool change, for bundle +bundle+. A name
28
- # that names no tool at all is logged; a declared or removed one is
29
- # skipped quietly.
31
+ # (Tools::ToolIndex#deferred_name, on the published Registry) that the
32
+ # session neither declares nor loaded with an earlier tool change, for
33
+ # bundle +bundle+. A name that names no tool at all is logged; a
34
+ # declared or removed one is skipped quietly.
30
35
  # @return [Array<String>] the wire names staged
31
36
  def load_tools(names, bundle)
32
37
  added = ToolLoads.added(@session&.messages)
38
+ published = @toolkit_binding.published
39
+ surface = Tools::ToolSurface.for(published)
33
40
  wanted = names.filter_map do |name|
34
- wire = @tool_index.deferred_name(name)
35
- Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || @tools.key?(name)
41
+ wire = @tool_index.deferred_name(name, surface)
42
+ Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || published.key?(name)
36
43
  wire
37
44
  end
38
45
  @tool_loads.stage(wanted - added, by: bundle)
@@ -79,191 +86,27 @@ module Samagotchi
79
86
  def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
80
87
  private :apply_toolkit!
81
88
 
82
- # Start the toolkit classifier after :turn_started, for audit and on
83
- # mode alike (one eligibility, one snapshot, one thread). All session
84
- # and tool-registry reads happen here; the thread receives only this
85
- # snapshot. Audit never blocks: the thread logs its line. On mode
86
- # returns the ask's Pending, which the turn joins before apply_toolkit!
87
- # (#settle_toolkit_suggestion), with the turn's controller as the
88
- # request's cancel controller.
89
- # @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
90
- def start_toolkit_suggestion(turn)
91
- mode = Config.get("toolkit.suggest.mode").to_s
92
- return if mode == "off"
93
-
94
- joined = mode == "on"
95
- session = turn.session
96
- default = Toolkit.of(session.toolkit)
97
- log = toolkit_suggestion_log
98
- # The predicate checks the session first; the kits (memory kits are
99
- # read from disk) and the log (read whole) only when it gets there.
100
- kits = nil
101
- candidates = nil
102
- return unless ToolkitSuggest.eligible?(
103
- session: session, prompt: turn.prompt, author: turn.origin.author,
104
- candidate_count: lambda {
105
- kits = @toolkit_binding.kits
106
- candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
107
- candidates.size
108
- },
109
- last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
110
- defer_turns: Config.get("toolkit.suggest.defer_turns"),
111
- defer_tokens: Config.get("toolkit.suggest.defer_tokens")
112
- )
89
+ # System 1's toolkit suggestion (TurnStages::ToolkitSuggestion):
90
+ # prepare_turn starts, settles and abandons it; the Worker warms it.
91
+ attr_reader :toolkit_suggestion
92
+ private :toolkit_suggestion
113
93
 
114
- # One ask at a time: an eligible turn while the last one still runs
115
- # goes unasked, and says so.
116
- if @side_judgments.in_flight?(:toolkit)
117
- Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
118
- return
119
- end
120
-
121
- client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
122
- ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
123
- return unless client
124
-
125
- # The warm-up's only value is the cache: the real ask never queues
126
- # behind it.
127
- stop_toolkit_warmup
128
-
129
- # Rules, not tool lists: this runs before await_init_tasks and
130
- # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
131
- # registered yet; the report expands the rules (ToolkitSuggest.rules).
132
- snapshots = candidates.map do |kit|
133
- ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
134
- rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
135
- end.freeze
136
- default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
137
- rules: ToolkitSuggest.rules(default.resolve(kits)))
138
- registered = @tools.entries(toolkit: nil).reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
139
- core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
140
- state = ToolkitSuggest.question(
141
- default: default, kits: kits, project: File.basename(session.project_root.to_s),
142
- prompt: turn.prompt
143
- ).last
144
- snapshot = ToolkitSuggest::Snapshot.new(
145
- session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
146
- default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
147
- state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
148
- )
149
- threshold = Config.get("toolkit.suggest.threshold")
150
- runs = session.scratch ? nil : system_one_runs
151
- pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
152
- acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
153
- timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
154
- # On mode: cut by a Stop while the turn waits (Pending#wait), not
155
- # by one after it went on; audit's ask is never cut.
156
- controller = joined ? pending.cancel_controller : nil
157
- @side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
158
- ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
159
- cancel_controller: controller)
160
- end
161
- joined ? pending : nil
162
- rescue StandardError => e
163
- ToolkitSuggest.warn(e)
164
- nil
165
- end
166
- private :start_toolkit_suggestion
167
-
168
- # On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
169
- # suggestion) up to System 1's timeout from its start, or until a Stop,
170
- # and put the session on a candidate picked with confidence ≥ threshold
171
- # (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
172
- # other outcome (the default, below threshold, undecided, no judgment,
173
- # a timeout, a Stop) leaves the session on its default. Nothing here
174
- # sends a request; prepare_turn's cancel check ends a stopped turn. A
175
- # Stop wins over an answer already in (Pending#wait checks it first).
176
- def settle_toolkit_suggestion(turn, pending)
177
- return if pending.nil?
178
-
179
- outcome, judged = pending.wait(turn.controller)
180
- if outcome == :timeout
181
- # The session starts on its default without a word otherwise: a
182
- # slow System 1 host (a local one) is the usual cause.
183
- timeout_ms = SystemOne::Client.configured_timeout_ms
184
- Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
185
- reason: "System 1 gave no answer within system_one.timeout_ms")
186
- @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
187
- return
188
- end
189
- unless outcome == :answered
190
- Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
191
- return
192
- end
193
- return if judged.nil?
194
-
195
- pending.record(toolkit_suggestion_outcome(turn.session, judged))
196
- rescue StandardError => e
197
- ToolkitSuggest.warn(e)
198
- pending&.record("failed")
199
- end
200
- private :settle_toolkit_suggestion
201
-
202
- # What the turn does with an answered ask, as the log line's did:
203
- # suggested (switched), chosen (the toolkit isn't the default any
204
- # more), unavailable (the kit is gone), defer or default.
205
- def toolkit_suggestion_outcome(session, judged)
206
- case judged.would
207
- when "suggested"
208
- return "chosen" unless session.toolkit_origin == "default"
209
- return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
210
-
211
- switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
212
- deferred: ToolkitSuggest.model_reply?(session.messages))
213
- switched ? "suggested" : "chosen"
214
- when "defer" then "defer"
215
- when "no_judgment"
216
- # System 1 failed (its own warn) or answered nothing usable.
217
- @toolkit_binding.fell_back(session, "System 1 gave no answer")
218
- "default"
219
- else "default"
220
- end
221
- end
222
- private :toolkit_suggestion_outcome
223
-
224
- # On mode, as an idle worker starts (Worker#start_background): one ask
225
- # with the real question and a stand-in prompt, on its own thread,
226
- # never logged and never waited for, so System 1's server has the
227
- # question's prefix cached for the first prompt. Only for a session a
228
- # first prompt could be asked for: origin default, no model reply, not
229
- # a delegate child or a review session, at least two candidates; and
230
- # not when the worker starts on a first prompt (that ask comes at once).
231
- def warm_toolkit_suggestion!
232
- return unless Config.get("toolkit.suggest.mode").to_s == "on"
233
-
234
- session = @session
235
- return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
236
- return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
237
-
238
- default = Toolkit.of(session.toolkit)
239
- kits = @toolkit_binding.kits
240
- return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
241
-
242
- client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
243
- return unless client
244
-
245
- project = File.basename(session.project_root.to_s)
246
- @toolkit_warmup_thread = Thread.new do
247
- ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
248
- end
249
- rescue StandardError => e
250
- ToolkitSuggest.warn(e)
251
- nil
252
- end
94
+ # On mode, as an idle worker starts (Worker#start_background): the
95
+ # warm-up ask (TurnStages::ToolkitSuggestion#warm!).
96
+ def warm_toolkit_suggestion! = @toolkit_suggestion.warm!(@session)
253
97
  public :warm_toolkit_suggestion!
254
98
 
255
- # A still-running warm-up, stopped: a real ask (or the worker leaving)
256
- # doesn't wait behind it.
257
- def stop_toolkit_warmup
258
- @toolkit_warmup_thread&.kill
259
- @toolkit_warmup_thread = nil
260
- end
261
- private :stop_toolkit_warmup
262
-
263
- def toolkit_suggestion_log
264
- SideJudgments.log(ToolkitSuggest.log_path(session_state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
99
+ # The stage over this Engine's side asks, binding and tools (built in
100
+ # #build_tools_and_plugins, before the plugins load).
101
+ def build_toolkit_suggestion
102
+ TurnStages::ToolkitSuggestion.new(
103
+ side_judgments: @side_judgments, host_registry: @host_registry, toolkit_binding: @toolkit_binding,
104
+ tools: @tools, state_dir: -> { session_state_dir },
105
+ recording: ->(client, judgment, session, mode:) { system_one_recording(client, judgment, session, mode: mode) },
106
+ runs: -> { system_one_runs }
107
+ )
265
108
  end
266
- private :toolkit_suggestion_log
109
+ private :build_toolkit_suggestion
267
110
  end
268
111
  end
269
112
  end
@@ -117,7 +117,7 @@ module Samagotchi
117
117
  # a display version of the answer (AnswerDisplay) and event[:refs]
118
118
  # for refs on it (AnswerRefs).
119
119
  session = turn.session
120
- audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
120
+ @objectives_stage.outgoing(turn, result) unless canceled || result.exhausted?
121
121
  display = AnswerDisplay.new(session.messages)
122
122
  refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
123
123
  after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
@@ -5,8 +5,9 @@ module Samagotchi
5
5
  # What a turn's steps use before and around the model: its settings,
6
6
  # images and vision, the model ref and resumed run start it generates
7
7
  # with, the used memories it publishes, and the turn-end warm-up of the
8
- # next one. Writes @next_turn_waiting; reads @model_settings, @kernel,
9
- # @prompt_warmup, @thinking_notices, @turn_state.
8
+ # next one (TurnStages::Warmup, built here). Writes @next_turn_waiting;
9
+ # reads @model_settings, @kernel, @prompt_warmup, @thinking_notices,
10
+ # @turn_state, @turn_warmup.
10
11
  module TurnHelpersPart
11
12
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
12
13
  # sampling and thinking, the thinking resolved before the hooks run.
@@ -34,44 +35,20 @@ module Samagotchi
34
35
  # warm-up then. nil: neither.
35
36
  attr_accessor :next_turn_waiting
36
37
 
37
- # After a completed turn, prefill the next turn's prompt up to its user
38
- # message while the user reads (PromptWarmup, cache.warmup). Only on a
39
- # local llama.cpp host on the native loop, and not when the next turn's
40
- # start differs anyway or starts at once: a reminder due, a continue
41
- # offer (the turn ran out of steps), a carried steer or queued input.
42
- # Never fails the turn.
43
- def warm_up_next_turn(turn, result)
44
- return if result.canceled? || result.resumable?
45
-
46
- model = effective_model
47
- client = model.client
48
- return unless warmup_target?(model, client)
49
- return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
50
-
51
- messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
52
- # Under the next turn's strategy (a /model switch may have changed it);
53
- # the last turn's when it can't be resolved.
54
- prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || @kernel.turn_settings&.llm_context)
55
- return unless prompt
56
-
57
- started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
58
- slot: @kernel.last_slot, images: images)
59
- @kernel.warmed_up! if started
60
- rescue StandardError => e
61
- Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
62
- end
63
- private :warm_up_next_turn
64
-
65
- # Never a paid or remote host (cache.warmup auto means local only), nor
66
- # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
67
- # not measured), nor a client that can't warm up (a spec's stub).
68
- def warmup_target?(model, client)
69
- return false if model.chat? || model.entry.remote?
70
- return false unless client.respond_to?(:warm_up)
71
-
72
- PromptWarmup.enabled?
38
+ # The turn-end warm-up's stage (TurnStages::Warmup).
39
+ attr_reader :turn_warmup
40
+ private :turn_warmup
41
+
42
+ # The stage over the kernel and the Engine's PromptWarmup (built in
43
+ # #initialize, once the kernel is).
44
+ def build_turn_warmup
45
+ TurnStages::Warmup.new(
46
+ prompt_warmup: @prompt_warmup, kernel: @kernel, turn_state: @turn_state, model_settings: @model_settings,
47
+ model: -> { effective_model }, system_prompt: ->(model) { system_prompt(model) },
48
+ reminders_due: -> { reminders_due? }, next_turn_waiting: -> { next_turn_waiting&.call }
49
+ )
73
50
  end
74
- private :warmup_target?
51
+ private :build_turn_warmup
75
52
 
76
53
  # The ref (host:model, as asked) a turn's generations ran on: the model
77
54
  # #generate resolved, after the before_turn hooks (which may switch it).