samagotchi 0.104.0 → 0.106.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 +70 -1
- data/docs/architecture.md +6 -3
- data/docs/cli.md +62 -19
- data/docs/configuration.md +10 -6
- data/docs/guardrails.md +69 -0
- data/docs/hooks.md +9 -0
- data/docs/internals/anthropic.md +2 -1
- data/docs/internals/engine.md +37 -1
- data/docs/internals/participants.md +229 -21
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/internals/working-set.md +12 -5
- data/docs/plugins.md +2 -0
- data/docs/testing.md +23 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -4
- data/lib/samagotchi/bridge.rb +45 -8
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +21 -9
- data/lib/samagotchi/config.rb +17 -2
- data/lib/samagotchi/continue_offer.rb +13 -3
- data/lib/samagotchi/delivery.rb +10 -0
- data/lib/samagotchi/engine.rb +98 -8
- data/lib/samagotchi/guardrail_wiring.rb +13 -3
- data/lib/samagotchi/guardrails/review_boundary.rb +148 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hook_routes.rb +5 -2
- data/lib/samagotchi/hooks/registry.rb +12 -3
- data/lib/samagotchi/idle_recap.rb +9 -0
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/parent_report.rb +17 -5
- data/lib/samagotchi/participant_messages.rb +16 -0
- data/lib/samagotchi/participant_name.rb +21 -0
- data/lib/samagotchi/participants.rb +29 -6
- data/lib/samagotchi/participants_watch.rb +90 -0
- data/lib/samagotchi/plugin/context.rb +7 -1
- data/lib/samagotchi/plugin/side_question.rb +9 -1
- data/lib/samagotchi/queued_command.rb +9 -5
- data/lib/samagotchi/reply_wait/poll.rb +18 -3
- data/lib/samagotchi/run_options.rb +1 -1
- data/lib/samagotchi/send_command.rb +41 -3
- data/lib/samagotchi/session_access/contract.rb +2 -0
- data/lib/samagotchi/session_access/local.rb +31 -9
- data/lib/samagotchi/session_access/remote.rb +19 -2
- data/lib/samagotchi/session_access/web_bridge_client.rb +4 -2
- data/lib/samagotchi/session_access.rb +6 -3
- data/lib/samagotchi/session_commands.rb +6 -0
- data/lib/samagotchi/session_inbox.rb +31 -5
- data/lib/samagotchi/session_manager.rb +43 -3
- data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
- data/lib/samagotchi/session_metrics.rb +94 -1
- data/lib/samagotchi/session_participants.rb +59 -0
- data/lib/samagotchi/sessions_command.rb +10 -7
- data/lib/samagotchi/share_command.rb +61 -22
- data/lib/samagotchi/side_usage.rb +4 -2
- data/lib/samagotchi/steer.rb +120 -11
- data/lib/samagotchi/system_prompt.rb +21 -2
- data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +94 -22
- data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
- data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
- data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
- data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/testing/host.rb +4 -2
- data/lib/samagotchi/testing/recorder.rb +3 -1
- data/lib/samagotchi/toolkit.rb +21 -3
- data/lib/samagotchi/turn_checkpoint.rb +5 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +45 -20
- data/lib/samagotchi/web/grants.rb +89 -8
- data/lib/samagotchi/web/participant_access.rb +206 -35
- data/lib/samagotchi/web/participant_review.rb +165 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +157 -0
- data/lib/samagotchi/web/participant_view.rb +415 -0
- data/lib/samagotchi/web/public/app.js +15 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/data.js +20 -0
- data/lib/samagotchi/web/public/index.html +75 -30
- data/lib/samagotchi/web/public/info_bar.js +12 -5
- data/lib/samagotchi/web/public/info_bar_model.js +8 -5
- data/lib/samagotchi/web/public/participant.html +119 -0
- data/lib/samagotchi/web/public/participant.js +300 -0
- data/lib/samagotchi/web/public/participant_model.js +160 -0
- data/lib/samagotchi/web/public/participant_names.js +21 -0
- data/lib/samagotchi/web/public/participants_panel.js +311 -0
- data/lib/samagotchi/web/public/stream_handlers.js +8 -1
- data/lib/samagotchi/web/public/tokens.css +12 -0
- data/lib/samagotchi/web/public/turn_events.js +16 -5
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/web/public/working_set.js +6 -4
- data/lib/samagotchi/web/public/working_set_view.js +4 -4
- data/lib/samagotchi/web/qr.rb +14 -0
- data/lib/samagotchi/web/running.rb +52 -0
- data/lib/samagotchi/web/server.rb +13 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -0
- data/lib/samagotchi/worker.rb +125 -11
- data/lib/samagotchi/worker_inbound.rb +12 -1
- metadata +17 -1
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative "engine"
|
|
|
22
22
|
require_relative "session_commands"
|
|
23
23
|
require_relative "queued_command"
|
|
24
24
|
require_relative "steer"
|
|
25
|
+
require_relative "participants"
|
|
25
26
|
require_relative "image_store"
|
|
26
27
|
require_relative "log"
|
|
27
28
|
require_relative "version"
|
|
@@ -322,11 +323,12 @@ module Samagotchi
|
|
|
322
323
|
# @param after_file [String, nil] the input file it came in (its
|
|
323
324
|
# basename): it runs once that file is taken (Worker#on_command)
|
|
324
325
|
# @return [String] its command_id
|
|
325
|
-
def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil)
|
|
326
|
+
def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil, author: nil)
|
|
326
327
|
# A card's action (the step-limit question's answer): the UIs leave
|
|
327
328
|
# its line out. +enqueued_id+: a file-delivered command's input, whose
|
|
328
|
-
# sender's wait its command_ran settles.
|
|
329
|
-
command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card
|
|
329
|
+
# sender's wait its command_ran settles. +author+: its input's.
|
|
330
|
+
command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card, author: author)
|
|
331
|
+
.with(enqueued_id: enqueued_id)
|
|
330
332
|
@engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
|
|
331
333
|
command.command_id
|
|
332
334
|
end
|
|
@@ -542,8 +544,11 @@ module Samagotchi
|
|
|
542
544
|
# Stop's last_turn carry the turn's enqueued_id (ReplyWait.own_turn).
|
|
543
545
|
# unqueue: POST turn/dequeue (#handle_dequeue_turn) takes a queued
|
|
544
546
|
# message back before it runs.
|
|
547
|
+
# review_turns: a participant's turn runs as its own, inside the review
|
|
548
|
+
# boundary (Guardrails::ReviewBoundary); a worker without it has none,
|
|
549
|
+
# so chi web sends it no participant's turn (Delivery::REVIEW_FEATURE).
|
|
545
550
|
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
|
|
546
|
-
SessionInbox::UNQUEUE_FEATURE].freeze
|
|
551
|
+
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE].freeze
|
|
547
552
|
|
|
548
553
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
549
554
|
# the session id and the request body and returns [headers, status, body].
|
|
@@ -792,10 +797,11 @@ module Samagotchi
|
|
|
792
797
|
|
|
793
798
|
# Queues +line+ (a session command) for the worker and announces its
|
|
794
799
|
# :command_queued, unless +deadline+ has passed. Returns [headers,
|
|
795
|
-
# status, body]: 202 with its command_id, or 408.
|
|
796
|
-
|
|
800
|
+
# status, body]: 202 with its command_id, or 408. +author+: a POST
|
|
801
|
+
# /turn's (a /word sent as a message).
|
|
802
|
+
def queue_command_request(session_id, line, client_id:, deadline: nil, card: false, author: nil)
|
|
797
803
|
# A card's action: the UIs leave its line out (the card is the echo).
|
|
798
|
-
command = QueuedCommand.mint(line, client_id: client_id, card: card)
|
|
804
|
+
command = QueuedCommand.mint(line, client_id: client_id, card: card, author: author)
|
|
799
805
|
queued = @engine.synchronize_events do
|
|
800
806
|
raise IntakeClosed if stopped?
|
|
801
807
|
next false if expired?("command_expired", deadline, sid: session_id, client_id: command.client_id)
|
|
@@ -914,13 +920,26 @@ module Samagotchi
|
|
|
914
920
|
end
|
|
915
921
|
|
|
916
922
|
delivery = Delivery.parse(raw_delivery)
|
|
923
|
+
# How many of a participant's turns may be running or waiting
|
|
924
|
+
# (chi web's, from their grant: its queue limit plus the running one).
|
|
925
|
+
pending_limit = nil
|
|
926
|
+
if Steer.participant_author?(author)
|
|
927
|
+
# A participant's message is a turn of its own, whatever the body
|
|
928
|
+
# asks (D6), and never a command or a shell line: chi web refuses
|
|
929
|
+
# those first, and this is the worker's own layer.
|
|
930
|
+
return participant_refused(sid, author, "command") if command_registry.command?(prompt.to_s)
|
|
931
|
+
|
|
932
|
+
delivery = Delivery::QUEUE
|
|
933
|
+
pending_limit = fetched(parsed, "pending_limit")
|
|
934
|
+
pending_limit = nil unless pending_limit.is_a?(Integer) && pending_limit.positive?
|
|
935
|
+
end
|
|
917
936
|
|
|
918
937
|
# A session command sent as a message (chi send -m "/model x", chi -p,
|
|
919
938
|
# a UI whose command list lags) runs as the command, as typed in a
|
|
920
939
|
# TUI; an unknown /word stays a prompt. Not with images: those are
|
|
921
940
|
# for the model.
|
|
922
941
|
if images.empty? && @on_command && command_registry.command?(prompt.to_s)
|
|
923
|
-
return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline)
|
|
942
|
+
return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline, author: author)
|
|
924
943
|
end
|
|
925
944
|
|
|
926
945
|
enqueued_id = SecureRandom.uuid
|
|
@@ -936,6 +955,7 @@ module Samagotchi
|
|
|
936
955
|
enqueued = @engine.synchronize_events do
|
|
937
956
|
raise IntakeClosed if stopped?
|
|
938
957
|
next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
|
|
958
|
+
next :too_many if pending_limit && pending_turns(author) >= pending_limit
|
|
939
959
|
|
|
940
960
|
queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
|
|
941
961
|
enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
@@ -945,11 +965,13 @@ module Samagotchi
|
|
|
945
965
|
enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
|
|
946
966
|
enqueued_event[:images] = images unless images.empty?
|
|
947
967
|
enqueued_event[:delivery] = Delivery::QUEUE if queued_behind_turn
|
|
968
|
+
enqueued_event[:author] = author if author
|
|
948
969
|
@engine.announce(enqueued_event)
|
|
949
970
|
@on_input&.call
|
|
950
971
|
end
|
|
951
972
|
end
|
|
952
973
|
return deadline_passed("turn") if enqueued == :expired
|
|
974
|
+
return [{}, 429, { error: "too_many_turns", detail: ParticipantMessages::TOO_MANY_TURNS }] if enqueued == :too_many
|
|
953
975
|
return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
|
|
954
976
|
|
|
955
977
|
# Queued (outside the event lock): a message sent as +cut+ cuts a
|
|
@@ -1003,6 +1025,21 @@ module Samagotchi
|
|
|
1003
1025
|
dequeue_reply(taken, id, input)
|
|
1004
1026
|
end
|
|
1005
1027
|
|
|
1028
|
+
# +author+'s turns running or waiting: the running turn when it is
|
|
1029
|
+
# theirs, and their queued ones. Read with the event log held.
|
|
1030
|
+
def pending_turns(author)
|
|
1031
|
+
running = Steer.sender_author(@accumulator.current_turn&.dig(:origin, :author)) == author ? 1 : 0
|
|
1032
|
+
running + @accumulator.queued.count { |row| row[:author] == author }
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# 403 not_permitted for a participant's +what+ (a command, a shell
|
|
1036
|
+
# line), with its audit line.
|
|
1037
|
+
def participant_refused(sid, author, what)
|
|
1038
|
+
Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant: author.delete_prefix("p:"), what: what,
|
|
1039
|
+
reason: "not_permitted")
|
|
1040
|
+
[{}, 403, { error: "not_permitted", detail: Participants::NOT_PERMITTED }]
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1006
1043
|
def dequeue_reply(taken, id, input)
|
|
1007
1044
|
case taken
|
|
1008
1045
|
when :removed then [{}, 200, { status: "removed", enqueued_id: id, prompt: input.prompt.to_s, session_id: @session_id }]
|
|
@@ -167,10 +167,13 @@ module Samagotchi
|
|
|
167
167
|
# the session's images/
|
|
168
168
|
# @param author [String, nil] who wrote it when it isn't the session's
|
|
169
169
|
# owner; sent only when given (SessionManager.deliver_turn)
|
|
170
|
-
|
|
170
|
+
# @param pending_limit [Integer, nil] with a participant's +author+: how
|
|
171
|
+
# many of their turns may be running or waiting (429 too_many_turns past it)
|
|
172
|
+
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil)
|
|
171
173
|
body = { session_id: @session_id, prompt: prompt, client_id: client_id }
|
|
172
174
|
body[:no_interrupt] = true if no_interrupt
|
|
173
175
|
body[:author] = author if author
|
|
176
|
+
body[:pending_limit] = pending_limit if author && pending_limit
|
|
174
177
|
body[:images] = images if images && !images.empty?
|
|
175
178
|
body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
|
|
176
179
|
body[:deadline] = deadline
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -34,12 +34,16 @@ module Samagotchi
|
|
|
34
34
|
PLUGIN = "plugin"
|
|
35
35
|
# A relayed answer (RelayVerifier): relay:<parent8>.
|
|
36
36
|
RELAY_PREFIX = "relay:"
|
|
37
|
+
# A participant's review turn: review:<participant id> (chi web stamps
|
|
38
|
+
# it from the participant's token, never from the request's body).
|
|
39
|
+
REVIEW_PREFIX = "review:"
|
|
37
40
|
|
|
38
|
-
# Input a human typed: a web tab, a chi TUI, chi send,
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
41
|
+
# Input a human typed: a web tab, a chi TUI, chi send, a participant
|
|
42
|
+
# (review:, a person or an agent they run: never automatic input), and
|
|
43
|
+
# nil (a worker's initial prompt; a web client may send none). An
|
|
44
|
+
# allowlist: delegates, plugins, reminders, context changes and any id
|
|
45
|
+
# chi doesn't know are not.
|
|
46
|
+
HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX, REVIEW_PREFIX].freeze
|
|
43
47
|
HUMAN_IDS = [CLI_SEND].freeze
|
|
44
48
|
|
|
45
49
|
# A person at a screen that shows a command's reply and can answer it
|
|
@@ -47,16 +51,17 @@ module Samagotchi
|
|
|
47
51
|
INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
48
52
|
|
|
49
53
|
# A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
|
|
50
|
-
# runs turns for on its own, by prefix
|
|
51
|
-
# anything else "client".
|
|
54
|
+
# runs turns for on its own, by prefix, and a participant's ("review");
|
|
55
|
+
# system:reminder is "reminder", anything else "client".
|
|
52
56
|
ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
|
|
53
|
-
CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
|
|
57
|
+
CONTEXT_PREFIX => "context", TASK_PREFIX => "task", REVIEW_PREFIX => "review" }.freeze
|
|
54
58
|
|
|
55
59
|
module_function
|
|
56
60
|
|
|
57
61
|
# What sent a turn: "delegate" (a delegating parent), "delegate_report"
|
|
58
62
|
# (a child's report), "context" (a context wake), "task" (a task finish
|
|
59
|
-
# wake), "reminder", else "client" (
|
|
63
|
+
# wake), "review" (a participant), "reminder", else "client" (the
|
|
64
|
+
# owner, chi send, a plugin, nil).
|
|
60
65
|
# @return [String]
|
|
61
66
|
def origin(client_id)
|
|
62
67
|
id = client_id.to_s
|
|
@@ -81,5 +86,12 @@ module Samagotchi
|
|
|
81
86
|
id = client_id.to_s
|
|
82
87
|
HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
|
|
83
88
|
end
|
|
89
|
+
|
|
90
|
+
# The client id a participant's turn carries (chi web's, from their
|
|
91
|
+
# grant): review:<participant id>.
|
|
92
|
+
def review(participant_id) = "#{REVIEW_PREFIX}#{participant_id}"
|
|
93
|
+
|
|
94
|
+
# Whether +client_id+ is a participant's (review:).
|
|
95
|
+
def review?(client_id) = client_id.to_s.start_with?(REVIEW_PREFIX)
|
|
84
96
|
end
|
|
85
97
|
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -306,11 +306,16 @@ module Samagotchi
|
|
|
306
306
|
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
|
|
307
307
|
# The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
|
|
308
308
|
# footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
|
|
309
|
-
Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default:
|
|
309
|
+
Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
|
|
310
310
|
# Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
|
|
311
311
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
|
|
312
312
|
# What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
|
|
313
313
|
Entry.new(key: "web.editor", yaml_path: %w[web editor], type: :string, default: "vscode", expose: %i[env config cli], lifecycle: :web_start),
|
|
314
|
+
# Participants (chi share): a new grant's lifetime (Nh or Nd, 1h to 30d), and what a grant without its own
|
|
315
|
+
# takes: its spending cap in dollars ($0 to $1000) and how many of its turns may wait (0 to 20). Read when used.
|
|
316
|
+
Entry.new(key: "web.participants.expires", yaml_path: %w[web participants expires], type: :string, default: "7d", expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
317
|
+
Entry.new(key: "web.participants.cap", yaml_path: %w[web participants cap], type: :float, default: 2.0, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
318
|
+
Entry.new(key: "web.participants.queue", yaml_path: %w[web participants queue], type: :integer, default: 3, expose: %i[env config], lifecycle: :next_matching_turn_display_poll_write),
|
|
314
319
|
|
|
315
320
|
Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
|
|
316
321
|
Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli], lifecycle: :next_cli_run),
|
|
@@ -609,7 +614,7 @@ module Samagotchi
|
|
|
609
614
|
elsif MAP_ENTRY_KEYS.key?(key)
|
|
610
615
|
map_entry_problems(key, value)
|
|
611
616
|
elsif value.is_a?(Hash) && (known_sections.include?(key) || ENTRIES.any? { |e| e.yaml_path.size > 1 && e.section == key })
|
|
612
|
-
value.
|
|
617
|
+
value.flat_map { |leaf, inner| leaf_problems("#{key}.#{leaf}", inner) }
|
|
613
618
|
elsif known_sections.include?(key)
|
|
614
619
|
# An all-commented section is nil; recap: false turns recaps off.
|
|
615
620
|
value.nil? || (key == "recap" && value == false) ? [] : ["config: '#{key}' must be a mapping of settings; ignored"]
|
|
@@ -619,6 +624,16 @@ module Samagotchi
|
|
|
619
624
|
end + model_ref_problems(data)
|
|
620
625
|
end
|
|
621
626
|
|
|
627
|
+
# A section's leaf: a key the registry knows, or a mapping of a
|
|
628
|
+
# third level (web.participants.cap) whose keys it knows.
|
|
629
|
+
def leaf_problems(path, value)
|
|
630
|
+
if value.is_a?(Hash) && ENTRIES.any? { |e| e.key.start_with?("#{path}.") }
|
|
631
|
+
value.keys.filter_map { |leaf| key_problem("#{path}.#{leaf}") }
|
|
632
|
+
else
|
|
633
|
+
[key_problem(path)].compact
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
622
637
|
# What a model ref in config.yml won't do as written: an alias whose
|
|
623
638
|
# target is another alias (aliases apply once, ModelRef), a ref that
|
|
624
639
|
# starts with "<host>/" (only ':' names a host).
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "last_turn"
|
|
5
5
|
require_relative "log"
|
|
6
|
+
require_relative "steer"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# A session worker's continue offer: what it announces when a turn runs out
|
|
@@ -34,12 +35,15 @@ module Samagotchi
|
|
|
34
35
|
# @param max_iterations [#call] (no_interrupt) → Integer
|
|
35
36
|
# @param queue_command [#call] (line, client_id:) queues a session
|
|
36
37
|
# command as a card's (Bridge#queue_command, card: true)
|
|
37
|
-
|
|
38
|
+
# +participant_refused+ (author → Boolean): whether a participant's
|
|
39
|
+
# turn may no longer run (revoked, expired, at the cap: Worker's check).
|
|
40
|
+
def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil, participant_refused: ->(_) { false })
|
|
38
41
|
@engine = engine
|
|
39
42
|
@turn_flow = turn_flow
|
|
40
43
|
@run_turn = run_turn
|
|
41
44
|
@max_iterations = max_iterations
|
|
42
45
|
@queue_command = queue_command
|
|
46
|
+
@participant_refused = participant_refused
|
|
43
47
|
# A question was posted since the last close (it may have been
|
|
44
48
|
# answered or superseded meanwhile).
|
|
45
49
|
@asked = false
|
|
@@ -124,12 +128,18 @@ module Samagotchi
|
|
|
124
128
|
# never began leaves it to no other.
|
|
125
129
|
# The continue turn is the offer turn's: its origin carries that turn's
|
|
126
130
|
# enqueued_id, so its last_turn, its reply file and its checkpoint mark
|
|
127
|
-
# do too, and a wait on that turn (chi answer) gets its reply
|
|
131
|
+
# do too, and a wait on that turn (chi answer) gets its reply; and its
|
|
132
|
+
# author, so a participant's turn continues as theirs, inside the
|
|
133
|
+
# review boundary and on their spend (never as the owner's). One whose
|
|
134
|
+
# participant may no longer run a turn isn't continued.
|
|
128
135
|
def run_continue_turn(command)
|
|
129
136
|
offer = @turn_flow.offer
|
|
130
137
|
resume_from = @turn_flow.resume_from
|
|
131
138
|
enqueued_id = offer_turn_id
|
|
139
|
+
author = Steer.sender_author(@engine.session&.last_turn&.author)
|
|
132
140
|
close("answered")
|
|
141
|
+
return if author && @participant_refused.call(author)
|
|
142
|
+
|
|
133
143
|
steer = @continue_steer
|
|
134
144
|
@continue_steer = nil
|
|
135
145
|
begin
|
|
@@ -140,7 +150,7 @@ module Samagotchi
|
|
|
140
150
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
141
151
|
return announce_offer(offer)
|
|
142
152
|
end
|
|
143
|
-
origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
|
|
153
|
+
origin = { client_id: command.client_id, enqueued_id: enqueued_id, author: author }.compact
|
|
144
154
|
@run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
|
|
145
155
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
146
156
|
if error
|
data/lib/samagotchi/delivery.rb
CHANGED
|
@@ -48,6 +48,16 @@ module Samagotchi
|
|
|
48
48
|
"restart it (chi sessions restart ID, or the web's update badge) and send again"
|
|
49
49
|
}.freeze
|
|
50
50
|
|
|
51
|
+
# A worker that runs participants' turns inside the review boundary
|
|
52
|
+
# (Bridge::FEATURES): one without it would run theirs with the owner's
|
|
53
|
+
# tools, so none is sent to it.
|
|
54
|
+
REVIEW_FEATURE = "review_turns"
|
|
55
|
+
REVIEW_UNAVAILABLE = {
|
|
56
|
+
"error" => "review_unavailable",
|
|
57
|
+
"detail" => "this session's worker predates participants' turns, so it has no review boundary; " \
|
|
58
|
+
"the owner must restart it (chi sessions restart ID, or the web's update badge)"
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
51
61
|
# Whether it is the one every sender defaulting to a step boundary uses.
|
|
52
62
|
def self.next_step?(value) = parse(value) == NEXT_STEP
|
|
53
63
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -85,6 +85,8 @@ require_relative "guardrail_wiring"
|
|
|
85
85
|
require_relative "plugin_tasks"
|
|
86
86
|
require_relative "prompt_warmup"
|
|
87
87
|
require_relative "working_set"
|
|
88
|
+
require_relative "participants_watch"
|
|
89
|
+
require_relative "session_participants"
|
|
88
90
|
|
|
89
91
|
module Samagotchi
|
|
90
92
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -218,6 +220,13 @@ module Samagotchi
|
|
|
218
220
|
Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
|
|
219
221
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
220
222
|
@prompt_builder = build_system_prompt(memories, appended_prompt_files)
|
|
223
|
+
# The session's participants (participants.json), read at each turn's
|
|
224
|
+
# start: their names for the messages they wrote.
|
|
225
|
+
@participants = SessionParticipants.new(state_dir: -> { session_state_dir })
|
|
226
|
+
# Whether the system prompt carries the participants line.
|
|
227
|
+
@participants_line = false
|
|
228
|
+
# Who is in the review now, and who came or went (#announce_participants).
|
|
229
|
+
@participants_watch = ParticipantsWatch.new(session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
221
230
|
@session = nil
|
|
222
231
|
@session_observer = SessionObserver.new
|
|
223
232
|
@metrics = SessionMetrics.new
|
|
@@ -414,7 +423,8 @@ module Samagotchi
|
|
|
414
423
|
SystemPrompt.new(profile: -> { profile }, tools: -> { @tools }, session: -> { @session },
|
|
415
424
|
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
416
425
|
model_notes: -> { model_notes }, memories: memories,
|
|
417
|
-
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files
|
|
426
|
+
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files,
|
|
427
|
+
participants: -> { @participants_line })
|
|
418
428
|
end
|
|
419
429
|
private :build_system_prompt
|
|
420
430
|
|
|
@@ -588,9 +598,12 @@ module Samagotchi
|
|
|
588
598
|
# recreate a deleted session's folder; the folder itself may not exist
|
|
589
599
|
# yet before a first turn). Safe from any thread: the recap's, a
|
|
590
600
|
# plugin's, the turn's.
|
|
601
|
+
# A side request counts against the participant who caused it
|
|
602
|
+
# (#side_author), its usage's own author when it names one.
|
|
591
603
|
# @param usage [SideUsage]
|
|
592
604
|
# @return [void]
|
|
593
605
|
def note_side_usage(usage)
|
|
606
|
+
usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
|
|
594
607
|
synchronize_events do
|
|
595
608
|
block = @metrics.add_side(usage)
|
|
596
609
|
@session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
|
|
@@ -602,6 +615,24 @@ module Samagotchi
|
|
|
602
615
|
@metrics.persist(state_dir: session_state_dir)
|
|
603
616
|
end
|
|
604
617
|
|
|
618
|
+
# Who caused a side request of +kind+ now: the running turn's author (a
|
|
619
|
+
# plugin's ctx.ask_model in it), the last turn's for the idle recap
|
|
620
|
+
# (it recaps that turn); nil, the owner's, for anything else (an owner
|
|
621
|
+
# command's ask_model between turns).
|
|
622
|
+
# @return [String, nil]
|
|
623
|
+
def side_author(kind)
|
|
624
|
+
return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
|
|
625
|
+
|
|
626
|
+
Steer.sender_author(@guardrail_wiring.turn_author)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
RECAP_SIDE_KIND = "recap"
|
|
630
|
+
|
|
631
|
+
# The running turn's author when a participant wrote it, else nil
|
|
632
|
+
# (ctx.turn_author, the hooks' author).
|
|
633
|
+
# @return [String, nil]
|
|
634
|
+
def turn_author = turn_running? ? Steer.sender_author(@guardrail_wiring.turn_author) : nil
|
|
635
|
+
|
|
605
636
|
# @return [SessionMetrics] the per-session analytics collector
|
|
606
637
|
attr_reader :metrics
|
|
607
638
|
|
|
@@ -790,7 +821,7 @@ module Samagotchi
|
|
|
790
821
|
# UIs need in the event log, emitted outside any turn's stream.
|
|
791
822
|
ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
|
|
792
823
|
command_queued command_ran context_added card hook_notice guardrail_warning
|
|
793
|
-
plugin_init_started plugin_init_finished working_set_updated].freeze
|
|
824
|
+
plugin_init_started plugin_init_finished working_set_updated participants_changed].freeze
|
|
794
825
|
|
|
795
826
|
# Put a transport-level event into the ordered event log. Unlike turn
|
|
796
827
|
# events it reaches only persistent observers (no turn sink, no memory
|
|
@@ -1179,6 +1210,11 @@ module Samagotchi
|
|
|
1179
1210
|
price_host: model.host_name)
|
|
1180
1211
|
kit_prompt = kit_prompt_stats
|
|
1181
1212
|
snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
|
|
1213
|
+
# Each participant's spend under their name (participants.json's).
|
|
1214
|
+
if (spend = snapshot[:spend_by_author])
|
|
1215
|
+
named = spend.to_h { |author, row| [author, row.merge(name: @participants.name_for(author, session: @session))] }
|
|
1216
|
+
snapshot = snapshot.merge(spend_by_author: named)
|
|
1217
|
+
end
|
|
1182
1218
|
# The session's own settings (chi --set), as --set names them.
|
|
1183
1219
|
settings = @session&.settings&.flat || {}
|
|
1184
1220
|
snapshot = snapshot.merge(settings: settings) unless settings.empty?
|
|
@@ -1295,6 +1331,8 @@ module Samagotchi
|
|
|
1295
1331
|
# that served model under served: (#served_expected_by)
|
|
1296
1332
|
# :recap_enabled [Boolean] whether an idle recap is configured, with
|
|
1297
1333
|
# :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
|
|
1334
|
+
# :participants [Array<Hash>] who is in the review now ({id:, name:,
|
|
1335
|
+
# kind:}; ParticipantsWatch#active), [] for nobody
|
|
1298
1336
|
def session_state_snapshot
|
|
1299
1337
|
served_pair = served_model(probe: false)
|
|
1300
1338
|
metrics = @metrics.snapshot
|
|
@@ -1323,7 +1361,8 @@ module Samagotchi
|
|
|
1323
1361
|
**setting_chips(SessionSetting::ALL, model),
|
|
1324
1362
|
recap_enabled: !@recap.nil?,
|
|
1325
1363
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
1326
|
-
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
1364
|
+
recap_inactivity_seconds: @recap&.inactivity&.to_i,
|
|
1365
|
+
participants: @participants_watch.active
|
|
1327
1366
|
}
|
|
1328
1367
|
end
|
|
1329
1368
|
|
|
@@ -1462,9 +1501,46 @@ module Samagotchi
|
|
|
1462
1501
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1463
1502
|
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
1464
1503
|
enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
|
|
1504
|
+
author: Steer.sender_author(origin.is_a?(Hash) ? origin[:author] : nil),
|
|
1465
1505
|
**stop_facts(event))
|
|
1466
1506
|
end
|
|
1467
1507
|
|
|
1508
|
+
# A participant joined (invited) or left (revoked, expired) since the
|
|
1509
|
+
# last look: :participants_changed to the observers (ParticipantsWatch;
|
|
1510
|
+
# the worker looks on its idle tick). The first look announces nobody.
|
|
1511
|
+
# @return [ParticipantsWatch::Change, nil]
|
|
1512
|
+
def announce_participants
|
|
1513
|
+
change = @participants_watch.changes or return nil
|
|
1514
|
+
|
|
1515
|
+
announce(change.to_event)
|
|
1516
|
+
change
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
# Reads the session's participants at a turn's start; the first to join
|
|
1520
|
+
# and the last to leave rebuild the system prompt (its participants
|
|
1521
|
+
# line), once each: a turn in between sends the same prefix.
|
|
1522
|
+
def refresh_participants!(session)
|
|
1523
|
+
active = @participants.refresh!(session).active?
|
|
1524
|
+
return if active == @participants_line
|
|
1525
|
+
|
|
1526
|
+
@participants_line = active
|
|
1527
|
+
@prompt_builder.reset!
|
|
1528
|
+
end
|
|
1529
|
+
private :refresh_participants!
|
|
1530
|
+
|
|
1531
|
+
# The author fields a message +author+ wrote carries (Steer.author_fields:
|
|
1532
|
+
# author:, and author_name: from participants.json), {} for the owner.
|
|
1533
|
+
# The worker's drain names a participant's merged lines with it, and
|
|
1534
|
+
# its running save a participant's prompt.
|
|
1535
|
+
# @param origin [Hash, String, nil] a turn's origin, or an author
|
|
1536
|
+
# @param session [Session, nil] the session it is for (the turn's)
|
|
1537
|
+
def author_fields(origin, session: @session)
|
|
1538
|
+
author = origin.is_a?(Hash) ? origin[:author] : origin
|
|
1539
|
+
return {} unless Steer.participant_author?(author)
|
|
1540
|
+
|
|
1541
|
+
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1468
1544
|
# Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
|
|
1469
1545
|
# parent agent's wait (ReplyWait): a provider error's kind and whether
|
|
1470
1546
|
# a retry may help, a cancel's reason and the hook that stopped it.
|
|
@@ -1607,7 +1683,8 @@ module Samagotchi
|
|
|
1607
1683
|
event_routes: @event_routes,
|
|
1608
1684
|
question_desk: -> { @question_desk },
|
|
1609
1685
|
cancel_controller: -> { active_cancel_controller },
|
|
1610
|
-
steer: ->(text, source) { steer(text, source: source) }
|
|
1686
|
+
steer: ->(text, source) { steer(text, source: source) },
|
|
1687
|
+
turn_author: -> { turn_author }
|
|
1611
1688
|
)
|
|
1612
1689
|
end
|
|
1613
1690
|
private :build_hook_routes
|
|
@@ -2072,6 +2149,9 @@ module Samagotchi
|
|
|
2072
2149
|
turn_started[:resumed] = true if turn.resumed?
|
|
2073
2150
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
2074
2151
|
turn_started[:cut_after] = steer_cut_after
|
|
2152
|
+
# A participant's turn names them (author, author_name) for the UIs'
|
|
2153
|
+
# badge; the owner's turn_started stays as it was.
|
|
2154
|
+
turn_started.merge!(author_fields(turn.origin, session: session))
|
|
2075
2155
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
2076
2156
|
raise image_error if image_error
|
|
2077
2157
|
|
|
@@ -2103,6 +2183,7 @@ module Samagotchi
|
|
|
2103
2183
|
# prompt built next declares, unchanged until the next turn's start.
|
|
2104
2184
|
apply_staged_tools!
|
|
2105
2185
|
apply_toolkit!(session, turn.on_event)
|
|
2186
|
+
refresh_participants!(session)
|
|
2106
2187
|
# A Stop during the wait above: the turn ends here,
|
|
2107
2188
|
# before the hooks run or a reminder is used up.
|
|
2108
2189
|
return false if turn.controller.cancelled?
|
|
@@ -2222,7 +2303,9 @@ module Samagotchi
|
|
|
2222
2303
|
# Inject due reminders as a tail system message (after history, before
|
|
2223
2304
|
# the new user prompt) to preserve prefix KV cache. Mutating the head
|
|
2224
2305
|
# system prompt invalidates cache for the entire prefix.
|
|
2225
|
-
|
|
2306
|
+
# Not into a participant's turn: the owner's reminders are the owner's,
|
|
2307
|
+
# and would be used up there under the review boundary.
|
|
2308
|
+
due_reminders = Steer.participant_author?(turn.origin&.dig(:author)) ? [] : @reminder_queue.inject!(turn.messages)
|
|
2226
2309
|
if due_reminders.any?
|
|
2227
2310
|
emit_event(turn.on_event, {
|
|
2228
2311
|
type: :reminder_injected,
|
|
@@ -2231,7 +2314,7 @@ module Samagotchi
|
|
|
2231
2314
|
end
|
|
2232
2315
|
|
|
2233
2316
|
unless turn.continue
|
|
2234
|
-
user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
|
|
2317
|
+
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin) }
|
|
2235
2318
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2236
2319
|
turn.messages << user_message
|
|
2237
2320
|
session.last_prompt = turn.prompt
|
|
@@ -2583,12 +2666,18 @@ module Samagotchi
|
|
|
2583
2666
|
end
|
|
2584
2667
|
|
|
2585
2668
|
# Restore the current session's conversation to a #messages_checkpoint.
|
|
2669
|
+
# Between turns the working set is announced when that changed it (a
|
|
2670
|
+
# !rollback or a canceled continue dropping the turn's tool calls, the
|
|
2671
|
+
# set emptied too), so the page doesn't keep the dropped items until
|
|
2672
|
+
# the next request.
|
|
2586
2673
|
# @param checkpoint [Array<Hash>]
|
|
2587
2674
|
# @return [Array<Hash>] the session's messages
|
|
2588
2675
|
def rollback_to(checkpoint)
|
|
2589
2676
|
raise ArgumentError, "no current session" unless @session
|
|
2590
2677
|
|
|
2591
|
-
replace_session_messages(@session, clone_messages(checkpoint))
|
|
2678
|
+
messages = replace_session_messages(@session, clone_messages(checkpoint))
|
|
2679
|
+
announce_working_set unless turn_running?
|
|
2680
|
+
messages
|
|
2592
2681
|
end
|
|
2593
2682
|
|
|
2594
2683
|
# Clone a messages array (shallow dup of each element).
|
|
@@ -2792,7 +2881,8 @@ module Samagotchi
|
|
|
2792
2881
|
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
2793
2882
|
frontend: -> { interface },
|
|
2794
2883
|
cost: -> { @metrics.cost },
|
|
2795
|
-
usage: -> { @metrics.usage }
|
|
2884
|
+
usage: -> { @metrics.usage },
|
|
2885
|
+
turn_author: -> { turn_author }
|
|
2796
2886
|
)
|
|
2797
2887
|
end
|
|
2798
2888
|
|
|
@@ -113,11 +113,21 @@ module Samagotchi
|
|
|
113
113
|
|
|
114
114
|
# The gate's core checks, in order. A delegate child's boundary comes
|
|
115
115
|
# before the rules, so its ask keeps the child-boundary rule id (and
|
|
116
|
-
# its once/session scopes) when a strict rule asks too.
|
|
116
|
+
# its once/session scopes) when a strict rule asks too. The review
|
|
117
|
+
# boundary comes before both: a participant's turn is denied what it
|
|
118
|
+
# may not do, never asked about it (deny is sticky, so no later vote
|
|
119
|
+
# can turn it into a question).
|
|
117
120
|
def checks
|
|
118
121
|
rules = self.rules
|
|
119
|
-
[@failures, rules.hook_asks, protected_paths,
|
|
120
|
-
child_boundary, rules].compact
|
|
122
|
+
[@failures, review_boundary, rules.hook_asks, protected_paths,
|
|
123
|
+
(@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A participant's turn's boundary (Guardrails::ReviewBoundary): in
|
|
127
|
+
# every session, a no-op unless the running turn's author is a
|
|
128
|
+
# participant's.
|
|
129
|
+
def review_boundary
|
|
130
|
+
@review_boundary ||= Guardrails::ReviewBoundary.new(author: -> { turn_author }, root: -> { boundary_root })
|
|
121
131
|
end
|
|
122
132
|
|
|
123
133
|
# The delegate child's boundary (Guardrails::ChildBoundary); nil for
|