samagotchi 0.121.1 → 0.123.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 +67 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +19 -7
- data/docs/cli.md +32 -1
- data/docs/configuration.md +55 -5
- data/docs/guardrails.md +119 -7
- data/docs/internals/engine.md +11 -2
- data/docs/internals/participants.md +76 -20
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/memory.md +33 -0
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +14 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +8 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
- data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/engine.rb +289 -12
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +46 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +85 -0
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/plugin/api.rb +7 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +42 -10
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +14 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_access/remote.rb +32 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
- data/lib/samagotchi/session_manager.rb +21 -7
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +13 -6
- data/lib/samagotchi/system_one/client.rb +16 -7
- data/lib/samagotchi/system_one/log.rb +25 -5
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +11 -1
- data/lib/samagotchi/toolkit.rb +27 -8
- data/lib/samagotchi/toolkit_binding.rb +149 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +320 -0
- data/lib/samagotchi/toolkit_suggest.rb +377 -0
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/update_command.rb +7 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -13
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_access.rb +4 -1
- data/lib/samagotchi/web/participant_review.rb +8 -4
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/app.js +34 -10
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +12 -13
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +7 -5
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +31 -2
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +7 -3
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/session_hub.rb +24 -3
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/web/update_check.rb +179 -0
- data/lib/samagotchi/worker.rb +60 -8
- data/lib/samagotchi/worker_link.rb +1 -0
- metadata +8 -1
|
@@ -14,8 +14,19 @@ module Samagotchi
|
|
|
14
14
|
# a participant's turn; a review session's subjects (chi review, the
|
|
15
15
|
# participant page's Plan) go through the same check.
|
|
16
16
|
class ReviewPaths
|
|
17
|
-
# Home folders that hold keys and tokens, never read in a review turn
|
|
18
|
-
|
|
17
|
+
# Home folders that hold keys and tokens, never read in a review turn
|
|
18
|
+
# (nor read or written in a pair turn).
|
|
19
|
+
# A pair turn neither reads nor writes these either: the shell's and
|
|
20
|
+
# git's config folders, the user's launch agents and ~/.local/bin (what
|
|
21
|
+
# runs at login or comes first on PATH).
|
|
22
|
+
HOME_SECRET_DIRS = %w[.ssh .gnupg .aws .config/gh .config/fish .config/git Library/LaunchAgents .local/bin].freeze
|
|
23
|
+
# Home files that start a shell, hold credentials or keep what was
|
|
24
|
+
# typed: a pair turn neither reads nor writes them (a review turn reads
|
|
25
|
+
# only inside the project anyway).
|
|
26
|
+
HOME_SECRET_FILES = %w[.zshrc .zshenv .zprofile .zlogin .bashrc .bash_profile .bash_login .profile .gitconfig
|
|
27
|
+
.netrc .npmrc .pypirc .claude.json .zsh_history .bash_history .history .python_history
|
|
28
|
+
.node_repl_history .psql_history .mysql_history .irb_history .lesshst
|
|
29
|
+
.local/share/fish/fish_history].freeze
|
|
19
30
|
# Project files that hold secrets, by a path part's name: .env*,
|
|
20
31
|
# netrc, keys and certificates, anything named credentials, package
|
|
21
32
|
# registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
|
|
@@ -67,13 +78,34 @@ module Samagotchi
|
|
|
67
78
|
nil
|
|
68
79
|
end
|
|
69
80
|
|
|
70
|
-
|
|
81
|
+
# SECRET when one of +paths+ is on the read denylist (as given or as
|
|
82
|
+
# it resolves), wherever it is; nil otherwise. A pair turn's check
|
|
83
|
+
# (PairBoundary): it reads outside the project too, never these.
|
|
84
|
+
# @param paths [Array<String>] absolute, or relative to the cwd
|
|
85
|
+
# @return [Symbol, nil]
|
|
86
|
+
def secret_refusal(paths)
|
|
87
|
+
paths.each do |path|
|
|
88
|
+
given = File.expand_path(path)
|
|
89
|
+
return SECRET if secret?(given) || secret?(ProtectedPaths.real(given))
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The denylist's dirs and home files, absolute (as given and as they
|
|
95
|
+
# resolve): a pair turn's command check asks whether a word it can't
|
|
96
|
+
# resolve (a glob, a $var) may lead into one.
|
|
97
|
+
# @return [Array<String>]
|
|
98
|
+
def denied_roots
|
|
99
|
+
@denied_roots ||= begin
|
|
100
|
+
roots = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
|
|
101
|
+
roots += (HOME_SECRET_DIRS + HOME_SECRET_FILES).map { |name| File.join(@home, name) } unless @home.empty?
|
|
102
|
+
(roots + roots.map { |root| ProtectedPaths.real(root) }).uniq
|
|
103
|
+
end
|
|
104
|
+
end
|
|
71
105
|
|
|
106
|
+
# Whether +path+ (absolute) is on the read denylist.
|
|
72
107
|
def secret?(path)
|
|
73
|
-
|
|
74
|
-
dirs += dirs.map { |dir| ProtectedPaths.real(dir) }
|
|
75
|
-
dirs += HOME_SECRET_DIRS.map { |dir| File.join(@home, dir) } unless @home.empty?
|
|
76
|
-
return true if dirs.any? { |dir| ShellPaths.within?(path, dir) }
|
|
108
|
+
return true if denied_roots.any? { |root| ShellPaths.within?(path, root) }
|
|
77
109
|
|
|
78
110
|
parts = path.split(File::SEPARATOR)
|
|
79
111
|
return true if parts.any? { |part| SECRET_FILE.match?(part) }
|
|
@@ -82,6 +114,8 @@ module Samagotchi
|
|
|
82
114
|
git_config?(parts)
|
|
83
115
|
end
|
|
84
116
|
|
|
117
|
+
private
|
|
118
|
+
|
|
85
119
|
# A config file anywhere under a .git dir (.git/config,
|
|
86
120
|
# .git/modules/<sub>/config, a worktree's config.worktree).
|
|
87
121
|
def git_config?(parts)
|
|
@@ -87,8 +87,10 @@ module Samagotchi
|
|
|
87
87
|
# as data (DataReaders) cut out, the <<TAG word and the terminator line
|
|
88
88
|
# kept as written. This is what a rule's command: reads. A text the
|
|
89
89
|
# lexer isn't sure of (unterminated) comes back as it was, and so does
|
|
90
|
-
# one it can't read: the safe default.
|
|
91
|
-
|
|
90
|
+
# one it can't read: the safe default. +keep_written+ false cuts every
|
|
91
|
+
# data body, a cat or tee writing it to a file too (a pair turn's check:
|
|
92
|
+
# text written to a file is data; where it goes is checked apart).
|
|
93
|
+
def self.code_text(text, keep_written: true)
|
|
92
94
|
lexer = Lexer.new(text)
|
|
93
95
|
tokens = lexer.tokens
|
|
94
96
|
return text if lexer.unterminated?
|
|
@@ -104,7 +106,7 @@ module Samagotchi
|
|
|
104
106
|
|
|
105
107
|
span = lexer.spans[i]
|
|
106
108
|
cut = if bodies.include?(span)
|
|
107
|
-
body_cut(text, span, words, redirected) if value == HEREDOC
|
|
109
|
+
body_cut(text, span, words, redirected, keep_written) if value == HEREDOC
|
|
108
110
|
elsif value == SUBST
|
|
109
111
|
substitution_cut(text, tokens, i, span, words, position)
|
|
110
112
|
end
|
|
@@ -139,8 +141,8 @@ module Samagotchi
|
|
|
139
141
|
# cat or tee writing a file may be run later (D1), and so may one
|
|
140
142
|
# printing after an earlier exec sent the shell's output to a file
|
|
141
143
|
# (+redirected+).
|
|
142
|
-
def self.body_cut(text, span, words, redirected)
|
|
143
|
-
return nil
|
|
144
|
+
def self.body_cut(text, span, words, redirected, keep_written = true)
|
|
145
|
+
return nil if keep_written && !cut_body?(words, redirected)
|
|
144
146
|
|
|
145
147
|
[span, terminator_line(text[span])]
|
|
146
148
|
end
|
|
@@ -41,13 +41,22 @@ module Samagotchi
|
|
|
41
41
|
# @param tmp_roots [Array<String>] where a .git/hooks part doesn't count
|
|
42
42
|
def touches?(command, dirs:, text:, cwd:, home: Dir.home, env: ENV, tmp_roots: [])
|
|
43
43
|
roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
|
|
44
|
+
any_path?(command, text: text, cwd: cwd, home: home, env: env) { |path| names?(path, roots, tmp_roots) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether a word of +command+ names a path the block holds for (each
|
|
48
|
+
# word resolved as #touches? resolves it); a word that can't be
|
|
49
|
+
# resolved, a command with a $(…) or backticks, or one that doesn't
|
|
50
|
+
# lex, by the text match +text+ instead.
|
|
51
|
+
# @yieldparam path [String] an absolute path (not resolved for symlinks)
|
|
52
|
+
def any_path?(command, text:, cwd:, home: Dir.home, env: ENV)
|
|
44
53
|
walk = Walk.new(cwd, home, env)
|
|
45
54
|
tokens = ShellLex.lex(command.to_s)
|
|
46
55
|
code = ShellLex.code_text(command.to_s)
|
|
47
56
|
return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(text)
|
|
48
57
|
|
|
49
58
|
ShellLex.simple_commands(tokens).any? do |words|
|
|
50
|
-
walk.step(words).any? { |word, path| path ?
|
|
59
|
+
walk.step(words).any? { |word, path| path ? yield(path) : word.match?(text) }
|
|
51
60
|
end
|
|
52
61
|
rescue ArgumentError, EncodingError
|
|
53
62
|
command.to_s.scrub.match?(text)
|
|
@@ -38,6 +38,13 @@ module Samagotchi
|
|
|
38
38
|
# System 1's judgment that decided an ask, when it did: {id:, p:}.
|
|
39
39
|
attr_accessor :judgment
|
|
40
40
|
|
|
41
|
+
# An ask no stored approval answers (Gate#settle_ask): the one a
|
|
42
|
+
# participant's pair turn gets before a write outside the project
|
|
43
|
+
# (PairBoundary), which the owner decides each time.
|
|
44
|
+
attr_writer :fresh_ask
|
|
45
|
+
|
|
46
|
+
def fresh_ask? = @fresh_ask == true
|
|
47
|
+
|
|
41
48
|
def initialize(call:)
|
|
42
49
|
@call = call
|
|
43
50
|
@decision = :allow
|
|
@@ -14,6 +14,7 @@ require_relative "guardrails/protected_paths"
|
|
|
14
14
|
require_relative "guardrails/scratch_writes"
|
|
15
15
|
require_relative "guardrails/child_boundary"
|
|
16
16
|
require_relative "guardrails/review_boundary"
|
|
17
|
+
require_relative "guardrails/pair_boundary"
|
|
17
18
|
require_relative "guardrails/load_failures"
|
|
18
19
|
require_relative "guardrails/rules"
|
|
19
20
|
require_relative "guardrails/gate"
|
|
@@ -66,6 +66,7 @@ module Samagotchi
|
|
|
66
66
|
parent_cannot_allow: "answered by a parent that could not allow",
|
|
67
67
|
unanswered: "unanswered",
|
|
68
68
|
no_approval: "no approval card",
|
|
69
|
+
participant: "asked by a participant's turn",
|
|
69
70
|
no_question_id: "no question id"
|
|
70
71
|
}.freeze
|
|
71
72
|
|
|
@@ -200,13 +201,17 @@ module Samagotchi
|
|
|
200
201
|
|
|
201
202
|
# One card entry as an ask to judge: an Ask, a skip reason (:already_replayed,
|
|
202
203
|
# :judged_live, :answered_by_parent, :parent_cannot_allow, :unanswered,
|
|
203
|
-
# :no_approval, :no_question_id), or nil for an entry that is not a
|
|
204
|
+
# :no_approval, :participant, :no_question_id), or nil for an entry that is not a
|
|
204
205
|
# question at all.
|
|
205
206
|
def ask_of(entry, session_id, judged, sessions_dir)
|
|
206
207
|
return nil unless entry.is_a?(Hash) && entry[:type] == "question"
|
|
207
208
|
|
|
208
209
|
pending = entry[:pending_question]
|
|
209
210
|
return :no_approval unless pending.is_a?(Hash) && pending[:approval].is_a?(Hash)
|
|
211
|
+
# A participant's turn's ask is never System 1's to judge, live or here
|
|
212
|
+
# (GuardrailWiring#system_one_judge): no record of it is the user's
|
|
213
|
+
# rule-level decision (it offered once only).
|
|
214
|
+
return :participant if pending[:approval][:author]
|
|
210
215
|
|
|
211
216
|
question_id = pending[:id].to_s
|
|
212
217
|
return :no_question_id if question_id.empty?
|
|
@@ -67,6 +67,10 @@ module Samagotchi
|
|
|
67
67
|
|
|
68
68
|
def reminder_store = @kernel.reminder_store
|
|
69
69
|
def peers = @kernel.peers
|
|
70
|
+
|
|
71
|
+
# The running participant turn's mode (KernelLoop#participant_mode:
|
|
72
|
+
# :pair, :review), nil for the owner's turns.
|
|
73
|
+
def participant_mode = @kernel.participant_mode&.call
|
|
70
74
|
def model_key = @kernel.model_key
|
|
71
75
|
|
|
72
76
|
# memory_read with the session's mutes applied: a blank name (the index)
|
|
@@ -179,6 +183,21 @@ module Samagotchi
|
|
|
179
183
|
# Whether the current turn belongs to a participant, so pre-gate
|
|
180
184
|
# refusals can defer to ReviewBoundary.
|
|
181
185
|
attr_accessor :participant_turn
|
|
186
|
+
# The running participant turn's mode (a callable → :pair, :review or
|
|
187
|
+
# nil: GuardrailWiring#turn_mode): ToolRunner reads it. The Engine sets it.
|
|
188
|
+
attr_accessor :participant_mode
|
|
189
|
+
# Called before each generation, before the before_generation hook (a
|
|
190
|
+
# callable, nil: none): the Engine's check that may stop the turn (a
|
|
191
|
+
# pair turn past its participant's cap, Engine#guard_participant_spend).
|
|
192
|
+
attr_accessor :generation_guard
|
|
193
|
+
|
|
194
|
+
# The generation guard, if any; one that raises never breaks the turn.
|
|
195
|
+
def guard_generation
|
|
196
|
+
@generation_guard&.call
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
Log.warn(:turn, "generation_guard_failed", error: "#{e.class}: #{e.message}")
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
182
201
|
|
|
183
202
|
def participant_turn? = @participant_turn&.call == true
|
|
184
203
|
# @return [LLM::TurnSettings] the turn's vision, sampling, thinking and
|
|
@@ -426,6 +445,7 @@ module Samagotchi
|
|
|
426
445
|
def generate(turn, request)
|
|
427
446
|
# Before the generation starts: a hook that waits (a question) isn't
|
|
428
447
|
# the generation's time, and a Stop there leaves none started.
|
|
448
|
+
guard_generation
|
|
429
449
|
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
430
450
|
raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller&.cancelled?
|
|
431
451
|
|
|
@@ -389,6 +389,9 @@ module Samagotchi
|
|
|
389
389
|
nil
|
|
390
390
|
end
|
|
391
391
|
|
|
392
|
+
# The kernel's generation guard (KernelLoop#guard_generation).
|
|
393
|
+
def guard_generation = @kernel.respond_to?(:guard_generation) ? @kernel.guard_generation : nil
|
|
394
|
+
|
|
392
395
|
# A hook failing must not break the turn (as in ToolRunner).
|
|
393
396
|
def fire_hook(name, event)
|
|
394
397
|
@kernel.hooks&.fire(name, event)
|
|
@@ -727,6 +730,7 @@ module Samagotchi
|
|
|
727
730
|
observe_context(iteration, window)
|
|
728
731
|
# Before the generation starts: a hook that waits (a question)
|
|
729
732
|
# isn't the generation's time, and a Stop there leaves none started.
|
|
733
|
+
@loop.guard_generation
|
|
730
734
|
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
731
735
|
return [@cancel_controller.reason, "", ""] if @cancel_controller&.cancelled?
|
|
732
736
|
|
data/lib/samagotchi/log_line.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# (debug dumps) are indented by four spaces and belong to the record above.
|
|
17
17
|
module LogLine
|
|
18
18
|
LEVELS = %w[DEBUG INFO WARN ERROR].freeze
|
|
19
|
-
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast].freeze
|
|
19
|
+
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast toolkit].freeze
|
|
20
20
|
INDENT = " "
|
|
21
21
|
|
|
22
22
|
Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
|
|
@@ -51,6 +51,17 @@ module Samagotchi
|
|
|
51
51
|
found && text(found)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# A model-facing hint: report the name collision without giving the model
|
|
55
|
+
# a command that could move another live clone's memories.
|
|
56
|
+
def model_message(env: ENV, cwd: Dir.pwd)
|
|
57
|
+
found = find(env: env, cwd: cwd)
|
|
58
|
+
return unless found
|
|
59
|
+
|
|
60
|
+
name = File.basename(found.computed).sub(/_\h{8}\z/, "")
|
|
61
|
+
"a folder with the same project name (#{name}) exists with another path's hash; ask the user whether its " \
|
|
62
|
+
"memories belong to this project before using them."
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
def text(found)
|
|
55
66
|
computed = found.computed
|
|
56
67
|
lead = "no project memories in #{computed}"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "delivery"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The rules for a participant's turn: the one place that decides them.
|
|
7
|
+
# - :review — a review session (its review: key, chi review): the turn
|
|
8
|
+
# only reads (Guardrails::ReviewBoundary);
|
|
9
|
+
# - :pair — a working session: the turn acts with the session's tools
|
|
10
|
+
# under the owner's guardrails, asks go to the owner
|
|
11
|
+
# (Guardrails::PairBoundary keeps secrets and owner-voiced tools out).
|
|
12
|
+
# No session (nil) is :review. A worker that doesn't name
|
|
13
|
+
# Delivery::PAIR_FEATURE runs every participant's turn as :review, so
|
|
14
|
+
# chi web and chi share ask .live, which reads the session's live worker,
|
|
15
|
+
# before they say what a participant's turn may do.
|
|
16
|
+
#
|
|
17
|
+
# .of is the session's: what a participant invited now gets. A turn's is
|
|
18
|
+
# .for_turn: :pair only for a participant whose grant was issued for it
|
|
19
|
+
# (Participants::Entry#mode / Grant#mode "pair", stamped at invite when
|
|
20
|
+
# the owner was shown that their turns act); a grant from before, or one
|
|
21
|
+
# issued while the session's worker predated pair turns, stays :review.
|
|
22
|
+
module ParticipantMode
|
|
23
|
+
REVIEW = :review
|
|
24
|
+
PAIR = :pair
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param session [Session, nil]
|
|
29
|
+
# @param features [Array<String>, nil] the features of the worker that
|
|
30
|
+
# runs the turn (WorkerSidecar#features); nil: this chi's own (the
|
|
31
|
+
# worker itself, or the one a delivery will start)
|
|
32
|
+
# @return [Symbol] :review or :pair
|
|
33
|
+
def of(session, features: nil)
|
|
34
|
+
return REVIEW if session.nil? || !session.respond_to?(:review?) || session.review?
|
|
35
|
+
return REVIEW if features && !features.include?(Delivery::PAIR_FEATURE)
|
|
36
|
+
|
|
37
|
+
PAIR
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The mode a turn of the participant +entry+ (a Participants::Entry or a
|
|
41
|
+
# Web::Grants::Grant; nil: unknown) runs under in +session+.
|
|
42
|
+
# @return [Symbol]
|
|
43
|
+
def for_turn(session, entry, features: nil)
|
|
44
|
+
return REVIEW unless of(session, features: features) == PAIR
|
|
45
|
+
return REVIEW unless entry.respond_to?(:mode) && entry.mode.to_s == PAIR.to_s
|
|
46
|
+
|
|
47
|
+
PAIR
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# .for_turn as chi web sees it, by the live worker's features (.live).
|
|
51
|
+
def live_for(session, entry, state_dir: nil)
|
|
52
|
+
for_turn(session, entry, features: live_features(session, state_dir))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The mode a participant invited to +session+ now gets, as chi web and
|
|
56
|
+
# chi share see it: by the session's live worker's features (an older
|
|
57
|
+
# worker runs it as :review), else by this chi's (no live worker: the
|
|
58
|
+
# one a delivery starts is this chi's).
|
|
59
|
+
# @param state_dir [String, nil] the sessions' state dir (nil: the default)
|
|
60
|
+
# @return [Symbol]
|
|
61
|
+
def live(session, state_dir: nil)
|
|
62
|
+
of(session, features: live_features(session, state_dir))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether +session+ is a working session whose live worker still runs a
|
|
66
|
+
# participant's turn as a review (it predates Delivery::PAIR_FEATURE).
|
|
67
|
+
def older_worker?(session, state_dir: nil)
|
|
68
|
+
of(session) == PAIR && live(session, state_dir: state_dir) == REVIEW
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The live worker's features, nil without a live worker (or when it
|
|
72
|
+
# can't be read).
|
|
73
|
+
def live_features(session, state_dir)
|
|
74
|
+
return nil if session.nil? || !session.respond_to?(:id) || session.id.to_s.empty?
|
|
75
|
+
|
|
76
|
+
require_relative "session"
|
|
77
|
+
require_relative "worker_link"
|
|
78
|
+
dir = Session.session_dir(session.id, state_dir: state_dir || Session.default_state_dir)
|
|
79
|
+
link = WorkerLink.resolve(dir, unlink: false)
|
|
80
|
+
link.live? ? link.features : nil
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -36,7 +36,9 @@ module Samagotchi
|
|
|
36
36
|
# +expires_at+ says when). +cap+: their spending cap in dollars (the
|
|
37
37
|
# worker's check before their queued turn starts), nil in a sidecar
|
|
38
38
|
# from before caps.
|
|
39
|
-
|
|
39
|
+
# +mode+: "pair" when the owner invited them to act (Grant#mode); nil
|
|
40
|
+
# for a review participant and for a sidecar from before it.
|
|
41
|
+
Entry = Data.define(:id, :kind, :name, :role, :added_at, :expires_at, :removed_at, :cap, :mode) do
|
|
40
42
|
def self.from_h(hash) = new(**members.to_h { |key| [key, hash[key.to_s]] })
|
|
41
43
|
|
|
42
44
|
# Still in the review at +now+.
|
|
@@ -72,7 +74,7 @@ module Samagotchi
|
|
|
72
74
|
entries = grants.map do |grant|
|
|
73
75
|
{ "id" => grant.id, "kind" => grant.kind, "name" => grant.name, "role" => grant.role,
|
|
74
76
|
"added_at" => grant.created_at, "expires_at" => grant.expires_at, "removed_at" => grant.revoked_at,
|
|
75
|
-
"cap" => grant.spend_cap }
|
|
77
|
+
"cap" => grant.spend_cap, **(grant.mode ? { "mode" => grant.mode } : {}) }
|
|
76
78
|
end
|
|
77
79
|
AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate({ "participants" => entries })}\n", perm: 0o600)
|
|
78
80
|
end
|
|
@@ -106,7 +108,7 @@ module Samagotchi
|
|
|
106
108
|
# (Unbindable: an identity without such a public_url)
|
|
107
109
|
# @raise [ArgumentError] no such session
|
|
108
110
|
def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil, identity: nil, public_url: nil,
|
|
109
|
-
state_dir: Session.default_state_dir)
|
|
111
|
+
mode: nil, state_dir: Session.default_state_dir)
|
|
110
112
|
raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
|
|
111
113
|
|
|
112
114
|
identity = Web::Grants.clean_identity(identity) unless identity.nil?
|
|
@@ -116,11 +118,11 @@ module Samagotchi
|
|
|
116
118
|
end
|
|
117
119
|
|
|
118
120
|
issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap, identity: identity,
|
|
119
|
-
on_write: sidecar_writer(state_dir, [session_id]))
|
|
121
|
+
mode: mode, on_write: sidecar_writer(state_dir, [session_id]))
|
|
120
122
|
grant = issued.grant
|
|
121
123
|
log(session_id, :issued, state_dir: state_dir, participant: grant.id, name: grant.name, kind: grant.kind,
|
|
122
124
|
role: grant.role, expires_at: grant.expires_at, cap: grant.spend_cap,
|
|
123
|
-
**(grant.identity ? { identity: grant.identity } : {}))
|
|
125
|
+
**(grant.identity ? { identity: grant.identity } : {}), **(mode ? { mode: mode } : {}))
|
|
124
126
|
issued
|
|
125
127
|
end
|
|
126
128
|
|
|
@@ -312,17 +312,20 @@ module Samagotchi
|
|
|
312
312
|
# a bundle has) with +add+ declared and +remove+ left out, and
|
|
313
313
|
# +prompt+: text the sessions on it get in their system prompt (nil:
|
|
314
314
|
# its base's, "" none; at most Toolkit::PROMPT_MAX characters);
|
|
315
|
-
# +groups+: MCP source group names/globs (nil inherits its base)
|
|
315
|
+
# +groups+: MCP source group names/globs (nil inherits its base);
|
|
316
|
+
# +description+: optional classifier text, not included in prompts
|
|
317
|
+
# (at most Toolkit::DESCRIPTION_MAX characters).
|
|
316
318
|
# A
|
|
317
319
|
# name chi (full, lean, child) or another bundle has is a load error,
|
|
318
320
|
# and so is a prompt that isn't text or is too long; a base that
|
|
319
321
|
# doesn't resolve leaves the sessions on it with every tool
|
|
320
322
|
# (Toolkit#resolve).
|
|
321
|
-
def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
|
|
323
|
+
def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil, description: nil)
|
|
322
324
|
name = name.to_s
|
|
323
325
|
raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
|
|
324
326
|
|
|
325
|
-
problem = Toolkit.prompt_error(prompt) ||
|
|
327
|
+
problem = Toolkit.prompt_error(prompt) || Toolkit.description_error(description) ||
|
|
328
|
+
(Toolkit.groups_error(groups) unless groups == :all)
|
|
326
329
|
raise ArgumentError, "toolkit #{name}: #{problem}" if problem
|
|
327
330
|
raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
|
|
328
331
|
if Toolkit::BUILTIN_KITS.key?(name)
|
|
@@ -333,7 +336,7 @@ module Samagotchi
|
|
|
333
336
|
raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
|
|
334
337
|
|
|
335
338
|
@toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
|
|
336
|
-
groups: groups, source: @bundle)
|
|
339
|
+
groups: groups, source: @bundle, description: description)
|
|
337
340
|
nil
|
|
338
341
|
end
|
|
339
342
|
|
|
@@ -87,6 +87,19 @@ module Samagotchi
|
|
|
87
87
|
nil
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Whether +pid+ is stopped (SIGSTOP, Ctrl-Z; t: under a tracer): ps's
|
|
91
|
+
# state starts with T or t on macOS and Linux alike. Such a process
|
|
92
|
+
# takes no signal but KILL and answers no socket until it is continued.
|
|
93
|
+
# false when it isn't running or ps can't say.
|
|
94
|
+
def stopped?(pid)
|
|
95
|
+
return false unless signalable?(pid)
|
|
96
|
+
|
|
97
|
+
out, status = Open3.capture2("ps", "-o", "state=", "-p", pid.to_s, err: File::NULL)
|
|
98
|
+
status.success? && out.lstrip.start_with?("T", "t")
|
|
99
|
+
rescue SystemCallError, IOError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
90
103
|
# Field 22 (starttime); the command name (field 2) may hold spaces and
|
|
91
104
|
# parentheses, so the fields are counted after its last ")".
|
|
92
105
|
def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "participant_name"
|
|
4
4
|
require_relative "steer"
|
|
5
|
+
require_relative "participant_mode"
|
|
5
6
|
require_relative "output_formatter"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
@@ -76,7 +77,7 @@ module Samagotchi
|
|
|
76
77
|
|
|
77
78
|
def latest(list)
|
|
78
79
|
list.each_index.reverse_each do |at|
|
|
79
|
-
next unless opening?(list[at]) &&
|
|
80
|
+
next unless opening?(list[at]) && review_turn?(list[at])
|
|
80
81
|
|
|
81
82
|
proposal = proposal_at(list, at, check: false)
|
|
82
83
|
return proposal if proposal
|
|
@@ -88,7 +89,7 @@ module Samagotchi
|
|
|
88
89
|
def proposal_at(list, at, check: true)
|
|
89
90
|
opening = list[at]
|
|
90
91
|
author = field(opening, :author)
|
|
91
|
-
if check && !
|
|
92
|
+
if check && !review_turn?(opening)
|
|
92
93
|
raise Refused.new(format(NOT_REVIEW, short(field(opening, :turn_id))), code: :not_review_turn)
|
|
93
94
|
end
|
|
94
95
|
|
|
@@ -99,6 +100,13 @@ module Samagotchi
|
|
|
99
100
|
author_name: field(opening, :author_name).to_s, text: text)
|
|
100
101
|
end
|
|
101
102
|
|
|
103
|
+
# Whether the turn +opening+ opens is a participant's review turn: a
|
|
104
|
+
# participant's, not stamped pair (a pair turn acted itself: there is
|
|
105
|
+
# nothing to apply). A message from before the stamp is a review's.
|
|
106
|
+
def review_turn?(opening)
|
|
107
|
+
Steer.participant_author?(field(opening, :author)) && field(opening, :participant_mode).to_s != ParticipantMode::PAIR.to_s
|
|
108
|
+
end
|
|
109
|
+
|
|
102
110
|
# The turn's reply: its last model message with visible text, before
|
|
103
111
|
# the next turn opens.
|
|
104
112
|
def reply_text(list, at)
|
|
@@ -7,6 +7,7 @@ require_relative "session_chain"
|
|
|
7
7
|
require_relative "recap_store"
|
|
8
8
|
require_relative "guardrails/context"
|
|
9
9
|
require_relative "guardrails/review_paths"
|
|
10
|
+
require_relative "participant_mode"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
@@ -75,6 +76,7 @@ module Samagotchi
|
|
|
75
76
|
subjects = check_subjects(subjects, dir: dir)
|
|
76
77
|
raise Refused, NO_SUBJECTS if subjects.empty?
|
|
77
78
|
|
|
79
|
+
setup = setup.with(toolkit_origin: "chosen")
|
|
78
80
|
SessionManager.spawn_session(prompt: nil, preview: preview(subjects), working_directory: dir, model_name: model,
|
|
79
81
|
setup: setup, review: SessionReview.new(subjects: subjects),
|
|
80
82
|
note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir)
|
|
@@ -103,7 +105,8 @@ module Samagotchi
|
|
|
103
105
|
recap = RecapStore.read(Session.session_dir(source.id, state_dir: sd))
|
|
104
106
|
session = SessionManager.spawn_session(
|
|
105
107
|
prompt: nil, preview: preview(subjects, source: source), working_directory: dir, model_name: choice.model,
|
|
106
|
-
setup: SessionSetup.of(source)
|
|
108
|
+
setup: SessionSetup.of(source).with(toolkit_origin: "chosen"),
|
|
109
|
+
review: SessionReview.new(subjects: subjects, from: source.id),
|
|
107
110
|
note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE, state_dir: sd
|
|
108
111
|
)
|
|
109
112
|
warnings = [choice.warning, session.model_warning].compact
|
|
@@ -138,20 +141,49 @@ module Samagotchi
|
|
|
138
141
|
parts.join("\n\n")
|
|
139
142
|
end
|
|
140
143
|
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
144
|
+
# What inviting a participant to +session+ now means: the grant's mode
|
|
145
|
+
# (Web::Grants::PAIR_MODE only when the owner is shown PAIR_WARNING: a
|
|
146
|
+
# working session whose live worker runs pair turns; nil otherwise, a
|
|
147
|
+
# review participant), the mode their turns get (:pair or :review) and
|
|
148
|
+
# the warning shown for it (nil for a review session). One read of the
|
|
149
|
+
# live worker for all three, so the stamp is what the warning said.
|
|
150
|
+
ShareTerms = Data.define(:grant_mode, :mode, :warning)
|
|
151
|
+
|
|
145
152
|
# @param session [Session]
|
|
146
|
-
# @
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
# @param state_dir [String, nil] the sessions' state dir (nil: the default)
|
|
154
|
+
# @return [ShareTerms]
|
|
155
|
+
def share_terms(session, state_dir: nil)
|
|
156
|
+
return ShareTerms.new(grant_mode: nil, mode: ParticipantMode::REVIEW, warning: nil) if session.review?
|
|
149
157
|
|
|
150
158
|
count = session.tool_output_count
|
|
151
|
-
"#{
|
|
152
|
-
|
|
159
|
+
outputs = "#{count} tool output#{"s" unless count == 1} so far"
|
|
160
|
+
pair = ParticipantMode.live(session, state_dir: state_dir) == ParticipantMode::PAIR
|
|
161
|
+
warning = format(pair ? PAIR_WARNING : OLDER_WORKER_WARNING, id: session.id[0, 8], outputs: outputs)
|
|
162
|
+
ShareTerms.new(grant_mode: (ParticipantMode::PAIR.to_s if pair), mode: pair ? ParticipantMode::PAIR : ParticipantMode::REVIEW,
|
|
163
|
+
warning: warning)
|
|
153
164
|
end
|
|
154
165
|
|
|
166
|
+
# The warning for inviting a participant to +session+ when it isn't a
|
|
167
|
+
# review session (D2: sharing a working session is allowed, with a
|
|
168
|
+
# warning that says what a participant's turn does there and how many
|
|
169
|
+
# tool outputs its history holds); nil for a review session
|
|
170
|
+
# (#share_terms). chi share and the web's Invite show it as it is.
|
|
171
|
+
# @return [String, nil]
|
|
172
|
+
def share_warning(session, state_dir: nil) = share_terms(session, state_dir: state_dir).warning
|
|
173
|
+
|
|
174
|
+
# A working session's share warning (#share_warning): a participant's
|
|
175
|
+
# turns act there. chi share adds the command that starts a review
|
|
176
|
+
# session (chi review --from ID), as its invocation spells it.
|
|
177
|
+
PAIR_WARNING = "%<id>s is a working session: a participant's turns act in it with this session's tools " \
|
|
178
|
+
"(writes included; asks come to you) and read what its history holds (%<outputs>s). " \
|
|
179
|
+
"A review session only reads."
|
|
180
|
+
# The same while its live worker predates pair turns (Delivery::PAIR_FEATURE):
|
|
181
|
+
# a participant invited now gets review turns, whatever happens later.
|
|
182
|
+
OLDER_WORKER_WARNING = "%<id>s is a working session, but its worker predates pair turns: a participant invited " \
|
|
183
|
+
"now only reads, and can quote what its history holds (%<outputs>s). For turns that act " \
|
|
184
|
+
"in it, restart it (chi sessions restart %<id>s) and invite them again. A review session " \
|
|
185
|
+
"only reads."
|
|
186
|
+
|
|
155
187
|
def relative(full, root)
|
|
156
188
|
real_root = Guardrails::ProtectedPaths.real(root)
|
|
157
189
|
real = Guardrails::ProtectedPaths.real(full)
|
|
@@ -166,6 +166,7 @@ module Samagotchi
|
|
|
166
166
|
chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
|
|
167
167
|
chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
|
|
168
168
|
chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
|
|
169
|
+
chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
|
|
169
170
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
170
171
|
chi models [--format json] [TEXT] the models --model takes, from every host
|
|
171
172
|
chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Samagotchi
|
|
|
32
32
|
STATUS_RUNNING = "running"
|
|
33
33
|
STATUS_ERROR = "error"
|
|
34
34
|
STATUS_STOPPED = "stopped"
|
|
35
|
+
TOOLKIT_ORIGINS = %w[chosen default inherited suggested].freeze
|
|
35
36
|
|
|
36
37
|
# A stopped session's marker, <session dir>/stopped: a stop (chi
|
|
37
38
|
# sessions stop, from another process) creates it instead of rewriting
|
|
@@ -222,6 +223,15 @@ module Samagotchi
|
|
|
222
223
|
@toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
|
|
223
224
|
end
|
|
224
225
|
|
|
226
|
+
# Why this session is on its toolkit. Files from before the field read
|
|
227
|
+
# as chosen, so an old default session is never newly suggested.
|
|
228
|
+
attr_reader :toolkit_origin
|
|
229
|
+
|
|
230
|
+
def toolkit_origin=(value)
|
|
231
|
+
origin = value.to_s
|
|
232
|
+
@toolkit_origin = TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
|
|
233
|
+
end
|
|
234
|
+
|
|
225
235
|
# The session's own settings (chi --set), a SessionConfig::Settings:
|
|
226
236
|
# empty for none and for a session from before them. Its core part
|
|
227
237
|
# ({key => value}: recap.enabled) is the file's config_settings, its
|
|
@@ -322,7 +332,7 @@ module Samagotchi
|
|
|
322
332
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
323
333
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
324
334
|
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
325
|
-
toolkit: nil, settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
|
|
335
|
+
toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
|
|
326
336
|
working_set_pins: [], review: nil)
|
|
327
337
|
@id = id
|
|
328
338
|
@metadata_version = metadata_version
|
|
@@ -355,6 +365,7 @@ module Samagotchi
|
|
|
355
365
|
self.reasoning_history = reasoning_history
|
|
356
366
|
self.appended_prompt_files = appended_prompt_files
|
|
357
367
|
self.toolkit = toolkit
|
|
368
|
+
self.toolkit_origin = toolkit_origin
|
|
358
369
|
self.settings = settings
|
|
359
370
|
self.parent_answers = parent_answers
|
|
360
371
|
self.working_set_pins = working_set_pins
|
|
@@ -464,6 +475,7 @@ module Samagotchi
|
|
|
464
475
|
reasoning_history: setup.reasoning_history,
|
|
465
476
|
appended_prompt_files: setup.appended_prompt_files,
|
|
466
477
|
toolkit: setup.toolkit,
|
|
478
|
+
toolkit_origin: setup.toolkit_origin || "chosen",
|
|
467
479
|
settings: setup.settings,
|
|
468
480
|
parent_answers: setup.parent_answers,
|
|
469
481
|
working_set_pins: setup.working_set_pins,
|
|
@@ -534,6 +546,7 @@ module Samagotchi
|
|
|
534
546
|
"reasoning_history" => nil,
|
|
535
547
|
"appended_prompt_files" => [],
|
|
536
548
|
"toolkit" => nil,
|
|
549
|
+
"toolkit_origin" => "chosen",
|
|
537
550
|
"bundle_settings" => {},
|
|
538
551
|
"config_settings" => {},
|
|
539
552
|
"interrupted_turn" => nil,
|