samagotchi 0.16.0 → 0.18.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 +149 -1
- data/README.md +2 -0
- data/docs/architecture.md +38 -2
- data/docs/cli.md +37 -9
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +41 -0
- data/docs/plugins.md +5 -2
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +43 -4
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +86 -14
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +121 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +73 -19
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +136 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +46 -6
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +46 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +35 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +189 -43
- data/lib/samagotchi/web/public/app.js +157 -51
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +12 -5
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/stage_view.js +13 -1
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +67 -12
- data/lib/samagotchi/web/public/turn_events.js +74 -3
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +58 -60
- data/lib/samagotchi/worker_sidecar.rb +7 -1
- metadata +10 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "time"
|
|
7
7
|
require "yaml"
|
|
8
8
|
|
|
9
|
+
require_relative "iteration_limit"
|
|
9
10
|
require_relative "config"
|
|
10
11
|
require_relative "context_note"
|
|
11
12
|
require_relative "context_status"
|
|
@@ -27,6 +28,7 @@ require_relative "llm/backend"
|
|
|
27
28
|
require_relative "llm/openai_chat"
|
|
28
29
|
require_relative "llm/turn_settings"
|
|
29
30
|
require_relative "session"
|
|
31
|
+
require_relative "answer_tail"
|
|
30
32
|
require_relative "archive_store"
|
|
31
33
|
require_relative "session_observer"
|
|
32
34
|
require_relative "tool_declarations"
|
|
@@ -83,7 +85,7 @@ module Samagotchi
|
|
|
83
85
|
# @param profile [ModelProfile, Symbol, String, nil]
|
|
84
86
|
# @param session_id [String, nil] resume an existing session
|
|
85
87
|
# @param no_interrupt [Boolean] --no-interrupt: every turn runs with
|
|
86
|
-
#
|
|
88
|
+
# IterationLimit.for(no_interrupt: true), whichever loop runs it
|
|
87
89
|
# @param model_name [String, nil] defaults from SAMAGOTCHI_DEFAULT_MODEL
|
|
88
90
|
# @param model_typed [String, nil] the name the model was given as (a session's
|
|
89
91
|
# model_typed: an alias), for the models: lookup; model_name by default
|
|
@@ -92,10 +94,6 @@ module Samagotchi
|
|
|
92
94
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
93
95
|
# What memory_write answers in a scratch session.
|
|
94
96
|
SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
|
|
95
|
-
# A turn's iteration limit with no_interrupt (the worker's own
|
|
96
|
-
# --no-interrupt turns pass the same).
|
|
97
|
-
NO_INTERRUPT_MAX_ITERATIONS = 1000
|
|
98
|
-
|
|
99
97
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
100
98
|
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
101
99
|
# delegate (a child would outlive it) nor plugin fork
|
|
@@ -243,7 +241,7 @@ module Samagotchi
|
|
|
243
241
|
Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
|
|
244
242
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
245
243
|
@prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
|
|
246
|
-
thinking: -> { turn_thinking }, memories: memories,
|
|
244
|
+
thinking: -> { turn_thinking }, model: -> { model_identity }, memories: memories,
|
|
247
245
|
muted_memory_names: @muted_memory_names)
|
|
248
246
|
@session = nil
|
|
249
247
|
@session_observer = SessionObserver.new
|
|
@@ -411,6 +409,18 @@ module Samagotchi
|
|
|
411
409
|
model_ref_for(@effective_model_name)
|
|
412
410
|
end
|
|
413
411
|
|
|
412
|
+
# The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
|
|
413
|
+
# the ref, its host, the overlay key and, for a local llama.cpp host
|
|
414
|
+
# only, the model its /props names when that differs. Never a new
|
|
415
|
+
# request: only the /props answer profile resolution already cached.
|
|
416
|
+
def model_identity
|
|
417
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
418
|
+
entry = target.entry
|
|
419
|
+
host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
|
|
420
|
+
SystemPrompt::ModelIdentity.new(ref: effective_model_ref, host: host, key: @model_key,
|
|
421
|
+
served: prompt_served_model(target), fallback_key: @fallback_model_key)
|
|
422
|
+
end
|
|
423
|
+
|
|
414
424
|
# +name+'s resolved ref (ModelRef#ref).
|
|
415
425
|
def model_ref_for(name)
|
|
416
426
|
@host_registry.model_ref(name).ref
|
|
@@ -463,8 +473,8 @@ module Samagotchi
|
|
|
463
473
|
def sync_model_key!
|
|
464
474
|
@model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
|
|
465
475
|
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
|
|
466
|
-
|
|
467
|
-
@kernel.sync_model_key!(@model_key, fallback:
|
|
476
|
+
@fallback_model_key = typed_key == @model_key ? nil : typed_key
|
|
477
|
+
@kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
|
|
468
478
|
end
|
|
469
479
|
private :sync_model_key!
|
|
470
480
|
|
|
@@ -904,7 +914,7 @@ module Samagotchi
|
|
|
904
914
|
# else [nil, nil].
|
|
905
915
|
# @return [Array(String, String), Array(nil, nil)]
|
|
906
916
|
def served_model(probe: true)
|
|
907
|
-
served_model_for(@metrics.
|
|
917
|
+
served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
|
|
908
918
|
end
|
|
909
919
|
|
|
910
920
|
# Read-only snapshot of the engine's view of the current session plus the
|
|
@@ -1069,7 +1079,10 @@ module Samagotchi
|
|
|
1069
1079
|
|
|
1070
1080
|
# How the turn ended, on the session (Session#last_turn): the caller's
|
|
1071
1081
|
# save puts it in the file the session hub watches.
|
|
1072
|
-
|
|
1082
|
+
# @param limit [Integer, nil] the turn ran out of iterations at this
|
|
1083
|
+
# limit: "exhausted" => true, "limit" => N, so a wait with nobody to
|
|
1084
|
+
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
1085
|
+
def record_last_turn(session, outcome, seconds, origin, limit: nil)
|
|
1073
1086
|
client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
|
|
1074
1087
|
source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
|
|
1075
1088
|
elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
|
|
@@ -1077,6 +1090,7 @@ module Samagotchi
|
|
|
1077
1090
|
end
|
|
1078
1091
|
session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
|
|
1079
1092
|
"seconds" => seconds.round(1), "origin" => source }
|
|
1093
|
+
session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
|
|
1080
1094
|
end
|
|
1081
1095
|
private :record_last_turn
|
|
1082
1096
|
|
|
@@ -1089,14 +1103,15 @@ module Samagotchi
|
|
|
1089
1103
|
# returns [the messages to keep (nil: leave the session's), the end
|
|
1090
1104
|
# event]. +save:+ also saves the session (a failed turn: a worker exits
|
|
1091
1105
|
# after it). The disk writes come after the step: every emitter waits
|
|
1092
|
-
# for the event lock.
|
|
1093
|
-
|
|
1106
|
+
# for the event lock. +exhausted:+ the turn ran out of iterations
|
|
1107
|
+
# (its limit goes in last_turn).
|
|
1108
|
+
def end_turn(turn, outcome, save: false, exhausted: false)
|
|
1094
1109
|
seconds = turn.elapsed
|
|
1095
1110
|
synchronize_events do
|
|
1096
1111
|
kept, event = yield(seconds)
|
|
1097
1112
|
replace_session_messages(turn.session, kept) if kept
|
|
1098
1113
|
turn.session.status = Session::STATUS_IDLE
|
|
1099
|
-
record_last_turn(turn.session, outcome, seconds, turn.origin)
|
|
1114
|
+
record_last_turn(turn.session, outcome, seconds, turn.origin, limit: exhausted ? turn.limit : nil)
|
|
1100
1115
|
emit_event(turn.on_event, turn.tag(event))
|
|
1101
1116
|
end
|
|
1102
1117
|
turn.ended = true
|
|
@@ -1242,6 +1257,19 @@ module Samagotchi
|
|
|
1242
1257
|
@question_desk.open_question({ **fields.to_h, **more }, watch: watch)
|
|
1243
1258
|
end
|
|
1244
1259
|
|
|
1260
|
+
# Publish a standing question that blocks nobody (QuestionDesk#post: a
|
|
1261
|
+
# worker's step-limit question).
|
|
1262
|
+
# @return [Hash, nil] the pending question, nil while another is open
|
|
1263
|
+
def post_question(fields, on_answer:, on_superseded_close: nil)
|
|
1264
|
+
@question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# Withdraw the standing question (QuestionDesk#withdraw).
|
|
1268
|
+
# @return [Boolean]
|
|
1269
|
+
def withdraw_question(reason, id: nil)
|
|
1270
|
+
@question_desk.withdraw(reason, id: id)
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1245
1273
|
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
1246
1274
|
# @return [Hash] normalized answer
|
|
1247
1275
|
def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
@@ -1262,6 +1290,7 @@ module Samagotchi
|
|
|
1262
1290
|
|
|
1263
1291
|
# Cancel the pending question (QuestionDesk#cancel).
|
|
1264
1292
|
# @return [Boolean] whether it was cancelled
|
|
1293
|
+
# @raise [QuestionDesk::NotDismissable] +id+ names a standing question
|
|
1265
1294
|
def cancel_question(reason = "user", id: nil)
|
|
1266
1295
|
@question_desk.cancel(reason, id: id)
|
|
1267
1296
|
end
|
|
@@ -1307,6 +1336,7 @@ module Samagotchi
|
|
|
1307
1336
|
def state_dir = engine.peer_state_dir
|
|
1308
1337
|
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
1309
1338
|
def relay = engine.relay_peer
|
|
1339
|
+
def model_ref = engine.effective_model_ref
|
|
1310
1340
|
end
|
|
1311
1341
|
|
|
1312
1342
|
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
@@ -1401,8 +1431,9 @@ module Samagotchi
|
|
|
1401
1431
|
# set on the kernel in #generate.
|
|
1402
1432
|
# +id+: names the turn on :turn_started (its metrics record's id) and on
|
|
1403
1433
|
# its saved prompt, so a UI pairs the two whatever left the conversation.
|
|
1434
|
+
# +limit+: the iteration limit #generate ran it with.
|
|
1404
1435
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1405
|
-
:settings, :id) do
|
|
1436
|
+
:settings, :id, :limit) do
|
|
1406
1437
|
# The turn's boundary events carry the origin only when there is one,
|
|
1407
1438
|
# so payloads stay unchanged for callers that don't pass it.
|
|
1408
1439
|
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
@@ -1420,7 +1451,7 @@ module Samagotchi
|
|
|
1420
1451
|
# @param session [Session] the session to operate on
|
|
1421
1452
|
# @param prompt [String] user input
|
|
1422
1453
|
# @param on_event [Proc, nil] receives event hashes
|
|
1423
|
-
# @param max_iterations [Integer] max kernel iterations
|
|
1454
|
+
# @param max_iterations [Integer, nil] max kernel iterations (nil: IterationLimit.for)
|
|
1424
1455
|
# @param cancel_controller [CancellationController, nil]
|
|
1425
1456
|
# @param max_tool_output_chars [Integer, nil] per-output char cap for the
|
|
1426
1457
|
# :tool_call_completed event's `output:` (nil → max_tool_output_chars)
|
|
@@ -1449,7 +1480,7 @@ module Samagotchi
|
|
|
1449
1480
|
# other error (e.g. an LLM::ProviderError) emits :turn_failed and
|
|
1450
1481
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1451
1482
|
# one-line summary:.
|
|
1452
|
-
def run_turn(session, prompt, on_event: nil, max_iterations:
|
|
1483
|
+
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
|
|
1453
1484
|
images: [])
|
|
1454
1485
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
1455
1486
|
continue: continue)
|
|
@@ -1653,9 +1684,10 @@ module Samagotchi
|
|
|
1653
1684
|
# turn's model, not the last native one.
|
|
1654
1685
|
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
|
|
1655
1686
|
|
|
1687
|
+
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
1656
1688
|
backend.complete(
|
|
1657
1689
|
messages: turn.messages,
|
|
1658
|
-
max_iterations:
|
|
1690
|
+
max_iterations: turn.limit,
|
|
1659
1691
|
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
1660
1692
|
cancel_controller: turn.controller,
|
|
1661
1693
|
model_name: bare_for_backend,
|
|
@@ -1692,7 +1724,7 @@ module Samagotchi
|
|
|
1692
1724
|
# after_turn hooks run below and may present the answer (the web
|
|
1693
1725
|
# holds its pop until it knows).
|
|
1694
1726
|
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1695
|
-
end_turn(turn, canceled ? "canceled" : "completed") do |seconds|
|
|
1727
|
+
end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
|
|
1696
1728
|
kept = kept_messages(turn, result, seconds)
|
|
1697
1729
|
if canceled
|
|
1698
1730
|
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
|
|
@@ -1955,6 +1987,15 @@ module Samagotchi
|
|
|
1955
1987
|
# rollback after Ctrl-C). Each replaces the array rather than mutating it,
|
|
1956
1988
|
# so the recap's lock-free snapshot never sees a half-applied edit.
|
|
1957
1989
|
|
|
1990
|
+
# The answer a UI shows last (AnswerTail), a copy of that one message
|
|
1991
|
+
# only: walked back from the end, never a #messages_checkpoint of the
|
|
1992
|
+
# whole conversation (the Bridge's GET tail). +turn_id+: that turn's
|
|
1993
|
+
# answer (AnswerTail.find); unknown or nil: the newest.
|
|
1994
|
+
# @return [Hash, nil]
|
|
1995
|
+
def last_answer_message(turn_id: nil)
|
|
1996
|
+
AnswerTail.find(@session&.messages, turn_id: turn_id)&.dup
|
|
1997
|
+
end
|
|
1998
|
+
|
|
1958
1999
|
# @return [Array<Hash>] a copy of the current session's messages to hand
|
|
1959
2000
|
# back to #rollback_to later
|
|
1960
2001
|
def messages_checkpoint
|
|
@@ -2125,7 +2166,8 @@ module Samagotchi
|
|
|
2125
2166
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
|
|
2126
2167
|
ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2127
2168
|
},
|
|
2128
|
-
model_name: -> {
|
|
2169
|
+
model_name: -> { effective_model_ref },
|
|
2170
|
+
model_key: -> { @model_key },
|
|
2129
2171
|
state_dir: -> { session_state_dir },
|
|
2130
2172
|
scratch: -> { @scratch }
|
|
2131
2173
|
)
|
|
@@ -2320,6 +2362,18 @@ module Samagotchi
|
|
|
2320
2362
|
[nil, nil]
|
|
2321
2363
|
end
|
|
2322
2364
|
|
|
2365
|
+
# What a local native host's cached /props names, when it isn't the
|
|
2366
|
+
# model asked for; nil for a chat or remote host, or before any probe.
|
|
2367
|
+
def prompt_served_model(target)
|
|
2368
|
+
entry = target.entry
|
|
2369
|
+
return nil if entry.nil? || entry.chat? || entry.remote? || !target.client.respond_to?(:cached_server_props)
|
|
2370
|
+
|
|
2371
|
+
served = ServedModel.from_props(target.client.cached_server_props(model: target.bare_model))
|
|
2372
|
+
served if ServedModel.differs?(target.bare_model, served)
|
|
2373
|
+
rescue StandardError
|
|
2374
|
+
nil
|
|
2375
|
+
end
|
|
2376
|
+
|
|
2323
2377
|
# ── Prompt profile ─────────────────────────────────────────────────────────
|
|
2324
2378
|
|
|
2325
2379
|
def resolve_profile
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require_relative "protected_paths"
|
|
5
|
+
require_relative "../memory_bundle/index_sync"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Guardrails
|
|
9
|
+
# What `outside_repo` means (a rule's path: and git:): a path is outside
|
|
10
|
+
# when it isn't in the session's repo root (the cwd outside a repo),
|
|
11
|
+
# with symlinks resolved on both sides. Never outside:
|
|
12
|
+
# - a memory's file (MemoryBundle::IndexSync.memory_scope): write/edit
|
|
13
|
+
# there is what memory_write does; index.md, the bundles dir and the
|
|
14
|
+
# rest of chi's config dir still count;
|
|
15
|
+
# - a tmp dir (Dir.tmpdir, /tmp, /var/tmp), unless the session's root
|
|
16
|
+
# is itself in that tmp dir (a sandbox there still gets the check
|
|
17
|
+
# between its siblings).
|
|
18
|
+
# chi's state dir is not special: nothing there is the model's to write.
|
|
19
|
+
module Outside
|
|
20
|
+
TMP_DIRS = %w[/tmp /var/tmp].freeze
|
|
21
|
+
|
|
22
|
+
# @param path [String] absolute
|
|
23
|
+
# @param root [String] the session's repo root (or cwd)
|
|
24
|
+
def self.outside?(path, root:)
|
|
25
|
+
real = ProtectedPaths.real(path)
|
|
26
|
+
session = ProtectedPaths.real(root)
|
|
27
|
+
return false if within?(real, session)
|
|
28
|
+
return false if MemoryBundle::IndexSync.memory_scope(path)
|
|
29
|
+
return false if tmp_roots.any? { |tmp| within?(real, tmp) && !within?(session, tmp) }
|
|
30
|
+
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The tmp dirs that exist, resolved.
|
|
35
|
+
def self.tmp_roots
|
|
36
|
+
[Dir.tmpdir, *TMP_DIRS].filter_map do |dir|
|
|
37
|
+
File.realpath(dir) if File.directory?(dir)
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
nil
|
|
40
|
+
end.uniq
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.within?(path, root) = path == root || path.start_with?(File.join(root, ""))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -5,6 +5,7 @@ require_relative "../paths"
|
|
|
5
5
|
require_relative "../hooks/loader"
|
|
6
6
|
require_relative "approval"
|
|
7
7
|
require_relative "protected_paths"
|
|
8
|
+
require_relative "parent_continue"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Guardrails
|
|
@@ -115,6 +116,7 @@ module Samagotchi
|
|
|
115
116
|
def message(reason, typed: false)
|
|
116
117
|
deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
|
|
117
118
|
case reason
|
|
119
|
+
when :stop_only then ParentContinue.message
|
|
118
120
|
when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
|
|
119
121
|
when :off then "allowing a tool call is up to the user: #{deny}"
|
|
120
122
|
else "only Allow once (#{KEY}: once) can be given here: #{deny}"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Guardrails
|
|
7
|
+
# Whether a parent agent may answer Continue to a session's step-limit
|
|
8
|
+
# question (kind "continue", ContinueOffer): turn.parent_continue,
|
|
9
|
+
# config.yml only, default true. Continue grants no permission (every
|
|
10
|
+
# tool call still meets the guardrails), it spends time and tokens;
|
|
11
|
+
# false makes parents stop-only. Checked by `chi answer` before it posts
|
|
12
|
+
# and by the worker's question desk for an answer marked as chi
|
|
13
|
+
# answer's, the way ParentApprovals is.
|
|
14
|
+
module ParentContinue
|
|
15
|
+
KEY = "turn.parent_continue"
|
|
16
|
+
KIND = "continue"
|
|
17
|
+
CONTINUE = "Continue"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @return [Boolean] this process's setting
|
|
22
|
+
def allowed?
|
|
23
|
+
Config.get(KEY) != false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Why a parent may not give this answer, or nil.
|
|
27
|
+
# @param pending [Hash] the pending question (symbol or string keys)
|
|
28
|
+
# @param selected [Array<String>] the selected labels
|
|
29
|
+
# @return [Symbol, nil] :stop_only
|
|
30
|
+
def refusal(pending, selected, allowed: allowed?)
|
|
31
|
+
return nil if allowed
|
|
32
|
+
return nil unless (pending[:kind] || pending["kind"]).to_s == KIND
|
|
33
|
+
|
|
34
|
+
Array(selected).map(&:to_s).include?(CONTINUE) ? :stop_only : nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def message
|
|
38
|
+
"a parent agent may only stop this turn here (#{KEY}: false): answer Stop " \
|
|
39
|
+
"(--option Stop --text WHY), or tell your user it waits"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -14,6 +14,8 @@ module Samagotchi
|
|
|
14
14
|
# path: "outside_repo", or a glob on the resolved paths (absolute
|
|
15
15
|
# or "**/…" globs match the absolute path; others the path
|
|
16
16
|
# relative to the repo root, the cwd outside one)
|
|
17
|
+
# git: "outside_repo": a shell call runs a mutating git
|
|
18
|
+
# subcommand outside the session's repo (ShellGitDirs)
|
|
17
19
|
# models: "small" (Guardrails::ModelSize, guardrails.small_models),
|
|
18
20
|
# or a glob on the bare model name or the model key, or a
|
|
19
21
|
# list of them: the rule only votes for a model that matches
|
|
@@ -26,17 +28,19 @@ module Samagotchi
|
|
|
26
28
|
class Rules
|
|
27
29
|
class ParseError < StandardError; end
|
|
28
30
|
|
|
29
|
-
KEYS = %w[id tool command path models verdict reason scopes].freeze
|
|
31
|
+
KEYS = %w[id tool command path git models verdict reason scopes].freeze
|
|
32
|
+
MATCH_KEYS = %w[tool command path git].freeze
|
|
30
33
|
VERDICTS = %w[ask deny].freeze
|
|
31
34
|
GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
32
35
|
|
|
33
|
-
Rule = Struct.new(:id, :tools, :command, :path, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
|
|
36
|
+
Rule = Struct.new(:id, :tools, :command, :path, :git, :models, :verdict, :reason, :scopes, :source, keyword_init: true) do
|
|
34
37
|
def matches?(targets)
|
|
35
38
|
return false unless targets
|
|
36
39
|
return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
|
|
37
40
|
return false if tools && !tool_matches?(targets.tool)
|
|
38
41
|
return false if command && !(targets.command && command.match?(targets.command))
|
|
39
42
|
return false if path && !path_matches?(targets)
|
|
43
|
+
return false if git && !targets.git_outside_repo?
|
|
40
44
|
|
|
41
45
|
true
|
|
42
46
|
end
|
|
@@ -99,12 +103,12 @@ module Samagotchi
|
|
|
99
103
|
|
|
100
104
|
verdict = raw["verdict"].to_s
|
|
101
105
|
raise ParseError, "#{label}: verdict must be ask or deny (got #{verdict.inspect})" unless VERDICTS.include?(verdict)
|
|
102
|
-
unless
|
|
103
|
-
raise ParseError, "#{label}: give at least one of
|
|
106
|
+
unless MATCH_KEYS.any? { |k| raw.key?(k) }
|
|
107
|
+
raise ParseError, "#{label}: give at least one of #{MATCH_KEYS.join(", ")}"
|
|
104
108
|
end
|
|
105
109
|
|
|
106
110
|
Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
|
|
107
|
-
path: path_of(raw["path"], label), models: models_of(raw["models"], label), verdict: verdict.to_sym,
|
|
111
|
+
path: path_of(raw["path"], label), git: git_of(raw["git"], label), models: models_of(raw["models"], label), verdict: verdict.to_sym,
|
|
108
112
|
reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
|
|
109
113
|
end
|
|
110
114
|
|
|
@@ -135,6 +139,14 @@ module Samagotchi
|
|
|
135
139
|
value
|
|
136
140
|
end
|
|
137
141
|
|
|
142
|
+
# Only "outside_repo" for now (room for globs later).
|
|
143
|
+
def self.git_of(value, label)
|
|
144
|
+
return nil if value.nil?
|
|
145
|
+
raise ParseError, "#{label}: git must be outside_repo" unless value == "outside_repo"
|
|
146
|
+
|
|
147
|
+
value
|
|
148
|
+
end
|
|
149
|
+
|
|
138
150
|
def self.models_of(value, label)
|
|
139
151
|
return nil if value.nil?
|
|
140
152
|
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Guardrails
|
|
5
|
+
# Where a shell command runs a git subcommand that changes a checkout
|
|
6
|
+
# (commit, add, reset, …): the directories, or :unknown for one that
|
|
7
|
+
# can't be read from the text ($VAR, $(…), `cd -`). Pure text: it
|
|
8
|
+
# follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
|
|
9
|
+
# and GIT_DIR/GIT_WORK_TREE. It catches honest mistakes (cd into
|
|
10
|
+
# another checkout and commit there); sh -c, scripts, aliases and
|
|
11
|
+
# $(…) bodies get through. Never raises on odd input.
|
|
12
|
+
module ShellGitDirs
|
|
13
|
+
MUTATING = %w[commit add reset checkout switch rebase merge push stash rm mv
|
|
14
|
+
cherry-pick revert pull restore am].freeze
|
|
15
|
+
STASH_READS = %w[list show].freeze
|
|
16
|
+
# Longest first: "&&" before "&", ";;" before ";".
|
|
17
|
+
OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
|
|
18
|
+
PREFIXES = %w[command builtin exec time nohup env ! { if then else elif do while until].freeze
|
|
19
|
+
# git global options that take the next word as their value.
|
|
20
|
+
VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace].freeze
|
|
21
|
+
SUBST = "\0SUBST"
|
|
22
|
+
REDIRECT = /\A\d*[<>]/
|
|
23
|
+
|
|
24
|
+
# @param command [String] the shell command
|
|
25
|
+
# @param cwd [String] where it starts (the call's cwd:, else the session's)
|
|
26
|
+
# @param home [String] for ~, $HOME, ${HOME} and a bare cd
|
|
27
|
+
# @return [Array<String, Symbol>] absolute dirs and :unknown, unique
|
|
28
|
+
def self.for(command, cwd:, home: Dir.home)
|
|
29
|
+
Walk.new(cwd, home).run(simple_commands(lex(command.to_s)))
|
|
30
|
+
rescue ArgumentError, EncodingError
|
|
31
|
+
[] # a NUL byte, invalid UTF-8: the shell would refuse it too
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Words and operators ([:word, "x"] / [:op, "&&"]).
|
|
35
|
+
def self.lex(text)
|
|
36
|
+
Lexer.new(text).tokens
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The token stream split at operators into word lists; ( and ) become
|
|
40
|
+
# :open / :close markers.
|
|
41
|
+
def self.simple_commands(tokens)
|
|
42
|
+
commands = [[]]
|
|
43
|
+
tokens.each do |kind, value|
|
|
44
|
+
if kind == :op
|
|
45
|
+
commands << :open if value == "("
|
|
46
|
+
commands << :close if value == ")"
|
|
47
|
+
commands << []
|
|
48
|
+
else
|
|
49
|
+
commands.last << value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
commands.reject { |c| c.is_a?(Array) && c.empty? }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A hand-written shell lexer: quotes, backslashes, comments, $(…) and
|
|
56
|
+
# backticks (an opaque marker inside the word), redirections kept in
|
|
57
|
+
# their word (2>&1 doesn't split on &), operators.
|
|
58
|
+
class Lexer
|
|
59
|
+
def initialize(text)
|
|
60
|
+
@s = text
|
|
61
|
+
@i = 0
|
|
62
|
+
@tokens = []
|
|
63
|
+
@word = +""
|
|
64
|
+
@in_word = false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tokens
|
|
68
|
+
step while @i < @s.size
|
|
69
|
+
flush
|
|
70
|
+
@tokens
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def step
|
|
76
|
+
c = @s[@i]
|
|
77
|
+
case c
|
|
78
|
+
when "\\" then add(@s[@i + 1].to_s, 2)
|
|
79
|
+
when "'" then single_quote
|
|
80
|
+
when '"' then double_quote
|
|
81
|
+
when "`" then backtick
|
|
82
|
+
when "$" then @s[@i + 1] == "(" ? substitution : add(c, 1)
|
|
83
|
+
when "#" then @in_word ? add(c, 1) : comment
|
|
84
|
+
when ">", "<" then redirection
|
|
85
|
+
when " ", "\t" then flush && (@i += 1)
|
|
86
|
+
else operator_or_char(c)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def add(text, advance)
|
|
91
|
+
@word << text
|
|
92
|
+
@in_word = true
|
|
93
|
+
@i += advance
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def flush
|
|
97
|
+
@tokens << [:word, @word.dup] if @in_word
|
|
98
|
+
@word.clear
|
|
99
|
+
@in_word = false
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def single_quote
|
|
104
|
+
close = @s.index("'", @i + 1) || @s.size
|
|
105
|
+
add(@s[(@i + 1)...close].to_s, close + 1 - @i)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def double_quote
|
|
109
|
+
@i += 1
|
|
110
|
+
@in_word = true
|
|
111
|
+
while @i < @s.size && @s[@i] != '"'
|
|
112
|
+
if @s[@i] == "\\" && @i + 1 < @s.size
|
|
113
|
+
@word << @s[@i + 1]
|
|
114
|
+
@i += 2
|
|
115
|
+
else
|
|
116
|
+
@word << @s[@i]
|
|
117
|
+
@i += 1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
@i += 1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def backtick
|
|
124
|
+
close = @s.index("`", @i + 1) || @s.size
|
|
125
|
+
add(SUBST, close + 1 - @i)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def substitution
|
|
129
|
+
depth = 0
|
|
130
|
+
j = @i + 1
|
|
131
|
+
loop do
|
|
132
|
+
depth += 1 if @s[j] == "("
|
|
133
|
+
depth -= 1 if @s[j] == ")"
|
|
134
|
+
j += 1
|
|
135
|
+
break if depth.zero? || j >= @s.size
|
|
136
|
+
end
|
|
137
|
+
add(SUBST, j - @i)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def comment
|
|
141
|
+
@i = @s.index("\n", @i) || @s.size
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# >, >>, 2>&1, &>, <, <<: kept in the word (the walk drops them).
|
|
145
|
+
def redirection
|
|
146
|
+
add(@s[@i], 1)
|
|
147
|
+
add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.match?(/>\z/)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def operator_or_char(char)
|
|
151
|
+
op = OPERATORS.find { |o| @s[@i, o.size] == o }
|
|
152
|
+
return add(char, 1) unless op
|
|
153
|
+
|
|
154
|
+
if op == "&" && @word.match?(/\d*>\z/)
|
|
155
|
+
add(op, 1)
|
|
156
|
+
else
|
|
157
|
+
flush
|
|
158
|
+
@tokens << [:op, op]
|
|
159
|
+
@i += op.size
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Follows the current dir through the simple commands and records
|
|
165
|
+
# where each mutating git runs.
|
|
166
|
+
class Walk
|
|
167
|
+
def initialize(cwd, home)
|
|
168
|
+
@dir = cwd
|
|
169
|
+
@home = home
|
|
170
|
+
@subshells = []
|
|
171
|
+
@pushed = []
|
|
172
|
+
@found = []
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def run(commands)
|
|
176
|
+
commands.each do |words|
|
|
177
|
+
case words
|
|
178
|
+
when :open then @subshells << @dir
|
|
179
|
+
when :close then @dir = @subshells.pop || @dir
|
|
180
|
+
else simple(words.dup)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
@found.uniq
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def simple(words)
|
|
189
|
+
env = {}
|
|
190
|
+
while (word = words.first)
|
|
191
|
+
if word.match?(/\A[A-Za-z_]\w*=/)
|
|
192
|
+
key, value = word.split("=", 2)
|
|
193
|
+
env[key] = value
|
|
194
|
+
elsif !PREFIXES.include?(word)
|
|
195
|
+
break
|
|
196
|
+
end
|
|
197
|
+
words.shift
|
|
198
|
+
end
|
|
199
|
+
verb = words.shift or return
|
|
200
|
+
args = words.grep_v(REDIRECT)
|
|
201
|
+
case verb
|
|
202
|
+
when "cd" then cd(args)
|
|
203
|
+
when "pushd"
|
|
204
|
+
@pushed << @dir
|
|
205
|
+
@dir = args.first ? expand(args.first, @dir) : nil
|
|
206
|
+
when "popd" then @dir = @pushed.pop unless @pushed.empty?
|
|
207
|
+
else git(args, env) if verb == "git" || verb.end_with?("/git")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def cd(args)
|
|
212
|
+
target = args.reject { |a| %w[-P -L -e -@].include?(a) }.first
|
|
213
|
+
@dir = if target.nil? then @home
|
|
214
|
+
elsif target == "-" then nil
|
|
215
|
+
else expand(target, @dir)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def git(args, env)
|
|
220
|
+
dir = @dir
|
|
221
|
+
work_tree = env["GIT_WORK_TREE"] && expand(env["GIT_WORK_TREE"], dir)
|
|
222
|
+
git_dir = env["GIT_DIR"] && expand(env["GIT_DIR"], dir)
|
|
223
|
+
sub = nil
|
|
224
|
+
until args.empty?
|
|
225
|
+
arg = args.shift
|
|
226
|
+
case arg
|
|
227
|
+
when *VALUE_OPTIONS
|
|
228
|
+
value = args.shift.to_s
|
|
229
|
+
dir = expand(value, dir) if arg == "-C"
|
|
230
|
+
git_dir = expand(value, dir) if arg == "--git-dir"
|
|
231
|
+
work_tree = expand(value, dir) if arg == "--work-tree"
|
|
232
|
+
when /\A--git-dir=(.*)\z/m then git_dir = expand(Regexp.last_match(1), dir)
|
|
233
|
+
when /\A--work-tree=(.*)\z/m then work_tree = expand(Regexp.last_match(1), dir)
|
|
234
|
+
when /\A-/ then next
|
|
235
|
+
else
|
|
236
|
+
sub = arg
|
|
237
|
+
break
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
return unless MUTATING.include?(sub)
|
|
241
|
+
return if sub == "stash" && STASH_READS.include?(args.first)
|
|
242
|
+
|
|
243
|
+
@found << (work_tree || repo_of(git_dir) || dir || :unknown)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def repo_of(git_dir)
|
|
247
|
+
return nil unless git_dir
|
|
248
|
+
|
|
249
|
+
File.basename(git_dir) == ".git" ? File.dirname(git_dir) : git_dir
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# An absolute path for +arg+ against +dir+; nil when it can't be
|
|
253
|
+
# told from the text (a variable, a substitution, a relative path
|
|
254
|
+
# against an unknown dir).
|
|
255
|
+
def expand(arg, dir)
|
|
256
|
+
return nil if arg.include?(SUBST)
|
|
257
|
+
|
|
258
|
+
arg = arg.sub(/\A(~|\$HOME|\$\{HOME\})(?=\/|\z)/) { @home }
|
|
259
|
+
return nil if arg.include?("$") || arg.start_with?("~")
|
|
260
|
+
return File.expand_path(arg) if arg.start_with?("/")
|
|
261
|
+
|
|
262
|
+
dir && File.expand_path(arg, dir)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|