samagotchi 0.147.0 → 0.149.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 +32 -1
- data/README.md +2 -2
- data/docs/architecture.md +1 -1
- data/docs/bundles.md +64 -17
- data/docs/cli.md +18 -0
- data/docs/configuration.md +7 -3
- data/docs/guardrails.md +27 -5
- data/docs/hooks.md +2 -1
- data/docs/internals/objectives.md +264 -0
- data/docs/internals/web-frontend.md +4 -1
- data/docs/plugins.md +4 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +44 -2
- data/docs/system-one.md +18 -2
- data/lib/samagotchi/answer_tail.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/guardrails/merge.yml +21 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +9 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +5 -3
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +44 -13
- data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
- data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +12 -1
- data/lib/samagotchi/context_note.rb +17 -5
- data/lib/samagotchi/engine.rb +113 -6
- data/lib/samagotchi/engine_peers.rb +11 -0
- data/lib/samagotchi/guardrail_wiring.rb +11 -6
- data/lib/samagotchi/guardrails/approvals.rb +3 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
- data/lib/samagotchi/guardrails/rules.rb +6 -1
- data/lib/samagotchi/guardrails/verdict.rb +21 -1
- data/lib/samagotchi/guardrails_command.rb +47 -3
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +6 -2
- data/lib/samagotchi/objectives.rb +395 -0
- data/lib/samagotchi/objectives_audit.rb +115 -0
- data/lib/samagotchi/objectives_check.rb +335 -0
- data/lib/samagotchi/objectives_command.rb +192 -0
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +21 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_chain.rb +91 -8
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_continue.rb +6 -2
- data/lib/samagotchi/session_inbox.rb +14 -6
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/steer.rb +24 -4
- data/lib/samagotchi/system_one/record.rb +17 -3
- data/lib/samagotchi/testing/boundary.rb +1 -0
- data/lib/samagotchi/testing/host.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +17 -0
- data/lib/samagotchi/tool_declarations.rb +34 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/objectives_tool.rb +75 -0
- data/lib/samagotchi/tools/peers.rb +3 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/message_display.rb +9 -0
- data/lib/samagotchi/web/participant_view.rb +1 -1
- data/lib/samagotchi/web/public/app.js +10 -0
- data/lib/samagotchi/web/public/cards_place.js +20 -4
- data/lib/samagotchi/web/public/commands.js +4 -1
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/objectives.css +80 -0
- data/lib/samagotchi/web/public/objectives_entry.js +22 -0
- data/lib/samagotchi/web/public/objectives_model.js +100 -0
- data/lib/samagotchi/web/public/objectives_moves.js +145 -0
- data/lib/samagotchi/web/public/objectives_strip.js +242 -0
- data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -1
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/session_show.rb +12 -1
- data/lib/samagotchi/worker.rb +3 -0
- data/lib/samagotchi/worker_inbound.rb +16 -8
- metadata +16 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -51,6 +51,7 @@ require_relative "context_candidates"
|
|
|
51
51
|
require_relative "context_candidates_log"
|
|
52
52
|
require_relative "context_candidate_choice"
|
|
53
53
|
require_relative "context_judge"
|
|
54
|
+
require_relative "objectives_audit"
|
|
54
55
|
require_relative "owner_prompt"
|
|
55
56
|
require_relative "side_judgments"
|
|
56
57
|
require_relative "toolkit_suggest"
|
|
@@ -156,6 +157,8 @@ module Samagotchi
|
|
|
156
157
|
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
157
158
|
# may steer or ask whether a turn runs while it loads.
|
|
158
159
|
@turn_state = TurnState.new(clock: -> { monotonic_now })
|
|
160
|
+
@objective_proposals = []
|
|
161
|
+
@objective_proposals_mutex = Mutex.new
|
|
159
162
|
# A message for the running turn cutting its generation.
|
|
160
163
|
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
|
|
161
164
|
cut_after: -> { steer_cut_after })
|
|
@@ -767,6 +770,8 @@ module Samagotchi
|
|
|
767
770
|
# command's ask_model between turns).
|
|
768
771
|
# @return [String, nil]
|
|
769
772
|
def side_author(kind)
|
|
773
|
+
# Check this is only ever the owner's, whichever turn runs as it ends.
|
|
774
|
+
return nil if kind.to_s == ObjectivesCheck::SOURCE
|
|
770
775
|
return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
|
|
771
776
|
|
|
772
777
|
@guardrail_wiring.turn_author
|
|
@@ -956,7 +961,7 @@ module Samagotchi
|
|
|
956
961
|
ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
|
|
957
962
|
command_queued command_ran context_added card hook_notice guardrail_warning
|
|
958
963
|
plugin_init_started plugin_init_finished working_set_updated participants_changed
|
|
959
|
-
verdict_recorded].freeze
|
|
964
|
+
verdict_recorded objectives_updated].freeze
|
|
960
965
|
|
|
961
966
|
# Put a transport-level event into the ordered event log. Unlike turn
|
|
962
967
|
# events it reaches only persistent observers (no turn sink, no memory
|
|
@@ -987,6 +992,63 @@ module Samagotchi
|
|
|
987
992
|
synchronize_events { emit_working_set(nil, session, session.messages) }
|
|
988
993
|
end
|
|
989
994
|
|
|
995
|
+
# The session's objectives as the page draws them (Objectives.view), when
|
|
996
|
+
# that differs from the last announced: after an edit (its file written,
|
|
997
|
+
# pending), once a list note is in, at a turn's end (its moves) and after
|
|
998
|
+
# a rollback. A session with no list announces nothing. It only informs:
|
|
999
|
+
# a failure is logged, never the turn's.
|
|
1000
|
+
def announce_objectives(session = @session)
|
|
1001
|
+
return unless session
|
|
1002
|
+
|
|
1003
|
+
synchronize_events do
|
|
1004
|
+
view = Objectives.view(session.id, session.messages, state_dir: session_state_dir, running: live_conversation&.dup)
|
|
1005
|
+
next if view.nil? || view == @objectives_announced
|
|
1006
|
+
|
|
1007
|
+
@objectives_announced = view
|
|
1008
|
+
announce({ type: :objectives_updated, session_id: session.id, **view })
|
|
1009
|
+
end
|
|
1010
|
+
rescue StandardError => e
|
|
1011
|
+
Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# Check this (ObjectivesCheck)'s one side request. Its usage is in the
|
|
1015
|
+
# session's side line. Raises as ctx.ask_model's request does.
|
|
1016
|
+
# @param request [Array<Hash>] chat messages
|
|
1017
|
+
# @param model [String, nil] a configured model; nil: the session's
|
|
1018
|
+
# @return [String] the answer
|
|
1019
|
+
def ask_objective_check(request, model:)
|
|
1020
|
+
ask_side_model(request, timeout: ObjectivesCheck::TIMEOUT_SECONDS, max_tokens: ObjectivesCheck::MAX_TOKENS,
|
|
1021
|
+
cancel_controller: nil, model: model, source: ObjectivesCheck::SOURCE)
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# A change the model proposes (the objectives tool), kept until the turn
|
|
1025
|
+
# is over, as #context_choice's cards are: a card shown then is a quiet one, between turns, with its Apply kept
|
|
1026
|
+
# over a restart (CardStore), not a running turn's that says it waits on the
|
|
1027
|
+
# user. The same change twice in a turn is one card.
|
|
1028
|
+
# @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
|
|
1029
|
+
def propose_objective(proposal)
|
|
1030
|
+
@objective_proposals_mutex.synchronize do
|
|
1031
|
+
next true if @objective_proposals.any? { |p| p.line == proposal.line }
|
|
1032
|
+
next false if @objective_proposals.size >= Objectives::PROPOSALS_PER_TURN
|
|
1033
|
+
|
|
1034
|
+
@objective_proposals << proposal
|
|
1035
|
+
true
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def show_objective_proposals
|
|
1040
|
+
proposals = @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
|
|
1041
|
+
proposals.each do |proposal|
|
|
1042
|
+
id = "objectives-#{SecureRandom.hex(4)}"
|
|
1043
|
+
show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
|
|
1044
|
+
actions: [{ label: "Apply", command: proposal.action_line(id) },
|
|
1045
|
+
{ label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
|
|
1046
|
+
end
|
|
1047
|
+
rescue StandardError => e
|
|
1048
|
+
Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
|
|
1049
|
+
end
|
|
1050
|
+
private :show_objective_proposals
|
|
1051
|
+
|
|
990
1052
|
# The strategy the working set is built under now, as LLMContextView
|
|
991
1053
|
# takes it: the running turn's, else the next turn's (#emit_working_set).
|
|
992
1054
|
# /working-set's ops act under it.
|
|
@@ -1018,11 +1080,14 @@ module Samagotchi
|
|
|
1018
1080
|
next nil if Array(session.messages).any? { |m| m[:note_id] == note[:note_id] }
|
|
1019
1081
|
|
|
1020
1082
|
message = ContextNote.message(**note)
|
|
1083
|
+
entry = note[:objectives] && Objectives.entry(note[:objectives], before: Objectives.build(session.messages))
|
|
1021
1084
|
replace_session_messages(session, Array(session.messages) + [message])
|
|
1022
1085
|
announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
|
|
1023
1086
|
label: ContextNote.label_of(message), from_session: note[:from_session],
|
|
1024
1087
|
from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
|
|
1025
|
-
created_at: note[:created_at] }.compact)
|
|
1088
|
+
created_at: note[:created_at], objectives: entry }.compact)
|
|
1089
|
+
announce_objectives(session) if entry
|
|
1090
|
+
objectives_audit.note(session, note)
|
|
1026
1091
|
message
|
|
1027
1092
|
end
|
|
1028
1093
|
end
|
|
@@ -1677,6 +1742,7 @@ module Samagotchi
|
|
|
1677
1742
|
event[:verdict] = verdict if verdict
|
|
1678
1743
|
event[:revises] = revises.to_h if revises
|
|
1679
1744
|
emit_working_set(turn.on_event, turn.session, turn.session.messages)
|
|
1745
|
+
announce_objectives(turn.session)
|
|
1680
1746
|
turn.session.status = Session::STATUS_IDLE
|
|
1681
1747
|
record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
|
|
1682
1748
|
emit_event(turn.on_event, turn.tag(event))
|
|
@@ -2366,6 +2432,7 @@ module Samagotchi
|
|
|
2366
2432
|
ensure
|
|
2367
2433
|
release_turn(probe_cancel_before)
|
|
2368
2434
|
show_context_cards_after_turn
|
|
2435
|
+
show_objective_proposals
|
|
2369
2436
|
end
|
|
2370
2437
|
end
|
|
2371
2438
|
|
|
@@ -2469,6 +2536,7 @@ module Samagotchi
|
|
|
2469
2536
|
# The candidate judge: its asks run beside the turn; on mode's
|
|
2470
2537
|
# attaches reach the model at the next between-turn delivery.
|
|
2471
2538
|
start_context_judge(turn)
|
|
2539
|
+
audit_objectives_incoming(turn)
|
|
2472
2540
|
apply_toolkit!(session, turn.on_event)
|
|
2473
2541
|
refresh_participants!(session)
|
|
2474
2542
|
# A Stop during the waits above: the turn ends here,
|
|
@@ -2740,14 +2808,18 @@ module Samagotchi
|
|
|
2740
2808
|
|
|
2741
2809
|
# Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
|
|
2742
2810
|
# ends the turn in #run_turn without it), with a read-only
|
|
2743
|
-
# copy of the conversation the turn stored, event[:
|
|
2811
|
+
# copy of the conversation the turn stored, event[:answer] (the answer's
|
|
2812
|
+
# text: the native loop's stored message still holds its thought),
|
|
2813
|
+
# event[:present] for
|
|
2744
2814
|
# a display version of the answer (AnswerDisplay) and event[:refs]
|
|
2745
2815
|
# for refs on it (AnswerRefs).
|
|
2746
2816
|
session = turn.session
|
|
2817
|
+
audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
|
|
2747
2818
|
display = AnswerDisplay.new(session.messages)
|
|
2748
2819
|
refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
|
|
2749
2820
|
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
2750
|
-
messages: hook_messages(session.messages)
|
|
2821
|
+
messages: hook_messages(session.messages),
|
|
2822
|
+
answer: (result.text unless canceled || result.exhausted?) }
|
|
2751
2823
|
after_turn[:present] = display.presenter(after_turn)
|
|
2752
2824
|
after_turn[:refs] = refs.adder(after_turn)
|
|
2753
2825
|
@hooks.fire(:after_turn, after_turn)
|
|
@@ -2855,7 +2927,8 @@ module Samagotchi
|
|
|
2855
2927
|
# The running turn's conversation as its loop builds it (both loops
|
|
2856
2928
|
# register it on the kernel's llm_context_turn): the same array the loop
|
|
2857
2929
|
# appends to, not a copy, so read it on the turn's thread (the pending
|
|
2858
|
-
# input drain, at an iteration boundary: TurnCheckpoint)
|
|
2930
|
+
# input drain, at an iteration boundary: TurnCheckpoint), or from another
|
|
2931
|
+
# only as a dup, read only (/objectives). nil when no
|
|
2859
2932
|
# turn runs (llm_context_turn is never cleared, and still holds the
|
|
2860
2933
|
# last turn's then).
|
|
2861
2934
|
# @return [Array<Hash>, nil]
|
|
@@ -2885,7 +2958,10 @@ module Samagotchi
|
|
|
2885
2958
|
raise ArgumentError, "no current session" unless @session
|
|
2886
2959
|
|
|
2887
2960
|
messages = replace_session_messages(@session, clone_messages(checkpoint))
|
|
2888
|
-
|
|
2961
|
+
unless turn_running?
|
|
2962
|
+
announce_working_set
|
|
2963
|
+
announce_objectives
|
|
2964
|
+
end
|
|
2889
2965
|
messages
|
|
2890
2966
|
end
|
|
2891
2967
|
|
|
@@ -3296,6 +3372,36 @@ module Samagotchi
|
|
|
3296
3372
|
end
|
|
3297
3373
|
private :start_context_judge
|
|
3298
3374
|
|
|
3375
|
+
# System 1 on the objectives, in audit (ObjectivesAudit): the owner's turns
|
|
3376
|
+
# only, and for a message a person's: a reminder's or a delegate's prompt isn't one.
|
|
3377
|
+
def audit_objectives_incoming(turn)
|
|
3378
|
+
return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
|
|
3379
|
+
|
|
3380
|
+
objectives_audit.incoming(turn.session, turn.prompt)
|
|
3381
|
+
end
|
|
3382
|
+
private :audit_objectives_incoming
|
|
3383
|
+
|
|
3384
|
+
def audit_objectives_outgoing(turn, result)
|
|
3385
|
+
return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
|
|
3386
|
+
|
|
3387
|
+
objectives_audit.outgoing(turn.session, result.text)
|
|
3388
|
+
end
|
|
3389
|
+
private :audit_objectives_outgoing
|
|
3390
|
+
|
|
3391
|
+
# With saving off nothing would come of an ask, so none is made.
|
|
3392
|
+
def objectives_audit
|
|
3393
|
+
@objectives_audit ||= ObjectivesAudit.new(
|
|
3394
|
+
side: @side_judgments, state_dir: -> { session_state_dir },
|
|
3395
|
+
client_for: lambda do |judgment, session|
|
|
3396
|
+
next nil unless system_one_runs.on?
|
|
3397
|
+
|
|
3398
|
+
system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
|
|
3399
|
+
judgment, session)
|
|
3400
|
+
end
|
|
3401
|
+
)
|
|
3402
|
+
end
|
|
3403
|
+
private :objectives_audit
|
|
3404
|
+
|
|
3299
3405
|
# A late proposal no owner prompt mentions (Plugin::Host
|
|
3300
3406
|
# #candidate_proposed: github-pr's init task runs beside the first
|
|
3301
3407
|
# turn): judged on the owner's prompts so far, the running turn's too.
|
|
@@ -3446,6 +3552,7 @@ module Samagotchi
|
|
|
3446
3552
|
cwd: -> { @session&.working_directory },
|
|
3447
3553
|
messages: -> { plugin_messages },
|
|
3448
3554
|
messages_partial: -> { turn_running? && @running_turn_messages.nil? },
|
|
3555
|
+
turn_running: -> { turn_running? },
|
|
3449
3556
|
notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
|
|
3450
3557
|
ask_user: ->(question) { hook_ask_user(question) },
|
|
3451
3558
|
# An init task's is its own (a Ctrl-C ends a turn, not the task).
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "objectives"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# The views of an Engine its peers get: the kernel's tools
|
|
5
7
|
# (Engine#initialize sets @kernel.peers) and a delegate's approval relay
|
|
@@ -28,6 +30,15 @@ module Samagotchi
|
|
|
28
30
|
# The peer's name when +name+ is a source a peer bundle declared
|
|
29
31
|
# (PeerSources), for context_read's header.
|
|
30
32
|
def peer_label(name) = engine.peer_sources&.label_for(name)
|
|
33
|
+
|
|
34
|
+
# The session's objectives now (the objectives tool), nil outside one.
|
|
35
|
+
def objectives
|
|
36
|
+
session = engine.session
|
|
37
|
+
session && Objectives.current(session.id, session.messages, state_dir: engine.peer_state_dir)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A proposed change: its card shows at the turn's end (Engine#propose_objective).
|
|
41
|
+
def propose_objective(proposal) = engine.propose_objective(proposal)
|
|
31
42
|
end
|
|
32
43
|
|
|
33
44
|
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
@@ -323,7 +323,7 @@ module Samagotchi
|
|
|
323
323
|
# one that passes every gate is allowed once without reaching them
|
|
324
324
|
# (docs/guardrails.md, "System 1").
|
|
325
325
|
judgment = begin
|
|
326
|
-
system_one_judge(payload, targets: verdict.targets)
|
|
326
|
+
system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?)
|
|
327
327
|
rescue LLM::RequestCancelled
|
|
328
328
|
return turn_stopped(verdict)
|
|
329
329
|
end
|
|
@@ -366,8 +366,9 @@ module Samagotchi
|
|
|
366
366
|
# auto-allowed: its asks go to the owner. No client (mode off, no
|
|
367
367
|
# host) means no line at all. A judgment that fails is logged with p nil,
|
|
368
368
|
# so the failures are in the log too.
|
|
369
|
+
# With +ask_user+ (a fresh ask, a second rule's) it is judged and logged, never answered.
|
|
369
370
|
# @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
|
|
370
|
-
def system_one_judge(payload, targets: nil)
|
|
371
|
+
def system_one_judge(payload, targets: nil, ask_user: false)
|
|
371
372
|
return nil if Steer.participant_author?(turn_author)
|
|
372
373
|
|
|
373
374
|
client = @system_one.call
|
|
@@ -397,13 +398,13 @@ module Samagotchi
|
|
|
397
398
|
end
|
|
398
399
|
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
399
400
|
p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
|
|
400
|
-
auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
|
|
401
|
+
auto = !ask_user && system_one_auto_allow?(client, approval, p, entries, targets: targets)
|
|
401
402
|
decision = system_one_decision(client, auto)
|
|
402
403
|
|
|
403
404
|
id = log.judgment(id: run_id, session_id: @session_lookup.call&.id,
|
|
404
405
|
rule: approval.rule, source: approval.source, tool: approval.tool,
|
|
405
406
|
**SystemOne::ApprovalQuestion.call_fields(approval),
|
|
406
|
-
repo_name: approval.repo_name,
|
|
407
|
+
repo_name: approval.repo_name, repo: targets&.repo,
|
|
407
408
|
p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
|
|
408
409
|
latency_ms: latency_ms, decision: decision, origin: "live")
|
|
409
410
|
# A judgment the log cannot hold is no judgment: the user is asked.
|
|
@@ -443,7 +444,8 @@ module Samagotchi
|
|
|
443
444
|
|
|
444
445
|
# Every gate an auto-allow must pass, in order: the mode, the rule the
|
|
445
446
|
# user opted in, the never-list and chi's own files, the probability, and
|
|
446
|
-
# the rule's record
|
|
447
|
+
# the rule's record (this repo's for a rule that goes by repo; with no repo
|
|
448
|
+
# that is none). Any one that fails leaves the ask to the user. The
|
|
447
449
|
# entries are the judgment log read once for this ask.
|
|
448
450
|
# @param approval [Guardrails::ApprovalFacts] the ask's facts
|
|
449
451
|
# @param entries [Array<Hash>] SystemOne::Log#entries
|
|
@@ -460,7 +462,10 @@ module Samagotchi
|
|
|
460
462
|
return false if p.nil? || p.to_f < threshold
|
|
461
463
|
|
|
462
464
|
min_record = Config.get("guardrails.system_one.min_record").to_i
|
|
463
|
-
SystemOne::Record.
|
|
465
|
+
per_repo = SystemOne::Record.per_repo?(rule)
|
|
466
|
+
return false if per_repo && targets&.repo.nil?
|
|
467
|
+
|
|
468
|
+
SystemOne::Record.new(entries, rule: rule, repo: (targets.repo if per_repo)).qualifies?(threshold: threshold, min_record: min_record)
|
|
464
469
|
rescue StandardError => e
|
|
465
470
|
Samagotchi::Log.warn(:guardrails, "system_one_gate_failed", error: "#{e.class}: #{e.message}")
|
|
466
471
|
false
|
|
@@ -80,7 +80,9 @@ module Samagotchi
|
|
|
80
80
|
case e["scope"]
|
|
81
81
|
when "session" then session_id && e["session_id"] == session_id && e["key"] == key
|
|
82
82
|
when "repo" then here.call(e) && e["key"] == key
|
|
83
|
-
when "rule"
|
|
83
|
+
when "rule"
|
|
84
|
+
# One rule's grant can't answer for a second rule's ask on the call.
|
|
85
|
+
!verdict.second_rule_ask? && verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
88
|
end
|
|
@@ -34,7 +34,8 @@ module Samagotchi
|
|
|
34
34
|
# - owner-voiced tools (OWNER_VOICED, and any delegate_* tool): what
|
|
35
35
|
# speaks or acts in the owner's name beyond this turn (a delegate, a
|
|
36
36
|
# reminder, a note to another session, a background task, a
|
|
37
|
-
# forgotten output, the session's loaded tools
|
|
37
|
+
# forgotten output, the session's loaded tools, a move on the owner's
|
|
38
|
+
# objectives); the tools that read
|
|
38
39
|
# the owner's background tasks (OWNER_TASK_READERS); and memory_write:
|
|
39
40
|
# a memory reaches the owner's later sessions;
|
|
40
41
|
# - ask_user_question: there is no one on the participant's side to
|
|
@@ -51,7 +52,7 @@ module Samagotchi
|
|
|
51
52
|
RULE = "pair-boundary"
|
|
52
53
|
SOURCE = "core"
|
|
53
54
|
OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
|
|
54
|
-
find_tools].freeze
|
|
55
|
+
find_tools objectives].freeze
|
|
55
56
|
OWNER_VOICED_PREFIX = "delegate_"
|
|
56
57
|
# The background task tools that read the owner's tasks (a pair turn
|
|
57
58
|
# starts none: task_create is owner-voiced, and its execute is never
|
|
@@ -310,10 +310,13 @@ module Samagotchi
|
|
|
310
310
|
@disable.reject { |entry| @rules.any? { |rule| disables?(entry, rule) } }
|
|
311
311
|
end
|
|
312
312
|
|
|
313
|
-
# A core check: every matching rule votes (strictest wins).
|
|
313
|
+
# A core check: every matching rule votes (strictest wins). A second
|
|
314
|
+
# rule id that asks takes rule grants out (Verdict#second_rule_asks!);
|
|
315
|
+
# asks from before the rules (the child boundary's) stay as they are.
|
|
314
316
|
def check(verdict)
|
|
315
317
|
return verdict unless @enabled
|
|
316
318
|
|
|
319
|
+
asking = nil
|
|
317
320
|
@rules.each do |rule|
|
|
318
321
|
next if disabled?(rule)
|
|
319
322
|
next unless rule.for_mode?(@mode)
|
|
@@ -323,6 +326,8 @@ module Samagotchi
|
|
|
323
326
|
verdict.deny!(rule.reason, rule: rule.id, source: rule.source, decided_by: "rule")
|
|
324
327
|
else
|
|
325
328
|
verdict.ask!(rule.reason, scopes: rule.scopes, rule: rule.id, source: rule.source, decided_by: "rule")
|
|
329
|
+
verdict.second_rule_asks!(rule.scopes) if asking && asking != rule.id
|
|
330
|
+
asking ||= rule.id
|
|
326
331
|
end
|
|
327
332
|
end
|
|
328
333
|
verdict
|
|
@@ -8,7 +8,8 @@ module Samagotchi
|
|
|
8
8
|
# first one stays, except that a protected rule's ask (PROTECTED_RULES)
|
|
9
9
|
# takes over an unprotected ask: its rule id is what a parent's answer
|
|
10
10
|
# and a stored approval are checked against, and the offered scopes
|
|
11
|
-
# narrow to both asks' common ones.
|
|
11
|
+
# narrow to both asks' common ones. A second rule's ask takes rule grants
|
|
12
|
+
# out (#second_rule_asks!): the one id it keeps can't answer for both.
|
|
12
13
|
class Verdict
|
|
13
14
|
STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
|
|
14
15
|
# Rules that ask about chi's own config and hooks: the core
|
|
@@ -47,6 +48,9 @@ module Samagotchi
|
|
|
47
48
|
|
|
48
49
|
def fresh_ask? = @fresh_ask == true
|
|
49
50
|
|
|
51
|
+
# Whether a second distinct rule asks about this call (#second_rule_asks!).
|
|
52
|
+
def second_rule_ask? = @second_rule_ask == true
|
|
53
|
+
|
|
50
54
|
def initialize(call:)
|
|
51
55
|
@call = call
|
|
52
56
|
@decision = :allow
|
|
@@ -84,6 +88,22 @@ module Samagotchi
|
|
|
84
88
|
self
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
# A second distinct rule asks about this call (Rules#check). The ask
|
|
92
|
+
# names one rule, and a rule grant or System 1's record is about that
|
|
93
|
+
# rule alone, so neither answers for the other's: no rule-scope entry
|
|
94
|
+
# matches it (Approvals.find_in), System 1 doesn't auto-allow it, and it
|
|
95
|
+
# offers what both rules allow, minus the rule. An exact-call entry
|
|
96
|
+
# (session, repo) still answers: it is keyed on the whole command.
|
|
97
|
+
# @param scopes [Array<String>, nil] the second rule's scopes (default all)
|
|
98
|
+
def second_rule_asks!(scopes = nil)
|
|
99
|
+
@second_rule_ask = true
|
|
100
|
+
allowed = Array(scopes).map(&:to_s) & SCOPES
|
|
101
|
+
allowed = SCOPES if allowed.empty?
|
|
102
|
+
left = (@scopes & allowed) - %w[rule]
|
|
103
|
+
@scopes = left.empty? ? %w[once] : left
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
87
107
|
def self.protected_rule?(rule) = PROTECTED_RULES.include?(rule.to_s)
|
|
88
108
|
|
|
89
109
|
# A before_tool_call hook set the old event[:blocked] flag. Its model
|
|
@@ -8,6 +8,7 @@ require_relative "cli/flags"
|
|
|
8
8
|
require_relative "config"
|
|
9
9
|
require_relative "bridge/card_store"
|
|
10
10
|
require_relative "guardrails/approval"
|
|
11
|
+
require_relative "guardrail_wiring"
|
|
11
12
|
require_relative "guardrails/approvals"
|
|
12
13
|
require_relative "guardrails/parent_policy"
|
|
13
14
|
require_relative "host_registry"
|
|
@@ -20,7 +21,10 @@ module Samagotchi
|
|
|
20
21
|
# seed: System 1 judges every approval this machine's sessions already
|
|
21
22
|
# answered, and the judgment and the user's answer go to the log as they
|
|
22
23
|
# would live. A rule's record then starts with the asks the user really
|
|
23
|
-
# decided on, instead of waiting for a few days of live ones.
|
|
24
|
+
# decided on, instead of waiting for a few days of live ones. `rule` says
|
|
25
|
+
# whether one rule votes `ask` on this machine now, read from the config and
|
|
26
|
+
# the installed bundles as a worker reads them (GuardrailWiring#rules), by
|
|
27
|
+
# its id: the text of a command never comes near it.
|
|
24
28
|
class GuardrailsCommand
|
|
25
29
|
include CLI::Command
|
|
26
30
|
|
|
@@ -41,16 +45,24 @@ module Samagotchi
|
|
|
41
45
|
--dry-run judges and prints the table without writing a line. Needs
|
|
42
46
|
guardrails.system_one.mode audit or on and a system_one.host
|
|
43
47
|
(exit 2 without one).
|
|
48
|
+
rule <id>
|
|
49
|
+
Print exactly "asks" when the rule is loaded, not disabled and votes
|
|
50
|
+
ask in this guardrails.mode for every model; another line says why not
|
|
51
|
+
when it isn't (exit 0 either way). Reads config.yml and the installed
|
|
52
|
+
bundles, as a worker does: no session, no model, nothing written.
|
|
44
53
|
TEXT
|
|
45
54
|
|
|
46
|
-
SUBCOMMANDS = %w[replay].freeze
|
|
55
|
+
SUBCOMMANDS = %w[replay rule].freeze
|
|
47
56
|
FLAGS = {
|
|
48
57
|
"replay" => CLI::Flags.new(help: HELP_WORDS, args: false) do |f|
|
|
49
58
|
f.switch "--dry-run"
|
|
50
59
|
f.value "--threshold"
|
|
51
60
|
f.value "--model"
|
|
52
|
-
end
|
|
61
|
+
end,
|
|
62
|
+
"rule" => CLI::Flags.new(help: HELP_WORDS)
|
|
53
63
|
}.freeze
|
|
64
|
+
# What `rule` prints when a rule asks. An agent compares the line to it.
|
|
65
|
+
ASKS = "asks"
|
|
54
66
|
# The labels that mean the user let the call through.
|
|
55
67
|
ALLOW_LABELS = %w[allow_once allow_wide].freeze
|
|
56
68
|
# replay's table, in column order: the rule, how many asks it had, how
|
|
@@ -135,6 +147,8 @@ module Samagotchi
|
|
|
135
147
|
parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
|
|
136
148
|
return parsed if parsed.is_a?(Integer)
|
|
137
149
|
|
|
150
|
+
return rule(parsed.args) if @sub == "rule"
|
|
151
|
+
|
|
138
152
|
replay(parsed.options)
|
|
139
153
|
end
|
|
140
154
|
|
|
@@ -142,6 +156,36 @@ module Samagotchi
|
|
|
142
156
|
|
|
143
157
|
def command_name = @sub ? "chi guardrails #{@sub}" : "chi guardrails"
|
|
144
158
|
|
|
159
|
+
def rule(args)
|
|
160
|
+
return usage_error("give one rule id") unless args.size == 1
|
|
161
|
+
|
|
162
|
+
@stdout.puts(rule_status(args.first))
|
|
163
|
+
CLI::Exit::OK
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The rule set as a worker would load it now, the rule's id looked up in
|
|
167
|
+
# it. Every other answer than ASKS names what stands in the way.
|
|
168
|
+
def rule_status(id)
|
|
169
|
+
wiring = GuardrailWiring.new(scratch: false, hooks: -> {}, tools: -> {}, session: -> {}, model_key: -> {},
|
|
170
|
+
cancelled: -> { false }, ask: ->(_fields) {})
|
|
171
|
+
rules = wiring.rules
|
|
172
|
+
failed = wiring.failures.required
|
|
173
|
+
return "rules failed to load: #{failed.map(&:what).join(", ")}" unless failed.empty?
|
|
174
|
+
return "guardrails are off (guardrails.enabled: false)" unless rules.enabled?
|
|
175
|
+
|
|
176
|
+
named = rules.rules.select { |r| r.id == id }
|
|
177
|
+
return "no rule #{id} is loaded" if named.empty?
|
|
178
|
+
|
|
179
|
+
enabled = named.reject { |r| rules.disabled?(r) }
|
|
180
|
+
return "rule #{id} is disabled (guardrails.disable)" if enabled.empty?
|
|
181
|
+
|
|
182
|
+
voting = enabled.select { |r| r.for_mode?(rules.mode) }
|
|
183
|
+
return "rule #{id} doesn't vote in guardrails.mode #{rules.mode}" if voting.empty?
|
|
184
|
+
return "rule #{id} denies" if voting.any? { |r| r.verdict == :deny }
|
|
185
|
+
|
|
186
|
+
voting.any? { |r| r.models.nil? } ? ASKS : "rule #{id} asks for some models only"
|
|
187
|
+
end
|
|
188
|
+
|
|
145
189
|
def replay(options)
|
|
146
190
|
threshold = threshold(options[:threshold])
|
|
147
191
|
return usage_error(threshold) if threshold.is_a?(String)
|
|
@@ -259,7 +259,7 @@ module Samagotchi
|
|
|
259
259
|
# Claude model's cache breakpoint there (PromptCache) and drops it
|
|
260
260
|
# for every other request.
|
|
261
261
|
def system_message(entry, index)
|
|
262
|
-
text = entry[:content].to_s
|
|
262
|
+
text = Steer.wire_text(entry, entry[:content].to_s)
|
|
263
263
|
split = index.zero? && @stable_length&.call(text)
|
|
264
264
|
split ? { role: "system", content: text, cache_split: split } : { role: "system", content: text }
|
|
265
265
|
end
|
|
@@ -123,7 +123,7 @@ module Samagotchi
|
|
|
123
123
|
|
|
124
124
|
def outcome(result)
|
|
125
125
|
case result.status
|
|
126
|
-
when :refused then "refused:
|
|
126
|
+
when :refused then "refused: #{NoteDelivery::REPL_REFUSAL}"
|
|
127
127
|
when :queued then "queued: its worker adds it within a few seconds"
|
|
128
128
|
else "waits for the session's next start (#{result.queued} #{result.queued == 1 ? "note" : "notes"} queued)"
|
|
129
129
|
end
|
|
@@ -14,6 +14,9 @@ module Samagotchi
|
|
|
14
14
|
# note in its notes/ folder, which a live worker adds within seconds and
|
|
15
15
|
# a session with no worker at its next start. Callers word the outcome.
|
|
16
16
|
module NoteDelivery
|
|
17
|
+
# Why a chi REPL's session takes no note.
|
|
18
|
+
REPL_REFUSAL = "it is open in a chi REPL; notes need attached mode"
|
|
19
|
+
|
|
17
20
|
# The outcome for one session.
|
|
18
21
|
# @!attribute status [Symbol] :queued (a worker runs it), :waits (no
|
|
19
22
|
# worker: it waits for the next start) or :refused (a chi REPL owns it)
|
|
@@ -26,14 +29,15 @@ module Samagotchi
|
|
|
26
29
|
module_function
|
|
27
30
|
|
|
28
31
|
# @param id [String] a full session id (resolved by the caller)
|
|
32
|
+
# @param objectives [Hash, nil] an objectives note's payload
|
|
29
33
|
# @return [Result]
|
|
30
34
|
# @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
|
|
31
|
-
def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil)
|
|
35
|
+
def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil)
|
|
32
36
|
owner = SessionManager.session_owner(id, state_dir: state_dir)
|
|
33
37
|
return Result.new(id: id, status: :refused, queued: 0) if owner&.tui?
|
|
34
38
|
|
|
35
39
|
path = SessionInbox.write_note(id, text: text, source: source, from_session: from_session, from_cwd: from_cwd,
|
|
36
|
-
state_dir: state_dir)
|
|
40
|
+
objectives: objectives, state_dir: state_dir)
|
|
37
41
|
return Result.new(id: id, status: :queued, queued: 0) if owner
|
|
38
42
|
|
|
39
43
|
Result.new(id: id, status: :waits, queued: SessionInbox.find_new_note_files(File.dirname(path, 2)).size)
|