samagotchi 0.115.0 → 0.117.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 +2 -0
- data/docs/cli.md +33 -2
- data/docs/configuration.md +13 -0
- data/docs/guardrails.md +188 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
- data/lib/samagotchi/bridge.rb +114 -14
- data/lib/samagotchi/bridge_client.rb +20 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +34 -3
- data/lib/samagotchi/engine.rb +6 -1
- data/lib/samagotchi/guardrail_wiring.rb +166 -2
- data/lib/samagotchi/guardrails/approval.rb +20 -0
- data/lib/samagotchi/guardrails/targets.rb +23 -2
- data/lib/samagotchi/guardrails/verdict.rb +23 -3
- data/lib/samagotchi/guardrails_command.rb +329 -0
- data/lib/samagotchi/hold_stop.rb +89 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
- data/lib/samagotchi/prompt_warmup.rb +8 -3
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
- data/lib/samagotchi/session_commands.rb +79 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/system_one/answer.rb +78 -0
- data/lib/samagotchi/system_one/approval_question.rb +113 -0
- data/lib/samagotchi/system_one/client.rb +89 -0
- data/lib/samagotchi/system_one/errors.rb +14 -0
- data/lib/samagotchi/system_one/fake_backend.rb +45 -0
- data/lib/samagotchi/system_one/http_backend.rb +129 -0
- data/lib/samagotchi/system_one/log.rb +133 -0
- data/lib/samagotchi/system_one/question.rb +86 -0
- data/lib/samagotchi/system_one/record.rb +83 -0
- data/lib/samagotchi/system_one/self_protect.rb +90 -0
- data/lib/samagotchi/system_one/similar.rb +69 -0
- data/lib/samagotchi/system_one.rb +25 -0
- data/lib/samagotchi/task_watch.rb +23 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +145 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +20 -1
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/composer_fold.js +8 -8
- data/lib/samagotchi/web/public/composer_grip.js +65 -22
- data/lib/samagotchi/web/public/composer_size.js +40 -5
- data/lib/samagotchi/web/public/data.js +24 -0
- data/lib/samagotchi/web/public/favicon.svg +1 -1
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +21 -0
- data/lib/samagotchi/web/public/session_actions.js +39 -6
- data/lib/samagotchi/web/public/stop_sheet.js +126 -0
- data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
- data/lib/samagotchi/web/public/timing.js +9 -5
- data/lib/samagotchi/web/public/turn_events.js +1 -0
- data/lib/samagotchi/web/public/turn_html.js +13 -2
- data/lib/samagotchi/web/session_access_routes.rb +9 -6
- data/lib/samagotchi/web/session_control.rb +62 -3
- data/lib/samagotchi/web/session_show.rb +35 -5
- data/lib/samagotchi/worker.rb +57 -5
- data/lib/samagotchi/worker_idle_exit.rb +111 -9
- metadata +17 -1
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -19,6 +19,7 @@ require_relative "bridge/sse_writer"
|
|
|
19
19
|
require_relative "bridge/turn_accumulator"
|
|
20
20
|
require_relative "session"
|
|
21
21
|
require_relative "engine"
|
|
22
|
+
require_relative "system_one/log"
|
|
22
23
|
require_relative "session_commands"
|
|
23
24
|
require_relative "queued_command"
|
|
24
25
|
require_relative "steer"
|
|
@@ -86,21 +87,36 @@ module Samagotchi
|
|
|
86
87
|
# one, POST /command answers 501
|
|
87
88
|
# @param on_exit_request [#call, nil] a client asks the worker to exit
|
|
88
89
|
# now: takes the client_id and delete: (the exit is to delete the
|
|
89
|
-
# session, /exit --delete)
|
|
90
|
-
#
|
|
91
|
-
#
|
|
90
|
+
# session, /exit --delete) or stop: ("all": stop everything the user
|
|
91
|
+
# can stop first), returns nil when the worker will leave or
|
|
92
|
+
# what keeps it up (a WorkerIdleExit::Hold: #reason, #tasks, #hint,
|
|
93
|
+
# #holds); called with the event log held. Without one, POST /exit
|
|
94
|
+
# answers 501
|
|
92
95
|
# @param exit_discards [#call, nil] after an exit the worker agreed to:
|
|
93
96
|
# whether it will delete the session as empty (the 200 says
|
|
94
97
|
# +discard+); without one the reply leaves the field out
|
|
98
|
+
# @param exit_stopped [#call, nil] after a stop-all exit: what it stopped
|
|
99
|
+
# (a HoldStop::Stopped) for the 200's +stopped+; nil leaves it out
|
|
100
|
+
# @param on_holds [#call, nil] what the user could stop in this worker
|
|
101
|
+
# right now (WorkerIdleExit#stoppable_holds) for GET /holds; without
|
|
102
|
+
# one that route answers 501
|
|
103
|
+
# @param on_holds_stop [#call, nil] stop every one of them (HoldStop#stop_all)
|
|
104
|
+
# without the worker exiting, for POST /holds/stop; returns the
|
|
105
|
+
# HoldStop::Stopped the 200's +stopped+ carries; without one that route
|
|
106
|
+
# answers 501
|
|
95
107
|
def initialize(engine:, state_dir:, session_id:, bind: DEFAULT_BIND,
|
|
96
108
|
port: 0, ring_capacity: DEFAULT_RING_CAPACITY,
|
|
97
109
|
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, input_format: nil, on_input: nil,
|
|
98
|
-
on_command: nil, on_exit_request: nil, exit_discards: nil
|
|
110
|
+
on_command: nil, on_exit_request: nil, exit_discards: nil, exit_stopped: nil,
|
|
111
|
+
on_holds: nil, on_holds_stop: nil)
|
|
99
112
|
@engine = engine
|
|
100
113
|
@on_input = on_input
|
|
101
114
|
@on_command = on_command
|
|
102
115
|
@on_exit_request = on_exit_request
|
|
103
116
|
@exit_discards = exit_discards
|
|
117
|
+
@exit_stopped = exit_stopped
|
|
118
|
+
@on_holds = on_holds
|
|
119
|
+
@on_holds_stop = on_holds_stop
|
|
104
120
|
@input_format = input_format
|
|
105
121
|
@state_dir = state_dir
|
|
106
122
|
@session_id = session_id
|
|
@@ -584,10 +600,13 @@ module Samagotchi
|
|
|
584
600
|
%w[POST cancel] => :handle_cancel,
|
|
585
601
|
%w[POST answer] => :handle_answer,
|
|
586
602
|
%w[POST question/dismiss] => :handle_dismiss_question,
|
|
603
|
+
%w[POST guardrails/wrong] => :handle_system_one_wrong,
|
|
587
604
|
%w[POST turn] => :handle_post_turn,
|
|
588
605
|
%w[POST turn/dequeue] => :handle_dequeue_turn,
|
|
589
606
|
%w[POST command] => :handle_command,
|
|
590
607
|
%w[POST exit] => :handle_exit_request,
|
|
608
|
+
%w[GET holds] => :handle_holds,
|
|
609
|
+
%w[POST holds/stop] => :handle_holds_stop,
|
|
591
610
|
%w[POST recap] => :handle_recap,
|
|
592
611
|
%w[POST relay] => :handle_relay,
|
|
593
612
|
%w[POST relay/status] => :handle_relay_status,
|
|
@@ -730,6 +749,30 @@ module Samagotchi
|
|
|
730
749
|
end
|
|
731
750
|
end
|
|
732
751
|
|
|
752
|
+
# POST /session/:id/guardrails/wrong {id}: append a false-allow label
|
|
753
|
+
# only for this session's System 1 auto-allow. Unknown ids are not
|
|
754
|
+
# distinguishable from judgments belonging to another session.
|
|
755
|
+
def handle_system_one_wrong(session_id, body)
|
|
756
|
+
parsed = parse_json(body)
|
|
757
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
|
|
758
|
+
|
|
759
|
+
judgment_id = fetched(parsed, "id").to_s.strip
|
|
760
|
+
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }] if judgment_id.empty?
|
|
761
|
+
|
|
762
|
+
log = SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(@state_dir))
|
|
763
|
+
entry = log.entries.find do |judgment|
|
|
764
|
+
judgment["id"] == judgment_id &&
|
|
765
|
+
judgment["session_id"] == session_id &&
|
|
766
|
+
judgment["decision"] == "auto"
|
|
767
|
+
end
|
|
768
|
+
return [{}, 404, { error: "unknown_judgment" }] unless entry
|
|
769
|
+
return [{}, 200, { status: "labelled", id: judgment_id }] if entry["answer"] == "should_have_asked"
|
|
770
|
+
return [{}, 409, { error: "already_labelled" }] unless entry["answer"].nil?
|
|
771
|
+
return [{}, 500, { error: "label_failed", detail: "could not write the System 1 label" }] unless log.label(judgment_id, "should_have_asked")
|
|
772
|
+
|
|
773
|
+
[{}, 200, { status: "labelled", id: judgment_id }]
|
|
774
|
+
end
|
|
775
|
+
|
|
733
776
|
# What a parent's relay may say about this session's approval
|
|
734
777
|
# (BridgeClient#relay): opened and closed only change how the question
|
|
735
778
|
# shows (QuestionDesk#annotate); answered makes this worker ask the
|
|
@@ -857,14 +900,21 @@ module Samagotchi
|
|
|
857
900
|
|
|
858
901
|
# A client asks the worker to exit now (`/exit` in the attached TUI), or
|
|
859
902
|
# with "restart": true to hand the session to a new worker on the newest
|
|
860
|
-
# chi installed (the "restart" feature).
|
|
861
|
-
#
|
|
862
|
-
#
|
|
863
|
-
#
|
|
864
|
-
#
|
|
865
|
-
#
|
|
866
|
-
#
|
|
867
|
-
#
|
|
903
|
+
# chi installed (the "restart" feature). "stop": "all" first stops every
|
|
904
|
+
# hold the user can stop; "ask": false leaves the delegate children out of
|
|
905
|
+
# the list (a UI that can't ask about them, as /archive). The worker
|
|
906
|
+
# decides with the event log held, so no POST /turn lands between its
|
|
907
|
+
# check and its answer; it leaves from its loop after this reply.
|
|
908
|
+
# 200 {status: "exiting", discard?: the session is empty and goes,
|
|
909
|
+
# stopped?: what a "stop": "all" exit stopped} ({status: "restarting"} for
|
|
910
|
+
# a restart), or 409 {status: "held", reason:} naming what keeps it up
|
|
911
|
+
# (with tasks: [{id:, command:, age_seconds:}] and a hint: how to stop
|
|
912
|
+
# them, for background_tasks). When every hold is one the user can stop,
|
|
913
|
+
# the 409 also carries holds: [{kind:, items: [{id:, label:, detail:}]}
|
|
914
|
+
# — reminders, running tasks, delegate children — for the terminal's ask.
|
|
915
|
+
# A past +deadline+ (see #handle_post_turn), checked with the event log
|
|
916
|
+
# held: 408 deadline_passed, the worker not asked (its client said it
|
|
917
|
+
# didn't stop).
|
|
868
918
|
# Returns [headers, status, body].
|
|
869
919
|
def handle_exit_request(session_id, body)
|
|
870
920
|
return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
|
|
@@ -875,7 +925,13 @@ module Samagotchi
|
|
|
875
925
|
client_id = fetched(parsed, "client_id")
|
|
876
926
|
delete = fetched(parsed, "delete") == true
|
|
877
927
|
restart = fetched(parsed, "restart") == true
|
|
928
|
+
stop_all = fetched(parsed, "stop") == "all"
|
|
929
|
+
# A client that can ask about what still runs (an attached terminal's
|
|
930
|
+
# plain /exit) gets the delegates in the hold list; one that can't
|
|
931
|
+
# (/archive, and every worker's own last look) does not.
|
|
878
932
|
options = restart ? { delete: delete, restart: true } : { delete: delete }
|
|
933
|
+
options[:stop] = true if stop_all && !restart
|
|
934
|
+
options[:ask] = false if fetched(parsed, "ask") == false
|
|
879
935
|
deadline = fetched(parsed, "deadline")
|
|
880
936
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
881
937
|
|
|
@@ -892,13 +948,57 @@ module Samagotchi
|
|
|
892
948
|
|
|
893
949
|
body = { status: "exiting", session_id: @session_id }
|
|
894
950
|
body[:discard] = @exit_discards.call == true if @exit_discards
|
|
951
|
+
stopped = @exit_stopped&.call
|
|
952
|
+
body[:stopped] = stopped.to_h if stopped
|
|
895
953
|
return [{}, 200, body]
|
|
896
954
|
end
|
|
897
955
|
|
|
898
956
|
# A hold may name what it waits for (tasks: the background tasks a
|
|
899
|
-
# finish wake watches).
|
|
957
|
+
# finish wake watches). When every hold is one the user can stop, +holds+
|
|
958
|
+
# lists them for the terminal's ask: [{kind:, items: [{id:, label:,
|
|
959
|
+
# detail:}]} (docs/cli.md, /exit).
|
|
900
960
|
[{}, 409, { status: "held", reason: hold.reason.to_s, tasks: hold.tasks, hint: hold.hint,
|
|
901
|
-
session_id: @session_id }.compact]
|
|
961
|
+
holds: holds_json(hold.holds), session_id: @session_id }.compact]
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# The exit ask's stoppable holds as JSON; nil when there are none, so an
|
|
965
|
+
# older client sees today's plain 409.
|
|
966
|
+
def holds_json(holds)
|
|
967
|
+
return nil if holds.nil? || holds.empty?
|
|
968
|
+
|
|
969
|
+
holds.map { |hold| { kind: hold.kind.to_s, items: hold.items.map { |item| item.to_h } } }
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# GET /session/:id/holds: what the user could stop in this worker right
|
|
973
|
+
# now — the same holds the exit ask's 409 carries (reminders, the watched
|
|
974
|
+
# background tasks still running, the delegate children with a live
|
|
975
|
+
# worker), whatever else keeps the worker up: chi web's Stop asks about
|
|
976
|
+
# these before it TERMs the worker, and a Stop cancels a running turn
|
|
977
|
+
# anyway, so a turn is not a reason to hide them.
|
|
978
|
+
# 200 {session_id, holds: [{kind:, items: [...]}]} (no +holds+ when there
|
|
979
|
+
# are none); 501 holds_unavailable when this worker has no such step.
|
|
980
|
+
# Returns [headers, status, body].
|
|
981
|
+
def handle_holds(_session_id, _body = nil)
|
|
982
|
+
return [{}, 501, { error: "holds_unavailable" }] unless @on_holds
|
|
983
|
+
|
|
984
|
+
[{}, 200, { session_id: @session_id, holds: holds_json(@on_holds.call) }.compact]
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# POST /session/:id/holds/stop: stop everything the user can stop (HoldStop#stop_all)
|
|
988
|
+
# without the worker leaving — the reminders cleared, the watched tasks
|
|
989
|
+
# killed as task_stop does, the delegate children stopped as
|
|
990
|
+
# `chi sessions stop` does. What the user can't stop (a turn running,
|
|
991
|
+
# another UI attached) is untouched. The web's Stop's "Stop all" runs
|
|
992
|
+
# this, then stops the worker.
|
|
993
|
+
# 200 {status: "stopped", session_id, stopped: {reminders:, tasks:,
|
|
994
|
+
# delegates:}}; 501 holds_unavailable when this worker has no such step.
|
|
995
|
+
# Returns [headers, status, body].
|
|
996
|
+
def handle_holds_stop(_session_id, _body = nil)
|
|
997
|
+
return [{}, 501, { error: "holds_unavailable" }] unless @on_holds_stop
|
|
998
|
+
|
|
999
|
+
Log.info(:bridge, "holds_stop")
|
|
1000
|
+
stopped = @on_holds_stop.call
|
|
1001
|
+
[{}, 200, { status: "stopped", session_id: @session_id, stopped: stopped.to_h }]
|
|
902
1002
|
end
|
|
903
1003
|
|
|
904
1004
|
# Queue a turn for this session through its file IPC (the worker polls
|
|
@@ -145,6 +145,13 @@ module Samagotchi
|
|
|
145
145
|
post("question/dismiss", { id: id, deadline: deadline }, read_body: true)
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
# POST /session/:id/guardrails/wrong: mark this session's System 1
|
|
149
|
+
# auto-allow as one it should have asked about; 404 when unknown.
|
|
150
|
+
# @return [Response]
|
|
151
|
+
def mark_system_one_wrong(judgment_id:)
|
|
152
|
+
post("guardrails/wrong", { id: judgment_id }, read_body: true)
|
|
153
|
+
end
|
|
154
|
+
|
|
148
155
|
# POST /session/:id/turn/dequeue: take back a queued message (one that
|
|
149
156
|
# waits for the running turn's end) before it runs. 200 {status:
|
|
150
157
|
# "removed", enqueued_id, prompt}; 409 already_started (a turn took
|
|
@@ -199,10 +206,12 @@ module Samagotchi
|
|
|
199
206
|
# error "not_found" = a worker older than the route, 408 = too late.
|
|
200
207
|
# @param client_id [String] the asking UI, whose own streams don't hold
|
|
201
208
|
# @return [Response]
|
|
202
|
-
def request_exit(client_id:, delete: false, restart: false)
|
|
209
|
+
def request_exit(client_id:, delete: false, restart: false, stop: false, ask: true)
|
|
203
210
|
body = { client_id: client_id }
|
|
204
211
|
body[:delete] = true if delete
|
|
205
212
|
body[:restart] = true if restart
|
|
213
|
+
body[:stop] = "all" if stop
|
|
214
|
+
body[:ask] = false unless ask
|
|
206
215
|
body[:deadline] = deadline
|
|
207
216
|
post("exit", body, read_body: true)
|
|
208
217
|
end
|
|
@@ -229,6 +238,16 @@ module Samagotchi
|
|
|
229
238
|
post("tasks/stop", { task_id: task_id }, read_body: true)
|
|
230
239
|
end
|
|
231
240
|
|
|
241
|
+
# POST /session/:id/holds/stop: stop everything this worker's user can
|
|
242
|
+
# stop (its reminders, the background tasks a finish wake watches that
|
|
243
|
+
# still run, its delegate children) without the worker exiting
|
|
244
|
+
# (Bridge#handle_holds_stop). 200 {status: "stopped", stopped:
|
|
245
|
+
# {reminders:, tasks:, delegates:}}; 501 for a worker without the route.
|
|
246
|
+
# @return [Response]
|
|
247
|
+
def stop_holds
|
|
248
|
+
post("holds/stop", {}, read_body: true)
|
|
249
|
+
end
|
|
250
|
+
|
|
232
251
|
# How long the approval relay's calls wait for a reply: a parent's Stop
|
|
233
252
|
# isn't held for READ_TIMEOUT by a child that doesn't answer.
|
|
234
253
|
RELAY_TIMEOUT = 5
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -173,6 +173,21 @@ module Samagotchi
|
|
|
173
173
|
# (Guardrails::ParentPolicy): hard = deny and plain answers; standard = + spend (Continue); soft = + permit (Allow
|
|
174
174
|
# once). An invalid value is hard, the strictest.
|
|
175
175
|
Entry.new(key: "guardrails.parent_answers", yaml_path: %w[guardrails parent_answers], type: :enum, default: "soft", expose: %i[config], enum_values: %w[hard standard soft], invalid: "hard", lifecycle: :next_tool_or_approval),
|
|
176
|
+
# System 1 (the cheap judgment model that answers the asks a user always allows). Client and settings; in `on`
|
|
177
|
+
# mode GuardrailWiring also reads rules, threshold and min_record for the auto-allow (docs/guardrails.md).
|
|
178
|
+
# off (the default) | audit (judge every ask, log it, never decide) | on. An unknown value warns and is off.
|
|
179
|
+
Entry.new(key: "guardrails.system_one.mode", yaml_path: %w[guardrails system_one mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :next_tool_or_approval),
|
|
180
|
+
# The hosts: entry serving /v1/systemone (Splash, or the hosted Jev API); unset: no judgment.
|
|
181
|
+
Entry.new(key: "guardrails.system_one.host", yaml_path: %w[guardrails system_one host], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
|
|
182
|
+
# The judgment model; unset: the host's first model, and no judgment if it names none.
|
|
183
|
+
Entry.new(key: "guardrails.system_one.model", yaml_path: %w[guardrails system_one model], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
|
|
184
|
+
# The rule ids System 1 may answer for ("a|b", or a YAML list, parsed like guardrails.small_models).
|
|
185
|
+
Entry.new(key: "guardrails.system_one.rules", yaml_path: %w[guardrails system_one rules], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
|
|
186
|
+
# The probability an auto-allow needs, and how many clean records a rule needs before it is trusted.
|
|
187
|
+
Entry.new(key: "guardrails.system_one.threshold", yaml_path: %w[guardrails system_one threshold], type: :float, default: 0.8, expose: %i[config], lifecycle: :next_tool_or_approval),
|
|
188
|
+
Entry.new(key: "guardrails.system_one.min_record", yaml_path: %w[guardrails system_one min_record], type: :integer, default: 5, expose: %i[config], lifecycle: :next_tool_or_approval),
|
|
189
|
+
# How long one judgment may take, in milliseconds; a judgment that doesn't arrive asks the user.
|
|
190
|
+
Entry.new(key: "guardrails.system_one.timeout_ms", yaml_path: %w[guardrails system_one timeout_ms], type: :integer, default: 2000, expose: %i[config], lifecycle: :next_tool_or_approval),
|
|
176
191
|
|
|
177
192
|
Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli], lifecycle: :process_start),
|
|
178
193
|
Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli], lifecycle: :process_start),
|
|
@@ -355,6 +370,8 @@ module Samagotchi
|
|
|
355
370
|
# What a :bool value reads as (Config.coerce!), YAML 1.1's words and 1/0.
|
|
356
371
|
BOOL_WORDS = { "1" => true, "true" => true, "yes" => true, "on" => true,
|
|
357
372
|
"0" => false, "false" => false, "no" => false, "off" => false }.freeze
|
|
373
|
+
# The word an :enum takes for a boolean (an unquoted on/off is one in YAML 1.1).
|
|
374
|
+
BOOL_ENUM_WORDS = { true => "on", false => "off" }.freeze
|
|
358
375
|
|
|
359
376
|
# Fast lookup maps
|
|
360
377
|
BY_KEY = ENTRIES.each_with_object({}) { |e, h| h[e.key] = e }.freeze
|
|
@@ -382,8 +399,10 @@ module Samagotchi
|
|
|
382
399
|
# +raw+ as +entry+'s type, strictly: a value of another shape raises.
|
|
383
400
|
# :string as given (a YAML list joined with |), :integer, :float,
|
|
384
401
|
# :bool (true/yes/on/1, false/no/off/0/""), :enum in its canonical
|
|
385
|
-
# case
|
|
386
|
-
#
|
|
402
|
+
# case (an unquoted on/off, which YAML 1.1 reads as a boolean, is the
|
|
403
|
+
# word when the entry has it); nil for nil, and for a blank value of any
|
|
404
|
+
# other type than :string and :bool. #coerce is its lenient twin
|
|
405
|
+
# (config.yml, env).
|
|
387
406
|
# @raise [SessionConfig::Error] "KEY takes WHAT, not RAW"
|
|
388
407
|
def coerce!(entry, raw)
|
|
389
408
|
return nil if raw.nil?
|
|
@@ -399,7 +418,7 @@ module Samagotchi
|
|
|
399
418
|
when :integer then Integer(str, exception: false)
|
|
400
419
|
when :float then Float(str, exception: false)
|
|
401
420
|
when :bool then BOOL_WORDS[str.downcase]
|
|
402
|
-
when :enum then entry
|
|
421
|
+
when :enum then enum_word(entry, raw, str)
|
|
403
422
|
else str
|
|
404
423
|
end
|
|
405
424
|
raise SessionConfig::Error, "#{entry.key} takes #{takes(entry)}, not #{raw.to_s.inspect}" if value.nil?
|
|
@@ -407,6 +426,18 @@ module Samagotchi
|
|
|
407
426
|
value
|
|
408
427
|
end
|
|
409
428
|
|
|
429
|
+
# A value of +entry+'s enum, as written. YAML 1.1 reads an unquoted
|
|
430
|
+
# on/off/yes/no as a boolean, so `mode: on` reaches the enum branch as
|
|
431
|
+
# true: it is the entry's "on"/"off" when the entry has that word, and
|
|
432
|
+
# still invalid when it doesn't.
|
|
433
|
+
def enum_word(entry, raw, str)
|
|
434
|
+
found = entry.enum_values.find { |v| v.downcase == str.downcase }
|
|
435
|
+
return found if found
|
|
436
|
+
|
|
437
|
+
word = BOOL_ENUM_WORDS[raw]
|
|
438
|
+
word && entry.enum_values.include?(word) ? word : nil
|
|
439
|
+
end
|
|
440
|
+
|
|
410
441
|
# What +entry+'s type takes, as a refusal names it.
|
|
411
442
|
def takes(entry)
|
|
412
443
|
case entry.type
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -401,7 +401,12 @@ module Samagotchi
|
|
|
401
401
|
cancelled: -> { !!active_cancel_controller&.cancelled? },
|
|
402
402
|
# An approval of the model's call: :question_asked first (QuestionAsked).
|
|
403
403
|
ask: ->(fields) { open_question(question_asked(QuestionAsked::KIND_APPROVAL, fields)) },
|
|
404
|
-
notify: ->(message) { guardrail_notify(message) }
|
|
404
|
+
notify: ->(message) { guardrail_notify(message) },
|
|
405
|
+
# System 1 (experimental, audit phase): the judgment client is read
|
|
406
|
+
# from config on every ask, and the running turn's cancel controller
|
|
407
|
+
# closes a judgment that is no longer wanted.
|
|
408
|
+
system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
|
|
409
|
+
cancel_controller: -> { active_cancel_controller }
|
|
405
410
|
)
|
|
406
411
|
end
|
|
407
412
|
private :build_guardrail_wiring
|
|
@@ -6,10 +6,13 @@ require_relative "config"
|
|
|
6
6
|
require_relative "log"
|
|
7
7
|
require_relative "guardrails"
|
|
8
8
|
require_relative "guardrails/parent_approvals"
|
|
9
|
+
require_relative "guardrails/parent_policy"
|
|
10
|
+
require_relative "model_match"
|
|
9
11
|
require_relative "memory_bundle/manifest"
|
|
10
12
|
require_relative "version"
|
|
11
13
|
require_relative "hooks"
|
|
12
14
|
require_relative "edit_preview"
|
|
15
|
+
require_relative "system_one"
|
|
13
16
|
require_relative "tool_activity"
|
|
14
17
|
require_relative "turn_origin"
|
|
15
18
|
|
|
@@ -38,8 +41,11 @@ module Samagotchi
|
|
|
38
41
|
# @param ask [#call] (fields) → the answer; the Engine's question flow
|
|
39
42
|
# @param notify [#call] (message) → shows a guardrails line in every UI
|
|
40
43
|
# (a bundle's rules kept because it needs a newer chi)
|
|
44
|
+
# @param system_one [#call] → SystemOne::Client or nil: the judgment client
|
|
45
|
+
# (nil while `guardrails.system_one.mode` is off or no host is set)
|
|
46
|
+
# @param cancel_controller [#call] → the running turn's CancellationController, nil
|
|
41
47
|
def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
|
|
42
|
-
notify: ->(_message) {})
|
|
48
|
+
notify: ->(_message) {}, system_one: -> {}, cancel_controller: -> {})
|
|
43
49
|
@scratch = scratch
|
|
44
50
|
@hooks_lookup = hooks
|
|
45
51
|
@tools_lookup = tools
|
|
@@ -49,6 +55,8 @@ module Samagotchi
|
|
|
49
55
|
@cancelled_lookup = cancelled
|
|
50
56
|
@ask = ask
|
|
51
57
|
@notify = notify
|
|
58
|
+
@system_one = system_one
|
|
59
|
+
@cancel_controller = cancel_controller
|
|
52
60
|
# Bundle name → the rules last loaded from it whole, kept while the
|
|
53
61
|
# installed version needs a newer chi; [name, version] said so once.
|
|
54
62
|
@loaded_bundle_rules = {}
|
|
@@ -82,6 +90,7 @@ module Samagotchi
|
|
|
82
90
|
def state_dir=(state_dir)
|
|
83
91
|
@approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
|
|
84
92
|
@approvals = Guardrails::Approvals::InMemory.new(@approvals) if @scratch
|
|
93
|
+
@system_one_log = nil
|
|
85
94
|
@protected = nil
|
|
86
95
|
end
|
|
87
96
|
|
|
@@ -259,11 +268,166 @@ module Samagotchi
|
|
|
259
268
|
# A call on chi's own config, hooks or guardrails is the user's to
|
|
260
269
|
# allow, never a parent agent's (Guardrails::ParentPolicy).
|
|
261
270
|
payload[:user_only] = Guardrails::ParentApprovals.protected_facts?(payload[:approval])
|
|
262
|
-
|
|
271
|
+
# System 1: the ask is judged before the user sees it, and in `on` mode
|
|
272
|
+
# one that passes every gate is allowed once without reaching them
|
|
273
|
+
# (docs/guardrails.md, "System 1").
|
|
274
|
+
judgment = system_one_judge(payload)
|
|
275
|
+
if judgment && judgment[:auto]
|
|
276
|
+
verdict.judgment = { id: judgment[:id], p: judgment[:p] }
|
|
277
|
+
verdict.settle!(:allow, decided_by: "system one")
|
|
278
|
+
verdict.scope = "once"
|
|
279
|
+
return verdict
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
answer = @ask.call(payload)
|
|
283
|
+
system_one_label(judgment, answer, payload)
|
|
284
|
+
Guardrails::Approval.settle(verdict, answer, payload[:approval][:scopes])
|
|
263
285
|
end
|
|
264
286
|
|
|
265
287
|
private
|
|
266
288
|
|
|
289
|
+
# The judgments log (SystemOne::Log), beside the approvals store; nil
|
|
290
|
+
# while there is no store dir.
|
|
291
|
+
def system_one_log
|
|
292
|
+
return nil if @approvals.nil?
|
|
293
|
+
|
|
294
|
+
@system_one_log ||= SystemOne::Log.new(dir: File.dirname(@approvals.path))
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# System 1's judgment of an ask, written to the log before the user is
|
|
298
|
+
# asked, with the decision it led to: "auto" (it answered for the user),
|
|
299
|
+
# "asked" (on mode, the user decides) or "audit" (audit mode). A
|
|
300
|
+
# participant's review turn is never judged, and no client (mode off, no
|
|
301
|
+
# host) means no line at all. A judgment that fails is logged with p nil,
|
|
302
|
+
# so the failures are in the log too.
|
|
303
|
+
# @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
|
|
304
|
+
def system_one_judge(payload)
|
|
305
|
+
return nil if Steer.participant_author?(turn_author)
|
|
306
|
+
|
|
307
|
+
client = @system_one.call
|
|
308
|
+
log = system_one_log
|
|
309
|
+
return nil if client.nil? || log.nil?
|
|
310
|
+
|
|
311
|
+
approval = payload[:approval]
|
|
312
|
+
# The judgments log is read once for this ask: the state's examples and
|
|
313
|
+
# the record gate both use these entries. The judgment of the ask being
|
|
314
|
+
# judged is written after it, so the examples are always earlier asks.
|
|
315
|
+
entries = log.entries
|
|
316
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
317
|
+
answers = begin
|
|
318
|
+
client.ask(state: SystemOne::ApprovalQuestion.state(approval, session_title: @session_lookup.call&.title,
|
|
319
|
+
entries: entries),
|
|
320
|
+
questions: { SystemOne::ApprovalQuestion::ID => SystemOne::ApprovalQuestion.question },
|
|
321
|
+
cancel_controller: @cancel_controller.call)
|
|
322
|
+
rescue StandardError => e
|
|
323
|
+
# The client already turns every failure it knows into a nil; this is
|
|
324
|
+
# the unexpected one. Either way the ask goes to the user.
|
|
325
|
+
Samagotchi::Log.warn(:guardrails, "system_one_ask_failed", error: "#{e.class}: #{e.message}")
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
329
|
+
p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
|
|
330
|
+
auto = system_one_auto_allow?(client, approval, p, entries)
|
|
331
|
+
decision = system_one_decision(client, auto)
|
|
332
|
+
|
|
333
|
+
id = log.judgment(session_id: @session_lookup.call&.id,
|
|
334
|
+
rule: approval[:rule], source: approval[:source], tool: approval[:tool],
|
|
335
|
+
**SystemOne::ApprovalQuestion.call_fields(approval),
|
|
336
|
+
repo_name: approval[:repo_name],
|
|
337
|
+
p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
|
|
338
|
+
latency_ms: latency_ms, decision: decision, origin: "live")
|
|
339
|
+
# A judgment the log cannot hold is no judgment: the user is asked.
|
|
340
|
+
{ id: id, p: p, auto: auto && !id.nil? }
|
|
341
|
+
rescue StandardError => e
|
|
342
|
+
# The approval never fails because of System 1.
|
|
343
|
+
Samagotchi::Log.warn(:guardrails, "system_one_judgment_failed", error: "#{e.class}: #{e.message}")
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# What the judgment line says it did: "auto" (System 1 answered for the
|
|
348
|
+
# user), "asked" (on mode, the user decides), "audit" (nothing decided).
|
|
349
|
+
def system_one_decision(client, auto)
|
|
350
|
+
return "auto" if auto
|
|
351
|
+
|
|
352
|
+
client.mode == "on" ? "asked" : "audit"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Every gate an auto-allow must pass, in order: the mode, the rule the
|
|
356
|
+
# user opted in, the never-list and chi's own files, the probability, and
|
|
357
|
+
# the rule's record. Any one that fails leaves the ask to the user. The
|
|
358
|
+
# entries are the judgment log read once for this ask.
|
|
359
|
+
# @param entries [Array<Hash>] SystemOne::Log#entries
|
|
360
|
+
def system_one_auto_allow?(client, approval, p, entries)
|
|
361
|
+
return false unless client.mode == "on"
|
|
362
|
+
|
|
363
|
+
rule = approval[:rule].to_s
|
|
364
|
+
return false if rule.empty?
|
|
365
|
+
return false unless system_one_rules.include?(rule)
|
|
366
|
+
return false if SystemOne::SelfProtect.refuse?(approval)
|
|
367
|
+
|
|
368
|
+
threshold = Config.get("guardrails.system_one.threshold").to_f
|
|
369
|
+
return false if p.nil? || p.to_f < threshold
|
|
370
|
+
|
|
371
|
+
min_record = Config.get("guardrails.system_one.min_record").to_i
|
|
372
|
+
SystemOne::Record.new(entries, rule: rule).qualifies?(threshold: threshold, min_record: min_record)
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
Samagotchi::Log.warn(:guardrails, "system_one_gate_failed", error: "#{e.class}: #{e.message}")
|
|
375
|
+
false
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# guardrails.system_one.rules: a "|"-separated string or a YAML list,
|
|
379
|
+
# read with the same grammar as guardrails.small_models.
|
|
380
|
+
def system_one_rules
|
|
381
|
+
ModelMatch.parse(Config.get("guardrails.system_one.rules"))
|
|
382
|
+
rescue StandardError
|
|
383
|
+
[]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The user's answer to that ask, as a label on the judgment: the scope
|
|
387
|
+
# they took, a denial, or none for an ask that was cancelled. A parent
|
|
388
|
+
# agent's answer is not the user's: its allow labels nothing, and its
|
|
389
|
+
# deny is a label only where the parent could have allowed — a parent
|
|
390
|
+
# that was not allowed to permit says nothing about what the user would
|
|
391
|
+
# have done (docs/guardrails.md, "System 1").
|
|
392
|
+
def system_one_label(judgment, answer, payload)
|
|
393
|
+
id = judgment && judgment[:id]
|
|
394
|
+
return nil if id.nil?
|
|
395
|
+
|
|
396
|
+
scopes = Array(payload[:approval][:scopes])
|
|
397
|
+
if !answer.is_a?(Samagotchi::QuestionAnswer)
|
|
398
|
+
label = "none"
|
|
399
|
+
elsif answer.parent_agent?
|
|
400
|
+
return nil if parent_allowed?(answer, scopes) || !parent_may_permit?(payload)
|
|
401
|
+
|
|
402
|
+
label = "deny"
|
|
403
|
+
else
|
|
404
|
+
index = answer.selected_indices.first
|
|
405
|
+
label = if index && index < scopes.size
|
|
406
|
+
scopes[index] == "once" ? "allow_once" : "allow_wide"
|
|
407
|
+
else
|
|
408
|
+
"deny"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
system_one_log.label(id, label)
|
|
412
|
+
rescue StandardError => e
|
|
413
|
+
Samagotchi::Log.warn(:guardrails, "system_one_label_failed", error: "#{e.class}: #{e.message}")
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Whether a parent's answer picked one of the offered scopes.
|
|
418
|
+
def parent_allowed?(answer, scopes)
|
|
419
|
+
index = answer.selected_indices.first
|
|
420
|
+
!index.nil? && index < scopes.size
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Whether this session's parent level lets a parent allow a call at all,
|
|
424
|
+
# and the ask is not one only the user may answer.
|
|
425
|
+
def parent_may_permit?(payload)
|
|
426
|
+
return false if payload[:user_only]
|
|
427
|
+
|
|
428
|
+
Guardrails::ParentPolicy.for_session(@session_lookup.call).allow?("permit")
|
|
429
|
+
end
|
|
430
|
+
|
|
267
431
|
# The work tree top of the session's working_directory (the folder
|
|
268
432
|
# itself outside a repo, or when it's gone); never Dir.pwd, which is
|
|
269
433
|
# the waker's cwd for a worker whose folder is gone.
|
|
@@ -51,6 +51,9 @@ module Samagotchi
|
|
|
51
51
|
paths: targets && !targets.paths.empty? ? targets.paths : nil,
|
|
52
52
|
# No command or path (an MCP tool): its arguments, as the question.
|
|
53
53
|
args: args_for_card(targets),
|
|
54
|
+
# A memory_write ask, in words: the entry, its scope, and for a
|
|
55
|
+
# removal the description its index line carries.
|
|
56
|
+
memory: memory_for_card(targets),
|
|
54
57
|
cwd: targets&.cwd,
|
|
55
58
|
repo_root: targets&.repo_root,
|
|
56
59
|
# The repository's name (a worktree's main checkout's folder).
|
|
@@ -78,6 +81,23 @@ module Samagotchi
|
|
|
78
81
|
text.empty? ? nil : text
|
|
79
82
|
end
|
|
80
83
|
|
|
84
|
+
# A memory_write ask names an entry, not a file: which one, and in which
|
|
85
|
+
# scope. For a removal the ask also carries that entry's description from
|
|
86
|
+
# its scoped index line, which is what System 1 otherwise cannot see
|
|
87
|
+
# (docs/guardrails.md, "System 1"). nil for every other tool.
|
|
88
|
+
def memory_for_card(targets)
|
|
89
|
+
entry = targets&.memory
|
|
90
|
+
return nil if entry.nil?
|
|
91
|
+
|
|
92
|
+
facts = { name: entry[:name], scope: entry[:scope] }
|
|
93
|
+
if targets.memory_remove?
|
|
94
|
+
require_relative "../memory_bundle/index_updater"
|
|
95
|
+
description = MemoryBundle::IndexUpdater.description_for(entry[:scope], entry[:name])
|
|
96
|
+
facts[:description] = description unless description.to_s.strip.empty?
|
|
97
|
+
end
|
|
98
|
+
facts
|
|
99
|
+
end
|
|
100
|
+
|
|
81
101
|
# Settle +verdict+ from open_question's result.
|
|
82
102
|
# @param answer [QuestionAnswer, Hash, String] the answer, or {error:} (anything else is a cancel)
|
|
83
103
|
# @param scopes [Array<String>] the offered scopes, in option order
|
|
@@ -74,6 +74,12 @@ module Samagotchi
|
|
|
74
74
|
attr_reader :memory_overlay
|
|
75
75
|
alias memory_overlay? memory_overlay
|
|
76
76
|
|
|
77
|
+
# @return [Hash, nil] a memory_write call's entry and its scope,
|
|
78
|
+
# {name:, scope:}: which memory the ask is about, in words rather than
|
|
79
|
+
# as the file it names (#paths); nil for another tool or a call the
|
|
80
|
+
# tool would refuse
|
|
81
|
+
attr_reader :memory
|
|
82
|
+
|
|
77
83
|
# @param call [Hash] the parsed tool call
|
|
78
84
|
# @param context [Context]
|
|
79
85
|
# @param model_key [String, nil] for a memory_write model overlay, and
|
|
@@ -114,9 +120,10 @@ module Samagotchi
|
|
|
114
120
|
end
|
|
115
121
|
memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
|
|
116
122
|
memory_overlay = tool == "memory_write" && !memory_remove && call[:current_model_only].to_s.strip.downcase == "true"
|
|
123
|
+
memory = tool == "memory_write" ? memory_facts(call) : nil
|
|
117
124
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
|
|
118
125
|
repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
|
|
119
|
-
memory_overlay: memory_overlay,
|
|
126
|
+
memory_overlay: memory_overlay, memory: memory,
|
|
120
127
|
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
|
|
121
128
|
chi_dirs: -> { chi_dirs(context, base) })
|
|
122
129
|
end
|
|
@@ -224,14 +231,28 @@ module Samagotchi
|
|
|
224
231
|
nil
|
|
225
232
|
end
|
|
226
233
|
|
|
234
|
+
# The memory a memory_write call is about, in the words the ask needs:
|
|
235
|
+
# the entry and its scope, not its file; nil for a call the tool would
|
|
236
|
+
# refuse (no entry, no scope, a scope chi has no folder for).
|
|
237
|
+
def self.memory_facts(call)
|
|
238
|
+
entry = call[:path].to_s.strip
|
|
239
|
+
scope = Tools::MemoryRead.normalize_scope(call[:scope])
|
|
240
|
+
return nil if entry.empty? || scope.nil?
|
|
241
|
+
|
|
242
|
+
{ name: entry, scope: scope }
|
|
243
|
+
rescue StandardError
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
227
247
|
# @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
|
|
228
248
|
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
|
|
229
249
|
model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false,
|
|
230
|
-
memory_overlay: false)
|
|
250
|
+
memory_overlay: false, memory: nil)
|
|
231
251
|
@acts_as = acts_as
|
|
232
252
|
@label = label
|
|
233
253
|
@memory_remove = memory_remove
|
|
234
254
|
@memory_overlay = memory_overlay
|
|
255
|
+
@memory = memory
|
|
235
256
|
@repo = repo
|
|
236
257
|
@chi_dirs = chi_dirs
|
|
237
258
|
@session_root = session_root || repo_root || cwd
|