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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/docs/architecture.md +25 -2
  4. data/docs/cli.md +34 -8
  5. data/docs/configuration.md +4 -2
  6. data/docs/guardrails.md +51 -8
  7. data/docs/hooks.md +4 -1
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +3 -2
  10. data/docs/sessions.md +6 -5
  11. data/docs/sub-agent.md +42 -4
  12. data/lib/samagotchi/answer_command.rb +23 -2
  13. data/lib/samagotchi/answer_tail.rb +52 -0
  14. data/lib/samagotchi/bridge.rb +49 -8
  15. data/lib/samagotchi/bridge_client.rb +18 -3
  16. data/lib/samagotchi/bundle_command.rb +108 -2
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/commands/registry.rb +28 -0
  22. data/lib/samagotchi/config.rb +7 -1
  23. data/lib/samagotchi/continue_offer.rb +177 -0
  24. data/lib/samagotchi/engine.rb +42 -14
  25. data/lib/samagotchi/guardrails/outside.rb +46 -0
  26. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  27. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  28. data/lib/samagotchi/guardrails/rules.rb +17 -5
  29. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  30. data/lib/samagotchi/guardrails/targets.rb +30 -13
  31. data/lib/samagotchi/guardrails.rb +2 -0
  32. data/lib/samagotchi/iteration_limit.rb +38 -0
  33. data/lib/samagotchi/kernel_loop.rb +2 -1
  34. data/lib/samagotchi/llm/backend.rb +2 -1
  35. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  36. data/lib/samagotchi/llm/native_backend.rb +2 -1
  37. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  38. data/lib/samagotchi/parent_report.rb +39 -9
  39. data/lib/samagotchi/question_desk.rb +205 -46
  40. data/lib/samagotchi/reply_wait.rb +10 -4
  41. data/lib/samagotchi/send_command.rb +2 -1
  42. data/lib/samagotchi/session.rb +3 -2
  43. data/lib/samagotchi/session_commands.rb +3 -2
  44. data/lib/samagotchi/session_manager.rb +4 -1
  45. data/lib/samagotchi/sessions_command.rb +3 -2
  46. data/lib/samagotchi/system_prompt.rb +1 -1
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
  48. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  49. data/lib/samagotchi/terminal_ui.rb +11 -2
  50. data/lib/samagotchi/tool_declarations.rb +2 -2
  51. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  52. data/lib/samagotchi/tools/edit.rb +6 -1
  53. data/lib/samagotchi/tools/execute.rb +5 -4
  54. data/lib/samagotchi/tools/read.rb +2 -1
  55. data/lib/samagotchi/turn_flow.rb +8 -7
  56. data/lib/samagotchi/turn_note.rb +7 -0
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +187 -42
  59. data/lib/samagotchi/web/public/app.js +122 -25
  60. data/lib/samagotchi/web/public/data.js +12 -13
  61. data/lib/samagotchi/web/public/index.html +6 -1
  62. data/lib/samagotchi/web/public/notify.js +5 -2
  63. data/lib/samagotchi/web/public/question_card.js +28 -1
  64. data/lib/samagotchi/web/public/question_cards.js +74 -3
  65. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  66. data/lib/samagotchi/web/public/timing.js +41 -0
  67. data/lib/samagotchi/web/public/turn_events.js +61 -0
  68. data/lib/samagotchi/web/public/update_notice.js +15 -0
  69. data/lib/samagotchi/worker.rb +30 -58
  70. data/lib/samagotchi/worker_sidecar.rb +6 -3
  71. 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
@@ -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
- # NO_INTERRUPT_MAX_ITERATIONS, whichever loop runs it
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
- def record_last_turn(session, outcome, seconds, origin)
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
- def end_turn(turn, outcome, save: false)
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: 100, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
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: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : 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 %w[tool command path].any? { |k| raw.key?(k) }
103
- raise ParseError, "#{label}: give at least one of tool, command, path"
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