samagotchi 0.85.0 → 0.87.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 +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +51 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +112 -39
- data/docs/hooks.md +19 -3
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +19 -7
- data/docs/sub-agent.md +39 -25
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge.rb +22 -11
- data/lib/samagotchi/bridge_client.rb +2 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +3 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +4 -4
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +5 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_answers_command.rb +80 -0
- data/lib/samagotchi/parent_report.rb +122 -30
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +11 -5
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +39 -25
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +14 -5
- data/lib/samagotchi/send_command.rb +103 -12
- data/lib/samagotchi/session.rb +26 -2
- data/lib/samagotchi/session_commands.rb +45 -8
- data/lib/samagotchi/session_setup.rb +10 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +20 -11
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +16 -7
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -10
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/stream_handlers.js +1 -1
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +27 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -13,7 +13,9 @@ require_relative "commands/registry"
|
|
|
13
13
|
require_relative "llm_context_command"
|
|
14
14
|
require_relative "thinking_command"
|
|
15
15
|
require_relative "toolkit_command"
|
|
16
|
+
require_relative "parent_answers_command"
|
|
16
17
|
require_relative "reasoning_history_command"
|
|
18
|
+
require_relative "guardrails/parent_policy"
|
|
17
19
|
|
|
18
20
|
module Samagotchi
|
|
19
21
|
# The session commands a REPL and a session worker both run: /model,
|
|
@@ -34,6 +36,7 @@ module Samagotchi
|
|
|
34
36
|
LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
|
|
35
37
|
THINKING_COMMAND = ThinkingCommand::NAME
|
|
36
38
|
TOOLKIT_COMMAND = ToolkitCommand::NAME
|
|
39
|
+
PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
|
|
37
40
|
REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
|
|
38
41
|
# A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
|
|
39
42
|
# shows this many per host; /models <text> lists every match.
|
|
@@ -76,12 +79,8 @@ module Samagotchi
|
|
|
76
79
|
id: :rollback) { |_text| rollback }
|
|
77
80
|
registry.register("!", "run a shell command; its output goes into the conversation",
|
|
78
81
|
id: :shell, mid_turn: :queue, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
83
|
-
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
|
|
84
|
-
end
|
|
82
|
+
# #run passes who sent it (as_parent:) per call.
|
|
83
|
+
registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") { |text| continue_command(text) }
|
|
85
84
|
# It reads the hosts only (the registry is mutex-guarded): mid-turn too.
|
|
86
85
|
registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
|
|
87
86
|
reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
|
|
@@ -108,6 +107,11 @@ module Samagotchi
|
|
|
108
107
|
registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
|
|
109
108
|
id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
|
|
110
109
|
match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
|
|
110
|
+
# Tighten-only from anyone; loosening is a user's at a UI (#run's user_ui:).
|
|
111
|
+
registry.register(PARENT_ANSWERS_COMMAND, "show or set what a parent agent may answer here (hard|standard|soft|default; " \
|
|
112
|
+
"never looser than config.yml's)",
|
|
113
|
+
id: :parent_answers, mid_turn: show_mid_turn(PARENT_ANSWERS_COMMAND),
|
|
114
|
+
match: ->(text) { text.match?(%r{\A/parent-answers(?:\s+.*)?\z}) }) { |text| parent_answers(text) }
|
|
111
115
|
registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
|
|
112
116
|
registry.register("/stats", "show the session's stats", local: true)
|
|
113
117
|
registry.register("/recap", "show the session's recap", local: true)
|
|
@@ -254,8 +258,14 @@ module Samagotchi
|
|
|
254
258
|
# change asks first (CacheConfirm): true for a person at a web tab or
|
|
255
259
|
# a TUI; false applies it at once (chi send, a delegate, a plugin,
|
|
256
260
|
# chi -p)
|
|
261
|
+
# @param as_parent [Boolean] a parent agent sent it (client cli:answer):
|
|
262
|
+
# a /continue yes is held to guardrails.parent_answers, as chi
|
|
263
|
+
# answer --option Continue is (Guardrails::ParentPolicy)
|
|
264
|
+
# @param user_ui [Boolean] a user at a UI sent it (the REPL, an attached
|
|
265
|
+
# TUI, the web; not a piped or marked one): only such a line loosens
|
|
266
|
+
# /parent-answers
|
|
257
267
|
# @return [Result, nil] nil when +line+ is not one of these commands
|
|
258
|
-
def run(line, confirm: true)
|
|
268
|
+
def run(line, confirm: true, as_parent: false, user_ui: false)
|
|
259
269
|
text = line.to_s.strip
|
|
260
270
|
entry = @registry.lookup(text)
|
|
261
271
|
return nil unless entry&.handler
|
|
@@ -263,14 +273,33 @@ module Samagotchi
|
|
|
263
273
|
|
|
264
274
|
# The two that may ask take the flag; the rest never ask.
|
|
265
275
|
return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
|
|
276
|
+
# Who sent it goes to its handler per call, never through an ivar:
|
|
277
|
+
# an anytime command runs on another thread against this instance.
|
|
278
|
+
return continue_command(text, as_parent: as_parent) if entry.id == :continue
|
|
279
|
+
return parent_answers(text, user_ui: user_ui) if entry.id == :parent_answers
|
|
266
280
|
|
|
267
281
|
instance_exec(text, &entry.handler)
|
|
268
282
|
end
|
|
269
283
|
|
|
284
|
+
# /continue [yes|no|no, <reason>]: the continue offer's answer.
|
|
285
|
+
def continue_command(text, as_parent: false)
|
|
286
|
+
return reply("nothing to continue") unless @turn_flow.awaiting_continue?
|
|
287
|
+
|
|
288
|
+
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
289
|
+
continue_answer(answer.empty? ? CONTINUE_COMMAND : answer, as_parent: as_parent)
|
|
290
|
+
end
|
|
291
|
+
|
|
270
292
|
# Answer the pending continue offer (yes / no / no, <reason>).
|
|
293
|
+
# @param as_parent [Boolean] a parent agent's answer: a yes its strategy
|
|
294
|
+
# doesn't allow is refused (the offer stays; decision :invalid)
|
|
271
295
|
# @return [Result] resume: true on yes
|
|
272
|
-
def continue_answer(text)
|
|
296
|
+
def continue_answer(text, as_parent: false)
|
|
273
297
|
decision, reason = TurnFlow.continue_decision(text)
|
|
298
|
+
if decision == :resume && as_parent &&
|
|
299
|
+
(refusal = Guardrails::ParentPolicy.continue_refusal(strategy: Guardrails::ParentPolicy.for_session(@engine.session)))
|
|
300
|
+
return reply(refusal, status: :error).tap { |result| result.decision = :invalid }
|
|
301
|
+
end
|
|
302
|
+
|
|
274
303
|
result = case decision
|
|
275
304
|
when :resume then Result.new(status: :ok, changed: [], model_name: model_name, resume: true, shell: false)
|
|
276
305
|
when :abort
|
|
@@ -485,6 +514,14 @@ module Samagotchi
|
|
|
485
514
|
reply(e.message, status: :error)
|
|
486
515
|
end
|
|
487
516
|
|
|
517
|
+
# /parent-answers (ParentAnswersCommand); :parent_answers in changed
|
|
518
|
+
# when it set the session's own level.
|
|
519
|
+
def parent_answers(text, user_ui: false)
|
|
520
|
+
output, changed, status = ParentAnswersCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
|
|
521
|
+
.run(text.delete_prefix(PARENT_ANSWERS_COMMAND), user_ui: user_ui)
|
|
522
|
+
reply(output, status: status, changed: changed ? [:parent_answers] : [])
|
|
523
|
+
end
|
|
524
|
+
|
|
488
525
|
# A session setting changed (/llm-context, /thinking, /reasoning-history):
|
|
489
526
|
# saved with its model.
|
|
490
527
|
def save_setup(session)
|
|
@@ -18,24 +18,26 @@ module Samagotchi
|
|
|
18
18
|
# +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
|
|
19
19
|
# session config's toolkit.default then), +bundle_settings+ the bundle
|
|
20
20
|
# settings chi --set gave ({bundle => {key => value}}, frozen; {} with
|
|
21
|
-
# none), which Plugin::Context#settings lays over config.yml's
|
|
21
|
+
# none), which Plugin::Context#settings lays over config.yml's,
|
|
22
|
+
# +parent_answers+ the session's own guardrails.parent_answers (a level
|
|
23
|
+
# String, tighten-only: Guardrails::ParentPolicy.effective) or nil.
|
|
22
24
|
#
|
|
23
25
|
# It doesn't save itself: the session keeps each setting in its own field
|
|
24
26
|
# and file key, and resolves it on its own. A delegate child starts
|
|
25
|
-
# without one (but the toolkit its delegate call named
|
|
26
|
-
# fork copy their session's (.of).
|
|
27
|
+
# without one (but the toolkit its delegate call named, and its parent's
|
|
28
|
+
# parent_answers); a continue and a fork copy their session's (.of).
|
|
27
29
|
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
|
|
28
|
-
:bundle_settings) do
|
|
30
|
+
:bundle_settings, :parent_answers) do
|
|
29
31
|
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
|
|
30
|
-
bundle_settings: {})
|
|
32
|
+
bundle_settings: {}, parent_answers: nil)
|
|
31
33
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
32
34
|
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
|
|
33
|
-
bundle_settings: BundleSessionSettings.normalize(bundle_settings))
|
|
35
|
+
bundle_settings: BundleSessionSettings.normalize(bundle_settings), parent_answers: parent_answers&.to_s)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def empty?
|
|
37
39
|
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
|
|
38
|
-
bundle_settings.empty?
|
|
40
|
+
bundle_settings.empty? && parent_answers.nil?
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
# A session's own setup, for a session that starts from it. Its
|
|
@@ -48,7 +50,7 @@ module Samagotchi
|
|
|
48
50
|
|
|
49
51
|
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
50
52
|
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
|
|
51
|
-
bundle_settings: session.bundle_settings)
|
|
53
|
+
bundle_settings: session.bundle_settings, parent_answers: session.parent_answers)
|
|
52
54
|
end
|
|
53
55
|
end
|
|
54
56
|
end
|
|
@@ -53,6 +53,10 @@ module Samagotchi
|
|
|
53
53
|
# the line an existing session's run prints (--set too)
|
|
54
54
|
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
55
55
|
# a new session's setup carries them; an existing session keeps its own
|
|
56
|
+
# @param parent_answers [String, nil] --parent-answers: a new session's
|
|
57
|
+
# own guardrails.parent_answers; an existing session keeps its own
|
|
58
|
+
# (never sent as a /parent-answers command: that would be a
|
|
59
|
+
# loosening route from a command line)
|
|
56
60
|
# @param state_dir [String, nil] where the sessions are (the default
|
|
57
61
|
# state dir by default): the worker's, for the attached loop too
|
|
58
62
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
@@ -60,12 +64,12 @@ module Samagotchi
|
|
|
60
64
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
61
65
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
62
66
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
63
|
-
toolkit_flags: [], bundle_settings: {}, state_dir: nil)
|
|
67
|
+
toolkit_flags: [], bundle_settings: {}, parent_answers: nil, state_dir: nil)
|
|
64
68
|
state_dir ||= Session.default_state_dir
|
|
65
69
|
client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
|
|
66
70
|
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
|
|
67
71
|
appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags,
|
|
68
|
-
bundle_settings: bundle_settings)
|
|
72
|
+
bundle_settings: bundle_settings, parent_answers: parent_answers)
|
|
69
73
|
commands = []
|
|
70
74
|
commands << "/model #{model}" if model && (attach || resume)
|
|
71
75
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
@@ -77,13 +81,15 @@ module Samagotchi
|
|
|
77
81
|
first_command: first_command, no_interrupt: no_interrupt,
|
|
78
82
|
default_input: default_input && !prompt && !attach && !resume,
|
|
79
83
|
wait_at_eof: !$stdin.tty?,
|
|
80
|
-
|
|
84
|
+
as_parent: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
|
|
81
85
|
ended = begin
|
|
82
86
|
attached.run
|
|
83
87
|
ensure
|
|
84
88
|
close_surface(surface)
|
|
85
89
|
end
|
|
86
|
-
|
|
90
|
+
if ended == :unanswered && attached.unanswered
|
|
91
|
+
report_unanswered(attached.unanswered, session_id: client.session_id, state_dir: state_dir)
|
|
92
|
+
end
|
|
87
93
|
report_ended(ended)
|
|
88
94
|
ended
|
|
89
95
|
end
|
|
@@ -92,9 +98,12 @@ module Samagotchi
|
|
|
92
98
|
# --wait` prints it (ParentReport), so a script or a parent agent
|
|
93
99
|
# can answer it with chi answer.
|
|
94
100
|
# @param pending [Hash] the pending question as the Bridge sent it
|
|
95
|
-
|
|
101
|
+
# @param state_dir [String, nil] where the session file is: what a
|
|
102
|
+
# parent may pick follows its own parent_answers too
|
|
103
|
+
def report_unanswered(pending, session_id:, err: $stderr, state_dir: nil)
|
|
96
104
|
question = PendingQuestion.from_file(pending)
|
|
97
|
-
|
|
105
|
+
strategy = ParentReport.strategy_for(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
106
|
+
err.print("chi: #{ParentReport.question_text(question, session_id: session_id, strategy: strategy)}")
|
|
98
107
|
err.flush
|
|
99
108
|
end
|
|
100
109
|
|
|
@@ -134,7 +143,7 @@ module Samagotchi
|
|
|
134
143
|
# @raise [Error]
|
|
135
144
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
136
145
|
memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
|
|
137
|
-
toolkit_flags: [], bundle_settings: {}, err: $stderr)
|
|
146
|
+
toolkit_flags: [], bundle_settings: {}, parent_answers: nil, err: $stderr)
|
|
138
147
|
sd = state_dir || Session.default_state_dir
|
|
139
148
|
if attach || resume
|
|
140
149
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
|
|
@@ -156,7 +165,8 @@ module Samagotchi
|
|
|
156
165
|
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
157
166
|
thinking: Thinking.session_level(thinking),
|
|
158
167
|
appended_prompt_files: appended_prompt_files,
|
|
159
|
-
toolkit: toolkit, bundle_settings: bundle_settings
|
|
168
|
+
toolkit: toolkit, bundle_settings: bundle_settings,
|
|
169
|
+
parent_answers: parent_answers))
|
|
160
170
|
end
|
|
161
171
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
162
172
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -105,7 +105,7 @@ module Samagotchi
|
|
|
105
105
|
# @param state_dir [String] the state dir the worker was found in: its
|
|
106
106
|
# session folder (@path images, saved records) is under it
|
|
107
107
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
108
|
-
default_input: false, wait_at_eof: false,
|
|
108
|
+
default_input: false, wait_at_eof: false, as_parent: false,
|
|
109
109
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
110
110
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
111
111
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
|
|
@@ -121,8 +121,8 @@ module Samagotchi
|
|
|
121
121
|
@client_id = client_id
|
|
122
122
|
# A parent agent drives this attach (AttachLauncher:
|
|
123
123
|
# ParentApprovals.parent_process?): its answers go as chi answer's,
|
|
124
|
-
# so the worker holds them to
|
|
125
|
-
@
|
|
124
|
+
# so the worker holds them to Guardrails::ParentPolicy.
|
|
125
|
+
@as_parent = as_parent
|
|
126
126
|
@view = AttachedView.new(screen)
|
|
127
127
|
@renderer = EventRenderer.new(@view)
|
|
128
128
|
@shown_enqueued = Set.new
|
|
@@ -592,7 +592,7 @@ module Samagotchi
|
|
|
592
592
|
# The worker runs it and every UI renders its :command_ran, this one
|
|
593
593
|
# too, so nothing waits here.
|
|
594
594
|
def send_command(line)
|
|
595
|
-
reply = @client.post_command(line: line, client_id:
|
|
595
|
+
reply = @client.post_command(line: line, client_id: client_id_for(line))
|
|
596
596
|
if reply.status == 202
|
|
597
597
|
# Input from a pipe ends once its output is in (#command_ran).
|
|
598
598
|
command_id = reply.json&.fetch("command_id", nil)
|
|
@@ -805,13 +805,27 @@ module Samagotchi
|
|
|
805
805
|
steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
|
|
806
806
|
end
|
|
807
807
|
|
|
808
|
+
# The client id +line+ goes with, as a command (#send_command) or as a
|
|
809
|
+
# message the Bridge may run as one (#send_prompt: /cut and /queue
|
|
810
|
+
# text). A parent agent's /continue yes and /parent-answers go as chi
|
|
811
|
+
# answer's: the worker holds the first to guardrails.parent_answers and
|
|
812
|
+
# never takes the second as a user's loosening. The command is the
|
|
813
|
+
# registry's own match (the worker's), not a pattern of ours.
|
|
814
|
+
def client_id_for(line)
|
|
815
|
+
return @client_id unless @as_parent
|
|
816
|
+
return ClientId::CLI_ANSWER if TurnFlow.continue_yes?(line)
|
|
817
|
+
return ClientId::CLI_ANSWER if command_registry.lookup(line)&.id == :parent_answers
|
|
818
|
+
|
|
819
|
+
@client_id
|
|
820
|
+
end
|
|
821
|
+
|
|
808
822
|
def send_prompt(text, delivery: nil)
|
|
809
823
|
persist_recent_history(text)
|
|
810
824
|
images = attach_images(text)
|
|
811
825
|
return own_prompt_failed if images.nil?
|
|
812
826
|
|
|
813
827
|
running = @running
|
|
814
|
-
options = { prompt: text, client_id:
|
|
828
|
+
options = { prompt: text, client_id: client_id_for(text) }
|
|
815
829
|
options[:no_interrupt] = true if @no_interrupt
|
|
816
830
|
options[:images] = images unless images.empty?
|
|
817
831
|
options[:delivery] = delivery if delivery
|
|
@@ -990,7 +1004,7 @@ module Samagotchi
|
|
|
990
1004
|
return @screen.commit(answer.error)
|
|
991
1005
|
end
|
|
992
1006
|
|
|
993
|
-
parent = @
|
|
1007
|
+
parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
994
1008
|
reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
995
1009
|
case reply.status
|
|
996
1010
|
when 200
|
|
@@ -1554,14 +1568,13 @@ module Samagotchi
|
|
|
1554
1568
|
elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
|
|
1555
1569
|
@screen.commit(prompt_line(origin[:client_id], event[:prompt]))
|
|
1556
1570
|
end
|
|
1557
|
-
#
|
|
1558
|
-
#
|
|
1559
|
-
@renderer.call(
|
|
1571
|
+
# The own-send echo already names an image-only prompt; another
|
|
1572
|
+
# client's prompt line does too, here or at its enqueue.
|
|
1573
|
+
@renderer.call(without_named_images(event, event[:prompt]))
|
|
1560
1574
|
end
|
|
1561
1575
|
|
|
1562
|
-
# The turn's start without its image lines when the prompt
|
|
1563
|
-
#
|
|
1564
|
-
# that line names them already.
|
|
1576
|
+
# The turn's start without its image lines when the shown prompt is
|
|
1577
|
+
# the image-only "[image: name]" placeholder: that line names them.
|
|
1565
1578
|
def without_named_images(event, shown_prompt)
|
|
1566
1579
|
ImageRef.placeholder_only?(shown_prompt) ? event.except(:images) : event
|
|
1567
1580
|
end
|
|
@@ -11,9 +11,9 @@ module Samagotchi
|
|
|
11
11
|
# ThinkingTicker (so it changes at most once per dwell). Both TUIs use it
|
|
12
12
|
# in place of the raw tail of the stream.
|
|
13
13
|
#
|
|
14
|
-
# A chunk carries :text
|
|
15
|
-
# for every profile
|
|
16
|
-
#
|
|
14
|
+
# A chunk carries :text and :thinking: both loops split their stream,
|
|
15
|
+
# for every profile (the raw :content stays in the worker,
|
|
16
|
+
# Bridge::ClientEvent). A switch of lane starts the next sentence from the new
|
|
17
17
|
# lane's text; the line shown keeps the label of the lane it came from.
|
|
18
18
|
class ThinkingLine
|
|
19
19
|
include Formatting
|
|
@@ -43,12 +43,8 @@ module Samagotchi
|
|
|
43
43
|
# @return [Boolean] whether the line changed
|
|
44
44
|
def chunk(event)
|
|
45
45
|
# The Bridge's TurnAccumulator reads the lanes by the same rule.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
add(:writing, event[:text]) || changed
|
|
49
|
-
else
|
|
50
|
-
add(:thinking, event[:content], raw: true)
|
|
51
|
-
end
|
|
46
|
+
changed = add(:thinking, event[:thinking])
|
|
47
|
+
add(:writing, event[:text]) || changed
|
|
52
48
|
end
|
|
53
49
|
|
|
54
50
|
# A turn joined mid-way: the newest thinking or text part so far.
|
|
@@ -105,10 +105,14 @@ module Samagotchi
|
|
|
105
105
|
# @param bundle_settings [Hash] --set's values (BundleSessionSettings):
|
|
106
106
|
# a new session's own; a resumed one keeps its own (--set is in
|
|
107
107
|
# +toolkit_flags+ then)
|
|
108
|
+
# @param parent_answers [String, nil] --parent-answers: a new session's
|
|
109
|
+
# own guardrails.parent_answers; a resumed one keeps its own (named in
|
|
110
|
+
# +toolkit_flags+ then)
|
|
108
111
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
109
112
|
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
|
|
110
|
-
appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {})
|
|
113
|
+
appended_prompt_files: [], toolkit: nil, toolkit_flags: [], bundle_settings: {}, parent_answers: nil)
|
|
111
114
|
@scratch = scratch
|
|
115
|
+
@parent_answers = parent_answers
|
|
112
116
|
@toolkit = toolkit
|
|
113
117
|
@toolkit_flags = toolkit_flags
|
|
114
118
|
@bundle_settings = bundle_settings
|
|
@@ -281,7 +285,7 @@ module Samagotchi
|
|
|
281
285
|
muted_memory_names: @muted_memory_names,
|
|
282
286
|
scratch: @scratch,
|
|
283
287
|
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
|
|
284
|
-
bundle_settings: @bundle_settings)
|
|
288
|
+
bundle_settings: @bundle_settings, parent_answers: @parent_answers)
|
|
285
289
|
))
|
|
286
290
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
287
291
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -295,7 +299,7 @@ module Samagotchi
|
|
|
295
299
|
claim_session!(session.id) unless @owner_lock
|
|
296
300
|
# A turn a worker died in (TurnCheckpoint): ended as canceled, with its
|
|
297
301
|
# note, before this REPL's first turn appends after it.
|
|
298
|
-
session.save if @resume_session && TurnCheckpoint.settle(session)
|
|
302
|
+
session.save if @resume_session && TurnCheckpoint.settle(session, session_dir: Session.session_dir(session.id))
|
|
299
303
|
keep_cards(session)
|
|
300
304
|
# Saved at once, so a process killed before its first turn leaves a
|
|
301
305
|
# file the sweep knows to delete.
|
|
@@ -830,7 +834,7 @@ module Samagotchi
|
|
|
830
834
|
end
|
|
831
835
|
|
|
832
836
|
def answer_continue_offer(session, input)
|
|
833
|
-
answer = @commands.continue_answer(input)
|
|
837
|
+
answer = @commands.continue_answer(input, as_parent: as_parent?)
|
|
834
838
|
if answer.decision == :invalid
|
|
835
839
|
# The read left no echo: the line shows above its error.
|
|
836
840
|
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
|
|
@@ -876,7 +880,11 @@ module Samagotchi
|
|
|
876
880
|
command = if command_registry.anytime?(input)
|
|
877
881
|
@engine.running_anytime { @commands.run(input, confirm: confirm) }
|
|
878
882
|
else
|
|
879
|
-
|
|
883
|
+
# A parent's /continue yes is held to guardrails.parent_answers;
|
|
884
|
+
# only a person here (not piped, no agent marker) loosens
|
|
885
|
+
# /parent-answers.
|
|
886
|
+
parent = as_parent?
|
|
887
|
+
@commands.run(input, confirm: confirm, as_parent: parent && TurnFlow.continue_yes?(input), user_ui: !parent)
|
|
880
888
|
end
|
|
881
889
|
if command
|
|
882
890
|
show_command_result(command)
|
|
@@ -1579,15 +1587,16 @@ module Samagotchi
|
|
|
1579
1587
|
end
|
|
1580
1588
|
|
|
1581
1589
|
begin
|
|
1582
|
-
parent =
|
|
1590
|
+
parent = as_parent? ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
1583
1591
|
@engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
1584
1592
|
close_question_widget(prompt, prompt.answer_text(answer))
|
|
1585
1593
|
return true
|
|
1586
1594
|
rescue QuestionDesk::Refused => e
|
|
1587
|
-
# A parent's
|
|
1588
|
-
# the question stays open for a deny
|
|
1595
|
+
# A parent's pick its strategy doesn't let through
|
|
1596
|
+
# (Guardrails::ParentPolicy): the question stays open for a deny
|
|
1597
|
+
# (or the end of the input).
|
|
1589
1598
|
echo_choice(raw)
|
|
1590
|
-
@surface.commit(
|
|
1599
|
+
@surface.commit(e.detail(typed: true))
|
|
1591
1600
|
next
|
|
1592
1601
|
rescue ArgumentError => e
|
|
1593
1602
|
@surface.commit("Invalid: #{e.message}. Try again.")
|
|
@@ -1600,8 +1609,8 @@ module Samagotchi
|
|
|
1600
1609
|
end
|
|
1601
1610
|
|
|
1602
1611
|
# Whether the answers typed here are a parent agent's (piped stdin, an
|
|
1603
|
-
# agent marker): QuestionDesk holds them to
|
|
1604
|
-
def
|
|
1612
|
+
# agent marker): QuestionDesk holds them to Guardrails::ParentPolicy.
|
|
1613
|
+
def as_parent?
|
|
1605
1614
|
Guardrails::ParentApprovals.parent_process?
|
|
1606
1615
|
end
|
|
1607
1616
|
|
|
@@ -79,9 +79,6 @@ module Samagotchi
|
|
|
79
79
|
"Samagotchi::Session.new_session" => "spec: #{SESSION} (ctx.delegate?, ctx.fork?, ctx.context read it)",
|
|
80
80
|
"Samagotchi::ContextSources.session_location" => "spec: reads and removes the session's sources (added_by), " \
|
|
81
81
|
"which ctx.context.list doesn't show",
|
|
82
|
-
'require "samagotchi/memory_bundle/installer"' => "spec: for Installer",
|
|
83
|
-
"Samagotchi::MemoryBundle::Installer.new" => "spec: installs the bundle so its context provider resolves: " \
|
|
84
|
-
"needs a kit install helper",
|
|
85
82
|
'require "samagotchi/context_providers"' => "spec: for ContextProviders",
|
|
86
83
|
"Samagotchi::ContextProviders.resolve" => "spec: the manifest's URL provider through chi's own resolver",
|
|
87
84
|
'require "samagotchi/answer_display"' => "spec: for AnswerDisplay",
|
|
@@ -240,6 +240,13 @@ module Samagotchi
|
|
|
240
240
|
failing { entry.handler.call({ name: name.to_s, args: stringify(args) }, nil) }
|
|
241
241
|
end
|
|
242
242
|
|
|
243
|
+
# Install this bundle's dir into its sandbox as chi does and load the
|
|
244
|
+
# installed copy with chi's loaders (Install.run, with its settings):
|
|
245
|
+
# what a test of the installed bundle needs (its scripts, context
|
|
246
|
+
# providers), and the install warnings and load failures.
|
|
247
|
+
# @return [Install::Result]
|
|
248
|
+
def install(force: false) = Install.run(@dir, settings: @settings, sandbox: @sandbox, force: force)
|
|
249
|
+
|
|
243
250
|
# Run the chi.init blocks in order, as chi's init tasks do.
|
|
244
251
|
# @return [Array] their results
|
|
245
252
|
def run_inits = failing { @inits.map { |init| init.block.call } }
|
|
@@ -27,8 +27,9 @@ module Samagotchi
|
|
|
27
27
|
messages: -> { recorder[:messages] },
|
|
28
28
|
messages_partial: -> { recorder[:messages_partial] },
|
|
29
29
|
notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
|
|
30
|
-
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook
|
|
31
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook
|
|
30
|
+
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
|
|
31
|
+
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
32
|
+
effects: effects)
|
|
32
33
|
},
|
|
33
34
|
cancelled: -> { recorder[:cancelled] },
|
|
34
35
|
card: ->(**card) { recorder.card(**card) },
|
|
@@ -88,8 +89,9 @@ module Samagotchi
|
|
|
88
89
|
def build(recorder)
|
|
89
90
|
Hooks::Runtime.new(
|
|
90
91
|
notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
|
|
91
|
-
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook
|
|
92
|
-
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook
|
|
92
|
+
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
|
|
93
|
+
recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
|
|
94
|
+
effects: effects)
|
|
93
95
|
},
|
|
94
96
|
stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
|
|
95
97
|
steer: ->(text:, hook:) { recorder.steer(text, hook) },
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../extension_load"
|
|
4
|
+
require_relative "../guardrails/load_failures"
|
|
5
|
+
require_relative "../memory_bundle/installer"
|
|
6
|
+
require_relative "../memory_bundle/provenance"
|
|
7
|
+
require_relative "../hooks"
|
|
8
|
+
require_relative "bundle"
|
|
9
|
+
require_relative "sandbox"
|
|
10
|
+
require_relative "recorder"
|
|
11
|
+
require_relative "host"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
module Testing
|
|
15
|
+
# A bundle as chi installs and loads it, unlike Bundle (its source as it
|
|
16
|
+
# is): MemoryBundle::Installer copies it into the sandbox's state (sha256
|
|
17
|
+
# and requires_chi checked), then chi's own loaders load what is
|
|
18
|
+
# installed (ExtensionLoad: the bundle hooks, then the plugins). What
|
|
19
|
+
# failed names the bundle's source files, not the installed copies.
|
|
20
|
+
#
|
|
21
|
+
# result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
|
|
22
|
+
# result.warnings, result.plugin_failures, result.hook_failures, result.registered[:commands]
|
|
23
|
+
module Install
|
|
24
|
+
# warnings: the Installer's; plugin_failures / hook_failures:
|
|
25
|
+
# [Guardrails::LoadFailures::Failure] of this bundle, each location
|
|
26
|
+
# in the source dir; registered: what it registered (#registered).
|
|
27
|
+
Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
|
|
28
|
+
def failures = plugin_failures + hook_failures
|
|
29
|
+
def ok? = failures.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Install the bundle at +dir+ into +sandbox+ (default Sandbox.current,
|
|
35
|
+
# else one of its own, left before returning) and load it.
|
|
36
|
+
# @param settings [Hash] config.yml `bundles: <name>:` (written to the
|
|
37
|
+
# sandbox's config.yml)
|
|
38
|
+
# @param force [Boolean] the Installer's --force (a name chi ships)
|
|
39
|
+
# @return [Result]
|
|
40
|
+
# @raise [MemoryBundle::Installer::InstallError] what the Installer refused
|
|
41
|
+
def run(dir, settings: {}, sandbox: nil, force: false)
|
|
42
|
+
return Sandbox.with { |own| run(dir, settings: settings, sandbox: own, force: force) } if sandbox.nil? && Sandbox.current.nil?
|
|
43
|
+
|
|
44
|
+
sandbox ||= Sandbox.current
|
|
45
|
+
dir = File.expand_path(dir)
|
|
46
|
+
name = MemoryBundle::Manifest.read(dir: dir).name
|
|
47
|
+
sandbox.config("bundles" => { name => settings })
|
|
48
|
+
installer = MemoryBundle::Installer.new(source: dir, name: name, scope: "system", force: force)
|
|
49
|
+
installer.run
|
|
50
|
+
load_installed(name, dir, installer.warnings, sandbox)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def load_installed(name, dir, warnings, sandbox)
|
|
54
|
+
inits = []
|
|
55
|
+
# A session's values, as an Engine's host gives them (only what the loaders register matters).
|
|
56
|
+
host = Host.build(Recorder.new(cwd: sandbox.work_dir))
|
|
57
|
+
hook_failures = Guardrails::LoadFailures.new
|
|
58
|
+
plugin_failures = Guardrails::LoadFailures.new
|
|
59
|
+
extension = ExtensionLoad.new(hook_failures: hook_failures)
|
|
60
|
+
registries = Plugin::Registries.new(
|
|
61
|
+
commands: SessionCommands.register_builtins(Commands::Registry.new),
|
|
62
|
+
tools: Tools::Builtins.registry, hooks: extension.hooks,
|
|
63
|
+
context_for: lambda { |bundle, bundle_settings, label|
|
|
64
|
+
Plugin::Context.new(bundle: bundle, label: label, settings: bundle_settings, host: host)
|
|
65
|
+
},
|
|
66
|
+
services: Plugin::Services.new, stage_tools: ->(*) {},
|
|
67
|
+
init: ->(_bundle, label, _plugin_label, **, &_block) { inits << { label: label } },
|
|
68
|
+
toolkits: [], tool_index: Tools::ToolIndex.new, peer_sources: PeerSources.new
|
|
69
|
+
)
|
|
70
|
+
extension.load_plugins(registries, failures: plugin_failures)
|
|
71
|
+
installed = File.join(MemoryBundle::Provenance.bundles_dir, name)
|
|
72
|
+
Result.new(name: name, warnings: warnings.dup,
|
|
73
|
+
plugin_failures: own(plugin_failures, name, installed, dir),
|
|
74
|
+
hook_failures: own(hook_failures, name, installed, dir),
|
|
75
|
+
registered: registered(registries, name, inits))
|
|
76
|
+
ensure
|
|
77
|
+
registries&.services&.stop_all
|
|
78
|
+
end
|
|
79
|
+
private_class_method :load_installed
|
|
80
|
+
|
|
81
|
+
# What bundle +name+ registered, from the registries it loaded into:
|
|
82
|
+
# commands ([name, mid_turn, annotation labels]), tools, hooks per
|
|
83
|
+
# event ([bundle, hook, priority]; Hooks::EVENTS, then any other event
|
|
84
|
+
# the bundle registered for), inits, toolkits, tool sources and
|
|
85
|
+
# peer sources.
|
|
86
|
+
# @param inits [Array<Hash>] {label:} of each chi.init
|
|
87
|
+
# @return [Hash]
|
|
88
|
+
def registered(registries, name, inits)
|
|
89
|
+
{
|
|
90
|
+
commands: registries.commands.entries.select { |e| e.source == name }.map { |e| [e.name, e.mid_turn, e.annotations.map(&:label)] },
|
|
91
|
+
tools: registries.tools.by_source(name).map(&:name),
|
|
92
|
+
hooks: hooks_of(registries.hooks, name),
|
|
93
|
+
inits: inits.map { |init| init[:label] },
|
|
94
|
+
toolkits: Array(registries.toolkits).map(&:name),
|
|
95
|
+
tool_sources: registries.tool_index.sources.map(&:label),
|
|
96
|
+
peer_sources: registries.peer_sources.names
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def hooks_of(hooks, name)
|
|
101
|
+
own = ->(event) { hooks.bundle_hook_names(event).select { |bundle, _, _| bundle == name } }
|
|
102
|
+
others = (hooks.bundle_events - Hooks::EVENTS).to_h { |event| [event, own.call(event)] }.reject { |_, list| list.empty? }
|
|
103
|
+
Hooks::EVENTS.to_h { |event| [event, own.call(event)] }.merge(others)
|
|
104
|
+
end
|
|
105
|
+
private_class_method :hooks_of
|
|
106
|
+
|
|
107
|
+
# +failures+' entries for bundle +name+, their location moved from the
|
|
108
|
+
# installed copy to the source dir.
|
|
109
|
+
def own(failures, name, installed, dir)
|
|
110
|
+
failures.list.select { |f| f.what.end_with?("(bundle #{name})") }.map do |failure|
|
|
111
|
+
# The plugin is installed into plugin/, the hooks into hooks/.
|
|
112
|
+
location = failure.location&.sub(%r{\A#{Regexp.escape(installed)}/(?:plugin/)?}, "#{dir}/")
|
|
113
|
+
failure.dup.tap { |copy| copy.location = location }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :own
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../plugin/card"
|
|
4
4
|
require_relative "../plugin/context"
|
|
5
|
+
require_relative "../guardrails/parent_policy"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Testing
|
|
@@ -12,8 +13,11 @@ module Samagotchi
|
|
|
12
13
|
Steer = Data.define(:text, :hook)
|
|
13
14
|
# A stop_turn or stop_generation (+kind+ :turn or :generation).
|
|
14
15
|
Stop = Data.define(:kind, :reason, :hook)
|
|
15
|
-
# An ask_user question
|
|
16
|
-
|
|
16
|
+
# An ask_user question; effects: its options' tags as the bundle gave
|
|
17
|
+
# them (label => effect), nil for none.
|
|
18
|
+
Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
|
|
19
|
+
def initialize(effects: nil, **) = super
|
|
20
|
+
end
|
|
17
21
|
# An ask_model request: the request Context built (SideQuestion), its
|
|
18
22
|
# limits, cancel controller and the model: it named (nil: the session's;
|
|
19
23
|
# the kit doesn't check it against any hosts), and +source+, the asking
|
|
@@ -100,9 +104,14 @@ module Samagotchi
|
|
|
100
104
|
end
|
|
101
105
|
|
|
102
106
|
# The next queued answer (#answers), nil with none (no one to ask).
|
|
103
|
-
|
|
107
|
+
# Effects chi would refuse (a label the options don't have, an
|
|
108
|
+
# unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
|
|
109
|
+
# in chi: nil, nothing recorded, no answer taken.
|
|
110
|
+
def ask_user(question:, options:, header:, allow_freeform:, hook:, effects: nil)
|
|
111
|
+
return nil if Guardrails::ParentPolicy.option_tags(options, effects) == false
|
|
112
|
+
|
|
104
113
|
record(@questions, Question.new(question: question, options: options, header: header,
|
|
105
|
-
allow_freeform: allow_freeform, hook: hook))
|
|
114
|
+
allow_freeform: allow_freeform, hook: hook, effects: effects))
|
|
106
115
|
answer = @mutex.synchronize { @answers.shift }
|
|
107
116
|
answer.respond_to?(:call) ? answer.call(@questions.last) : answer
|
|
108
117
|
end
|