samagotchi 0.17.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 +64 -1
- data/docs/architecture.md +25 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- 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 +2 -2
- 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 +42 -14
- 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/trash.rb +81 -2
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- 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/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +11 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- 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/read.rb +2 -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 +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- 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 +5 -2
- 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/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- metadata +8 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session worker's continue offer: what it announces when a turn runs out
|
|
7
|
+
# of iterations, when a prompt or a reminder drops the offer, when a
|
|
8
|
+
# /continue answers it, and the continue turn a yes runs. TurnFlow keeps
|
|
9
|
+
# the offer itself (its checkpoint and context); this is the worker's side
|
|
10
|
+
# of it, which every UI hears about.
|
|
11
|
+
#
|
|
12
|
+
# The offer is also a question (kind "continue", the step-limit
|
|
13
|
+
# question), standing on the Engine's question desk between turns, so
|
|
14
|
+
# everything built on a pending question sees it: chi send --wait (exit
|
|
15
|
+
# 3), chi answer, the lists' waiting, the web's badge and bell. Its
|
|
16
|
+
# answer becomes the matching /continue line on the worker's command
|
|
17
|
+
# queue, which runs it as a typed one (#run_command's path).
|
|
18
|
+
class ContinueOffer
|
|
19
|
+
KIND = "continue"
|
|
20
|
+
HEADER = "Step limit"
|
|
21
|
+
CONTINUE = "Continue"
|
|
22
|
+
STOP = "Stop"
|
|
23
|
+
PROMPT_PREVIEW = 200
|
|
24
|
+
# last_turn's outcome after a Stop: no turn ran, but the wait of
|
|
25
|
+
# whoever answered (chi answer) ends on it.
|
|
26
|
+
NOT_CONTINUED = "not_continued"
|
|
27
|
+
|
|
28
|
+
# @param engine [Engine] announces, records activity, holds the question
|
|
29
|
+
# @param turn_flow [TurnFlow] owns the offer
|
|
30
|
+
# @param run_turn [#call] (prompt, **turn_args, &block) the worker's
|
|
31
|
+
# run_engine_turn
|
|
32
|
+
# @param max_iterations [#call] (no_interrupt) → Integer
|
|
33
|
+
# @param queue_command [#call] (line, client_id:) queues a session
|
|
34
|
+
# command as a card's (Bridge#queue_command, card: true)
|
|
35
|
+
def initialize(engine:, turn_flow:, run_turn:, max_iterations:, queue_command: nil)
|
|
36
|
+
@engine = engine
|
|
37
|
+
@turn_flow = turn_flow
|
|
38
|
+
@run_turn = run_turn
|
|
39
|
+
@max_iterations = max_iterations
|
|
40
|
+
@queue_command = queue_command
|
|
41
|
+
# A question was posted since the last close (it may have been
|
|
42
|
+
# answered or superseded meanwhile).
|
|
43
|
+
@asked = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_writer :queue_command
|
|
47
|
+
|
|
48
|
+
def awaiting? = @turn_flow.awaiting_continue?
|
|
49
|
+
|
|
50
|
+
# TurnFlow keeps the checkpoint (a cancelled turn's, for !rollback) and
|
|
51
|
+
# the continue offer of a turn that ran out of iterations; an offer made
|
|
52
|
+
# (or kept, after a cancelled continue turn) is announced and asked.
|
|
53
|
+
def after_turn(result, continue: false, no_interrupt: false)
|
|
54
|
+
outcome = @turn_flow.after_turn(result, continue: continue, no_interrupt: no_interrupt)
|
|
55
|
+
return unless outcome == :continue_offered || outcome == :continue_cancelled
|
|
56
|
+
|
|
57
|
+
open
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A prompt (or a reminder turn) taken while a continue is offered
|
|
61
|
+
# replaces the answer (D2): the offer goes, the partial turn stays.
|
|
62
|
+
def drop(origin)
|
|
63
|
+
return unless awaiting?
|
|
64
|
+
|
|
65
|
+
close("dropped")
|
|
66
|
+
@engine.synchronize_events do
|
|
67
|
+
@turn_flow.drop_offer!
|
|
68
|
+
@engine.announce(type: :continue_resolved, decision: "dropped", client_id: origin&.dig(:client_id))
|
|
69
|
+
end
|
|
70
|
+
@engine.record_activity
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Whether a command's +result+ answered the offer that was pending
|
|
74
|
+
# (+awaiting+) as it started.
|
|
75
|
+
def resolved?(awaiting, result)
|
|
76
|
+
awaiting && result.decision && result.decision != :invalid ? true : false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Called with the event log held, before the command's command_ran. A
|
|
80
|
+
# Stop runs no turn: last_turn says so (not_continued), so a wait on
|
|
81
|
+
# it ends (chi answer --option Stop).
|
|
82
|
+
def announce_resolved(result, command)
|
|
83
|
+
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
|
|
84
|
+
return if result.resume
|
|
85
|
+
|
|
86
|
+
session = @engine.session
|
|
87
|
+
session.last_turn = { "outcome" => NOT_CONTINUED, "ended_at" => Time.now.iso8601(3) } if session
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# After a command's command_ran and save: a /continue that answered the
|
|
91
|
+
# offer closes its question; a command that dropped the offer
|
|
92
|
+
# otherwise (!rollback) too.
|
|
93
|
+
# @param resolved [Boolean] it answered the offer (#resolved?)
|
|
94
|
+
def after_command(result, resolved:)
|
|
95
|
+
close(resolved ? "answered" : "dropped") unless awaiting?
|
|
96
|
+
return unless resolved
|
|
97
|
+
|
|
98
|
+
# An offer answered without a turn ("no") is activity: the recap
|
|
99
|
+
# written at the offer (it says the turn stopped) gets rewritten.
|
|
100
|
+
@engine.record_activity unless result.resume
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The continue offer was answered yes: resume the conversation without a
|
|
104
|
+
# user message, with the iteration limit the offer's turn had. A failure
|
|
105
|
+
# keeps the offer, as the REPL does ("continue prompt preserved"), and
|
|
106
|
+
# asks again.
|
|
107
|
+
def run_continue_turn(command)
|
|
108
|
+
offer = @turn_flow.offer
|
|
109
|
+
close("answered")
|
|
110
|
+
@turn_flow.before_continue_turn
|
|
111
|
+
@run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
|
|
112
|
+
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
113
|
+
if error
|
|
114
|
+
open(offer)
|
|
115
|
+
else
|
|
116
|
+
after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The question goes (the offer went, or was answered).
|
|
122
|
+
# @param reason [String] dropped, answered
|
|
123
|
+
def close(reason)
|
|
124
|
+
return unless @asked
|
|
125
|
+
|
|
126
|
+
@asked = false
|
|
127
|
+
@engine.withdraw_question(reason)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def open(offer = @turn_flow.offer)
|
|
133
|
+
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
134
|
+
ask
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Post the step-limit question while the offer stands (again, after a
|
|
138
|
+
# question that superseded it closed).
|
|
139
|
+
def ask
|
|
140
|
+
return unless awaiting?
|
|
141
|
+
|
|
142
|
+
@asked = true
|
|
143
|
+
@engine.post_question(question_fields(@turn_flow.offer), on_answer: method(:answered),
|
|
144
|
+
on_superseded_close: method(:ask))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def question_fields(offer)
|
|
148
|
+
limit = limit_of_last_turn
|
|
149
|
+
context = offer[:context] || {}
|
|
150
|
+
text = "The turn ran out of iterations#{" (#{limit} steps)" if limit} before it answered. Continue it?"
|
|
151
|
+
prompt = context[:original_prompt].to_s.strip
|
|
152
|
+
unless prompt.empty?
|
|
153
|
+
prompt = "#{prompt[0, PROMPT_PREVIEW].rstrip}..." if prompt.length > PROMPT_PREVIEW
|
|
154
|
+
text += "\nPrompt: #{prompt}"
|
|
155
|
+
end
|
|
156
|
+
{ kind: KIND, header: HEADER, question: text, options: [CONTINUE, STOP], multi_select: false,
|
|
157
|
+
allow_freeform: true, limit: limit, context: context }.compact
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The limit the offer's turn ran out at (Engine's last_turn marker).
|
|
161
|
+
def limit_of_last_turn
|
|
162
|
+
last = @engine.session&.last_turn
|
|
163
|
+
last.is_a?(Hash) && last["exhausted"] ? last["limit"] : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The question's answer as the /continue line a typed answer would be:
|
|
167
|
+
# Continue → yes; Stop → no; a text (with Stop, or alone) → no, <text>.
|
|
168
|
+
def answered(answer, client_id:)
|
|
169
|
+
text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
|
|
170
|
+
line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
|
|
171
|
+
elsif text.empty? then "/continue no"
|
|
172
|
+
else "/continue no, #{text}"
|
|
173
|
+
end
|
|
174
|
+
@queue_command&.call(line, client_id: client_id)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
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
|
|
@@ -1081,7 +1079,10 @@ module Samagotchi
|
|
|
1081
1079
|
|
|
1082
1080
|
# How the turn ended, on the session (Session#last_turn): the caller's
|
|
1083
1081
|
# save puts it in the file the session hub watches.
|
|
1084
|
-
|
|
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)
|
|
1085
1086
|
client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
|
|
1086
1087
|
source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
|
|
1087
1088
|
elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
|
|
@@ -1089,6 +1090,7 @@ module Samagotchi
|
|
|
1089
1090
|
end
|
|
1090
1091
|
session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
|
|
1091
1092
|
"seconds" => seconds.round(1), "origin" => source }
|
|
1093
|
+
session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
|
|
1092
1094
|
end
|
|
1093
1095
|
private :record_last_turn
|
|
1094
1096
|
|
|
@@ -1101,14 +1103,15 @@ module Samagotchi
|
|
|
1101
1103
|
# returns [the messages to keep (nil: leave the session's), the end
|
|
1102
1104
|
# event]. +save:+ also saves the session (a failed turn: a worker exits
|
|
1103
1105
|
# after it). The disk writes come after the step: every emitter waits
|
|
1104
|
-
# for the event lock.
|
|
1105
|
-
|
|
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)
|
|
1106
1109
|
seconds = turn.elapsed
|
|
1107
1110
|
synchronize_events do
|
|
1108
1111
|
kept, event = yield(seconds)
|
|
1109
1112
|
replace_session_messages(turn.session, kept) if kept
|
|
1110
1113
|
turn.session.status = Session::STATUS_IDLE
|
|
1111
|
-
record_last_turn(turn.session, outcome, seconds, turn.origin)
|
|
1114
|
+
record_last_turn(turn.session, outcome, seconds, turn.origin, limit: exhausted ? turn.limit : nil)
|
|
1112
1115
|
emit_event(turn.on_event, turn.tag(event))
|
|
1113
1116
|
end
|
|
1114
1117
|
turn.ended = true
|
|
@@ -1254,6 +1257,19 @@ module Samagotchi
|
|
|
1254
1257
|
@question_desk.open_question({ **fields.to_h, **more }, watch: watch)
|
|
1255
1258
|
end
|
|
1256
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
|
+
|
|
1257
1273
|
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
1258
1274
|
# @return [Hash] normalized answer
|
|
1259
1275
|
def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
@@ -1274,6 +1290,7 @@ module Samagotchi
|
|
|
1274
1290
|
|
|
1275
1291
|
# Cancel the pending question (QuestionDesk#cancel).
|
|
1276
1292
|
# @return [Boolean] whether it was cancelled
|
|
1293
|
+
# @raise [QuestionDesk::NotDismissable] +id+ names a standing question
|
|
1277
1294
|
def cancel_question(reason = "user", id: nil)
|
|
1278
1295
|
@question_desk.cancel(reason, id: id)
|
|
1279
1296
|
end
|
|
@@ -1414,8 +1431,9 @@ module Samagotchi
|
|
|
1414
1431
|
# set on the kernel in #generate.
|
|
1415
1432
|
# +id+: names the turn on :turn_started (its metrics record's id) and on
|
|
1416
1433
|
# its saved prompt, so a UI pairs the two whatever left the conversation.
|
|
1434
|
+
# +limit+: the iteration limit #generate ran it with.
|
|
1417
1435
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1418
|
-
:settings, :id) do
|
|
1436
|
+
:settings, :id, :limit) do
|
|
1419
1437
|
# The turn's boundary events carry the origin only when there is one,
|
|
1420
1438
|
# so payloads stay unchanged for callers that don't pass it.
|
|
1421
1439
|
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
@@ -1433,7 +1451,7 @@ module Samagotchi
|
|
|
1433
1451
|
# @param session [Session] the session to operate on
|
|
1434
1452
|
# @param prompt [String] user input
|
|
1435
1453
|
# @param on_event [Proc, nil] receives event hashes
|
|
1436
|
-
# @param max_iterations [Integer] max kernel iterations
|
|
1454
|
+
# @param max_iterations [Integer, nil] max kernel iterations (nil: IterationLimit.for)
|
|
1437
1455
|
# @param cancel_controller [CancellationController, nil]
|
|
1438
1456
|
# @param max_tool_output_chars [Integer, nil] per-output char cap for the
|
|
1439
1457
|
# :tool_call_completed event's `output:` (nil → max_tool_output_chars)
|
|
@@ -1462,7 +1480,7 @@ module Samagotchi
|
|
|
1462
1480
|
# other error (e.g. an LLM::ProviderError) emits :turn_failed and
|
|
1463
1481
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1464
1482
|
# one-line summary:.
|
|
1465
|
-
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,
|
|
1466
1484
|
images: [])
|
|
1467
1485
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
1468
1486
|
continue: continue)
|
|
@@ -1666,9 +1684,10 @@ module Samagotchi
|
|
|
1666
1684
|
# turn's model, not the last native one.
|
|
1667
1685
|
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting)
|
|
1668
1686
|
|
|
1687
|
+
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
1669
1688
|
backend.complete(
|
|
1670
1689
|
messages: turn.messages,
|
|
1671
|
-
max_iterations:
|
|
1690
|
+
max_iterations: turn.limit,
|
|
1672
1691
|
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
1673
1692
|
cancel_controller: turn.controller,
|
|
1674
1693
|
model_name: bare_for_backend,
|
|
@@ -1705,7 +1724,7 @@ module Samagotchi
|
|
|
1705
1724
|
# after_turn hooks run below and may present the answer (the web
|
|
1706
1725
|
# holds its pop until it knows).
|
|
1707
1726
|
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1708
|
-
end_turn(turn, canceled ? "canceled" : "completed") do |seconds|
|
|
1727
|
+
end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
|
|
1709
1728
|
kept = kept_messages(turn, result, seconds)
|
|
1710
1729
|
if canceled
|
|
1711
1730
|
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
|
|
@@ -1968,6 +1987,15 @@ module Samagotchi
|
|
|
1968
1987
|
# rollback after Ctrl-C). Each replaces the array rather than mutating it,
|
|
1969
1988
|
# so the recap's lock-free snapshot never sees a half-applied edit.
|
|
1970
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
|
+
|
|
1971
1999
|
# @return [Array<Hash>] a copy of the current session's messages to hand
|
|
1972
2000
|
# back to #rollback_to later
|
|
1973
2001
|
def messages_checkpoint
|
|
@@ -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
|
|