samagotchi 0.81.0 → 0.83.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +2 -2
  4. data/docs/architecture.md +3 -0
  5. data/docs/cli.md +1 -1
  6. data/docs/configuration.md +54 -10
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +1 -1
  9. data/docs/plugins.md +58 -1
  10. data/docs/releasing.md +1 -1
  11. data/docs/sessions.md +2 -1
  12. data/docs/sub-agent.md +23 -8
  13. data/lib/samagotchi/bundles/core/manifest.yml +4 -2
  14. data/lib/samagotchi/bundles/cost-cap/manifest.yml +11 -0
  15. data/lib/samagotchi/bundles/cost-cap/plugin.rb +228 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/child_reports.rb +25 -8
  18. data/lib/samagotchi/cli/parent_wait.rb +9 -3
  19. data/lib/samagotchi/engine.rb +3 -2
  20. data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
  21. data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
  22. data/lib/samagotchi/idle_client.rb +2 -7
  23. data/lib/samagotchi/llm/http.rb +7 -2
  24. data/lib/samagotchi/parent_report.rb +7 -3
  25. data/lib/samagotchi/plugin/context.rb +8 -1
  26. data/lib/samagotchi/reply_wait.rb +66 -2
  27. data/lib/samagotchi/send_command.rb +47 -8
  28. data/lib/samagotchi/session_metrics/cost.rb +37 -0
  29. data/lib/samagotchi/session_metrics/side_totals.rb +16 -9
  30. data/lib/samagotchi/session_metrics.rb +12 -0
  31. data/lib/samagotchi/testing/boundary.rb +15 -0
  32. data/lib/samagotchi/testing/bundle.rb +35 -9
  33. data/lib/samagotchi/testing/host.rb +26 -1
  34. data/lib/samagotchi/testing/recorder.rb +4 -1
  35. data/lib/samagotchi/tools/delegate_cursor.rb +45 -7
  36. data/lib/samagotchi/tools/delegate_wait.rb +40 -6
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/public/app.js +3 -1
  39. data/lib/samagotchi/web/public/question_cards.js +10 -5
  40. metadata +4 -1
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The cost-cap bundle (docs/plugins.md, The cost-cap bundle): a per-session
4
+ # cap on what the session spends (ctx.cost: reported + estimated, side
5
+ # requests included). Before each generation it compares the spend with
6
+ # the cap: at warn_at of it the user (only) gets one notice, at the cap the
7
+ # turn pauses on a question, Continue (the cap goes up by a step) or Stop
8
+ # (the turn stops, with a card). With no one to ask it stops. The model
9
+ # never sees any of it: no tool, no steer, no prompt text.
10
+ #
11
+ # Settings (config.yml, bundles: cost-cap:), read at each use from
12
+ # ctx.settings, never at load:
13
+ # default: 5.00 USD cap for a session with none set; absent/0/off: none
14
+ # child: 2.00 for delegate children (ctx.delegate?); absent: default
15
+ # warn_at: 0.8 the share of the cap that warns; 0/off: no warning
16
+ #
17
+ # State: <data_dir>/sessions/<session id>.json, {"cap": 3.0, "step": 3.0},
18
+ # written by /cap and by Continue; "cap": null (or false) is off for the
19
+ # session, over the config. Without a file the cap comes from the settings.
20
+ # The cap is resolved once per session id (ctx.delegate? parses the session
21
+ # file) and kept until /cap or Continue writes a new one.
22
+ require "json"
23
+ require "fileutils"
24
+
25
+ class Plugin
26
+ USAGE = "usage: /cap [N | $N | off] (USD; /cap alone shows the spend)"
27
+ OPTIONS = %w[Continue Stop].freeze
28
+ WARN_AT = 0.8
29
+
30
+ def initialize(_settings)
31
+ @mutex = Mutex.new
32
+ @resolved = nil # {session:, cap:, step:}
33
+ @warned = nil # [session id, cap] the share warning was given for
34
+ @unknown_warned = nil # the session id the "can't be enforced" notice was given for
35
+ end
36
+
37
+ def register(chi)
38
+ chi.on(:before_generation) { |_event, ctx| check(ctx) }
39
+ # Only the "can't be enforced" notice: a turn of one generation (or a
40
+ # session's last) has no next before_generation to give it.
41
+ chi.on(:after_generation) { |_event, ctx| check_known(ctx) }
42
+ chi.command("/cap", "see or set this session's cost cap: /cap, /cap 5, /cap off", anytime: true) do |args, ctx|
43
+ command(args, ctx)
44
+ end
45
+ end
46
+
47
+ # The cap in USD (a positive Float) or nil (none, 0, off, false).
48
+ def self.amount(value)
49
+ number = case value
50
+ when Numeric then value.to_f
51
+ when String then value.strip.delete_prefix("$").then { |s| s.match?(/\A\d+(\.\d+)?\z/) ? s.to_f : nil }
52
+ end
53
+ number&.positive? ? number : nil
54
+ end
55
+
56
+ # Cents, or up to four decimals under $0.10, so a small cap (/cap 0.003)
57
+ # doesn't read "$0.00 spent of $0.00".
58
+ def self.money(value)
59
+ return format("$%.2f", value) if value.zero? || value.abs >= 0.1
60
+
61
+ format("$%.4f", value).sub(/(\.\d\d\d*?)0+\z/, '\1')
62
+ end
63
+
64
+ private
65
+
66
+ def check(ctx)
67
+ resolved = resolve(ctx)
68
+ cap = resolved[:cap]
69
+ return unless cap
70
+
71
+ cost = ctx.cost
72
+ return unenforceable(ctx, resolved[:session], cap) unless cost.known?
73
+
74
+ total = cost.total
75
+ return at_cap(ctx, resolved, cost) if total >= cap
76
+
77
+ share = warn_share(ctx)
78
+ return unless share && total >= share * cap
79
+ return unless first_time?(:@warned, [resolved[:session], cap])
80
+
81
+ ctx.notify("#{money(total)} of #{money(cap)} spent (#{(total / cap * 100).floor}%)", level: :warn)
82
+ end
83
+
84
+ def check_known(ctx)
85
+ resolved = resolve(ctx)
86
+ return unless resolved[:cap]
87
+
88
+ unenforceable(ctx, resolved[:session], resolved[:cap]) unless ctx.cost.known?
89
+ end
90
+
91
+ def unenforceable(ctx, session, cap)
92
+ return unless first_time?(:@unknown_warned, session)
93
+
94
+ ctx.notify("the #{money(cap)} cap can't be enforced: #{ctx.model || "the model"} reports no cost and has no price",
95
+ level: :warn)
96
+ end
97
+
98
+ def at_cap(ctx, resolved, cost)
99
+ cap = resolved[:cap]
100
+ raised = raised_cap(cap, resolved[:step] || cap, cost.total)
101
+ answer = ctx.ask_user(question: "Cost cap reached: #{money(cost.total)} spent of #{money(cap)}. " \
102
+ "Continue raises it to #{money(raised)}.",
103
+ header: "Cost cap", options: OPTIONS)
104
+ if continue?(answer)
105
+ save(ctx, resolved[:session], "cap" => raised, "step" => resolved[:step] || cap)
106
+ ctx.notify("cap raised to #{money(raised)}")
107
+ else
108
+ stop(ctx, cap, cost)
109
+ end
110
+ end
111
+
112
+ # Another step on the cap, as many steps as it takes to pass the spend
113
+ # (a turn that overshot by more than a step asks once, not each step).
114
+ def raised_cap(cap, step, total)
115
+ raised = cap + step
116
+ raised += step while raised <= total
117
+ raised.round(6)
118
+ end
119
+
120
+ def continue?(answer)
121
+ return false unless answer
122
+
123
+ Array(answer[:selected]).first == "Continue" || Array(answer[:selected_indices]).first&.zero?
124
+ end
125
+
126
+ def stop(ctx, cap, cost)
127
+ return unless ctx.stop_turn("cost cap reached (#{money(cost.total)} of #{money(cap)})")
128
+
129
+ ctx.card(title: "cost-cap stopped the turn",
130
+ body: "Spent #{breakdown(cost)} of a #{money(cap)} cap.\n\n`/cap N` raises it, `/cap off` removes it.",
131
+ level: :warn)
132
+ end
133
+
134
+ def command(args, ctx)
135
+ arg = args.to_s.strip.downcase
136
+ return show(ctx) if arg.empty?
137
+
138
+ session = ctx.session_id
139
+ return "cost-cap: no session yet" unless session
140
+
141
+ if %w[off 0].include?(arg)
142
+ save(ctx, session, "cap" => nil)
143
+ return "cost-cap: no cap for this session"
144
+ end
145
+ value = arg.match?(/\A\$?\d+(\.\d+)?\z/) ? self.class.amount(arg) : nil
146
+ return USAGE unless value
147
+
148
+ save(ctx, session, "cap" => value, "step" => value)
149
+ "cost-cap: cap set to #{money(value)} (#{money(ctx.cost.total)} spent)"
150
+ rescue StandardError => e
151
+ "cost-cap: #{e.message}"
152
+ end
153
+
154
+ def show(ctx)
155
+ cost = ctx.cost
156
+ cap = resolve(ctx)[:cap]
157
+ return "cost-cap: no cap (#{money(cost.total)} spent)" unless cap
158
+
159
+ line = "cost-cap: #{money(cost.total)} spent of #{money(cap)} (#{parts(cost)})"
160
+ cost.known? ? line : "#{line} (can't be enforced: no cost reported or priced)"
161
+ end
162
+
163
+ def parts(cost) = "reported #{money(cost.reported)}, estimated #{money(cost.estimated)}, side #{money(cost.side)}"
164
+
165
+ def breakdown(cost) = "#{money(cost.total)} (#{parts(cost)})"
166
+
167
+ # The session's cap and step now: the state file's, else child: for a
168
+ # delegate, else default:. Cached per session id.
169
+ def resolve(ctx)
170
+ session = ctx.session_id
171
+ cached = @mutex.synchronize { @resolved }
172
+ return cached if cached && cached[:session] == session
173
+
174
+ resolved = { session: session, **(state(ctx, session) || configured(ctx)) }
175
+ @mutex.synchronize { @resolved = resolved }
176
+ end
177
+
178
+ def state(ctx, session)
179
+ return nil unless session
180
+
181
+ saved = JSON.parse(File.read(state_path(ctx, session)))
182
+ return nil unless saved.is_a?(Hash) && saved.key?("cap")
183
+
184
+ cap = self.class.amount(saved["cap"])
185
+ { cap: cap, step: self.class.amount(saved["step"]) || cap }
186
+ rescue Errno::ENOENT, JSON::ParserError
187
+ nil
188
+ end
189
+
190
+ def configured(ctx)
191
+ settings = ctx.settings
192
+ return { cap: nil, step: nil } unless settings.key?("default") || settings.key?("child")
193
+
194
+ key = settings.key?("child") && ctx.delegate? ? "child" : "default"
195
+ cap = self.class.amount(settings[key])
196
+ { cap: cap, step: cap }
197
+ end
198
+
199
+ def warn_share(ctx)
200
+ settings = ctx.settings
201
+ return WARN_AT unless settings.key?("warn_at")
202
+
203
+ share = settings["warn_at"]
204
+ share.is_a?(Numeric) && share.positive? ? share : nil
205
+ end
206
+
207
+ def save(ctx, session, data)
208
+ path = state_path(ctx, session)
209
+ FileUtils.mkdir_p(File.dirname(path))
210
+ Samagotchi::AtomicFile.write(path, "#{JSON.generate(data)}\n")
211
+ cap = self.class.amount(data["cap"])
212
+ @mutex.synchronize { @resolved = { session: session, cap: cap, step: self.class.amount(data["step"]) || cap } }
213
+ end
214
+
215
+ def state_path(ctx, session) = File.join(ctx.data_dir, "sessions", "#{session.gsub(/[^A-Za-z0-9_.-]/, "_")}.json")
216
+
217
+ def money(value) = self.class.money(value)
218
+
219
+ # Whether +key+ is new for the once-only notice kept in +ivar+ (and keep it).
220
+ def first_time?(ivar, key)
221
+ @mutex.synchronize do
222
+ next false if instance_variable_get(ivar) == key
223
+
224
+ instance_variable_set(ivar, key)
225
+ true
226
+ end
227
+ end
228
+ end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.81.0
3
+ version: 0.83.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "securerandom"
4
5
  require_relative "client_id"
5
6
  require_relative "session"
6
7
  require_relative "session_inbox"
@@ -129,7 +130,7 @@ module Samagotchi
129
130
  # (DelegateWait.finish, its report variant), the cursor it moves the child to,
130
131
  # and the cursor it was read from (a commit after the model moved it
131
132
  # itself, with delegate_result, leaves it alone).
132
- Report = Data.define(:child_id, :text, :before, :after, :rings) do
133
+ Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id) do
133
134
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
134
135
  def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
135
136
  end
@@ -142,6 +143,7 @@ module Samagotchi
142
143
  @state_dir = state_dir
143
144
  @session_dir = Session.session_dir(session_id, state_dir: state_dir)
144
145
  @predecessor_dir = Session.valid_id?(predecessor) ? Session.session_dir(predecessor, state_dir: state_dir) : nil
146
+ Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
145
147
  # child id → the Report this turn took for it (rings merged).
146
148
  @taken = {}
147
149
  end
@@ -177,12 +179,16 @@ module Samagotchi
177
179
  end
178
180
 
179
181
  # The turn that took the reports was kept: move each child's cursor on
180
- # (unless something else moved it since: the model's own
181
- # delegate_result), then delete the rings.
182
+ # and delete the rings.
182
183
  def commit
183
184
  @taken.each_value do |report|
184
185
  Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
185
- cursor == report.before ? report.after : cursor
186
+ next cursor unless cursor.report_claim&.fetch("id", nil) == report.claim_id
187
+
188
+ before = report.before.with(report_claim: nil)
189
+ after = report.after.with(report_claim: nil)
190
+ current = cursor.with(report_claim: nil)
191
+ current == before ? after : current
186
192
  end
187
193
  delete_rings(report.rings)
188
194
  rescue StandardError => e
@@ -193,6 +199,11 @@ module Samagotchi
193
199
 
194
200
  # The turn was rolled back: its rings stay for the next one.
195
201
  def release
202
+ @taken.each_value do |report|
203
+ Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
204
+ cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
205
+ end
206
+ end
196
207
  @taken = {}
197
208
  end
198
209
 
@@ -237,13 +248,19 @@ module Samagotchi
237
248
  timeout: 0, turn: before.turn)
238
249
  return nil unless reportable?(wait)
239
250
 
240
- # A reported end is the follow-up turn's too (DelegateCursor#turn):
241
- # a later wait on it would repeat its reply.
251
+ claim_id = before.report_claim&.fetch("id", nil) || SecureRandom.hex(16)
242
252
  after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
243
- turn: wait.status == :waiting_for_answer ? before.turn : nil)
253
+ turn: wait.status == :waiting_for_answer ? before.turn : nil, report_claim: nil)
244
254
  .with_baseline(ReplyWait.baseline_of(wait.session))
255
+ claim = { "id" => claim_id, "file" => wait.file, "cursor" => after.to_json_hash }
256
+ return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
257
+ state_dir: @state_dir)
258
+
259
+ # A reported end is the follow-up turn's too (DelegateCursor#turn):
260
+ # a later wait on it would repeat its reply.
261
+ after = after.with(report_claim: claim)
245
262
  text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
246
- Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
263
+ Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id)
247
264
  rescue ArgumentError
248
265
  nil # the child is gone
249
266
  end
@@ -62,19 +62,25 @@ module Samagotchi
62
62
  # a --wait with no message is about (ReplyWait.own_turn)
63
63
  # @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
64
64
  # queued:)
65
+ # @param ended_before [Boolean] a last_turn that ended before the
66
+ # baseline is the answer (ReplyWait's ended_before:) — the
67
+ # --wait-with-no-message's settled session
68
+ # @param report_turn_id [String, nil] the turn id to include in the
69
+ # report, even when ReplyWait uses the next-reply path
65
70
  # @return [Integer] the exit status
66
71
  def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
67
- queued: false)
72
+ queued: false, ended_before: false, report_turn_id: turn)
68
73
  result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
69
74
  owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
70
- queued: queued)
75
+ queued: queued, ended_before: ended_before)
76
+ result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
71
77
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
72
78
  @reported = true
73
79
  ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
74
80
  json: @json, timeout: timeout, extra: report_extra)
75
81
  rescue Interrupt
76
82
  if @json
77
- @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id,
83
+ @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
78
84
  extra: report_extra))
79
85
  @stdout.flush
80
86
  @reported = true
@@ -569,7 +569,7 @@ module Samagotchi
569
569
  block = @metrics.add_side(usage)
570
570
  @session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
571
571
  end
572
- sid = @session&.id
572
+ sid = @metrics.session_id
573
573
  return if sid.nil? || turn_running?
574
574
  return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
575
575
 
@@ -2581,7 +2581,8 @@ module Samagotchi
2581
2581
  # build and after a rebuild that drops them (SystemPrompt#prompt_files
2582
2582
  # returns [] itself: the || keeps a nil builder safe).
2583
2583
  prompt_files: -> { @prompt_builder&.prompt_files || [] },
2584
- frontend: -> { interface }
2584
+ frontend: -> { interface },
2585
+ cost: -> { @metrics.cost }
2585
2586
  )
2586
2587
  end
2587
2588
 
@@ -33,8 +33,10 @@ module Samagotchi
33
33
  # @param session_root [String] the session's repo root (or cwd)
34
34
  def outside_tmp?(command, cwd:, tmp_roots:, session_root:, home: Dir.home, env: ENV)
35
35
  tokens = ShellLex.lex(command.to_s)
36
- # rm inside $(…) or backticks: the lexer keeps no body.
37
- return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && command.to_s.match?(/\brm\b/)
36
+ # rm inside $(…) or backticks: the lexer keeps no body. A message
37
+ # heredoc's text is data, so leave it out of this fallback.
38
+ code = ShellLex.code_text(command.to_s)
39
+ return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(/\brm\b/)
38
40
 
39
41
  session = ProtectedPaths.real(session_root.to_s)
40
42
  roots = tmp_roots.reject { |tmp| ShellPaths.within?(session, tmp) }
@@ -16,8 +16,9 @@ module Samagotchi
16
16
  # A word it can't resolve (another $VAR, $(…), a glob, a relative path
17
17
  # after cd $X) or one that reads as a script (sh -c '…', ruby -e '…':
18
18
  # spaces, quotes, operators in it) falls back to a text match (+text+);
19
- # so does the whole command when it holds a $(…) or backticks (the
20
- # lexer keeps no body).
19
+ # so does the whole command when it holds a $(…) or backticks (the lexer
20
+ # keeps no body), except for message heredoc bodies ShellLex.code_text
21
+ # recognizes as data.
21
22
  module ShellPaths
22
23
  # A word that is a script rather than a path.
23
24
  SCRIPT = /[\s;&|<>()'"`]/
@@ -42,7 +43,8 @@ module Samagotchi
42
43
  roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
43
44
  walk = Walk.new(cwd, home, env)
44
45
  tokens = ShellLex.lex(command.to_s)
45
- return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && command.to_s.match?(text)
46
+ code = ShellLex.code_text(command.to_s)
47
+ return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(text)
46
48
 
47
49
  ShellLex.simple_commands(tokens).any? do |words|
48
50
  walk.step(words).any? { |word, path| path ? names?(path, roots, tmp_roots) : word.match?(text) }
@@ -187,13 +187,8 @@ module Samagotchi
187
187
  raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
188
188
  end
189
189
 
190
- # The request's token counts and reported cost (a broadcast's triage
191
- # has no session to count them in). The cache counts show whether the
192
- # host reused a cached prefix. A side request evicts a session's cached
193
- # prompt only on a llama.cpp with no host-memory cache (--cache-ram 0)
194
- # or an old single-slot build, or when pinned to the session's slot,
195
- # which chi never does; paid hosts and current llama.cpp aren't
196
- # affected (docs/internals/prompt-caching.md).
190
+ # Log reported request usage when cache counts are present; session
191
+ # side-usage counting is handled separately by #report_usage.
197
192
  def log_usage(response, kind)
198
193
  usage = response.usage
199
194
  fields = usage&.cache_fields || {}
@@ -390,7 +390,7 @@ module Samagotchi
390
390
  end
391
391
  Log.public_send(failure_level, :http, event, **log_base(current), status: e.is_a?(ProviderError) ? e.status : nil,
392
392
  ms: elapsed_ms(current), attempts: current[:attempts], error: e.class.name,
393
- msg: (e.respond_to?(:summary) ? e.summary : e.message).to_s[0, 300])
393
+ msg: log_message(e.respond_to?(:summary) ? e.summary : e.message))
394
394
  raise
395
395
  end
396
396
 
@@ -410,13 +410,18 @@ module Samagotchi
410
410
  def retry_after(error, attempts, delay, on_retry, cancel_controller, current)
411
411
  Log.warn(:http, "retry", **log_base(current), attempt: attempts, max_retries: @retry_policy.max, delay_s: delay,
412
412
  status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
413
- msg: error.message.to_s[0, 300])
413
+ msg: log_message(error.message))
414
414
  on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
415
415
  error_class: error.class.name, error_message: error.message,
416
416
  status: error.is_a?(ProviderError) ? error.status : nil)
417
417
  wait(delay, cancel_controller)
418
418
  end
419
419
 
420
+ def log_message(value)
421
+ value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
422
+ .gsub(/[[:cntrl:]\u2028\u2029]+/, " ").split.join(" ")[0, 300]
423
+ end
424
+
420
425
  def check_status!(response)
421
426
  status = response.code.to_i
422
427
  return if status.between?(200, 299)
@@ -76,6 +76,7 @@ module Samagotchi
76
76
  # @return [Hash] the --format json object
77
77
  def json(result, session_id:, timeout: nil, extra: {})
78
78
  report = { status: status(result), session_id: session_id }
79
+ report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
79
80
  case report[:status]
80
81
  when "answered" then report[:text] = result.text.to_s
81
82
  when "question"
@@ -122,7 +123,7 @@ module Samagotchi
122
123
  text = if result.status == :waiting_for_answer
123
124
  question_text(result.question, session_id: session_id)
124
125
  else
125
- "#{detail(result, session_id: session_id, timeout: timeout)}\n"
126
+ "#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
126
127
  end
127
128
  stderr.print("#{command}: #{text}")
128
129
  end
@@ -200,7 +201,7 @@ module Samagotchi
200
201
  end
201
202
 
202
203
  # What a wait that ended without an answer says, one line.
203
- def detail(result, session_id:, timeout: nil)
204
+ def detail(result, session_id:, timeout: nil, turn_id: nil)
204
205
  attach = "chi --attach #{session_id}"
205
206
  case result.status
206
207
  when :waiting_for_answer
@@ -217,7 +218,10 @@ module Samagotchi
217
218
  when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
218
219
  when :worker_gone then "the worker is gone; #{attach} shows what happened"
219
220
  when :stopped then "the session was stopped (chi sessions stop)"
220
- else timeout ? "still running after #{format("%g", timeout)} s: #{attach}" : "still running: #{attach}"
221
+ else
222
+ wait_again = "chi send --wait --turn #{turn_id} #{session_id}" unless turn_id.to_s.empty?
223
+ hint = wait_again || attach
224
+ timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
221
225
  end
222
226
  end
223
227
 
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require_relative "../log"
5
5
  require_relative "../paths"
6
+ require_relative "../session_metrics/cost"
6
7
  require_relative "../guardrails/context"
7
8
  require_relative "../idle_client"
8
9
  require_relative "service"
@@ -51,10 +52,11 @@ module Samagotchi
51
52
  # ctx.http call without cancel: uses, nil for none: an init task's own,
52
53
  # else the running turn's for work done for it (the turn's thread, or a
53
54
  # plugin handler: +handler+) until the turn has ended.
55
+ # +cost+ returns the session's spend now (SessionMetrics::Cost).
54
56
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
55
57
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
56
58
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
57
- :cancel_controller, keyword_init: true)
59
+ :cancel_controller, :cost, keyword_init: true)
58
60
 
59
61
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
60
62
  # sent nothing usable. The message says why, for the user.
@@ -140,6 +142,11 @@ module Samagotchi
140
142
  # current_model_only names it
141
143
  def model_key = @host.model_key&.call
142
144
 
145
+ # @return [SessionMetrics::Cost] the session's spend so far, read now:
146
+ # #total, #reported, #estimated, #side (Float USD) and #known?
147
+ # (docs/plugins.md); zero and known with no session
148
+ def cost = @host.cost&.call || SessionMetrics::Cost.zero
149
+
143
150
  # @return [String, nil] the name of the session's toolkit ("full" for
144
151
  # one from before toolkits; nil with no session)
145
152
  def toolkit = @host.toolkit&.call
@@ -52,8 +52,10 @@ module Samagotchi
52
52
  # @!attribute session [Session, nil] the session as the look that
53
53
  # decided the outcome loaded it (a caller's next baseline); nil for
54
54
  # :canceled and :timeout
55
+ # @!attribute turn_id [String, nil] the id of the turn this result is
56
+ # about, when known
55
57
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
56
- :stopped_by, :kept_steps, :session, keyword_init: true)
58
+ :stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
57
59
 
58
60
  module_function
59
61
 
@@ -94,10 +96,18 @@ module Samagotchi
94
96
  # @param queued [Boolean] the turn: input was sent as a queue (or with
95
97
  # images), which a running turn never merges: no fallback, even when
96
98
  # its file was taken before the first look
99
+ # @param ended_before [Boolean] a last_turn that ended before the
100
+ # baseline (chi send --wait with no message, on a session that went
101
+ # idle in the gap between waits) is the turn's end too: with the
102
+ # session idle and its last_turn still the baseline's, its end is
103
+ # the answer — its reply, or its no-reply outcome. Its reply file
104
+ # sorts at the cursor, not past it, and its end precedes the
105
+ # baseline, so without it the wait would time out "running" again.
106
+ # Not for a turn: wait (chi answer) whose own end it waits for.
97
107
  # @return [Result]
98
108
  # @raise [ArgumentError] no such session
99
109
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
100
- baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
110
+ baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
101
111
  deadline = timeout && (monotonic + timeout.to_f)
102
112
  seen_running = false
103
113
  gone_since = nil
@@ -159,6 +169,38 @@ module Samagotchi
159
169
 
160
170
  return Result.new(status: :canceled) if cancelled.call
161
171
 
172
+ # The turn that ended before this wait (ended_before: chi send
173
+ # --wait with no message, on a session that went idle in the gap
174
+ # between waits): its last_turn still stands, the session is idle,
175
+ # so its end is the answer, not a wait that times out "running"
176
+ # again. Its reply file sorts at the cursor (the newest), not past
177
+ # it: it is the turn's when it is at least as new as the turn's
178
+ # end (the worker writes it after recording the ended_at), else the
179
+ # turn left no reply and an earlier turn's file is not its.
180
+ if ended_before && turn.nil? && session.status == Session::STATUS_IDLE &&
181
+ (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
182
+ baseline && ended == baseline[:last_turn]
183
+ file = newest_reply(id, state_dir: state_dir)
184
+ path = File.join(reply_dir(id, state_dir: state_dir), file) if file
185
+ if file && reply_is_turns?(path, ended)
186
+ reply = Result.new(status: :done, text: File.read(path), file: file)
187
+ reply.session = settled(session, id, state_dir: state_dir)
188
+ return reply
189
+ end
190
+
191
+ return no_reply(session, baseline, fresh: true)
192
+ end
193
+
194
+ if ended_before && turn && session.last_turn&.enqueued_id == turn &&
195
+ !(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
196
+ (ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
197
+ baseline && ended == baseline[:last_turn]
198
+ # The turn-specific reply lookup above already found its file, if
199
+ # one exists. Without it, don't use the newest file as a fallback:
200
+ # it could belong to another turn.
201
+ return no_reply(session, baseline, fresh: true)
202
+ end
203
+
162
204
  if turn
163
205
  # Only the turn's own end: the session runs another turn when the
164
206
  # wait starts, and that one's end is not the answer. A last_turn
@@ -257,6 +299,18 @@ module Samagotchi
257
299
  session.last_turn&.enqueued_id
258
300
  end
259
301
 
302
+ # Whether +turn+ is a turn the caller can wait for: running now, queued
303
+ # as its own turn, or the last one to have ended.
304
+ def known_turn?(session, session_dir:, turn:)
305
+ id = turn.to_s
306
+ return false if id.empty?
307
+
308
+ running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
309
+ queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
310
+ ended = session.last_turn&.enqueued_id == id
311
+ running || queued || ended
312
+ end
313
+
260
314
  # Whether the session's worker sidecar names TURN_IDS_FEATURE.
261
315
  def turn_ids?(session_dir)
262
316
  WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
@@ -390,6 +444,16 @@ module Samagotchi
390
444
  cursor.nil? || file > cursor
391
445
  end
392
446
 
447
+ # Whether the reply file at +path+ is the turn that ended at +ended_at+'s,
448
+ # not an earlier turn's: the worker records ended_at before writing the
449
+ # reply, so its mtime is at least the turn's end and earlier replies'
450
+ # mtimes precede it. Unlike the filename, mtime is independent of TZ.
451
+ def reply_is_turns?(path, ended_at)
452
+ File.mtime(path) >= Time.parse(ended_at.to_s)
453
+ rescue ArgumentError, SystemCallError
454
+ false
455
+ end
456
+
393
457
  def monotonic
394
458
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
395
459
  end