samagotchi 0.85.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +51 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +112 -39
- data/docs/hooks.md +19 -3
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +19 -7
- data/docs/sub-agent.md +39 -25
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge.rb +22 -11
- data/lib/samagotchi/bridge_client.rb +2 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +3 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +4 -4
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +5 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_answers_command.rb +80 -0
- data/lib/samagotchi/parent_report.rb +122 -30
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +11 -5
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +39 -25
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +14 -5
- data/lib/samagotchi/send_command.rb +103 -12
- data/lib/samagotchi/session.rb +26 -2
- data/lib/samagotchi/session_commands.rb +45 -8
- data/lib/samagotchi/session_setup.rb +10 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +20 -11
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +16 -7
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -10
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/stream_handlers.js +1 -1
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +27 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -15,6 +15,7 @@ module Samagotchi
|
|
|
15
15
|
KIND = "approval"
|
|
16
16
|
HEADER = "Approve tool call?"
|
|
17
17
|
DENY = "Deny"
|
|
18
|
+
ONCE = "Allow once"
|
|
18
19
|
|
|
19
20
|
module_function
|
|
20
21
|
|
|
@@ -27,7 +28,9 @@ module Samagotchi
|
|
|
27
28
|
# write: the card shows its diff, the question text one change line
|
|
28
29
|
# @param only [Array<String>, nil] offer no scope beyond these (a
|
|
29
30
|
# scratch session: once, session); "once" when none is left
|
|
30
|
-
# @return [Hash] open_question fields
|
|
31
|
+
# @return [Hash] open_question fields; its caller adds user_only:
|
|
32
|
+
# (ParentApprovals.protected_facts? on approval:, a file that
|
|
33
|
+
# requires this one)
|
|
31
34
|
def payload(verdict, label: nil, preview: nil, only: nil)
|
|
32
35
|
scopes = offered_scopes(verdict, only: only)
|
|
33
36
|
targets = verdict.targets
|
|
@@ -37,6 +40,7 @@ module Samagotchi
|
|
|
37
40
|
header: HEADER,
|
|
38
41
|
multi_select: false,
|
|
39
42
|
allow_freeform: true,
|
|
43
|
+
effects: effects(scopes),
|
|
40
44
|
kind: KIND,
|
|
41
45
|
approval: {
|
|
42
46
|
tool: targets&.tool || verdict.call[:name].to_s,
|
|
@@ -61,6 +65,12 @@ module Samagotchi
|
|
|
61
65
|
}
|
|
62
66
|
end
|
|
63
67
|
|
|
68
|
+
# Each option's effect for a parent's answer (ParentPolicy): once
|
|
69
|
+
# permits one call, a wider scope more, Deny denies.
|
|
70
|
+
def effects(scopes)
|
|
71
|
+
scopes.map { |scope| scope == "once" ? "permit" : "permit_wide" } + ["deny"]
|
|
72
|
+
end
|
|
73
|
+
|
|
64
74
|
def args_for_card(targets)
|
|
65
75
|
return nil if targets.nil? || targets.command || !targets.paths.empty?
|
|
66
76
|
|
|
@@ -101,7 +111,7 @@ module Samagotchi
|
|
|
101
111
|
def label(scope, verdict)
|
|
102
112
|
place = verdict.targets&.repo_root ? "in this repo (#{verdict.targets.repo_name})" : "in this directory"
|
|
103
113
|
case scope
|
|
104
|
-
when "once" then
|
|
114
|
+
when "once" then ONCE
|
|
105
115
|
when "session" then "Allow this call for the session"
|
|
106
116
|
when "repo" then "Allow this call #{place}"
|
|
107
117
|
when "rule" then "Allow rule #{verdict.rule} #{place}"
|
|
@@ -7,7 +7,18 @@ module Samagotchi
|
|
|
7
7
|
# guardrail that silently vanished is the failure this guards against);
|
|
8
8
|
# the rest are only announced.
|
|
9
9
|
class LoadFailures
|
|
10
|
-
|
|
10
|
+
# +location+: "path:line" of the first frame of the error inside the
|
|
11
|
+
# bundle that failed (nil when none is), for `chi bundle check`.
|
|
12
|
+
Failure = Struct.new(:what, :reason, :required, :group, :location, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
# "path:line" where +error+ was raised inside +dir+ (its first
|
|
15
|
+
# backtrace frame there; a SyntaxError's own file:line), or nil.
|
|
16
|
+
def self.location(error, dir)
|
|
17
|
+
prefix = "#{File.expand_path(dir)}/"
|
|
18
|
+
frame = Array(error.backtrace).find { |line| line.start_with?(prefix) }
|
|
19
|
+
frame ||= error.message.lines.first if error.is_a?(SyntaxError)
|
|
20
|
+
frame&.start_with?(prefix) ? frame[/\A[^:]+:\d+/] : nil
|
|
21
|
+
end
|
|
11
22
|
|
|
12
23
|
def initialize
|
|
13
24
|
@list = []
|
|
@@ -16,8 +27,11 @@ module Samagotchi
|
|
|
16
27
|
|
|
17
28
|
# @param what [String] e.g. "hook guard.rb (config)"
|
|
18
29
|
# @param group [Symbol, nil] what loads it again (#drop): :rules
|
|
19
|
-
|
|
20
|
-
|
|
30
|
+
# @param location [String, nil] Failure#location
|
|
31
|
+
def add(what, reason, required:, group: nil, location: nil)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@list << Failure.new(what: what, reason: reason, required: required, group: group, location: location)
|
|
34
|
+
end
|
|
21
35
|
end
|
|
22
36
|
|
|
23
37
|
# Forget a group's failures before it loads again.
|
|
@@ -7,29 +7,18 @@ require_relative "../hooks/loader"
|
|
|
7
7
|
require_relative "approval"
|
|
8
8
|
require_relative "verdict"
|
|
9
9
|
require_relative "protected_paths"
|
|
10
|
-
require_relative "parent_continue"
|
|
11
10
|
|
|
12
11
|
module Samagotchi
|
|
13
12
|
module Guardrails
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# the
|
|
21
|
-
#
|
|
22
|
-
# An option allows when Approval.settle would allow it: its index is
|
|
23
|
-
# below the approval's scopes. The scope is read by index, never by
|
|
24
|
-
# label. When the scopes are missing or don't fit the options, it fails
|
|
25
|
-
# closed: only a last option labelled Deny denies.
|
|
26
|
-
#
|
|
27
|
-
# Whatever the setting, an approval for a call on chi's own config dir
|
|
28
|
-
# (config.yml, memories/.bundles' rules), the hooks dir or the approval
|
|
29
|
-
# store is the user's alone (:protected): "Allow once" on a config.yml
|
|
30
|
-
# rewrite would be "allow everything" from then on.
|
|
13
|
+
# The facts a parent agent's answer is judged by (ParentPolicy): who
|
|
14
|
+
# answers as a parent (#parent_process?), what an approval is
|
|
15
|
+
# (#approval?, its #scopes) and which approvals are the user's alone
|
|
16
|
+
# (#protected?): a call on chi's own config dir (config.yml,
|
|
17
|
+
# memories/.bundles' rules), the hooks dir or the approval store, since
|
|
18
|
+
# "Allow once" on a config.yml rewrite would be "allow everything" from
|
|
19
|
+
# then on. Also chi's dirs and text for the guardrails' own rules
|
|
20
|
+
# (Targets, the shell rules).
|
|
31
21
|
module ParentApprovals
|
|
32
|
-
KEY = "guardrails.parent_approvals"
|
|
33
22
|
# Set by chi's execute and task_create for the commands they run: a
|
|
34
23
|
# chi started there answers as a parent (#parent_process?).
|
|
35
24
|
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
@@ -56,7 +45,7 @@ module Samagotchi
|
|
|
56
45
|
module_function
|
|
57
46
|
|
|
58
47
|
# Whether an answer typed into this process is a parent agent's (so
|
|
59
|
-
#
|
|
48
|
+
# ParentPolicy applies to it): stdin isn't a terminal (a pipe, a file,
|
|
60
49
|
# /dev/null), or an agent marker is set. A person at chi --attach or
|
|
61
50
|
# the REPL has a terminal and no marker.
|
|
62
51
|
def parent_process?(env: ENV, stdin: $stdin)
|
|
@@ -65,35 +54,14 @@ module Samagotchi
|
|
|
65
54
|
!(stdin.respond_to?(:tty?) && stdin.tty?)
|
|
66
55
|
end
|
|
67
56
|
|
|
68
|
-
# This process's setting: "once" or "off" (anything else is off).
|
|
69
|
-
def setting
|
|
70
|
-
Config.get(KEY).to_s == "once" ? "once" : "off"
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Why a parent may not give this answer, or nil.
|
|
74
|
-
# @param pending [Hash] the pending question (symbol or string keys)
|
|
75
|
-
# @param indices [Array<Integer, nil>] the selected options' places
|
|
76
|
-
# @param setting [String, nil] "off" or "once"
|
|
77
|
-
# @return [Symbol, nil] :protected (chi's own config: the user's
|
|
78
|
-
# alone), :off (no allow at all), :once_only, or nil
|
|
79
|
-
def refusal(pending, indices, setting:)
|
|
80
|
-
return nil unless approval?(pending)
|
|
81
|
-
|
|
82
|
-
scopes = scopes(pending)
|
|
83
|
-
allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
|
|
84
|
-
return nil if allows.empty?
|
|
85
|
-
return :protected if protected?(pending)
|
|
86
|
-
return :off unless setting.to_s == "once"
|
|
87
|
-
return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
|
|
88
|
-
|
|
89
|
-
:once_only
|
|
90
|
-
end
|
|
91
|
-
|
|
92
57
|
# Whether +pending+ asks about a call on chi's own config, hooks,
|
|
93
58
|
# guardrail rules or approvals: by its rule, its paths or its command
|
|
94
59
|
# (the dirs as this process's environment and config.yml name them).
|
|
95
|
-
def protected?(pending)
|
|
96
|
-
|
|
60
|
+
def protected?(pending) = protected_facts?(pending.approval)
|
|
61
|
+
|
|
62
|
+
# #protected? on an approval's facts (Approval.payload's approval:),
|
|
63
|
+
# at ask time.
|
|
64
|
+
def protected_facts?(facts)
|
|
97
65
|
return false unless facts.is_a?(Hash)
|
|
98
66
|
return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
|
|
99
67
|
return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
|
|
@@ -120,21 +88,6 @@ module Samagotchi
|
|
|
120
88
|
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
121
89
|
end
|
|
122
90
|
|
|
123
|
-
# The refusal as a line for the parent: deny, and tell the user.
|
|
124
|
-
# Never how to allow it (the web, chi --attach): that is the user's.
|
|
125
|
-
# @param reason [Symbol] what #refusal returned
|
|
126
|
-
# @param typed [Boolean] the answer was typed at a ? prompt (the
|
|
127
|
-
# REPL), not given to chi answer
|
|
128
|
-
def message(reason, typed: false)
|
|
129
|
-
deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
|
|
130
|
-
case reason
|
|
131
|
-
when :stop_only then ParentContinue.message
|
|
132
|
-
when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
|
|
133
|
-
when :off then "allowing a tool call is up to the user: #{deny}"
|
|
134
|
-
else "only Allow once (#{KEY}: once) can be given here: #{deny}"
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
91
|
# An approval: its kind says so, or it carries approval facts.
|
|
139
92
|
def approval?(pending)
|
|
140
93
|
pending.kind.to_s == Approval::KIND || !pending.approval.nil?
|
|
@@ -151,15 +104,6 @@ module Samagotchi
|
|
|
151
104
|
scopes
|
|
152
105
|
end
|
|
153
106
|
|
|
154
|
-
def deny?(pending, scopes, index)
|
|
155
|
-
return false unless index.is_a?(Integer) && index >= 0
|
|
156
|
-
|
|
157
|
-
options = options(pending)
|
|
158
|
-
return index >= scopes.size && index < options.size if scopes
|
|
159
|
-
|
|
160
|
-
index == options.size - 1 && options[index].to_s == Approval::DENY
|
|
161
|
-
end
|
|
162
|
-
|
|
163
107
|
def options(pending) = Array(pending.options)
|
|
164
108
|
|
|
165
109
|
def fetch(hash, key)
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../pending_question"
|
|
5
|
+
require_relative "approval"
|
|
6
|
+
require_relative "parent_approvals"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module Guardrails
|
|
10
|
+
# What a parent agent may answer on a session's question: one check for
|
|
11
|
+
# every asker (an approval, the step-limit Continue, a plugin's or a
|
|
12
|
+
# hook's ask_user, a relayed delegate approval). The asker tags each
|
|
13
|
+
# option with its effect (PendingQuestion#effects); a strategy says
|
|
14
|
+
# which effects a parent may pick. `chi answer` checks it before it
|
|
15
|
+
# posts; the worker's QuestionDesk checks it again, with its own config,
|
|
16
|
+
# for an answer marked as a parent agent's. A convention for an honest
|
|
17
|
+
# but eager parent, not a security boundary.
|
|
18
|
+
#
|
|
19
|
+
# Strategies (guardrails.parent_answers, config.yml only): hard = answer
|
|
20
|
+
# and deny; standard = + spend; soft (the default) = + permit. A session
|
|
21
|
+
# may carry its own, stricter one (Session#parent_answers): config.yml
|
|
22
|
+
# is the ceiling (#effective).
|
|
23
|
+
#
|
|
24
|
+
# Effects: answer (an untagged option), deny, spend (more time and
|
|
25
|
+
# tokens: Continue), permit (one call: Allow once), permit_wide (more
|
|
26
|
+
# than one call: a session, repo or rule scope). A parent never picks
|
|
27
|
+
# permit_wide; on a user_only question (chi's own config, hooks or
|
|
28
|
+
# guardrails) it picks only deny or answer. Text alone and a dismissal
|
|
29
|
+
# always pass.
|
|
30
|
+
module ParentPolicy
|
|
31
|
+
ANSWER = "answer"
|
|
32
|
+
DENY = "deny"
|
|
33
|
+
SPEND = "spend"
|
|
34
|
+
PERMIT = "permit"
|
|
35
|
+
PERMIT_WIDE = "permit_wide"
|
|
36
|
+
EFFECTS = [ANSWER, DENY, SPEND, PERMIT, PERMIT_WIDE].freeze
|
|
37
|
+
# The effects a parent may pick with no setting at all.
|
|
38
|
+
ALWAYS = [ANSWER, DENY].freeze
|
|
39
|
+
# Beyond-the-strategy reasons, widest first (#refusal).
|
|
40
|
+
WIDEST_FIRST = [PERMIT_WIDE, PERMIT, SPEND].freeze
|
|
41
|
+
|
|
42
|
+
# The step-limit question (ContinueOffer).
|
|
43
|
+
CONTINUE_KIND = "continue"
|
|
44
|
+
CONTINUE = "Continue"
|
|
45
|
+
STOP = "Stop"
|
|
46
|
+
# Its shape, for a /continue yes sent as a command (#continue_refusal).
|
|
47
|
+
CONTINUE_QUESTION = PendingQuestion.new(kind: CONTINUE_KIND, options: [CONTINUE, STOP], effects: [SPEND, DENY])
|
|
48
|
+
|
|
49
|
+
KEY = "guardrails.parent_answers"
|
|
50
|
+
# A session's own value (Session#parent_answers): it may only tighten KEY.
|
|
51
|
+
SESSION_KEY = "parent_answers"
|
|
52
|
+
HARD = "hard"
|
|
53
|
+
STANDARD = "standard"
|
|
54
|
+
SOFT = "soft"
|
|
55
|
+
|
|
56
|
+
# A strategy: its name, the effects a parent may pick under it, and
|
|
57
|
+
# where it comes from: :config (config.yml's) or :session (a
|
|
58
|
+
# session's own, stricter one: #effective).
|
|
59
|
+
Strategy = Data.define(:name, :allowed, :origin) do
|
|
60
|
+
def initialize(name:, allowed:, origin: :config) = super
|
|
61
|
+
|
|
62
|
+
def allow?(effect) = allowed.include?(effect.to_s)
|
|
63
|
+
|
|
64
|
+
# How a refusal names the setting: "guardrails.parent_answers: hard",
|
|
65
|
+
# or "parent_answers: hard (this session)" when the session's binds.
|
|
66
|
+
def label = origin == :session ? "#{SESSION_KEY}: #{name} (this session)" : "#{KEY}: #{name}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
STRATEGIES = {
|
|
70
|
+
HARD => Strategy.new(name: HARD, allowed: ALWAYS),
|
|
71
|
+
STANDARD => Strategy.new(name: STANDARD, allowed: [*ALWAYS, SPEND].freeze),
|
|
72
|
+
SOFT => Strategy.new(name: SOFT, allowed: [*ALWAYS, SPEND, PERMIT].freeze)
|
|
73
|
+
}.freeze
|
|
74
|
+
|
|
75
|
+
module_function
|
|
76
|
+
|
|
77
|
+
# This process's strategy (config.yml only; Config makes an invalid
|
|
78
|
+
# value hard and warns).
|
|
79
|
+
# @return [Strategy]
|
|
80
|
+
def current = strategy(Config.get(KEY))
|
|
81
|
+
|
|
82
|
+
# The named strategy; anything else is hard, the strictest.
|
|
83
|
+
# @return [Strategy]
|
|
84
|
+
def strategy(name) = STRATEGIES.fetch(name.to_s, STRATEGIES[HARD])
|
|
85
|
+
|
|
86
|
+
# Strictest first: a session's own level may only move left of
|
|
87
|
+
# config.yml's.
|
|
88
|
+
ORDER = [HARD, STANDARD, SOFT].freeze
|
|
89
|
+
# A session value that means "none of its own" (config's).
|
|
90
|
+
UNSET = "default"
|
|
91
|
+
|
|
92
|
+
# The stricter of two strategies, by name; a tie keeps +a+.
|
|
93
|
+
# @return [Strategy]
|
|
94
|
+
def stricter(a, b) = ORDER.index(b.name) < ORDER.index(a.name) ? b : a
|
|
95
|
+
|
|
96
|
+
# A session's own value as a level: nil when it has none (nil, "",
|
|
97
|
+
# "default"), the level, or hard for anything else (a hand-edited
|
|
98
|
+
# file: the strictest, as config's invalid value; not logged, as this
|
|
99
|
+
# is read on every parent answer).
|
|
100
|
+
# @return [String, nil]
|
|
101
|
+
def session_level(value)
|
|
102
|
+
word = value.to_s.strip.downcase
|
|
103
|
+
return nil if word.empty? || word == UNSET
|
|
104
|
+
|
|
105
|
+
STRATEGIES.key?(word) ? word : HARD
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# What a parent may answer on a session whose own value is
|
|
109
|
+
# +session_value+: config.yml's (+config+) unless the session's is
|
|
110
|
+
# stricter. Config's own object when it binds, so its label stays.
|
|
111
|
+
# @return [Strategy]
|
|
112
|
+
def effective(session_value, config: current)
|
|
113
|
+
level = session_level(session_value)
|
|
114
|
+
return config if level.nil?
|
|
115
|
+
|
|
116
|
+
own = STRATEGIES[level]
|
|
117
|
+
stricter(config, own).equal?(config) ? config : own.with(origin: :session)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Why a session's own +value+ doesn't apply: "soft is looser than
|
|
121
|
+
# config.yml's guardrails.parent_answers: standard; standard applies";
|
|
122
|
+
# nil when it applies (or is none).
|
|
123
|
+
# @return [String, nil]
|
|
124
|
+
def capped_note(value, config: current)
|
|
125
|
+
level = session_level(value)
|
|
126
|
+
return nil if level.nil? || effective(level, config: config).name == level
|
|
127
|
+
|
|
128
|
+
"#{level} is looser than config.yml's #{config.label}; #{config.name} applies"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# #effective with +session+'s own value (a Session; nil: config's).
|
|
132
|
+
# @return [Strategy]
|
|
133
|
+
def for_session(session) = effective(session&.parent_answers)
|
|
134
|
+
|
|
135
|
+
# Whether setting a session's value +from+ → +to+ loosens it: unset
|
|
136
|
+
# (config's) is the loosest, an invalid value hard. Only the user
|
|
137
|
+
# loosens (/parent-answers in a UI).
|
|
138
|
+
def looser?(from, to) = session_rank(to) > session_rank(from)
|
|
139
|
+
|
|
140
|
+
# hard 0, standard 1, soft 2, unset 3.
|
|
141
|
+
def session_rank(value)
|
|
142
|
+
level = session_level(value)
|
|
143
|
+
level.nil? ? ORDER.size : ORDER.index(level)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [Array<String>] the effects +strategy+ lets a parent pick
|
|
147
|
+
def allowed_effects(strategy) = strategy.allowed
|
|
148
|
+
|
|
149
|
+
# Each option's effect, in option order. Tagged questions carry them;
|
|
150
|
+
# an untagged one (asked by an older worker, a relayed card built
|
|
151
|
+
# from an older child's question) gets them derived as before:
|
|
152
|
+
# an approval's from its scopes, the step-limit question's from its
|
|
153
|
+
# labels, anything else's all answer.
|
|
154
|
+
# @param pending [PendingQuestion]
|
|
155
|
+
# @return [Array<String>]
|
|
156
|
+
def effects_of(pending)
|
|
157
|
+
options = Array(pending.options)
|
|
158
|
+
tags = pending.effects
|
|
159
|
+
return options.each_index.map { |index| tag(tags[index]) } if tags.is_a?(Array)
|
|
160
|
+
return approval_effects(pending, options) if ParentApprovals.approval?(pending)
|
|
161
|
+
return options.map { |label| continue_effect(label) } if pending.kind.to_s == CONTINUE_KIND
|
|
162
|
+
|
|
163
|
+
options.map { ANSWER }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whether only the user may pick a non-deny/answer option. Unset on an
|
|
167
|
+
# approval (an older worker's): worked out from its facts here. A
|
|
168
|
+
# relayed card: the child's flag, or this process's own look at it.
|
|
169
|
+
def user_only?(pending)
|
|
170
|
+
return true if pending.user_only == true
|
|
171
|
+
return false unless ParentApprovals.approval?(pending)
|
|
172
|
+
return false if pending.user_only == false && !pending.relay
|
|
173
|
+
|
|
174
|
+
ParentApprovals.protected?(pending)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Why a parent may not give this answer, or nil.
|
|
178
|
+
# @param pending [PendingQuestion]
|
|
179
|
+
# @param indices [Array<Integer, nil>] the selected options' places
|
|
180
|
+
# @param strategy [Strategy]
|
|
181
|
+
# @return [Symbol, nil] :user_only, :permit_wide, :permit or :spend
|
|
182
|
+
def refusal(pending, indices, strategy:)
|
|
183
|
+
effects = effects_of(pending)
|
|
184
|
+
picked = Array(indices).map { |index| effect_at(effects, index) }
|
|
185
|
+
return nil if picked.empty?
|
|
186
|
+
return :user_only if user_only?(pending) && picked.any? { |effect| !ALWAYS.include?(effect) }
|
|
187
|
+
|
|
188
|
+
WIDEST_FIRST.each do |effect|
|
|
189
|
+
return effect.to_sym if picked.include?(effect) && !strategy.allow?(effect)
|
|
190
|
+
end
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The refusal as a line for the parent: what to answer instead, or
|
|
195
|
+
# tell the user. Never how to allow it (the web, chi --attach): that
|
|
196
|
+
# is the user's.
|
|
197
|
+
# @param reason [Symbol] what #refusal returned
|
|
198
|
+
# @param typed [Boolean] the answer was typed at a ? prompt (the
|
|
199
|
+
# REPL), not given to chi answer
|
|
200
|
+
def message(reason, pending:, strategy:, typed: false)
|
|
201
|
+
deny = deny_label(pending)
|
|
202
|
+
case reason.to_sym
|
|
203
|
+
when :spend
|
|
204
|
+
if pending.kind.to_s == CONTINUE_KIND
|
|
205
|
+
return "a parent agent may only stop this turn here (#{strategy.label}): answer #{STOP} " \
|
|
206
|
+
"(--option #{STOP} --text WHY), or tell your user it waits"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
"a parent agent may not pick #{tagged_labels(pending, SPEND)} here (#{strategy.label}): #{instead(deny, typed, "answer")}"
|
|
210
|
+
when :user_only
|
|
211
|
+
"this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{instead(deny, typed)}"
|
|
212
|
+
when :permit_wide
|
|
213
|
+
# An approval whose scopes don't fit its options has no permit
|
|
214
|
+
# option: its once is still what a parent may give.
|
|
215
|
+
permits = tagged_labels(pending, PERMIT)
|
|
216
|
+
permits = Approval::ONCE if permits.empty? && ParentApprovals.approval?(pending)
|
|
217
|
+
if strategy.allow?(PERMIT) && !permits.empty?
|
|
218
|
+
"only #{permits} (#{strategy.label}) can be given here: #{instead(deny, typed)}"
|
|
219
|
+
else
|
|
220
|
+
"#{allowing(pending)} is up to the user: #{instead(deny, typed)}"
|
|
221
|
+
end
|
|
222
|
+
else "#{allowing(pending)} is up to the user: #{instead(deny, typed)}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Why a parent agent may not continue a turn with /continue yes (a
|
|
227
|
+
# command: chi send -m, a piped attach), or nil: the step-limit
|
|
228
|
+
# question's Continue, checked the same way.
|
|
229
|
+
# @return [String, nil] the refusal line
|
|
230
|
+
def continue_refusal(strategy: current)
|
|
231
|
+
reason = refusal(CONTINUE_QUESTION, [0], strategy: strategy)
|
|
232
|
+
reason && message(reason, pending: CONTINUE_QUESTION, strategy: strategy)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The labels a parent may pick on +pending+ under +strategy+.
|
|
236
|
+
# @return [Array<String>]
|
|
237
|
+
def allowed_options(pending, strategy:)
|
|
238
|
+
user_only = user_only?(pending)
|
|
239
|
+
Array(pending.options).zip(effects_of(pending)).filter_map do |label, effect|
|
|
240
|
+
next nil if user_only && !ALWAYS.include?(effect)
|
|
241
|
+
|
|
242
|
+
label.to_s if strategy.allow?(effect)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# The question's deny-tagged option's label, or nil.
|
|
247
|
+
def deny_label(pending)
|
|
248
|
+
index = effects_of(pending).index(DENY)
|
|
249
|
+
index && Array(pending.options)[index].to_s
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# What the parent does instead: deny with the question's own deny
|
|
253
|
+
# option and tell the user, or (none) tell the user it waits.
|
|
254
|
+
def instead(deny, typed, verb = "deny it")
|
|
255
|
+
return "tell your user it waits" unless deny
|
|
256
|
+
|
|
257
|
+
how = typed ? "n; WHY" : "--option #{deny} --text WHY"
|
|
258
|
+
verb == "answer" ? "answer #{deny} (#{how}), or tell your user it waits" : "#{verb} (#{how}), and tell your user"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def allowing(pending)
|
|
262
|
+
ParentApprovals.approval?(pending) ? "allowing a tool call" : "allowing it"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def tagged_labels(pending, effect)
|
|
266
|
+
Array(pending.options).zip(effects_of(pending)).filter_map { |label, tag| label.to_s if tag == effect }.join(" or ")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# An index it can't place counts as the widest effect (fail closed).
|
|
270
|
+
def effect_at(effects, index)
|
|
271
|
+
return PERMIT_WIDE unless index.is_a?(Integer) && index >= 0 && index < effects.size
|
|
272
|
+
|
|
273
|
+
effects[index]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A tag as given; an unknown one fails closed.
|
|
277
|
+
def tag(value)
|
|
278
|
+
return ANSWER if value.nil?
|
|
279
|
+
|
|
280
|
+
EFFECTS.include?(value.to_s) ? value.to_s : PERMIT_WIDE
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# A plugin's or a hook's effects (label => effect, ask_user's
|
|
284
|
+
# effects:) as the options' tags, in option order; a label left out
|
|
285
|
+
# is answer (QuestionDesk#ask_for_hook, and the test kit's Recorder).
|
|
286
|
+
# @return [Array<String>, nil, false] the tags; nil for none given;
|
|
287
|
+
# false when invalid (not a Hash, a label the options don't have,
|
|
288
|
+
# an effect not in EFFECTS)
|
|
289
|
+
def option_tags(options, effects)
|
|
290
|
+
return nil if effects.nil?
|
|
291
|
+
return false unless effects.is_a?(Hash)
|
|
292
|
+
|
|
293
|
+
given = effects.to_h { |label, effect| [label.to_s, effect.to_s] }
|
|
294
|
+
return false unless (given.keys - Array(options).map(&:to_s)).empty?
|
|
295
|
+
return false unless (given.values - EFFECTS).empty?
|
|
296
|
+
|
|
297
|
+
Array(options).map { |label| given.fetch(label.to_s, ANSWER) }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# When the scopes fit the options: once permits, another scope
|
|
301
|
+
# permits wide, the options past them deny. When they don't (missing,
|
|
302
|
+
# malformed): only a last option labelled Deny denies.
|
|
303
|
+
def approval_effects(pending, options)
|
|
304
|
+
scopes = ParentApprovals.scopes(pending)
|
|
305
|
+
if scopes
|
|
306
|
+
return options.each_index.map do |index|
|
|
307
|
+
next DENY if index >= scopes.size
|
|
308
|
+
|
|
309
|
+
scopes[index] == "once" ? PERMIT : PERMIT_WIDE
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
options.each_with_index.map do |label, index|
|
|
314
|
+
index == options.size - 1 && label.to_s == Approval::DENY ? DENY : PERMIT_WIDE
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def continue_effect(label)
|
|
319
|
+
case label.to_s
|
|
320
|
+
when CONTINUE then SPEND
|
|
321
|
+
when STOP then DENY
|
|
322
|
+
else ANSWER
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -16,10 +16,13 @@ module Samagotchi
|
|
|
16
16
|
# command source for attached context (it runs later, ungated); chi
|
|
17
17
|
# broadcast, which is the user's; the bundle's asks before a memory
|
|
18
18
|
# that reaches the system prompt, or a model overlay, is written (a
|
|
19
|
-
# parent model would be writing a model's prompt)
|
|
20
|
-
#
|
|
19
|
+
# parent model would be writing a model's prompt); and the bundle's
|
|
20
|
+
# asks before answering chi's questions around chi answer (a parent
|
|
21
|
+
# allowing one would answer a grandchild's question as the user).
|
|
22
|
+
# Only the user may allow them (ParentApprovals.protected?,
|
|
23
|
+
# ParentPolicy's user_only).
|
|
21
24
|
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast prompt-memory-write
|
|
22
|
-
model-overlay-write].freeze
|
|
25
|
+
model-overlay-write chi-answer-piped chi-answer-http].freeze
|
|
23
26
|
SCOPES = %w[once session repo rule].freeze
|
|
24
27
|
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|
|
25
28
|
|
|
@@ -36,7 +36,9 @@ module Samagotchi
|
|
|
36
36
|
def runtime
|
|
37
37
|
Hooks::Runtime.new(
|
|
38
38
|
notify: ->(text:, level:, hook:, fallback_for: nil) { notify(text, level, hook, fallback_for: fallback_for) },
|
|
39
|
-
ask_user:
|
|
39
|
+
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
|
|
40
|
+
ask_user(question, options, header, allow_freeform, hook, effects: effects)
|
|
41
|
+
},
|
|
40
42
|
stop_turn: ->(reason:, hook:) { stop_turn(reason, hook) },
|
|
41
43
|
steer: ->(text:, hook:) { @steer.call(text, source(hook)) },
|
|
42
44
|
stop_generation: ->(reason:, hook:) { stop_generation(reason, hook) }
|
|
@@ -86,8 +88,8 @@ module Samagotchi
|
|
|
86
88
|
# to ask: nil at once. Anything but an answer (a sync handler's text, no
|
|
87
89
|
# answer, cancelled) is nil too.
|
|
88
90
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}
|
|
89
|
-
def ask_user(question, options, header, allow_freeform, hook)
|
|
90
|
-
@question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook)
|
|
91
|
+
def ask_user(question, options, header, allow_freeform, hook, effects: nil)
|
|
92
|
+
@question_desk.call.ask_for_hook(question, options, header, allow_freeform, hook, effects: effects)
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
# Cancel the running turn (reason :hook), after a notice that says why.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "registry"
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../log"
|
|
6
|
+
require_relative "../guardrails/load_failures"
|
|
6
7
|
require_relative "../memory_bundle/bundle_hook"
|
|
7
8
|
require_relative "../memory_bundle/provenance"
|
|
8
9
|
require_relative "../memory_bundle/manifest"
|
|
@@ -90,7 +91,8 @@ module Samagotchi
|
|
|
90
91
|
raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
|
|
91
92
|
|
|
92
93
|
Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
|
|
93
|
-
failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed
|
|
94
|
+
failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed,
|
|
95
|
+
location: Guardrails::LoadFailures.location(e, File.dirname(hooks_dir)))
|
|
94
96
|
end
|
|
95
97
|
end
|
|
96
98
|
loaded
|
|
@@ -111,8 +111,8 @@ module Samagotchi
|
|
|
111
111
|
# callables, each given the hook's label. +notify+ takes
|
|
112
112
|
# (text:, level:, hook:, and fallback_for: when the hook gave one: what
|
|
113
113
|
# the line stands in for, HookRoutes#notify) and shows one line to the
|
|
114
|
-
# user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook
|
|
115
|
-
# returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
|
|
114
|
+
# user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:, and
|
|
115
|
+
# effects: when the hook gave them) and returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
|
|
116
116
|
# cancels the running turn (true when it did); +steer+ takes (text:,
|
|
117
117
|
# hook:) and puts the text into the running turn (Engine#steer, true
|
|
118
118
|
# when queued); +stop_generation+ takes (reason:, hook:) and cuts the
|
|
@@ -270,9 +270,11 @@ module Samagotchi
|
|
|
270
270
|
# to the user; fallback_for: :display marks a line that stands in
|
|
271
271
|
# for the answer's display (event[:present]), which a UI that
|
|
272
272
|
# renders the display's links leaves out (HookRoutes#notify)
|
|
273
|
-
# event[:ask_user].call(question:, options:, header: nil, allow_freeform: false)
|
|
273
|
+
# event[:ask_user].call(question:, options:, header: nil, allow_freeform: false, effects: nil)
|
|
274
274
|
# -> {selected:, freeform:, selected_indices:} or nil (no one to
|
|
275
|
-
# ask, cancelled, bad options)
|
|
275
|
+
# ask, cancelled, bad options or effects); effects: tags the
|
|
276
|
+
# options a parent agent may not simply pick, by label
|
|
277
|
+
# ({ "Run it" => :permit }, see Plugin::Context#ask_user)
|
|
276
278
|
# event[:stop_turn].call(reason) -> true when the turn was cancelled;
|
|
277
279
|
# in a before_tool_call event it also denies the call; from
|
|
278
280
|
# after_turn / session_end it does nothing (false)
|
|
@@ -347,6 +349,10 @@ module Samagotchi
|
|
|
347
349
|
end
|
|
348
350
|
end
|
|
349
351
|
|
|
352
|
+
# The events bundle hooks are registered for (any symbol Api#on took).
|
|
353
|
+
# @return [Array<Symbol>]
|
|
354
|
+
def bundle_events = @mutex.synchronize { @bundle_hooks.keys }
|
|
355
|
+
|
|
350
356
|
# @return [Integer] total number of registered hooks (bundle + plain)
|
|
351
357
|
def size
|
|
352
358
|
@mutex.synchronize do
|
|
@@ -383,9 +389,10 @@ module Samagotchi
|
|
|
383
389
|
@runtime&.notify&.call(**notice)
|
|
384
390
|
nil
|
|
385
391
|
}
|
|
386
|
-
event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false|
|
|
392
|
+
event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false, effects: nil|
|
|
387
393
|
@runtime&.ask_user&.call(question: question, options: options, header: header,
|
|
388
|
-
allow_freeform: allow_freeform, hook: event[:hook]
|
|
394
|
+
allow_freeform: allow_freeform, hook: event[:hook],
|
|
395
|
+
**(effects.nil? ? {} : { effects: effects }))
|
|
389
396
|
}
|
|
390
397
|
event[:stop_turn] ||= lambda { |reason|
|
|
391
398
|
next false if TURN_OVER_EVENTS.include?(event[:type])
|
data/lib/samagotchi/hooks.rb
CHANGED
|
@@ -26,5 +26,10 @@ module Samagotchi
|
|
|
26
26
|
# event also has context: and targets: (docs/guardrails.md)
|
|
27
27
|
# :after_tool_call — after tool execution, before result injection
|
|
28
28
|
module Hooks
|
|
29
|
+
# Every event a hook can be registered for, in a turn's order
|
|
30
|
+
# (docs/hooks.md). Plugin::Api#on takes any symbol: `chi bundle check`
|
|
31
|
+
# warns on one not here.
|
|
32
|
+
EVENTS = %i[session_start before_turn before_generation generation_progress after_generation before_tool_call
|
|
33
|
+
after_tool_call after_turn session_end].freeze
|
|
29
34
|
end
|
|
30
35
|
end
|