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
@@ -43,7 +43,7 @@ module Samagotchi
43
43
  chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
44
44
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
45
45
  chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
46
- chi send --wait [--timeout S] [--format json] ID
46
+ chi send --wait [--timeout S] [--format json] [--turn T] ID
47
47
  Sends a message to each session, as if typed in it: a turn starts,
48
48
  or a running one takes it at its next step. A stopped session's
49
49
  worker starts.
@@ -91,7 +91,10 @@ module Samagotchi
91
91
  the web). With no message (no -m, nothing piped)
92
92
  nothing is sent: it waits for the session's next
93
93
  reply, a running turn's too (after exit 3, 4 or 130,
94
- wait again this way)
94
+ wait again this way). To an idle session whose last
95
+ turn already ended, that ended turn is the answer:
96
+ its reply, or its end when it left none. --turn T
97
+ waits for that running, queued or last-ended turn
95
98
  --cut ask the running turn to cut the generation it is
96
99
  streaming and take the message now (a generation that
97
100
  has streamed only thinking for steer.cut_after; else
@@ -108,10 +111,12 @@ module Samagotchi
108
111
  than queueing (it would steer; restart it)
109
112
  --timeout S (--wait) give up after S seconds, exit 4 (the turn
110
113
  goes on); default no limit
114
+ --turn T (--wait, no message) wait for this turn id, from the
115
+ JSON's turn_id; not with -m
111
116
  --format json
112
117
  (--wait) one JSON object on stdout, whatever the end:
113
118
  status answered (text), question (question,
114
- answer_with), running, or failed, canceled,
119
+ answer_with), turn_id when known, running, or failed, canceled,
115
120
  limit, no_answer, error, worker_gone, stopped,
116
121
  command (detail); with --cut or --queue also
117
122
  delivery (what the worker applied: next_step, cut,
@@ -142,6 +147,7 @@ module Samagotchi
142
147
  f.switch "--queue"
143
148
  f.value "--timeout"
144
149
  f.value "--format"
150
+ f.value "--turn"
145
151
  # Starting a turn in every live session at once is too easy to do
146
152
  # by accident.
147
153
  f.refuse "--all", "there is no --all: name the sessions"
@@ -204,6 +210,7 @@ module Samagotchi
204
210
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
205
211
  # Before the wait-only case, which would drop the images.
206
212
  return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
213
+ return usage_error("--turn cannot be used with a message") if options[:turn] && prompt
207
214
  return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
208
215
 
209
216
  # The next link of a chain may start idle, waiting for its first message.
@@ -268,6 +275,8 @@ module Samagotchi
268
275
  options[:timeout] = Float(options[:timeout], exception: false)
269
276
  return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
270
277
  end
278
+ return usage_error("--turn needs --wait") if options[:turn] && !options[:wait]
279
+ return usage_error("--turn is only for waiting without a message") if options[:turn] && (options[:new] || options[:queue] || options[:cut])
271
280
  return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
272
281
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
273
282
  return new_options(options) if options[:new]
@@ -388,7 +397,7 @@ module Samagotchi
388
397
  return report_command(session.id) if @sent_command
389
398
 
390
399
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
391
- timeout: options[:timeout])
400
+ timeout: options[:timeout], report_turn_id: @sent_enqueued_id)
392
401
  end
393
402
 
394
403
  # The model the new session will run, for the images check: --model
@@ -450,6 +459,9 @@ module Samagotchi
450
459
 
451
460
  cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
452
461
  baseline = baseline_of(session)
462
+ running_turn = if session.status == Session::STATUS_RUNNING
463
+ ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
464
+ end
453
465
  return 1 unless deliver(id, prompt)
454
466
  return report_command(id) if @sent_command
455
467
 
@@ -457,7 +469,15 @@ module Samagotchi
457
469
  # running at the check ended before it went in (an idle worker's ack
458
470
  # says next_step, and the file still runs as that turn).
459
471
  turn = @queue ? @sent_enqueued_id : nil
460
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?)
472
+ report_turn_id = if @queue
473
+ turn
474
+ elsif session.status == Session::STATUS_RUNNING
475
+ running_turn
476
+ else
477
+ @sent_enqueued_id
478
+ end
479
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?,
480
+ report_turn_id: report_turn_id)
461
481
  end
462
482
 
463
483
  # --wait with no message: the session's next reply, sending nothing,
@@ -468,16 +488,33 @@ module Samagotchi
468
488
  # waits for whatever wakes one (the web, chi send) rather than calling
469
489
  # it gone. The turn running now, or the one whose continue offer
470
490
  # stands, is the one waited for (ReplyWait.own_turn): its reply, not a
471
- # turn's queued behind it; with none known, the next reply.
491
+ # turn's queued behind it; with none known, the next reply. An idle
492
+ # session whose last turn already ended (no question standing) went
493
+ # idle in the gap between waits: that turn's end is the answer — its
494
+ # reply, or its no-reply outcome — so the wait returns it at once
495
+ # (ReplyWait's ended_before:) instead of timing out "running" again.
472
496
  # @return [Integer] the exit status
473
497
  def run_wait_only(options)
474
498
  id = resolve(options[:ids].first) or return 1
475
499
  session = Session.load(id, state_dir: @state_dir)
500
+ session_dir = Session.session_dir(id, state_dir: @state_dir)
501
+ requested_turn = options[:turn]&.to_s
502
+ if requested_turn && !ReplyWait.known_turn?(session, session_dir: session_dir, turn: requested_turn)
503
+ error_line("chi send: turn #{requested_turn} is not a running, queued, or last ended turn")
504
+ return 1
505
+ end
476
506
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
477
- turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
507
+ turn = requested_turn || ReplyWait.own_turn(session, session_dir: session_dir)
508
+ # The gate for the settled case: idle, a turn ended (last_turn), no
509
+ # question standing (a continue offer waits for its answer).
510
+ settled = session.status == Session::STATUS_IDLE && session.last_turn && session.pending_question.nil?
511
+ ended_before = requested_turn ? session.last_turn&.enqueued_id == requested_turn : settled
512
+ report_turn_id = turn
513
+ report_turn_id ||= session.last_turn&.enqueued_id if settled
478
514
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
479
515
  baseline: baseline_of(session).merge(messages: nil),
480
- timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil, turn: turn)
516
+ timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil,
517
+ turn: turn, ended_before: ended_before, report_turn_id: report_turn_id)
481
518
  end
482
519
 
483
520
  # The session as it was before the message went in (ReplyWait's
@@ -514,6 +551,8 @@ module Samagotchi
514
551
  return false
515
552
  end
516
553
 
554
+ ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
555
+ @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
517
556
  @sent_command = command_ack?(result[:ack])
518
557
  @info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
519
558
  true
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionMetrics
5
+ # The session's spend so far (SessionMetrics#cost, what a bundle's
6
+ # ctx.cost reads), in USD: +reported+ by providers (usage.cost) and
7
+ # +estimated+ from configured prices (ModelPrice.estimate: a generation
8
+ # is one or the other, never both), each the main loop's and the side
9
+ # requests' together; +side+ is the side requests' share of the total
10
+ # (recap, /btw, ask_model). +known+ is false while generations or side
11
+ # requests ran but none was reported or priced: a cap can't be enforced
12
+ # on such a session. A session with some priced and some unpriced
13
+ # generations reads known (the unpriced ones count 0).
14
+ # Bundles use its methods only, never this constant's name.
15
+ Cost = Data.define(:reported, :estimated, :side, :known) do
16
+ def self.zero = new(reported: 0.0, estimated: 0.0, side: 0.0, known: true)
17
+
18
+ # @param totals [Totals] the session's sums, the running turn folded in
19
+ # @param side [SideTotals]
20
+ # @return [Cost]
21
+ def self.of(totals, side)
22
+ side_reported = side.cost_sum.to_f
23
+ side_estimated = side.cost_estimate_sum.to_f
24
+ reported = totals.cost_sum.to_f + side_reported
25
+ estimated = totals.cost_estimate_sum.to_f + side_estimated
26
+ ran = (totals.prompt_sum + totals.completion_sum).positive? || side.requests.positive?
27
+ new(reported: reported, estimated: estimated, side: side_reported + side_estimated,
28
+ known: !ran || reported.positive? || estimated.positive?)
29
+ end
30
+
31
+ # @return [Float] reported + estimated, side requests included
32
+ def total = reported + estimated
33
+
34
+ def known? = known
35
+ end
36
+ end
37
+ end
@@ -31,14 +31,21 @@ module Samagotchi
31
31
  return zero unless hash.is_a?(Hash)
32
32
 
33
33
  hash = hash.transform_keys(&:to_s)
34
- kinds = hash["by_kind"].is_a?(Hash) ? hash["by_kind"] : {}
35
- new(**COUNTS.to_h { |key| [key, count(hash[key.to_s])] }, cost_sum: cost(hash["cost_sum"]),
36
- cost_estimate_sum: number(hash["cost_estimate_sum"]),
37
- by_kind: kinds.each_with_object({}) do |(kind, row), rows|
38
- row = row.is_a?(Hash) ? row.transform_keys(&:to_s) : {}
39
- rows[kind.to_s] = { requests: count(row["requests"]), cost_sum: cost(row["cost_sum"]),
40
- cost_estimate_sum: number(row["cost_estimate_sum"]) }.freeze
41
- end.freeze)
34
+ new(
35
+ **COUNTS.to_h { |key| [key, count(hash[key.to_s])] },
36
+ cost_sum: cost(hash["cost_sum"]),
37
+ cost_estimate_sum: number(hash["cost_estimate_sum"]),
38
+ by_kind: saved_kinds(hash["by_kind"])
39
+ )
40
+ end
41
+
42
+ def self.saved_kinds(hash)
43
+ kinds = hash.is_a?(Hash) ? hash : {}
44
+ kinds.each_with_object({}) do |(kind, row), rows|
45
+ row = row.is_a?(Hash) ? row.transform_keys(&:to_s) : {}
46
+ rows[kind.to_s] = { requests: count(row["requests"]), cost_sum: cost(row["cost_sum"]),
47
+ cost_estimate_sum: number(row["cost_estimate_sum"]) }.freeze
48
+ end.freeze
42
49
  end
43
50
 
44
51
  # These totals with one side request counted in.
@@ -87,7 +94,7 @@ module Samagotchi
87
94
 
88
95
  def self.cost(value) = value.is_a?(Numeric) ? value : nil
89
96
 
90
- private_class_method :count, :number, :cost
97
+ private_class_method :count, :number, :cost, :saved_kinds
91
98
 
92
99
  private
93
100
 
@@ -12,6 +12,7 @@ require_relative "model_price"
12
12
  require_relative "session"
13
13
  require_relative "session_metrics/totals"
14
14
  require_relative "session_metrics/model_segment"
15
+ require_relative "session_metrics/cost"
15
16
  require_relative "session_metrics/side_totals"
16
17
  require_relative "side_usage"
17
18
 
@@ -351,6 +352,9 @@ module Samagotchi
351
352
  end
352
353
  end
353
354
 
355
+ # @return [String, nil] the session id #persist writes under
356
+ def session_id = @mutex.synchronize { @session_id }
357
+
354
358
  # Count one side request (a recap, a plugin's ctx.ask_model) in the
355
359
  # side block, apart from the turns' totals. Safe from any thread.
356
360
  # @param usage [SideUsage]
@@ -362,6 +366,14 @@ module Samagotchi
362
366
  end
363
367
  end
364
368
 
369
+ # The session's spend now (ctx.cost): its turns' sums with the running
370
+ # turn's finished generations folded in, and its side requests.
371
+ # Safe from any thread.
372
+ # @return [Cost]
373
+ def cost
374
+ @mutex.synchronize { Cost.of(@totals.with_running(@turn), @side) }
375
+ end
376
+
365
377
  # Event sink. Safe to call from any thread; errors are isolated by the
366
378
  # caller (SessionObserver / TerminalUI) but we still guard internally.
367
379
  # @param event [Hash]
@@ -9,6 +9,8 @@ module Samagotchi
9
9
  # why: the APIs to design before that bundle could leave chi's repo
10
10
  # (spec/samagotchi/testing/boundary_spec.rb keeps both exact).
11
11
  module Boundary
12
+ LIB_DIR = File.expand_path("../..", __dir__)
13
+
12
14
  # A name a bundle may use, and the file that defines it (a require of
13
15
  # it is fine too). A name allows itself, its methods (".x") and what
14
16
  # it holds ("::X"); one ending in a method or constant allows only that.
@@ -124,6 +126,8 @@ module Samagotchi
124
126
  REFERENCE = /\bSamagotchi(?:::[A-Z]\w*)+(?:\.[a-z_]\w*[?!]?)?/
125
127
  # A require of one of chi's files.
126
128
  REQUIRE = %r{\brequire\s*\(?\s*["'](samagotchi(?:/[\w/]+)?)["']}
129
+ # A relative require that may leave the bundle.
130
+ REQUIRE_RELATIVE = /\brequire_relative\s*\(?\s*["']([^"']+)["']/
127
131
 
128
132
  # One reference outside ALLOWED, in a file of the bundle.
129
133
  Finding = Data.define(:reference, :file)
@@ -140,11 +144,22 @@ module Samagotchi
140
144
  text = File.read(path)
141
145
  refs = text.scan(REFERENCE).reject { |name| allowed?(name) }
142
146
  refs += text.scan(REQUIRE).flatten.reject { |file| ALLOWED_REQUIRES.include?(file) }.map { |file| %(require "#{file}") }
147
+ refs += text.scan(REQUIRE_RELATIVE).flatten.filter_map do |relative|
148
+ target = File.expand_path(relative, File.dirname(path))
149
+ next unless within?(target, LIB_DIR) && !within?(target, File.expand_path(dir))
150
+
151
+ %(require_relative "#{relative}")
152
+ end
143
153
  file = path.delete_prefix("#{File.expand_path(dir)}/")
144
154
  refs.uniq.map { |reference| Finding.new(reference: reference, file: file) }
145
155
  end
146
156
  end
147
157
 
158
+ def within?(path, dir)
159
+ path == dir || path.start_with?("#{dir}#{File::SEPARATOR}")
160
+ end
161
+ private_class_method :within?
162
+
148
163
  # Whether a bundle may use +name+ ("Samagotchi::X::Y", maybe ".method").
149
164
  def allowed?(name)
150
165
  ALLOWED.any? do |entry|
@@ -26,12 +26,17 @@ module Samagotchi
26
26
  class HookFailed < StandardError
27
27
  # @return [Hooks::Failure]
28
28
  attr_reader :failure
29
-
30
- def initialize(failure)
31
- @failure = failure
32
- error = failure.error
33
- super("#{failure.hook} raised on #{failure.event}: #{error.class}: #{error.message}")
34
- set_backtrace(error.backtrace) if error.backtrace
29
+ # @return [Array<Hooks::Failure>]
30
+ attr_reader :failures
31
+
32
+ def initialize(failures)
33
+ @failures = failures.dup.freeze
34
+ @failure = @failures.first
35
+ super(@failures.map do |failure|
36
+ error = failure.error
37
+ "#{failure.hook} raised on #{failure.event}: #{error.class}: #{error.message}"
38
+ end.join("; "))
39
+ set_backtrace(@failure.error.backtrace) if @failure.error.backtrace
35
40
  end
36
41
 
37
42
  def cause = @failure.error
@@ -142,7 +147,7 @@ module Samagotchi
142
147
  @plugin = built
143
148
  end
144
149
  end
145
- @hooks_namespace = Hooks::BundleLoader.namespace_for(@name).const_set(:"KitHooks#{object_id}", Module.new)
150
+ @hooks_namespace = Module.new
146
151
  @manifest.hooks.each do |basename, hook|
147
152
  next if hook.event.empty?
148
153
 
@@ -168,6 +173,27 @@ module Samagotchi
168
173
  payload
169
174
  end
170
175
 
176
+ # Build the before_tool_call payload from the real Gate, without firing
177
+ # this bundle's hooks.
178
+ # @param call [Hash] the parsed tool call
179
+ # @param iteration [Integer]
180
+ # @param params [String] the call's one-line preview
181
+ # @param context [Guardrails::Context, nil]
182
+ # @param verdict [Verdict, nil]
183
+ # @return [Hash]
184
+ def before_tool_call_event(call, params:, iteration: 1, context: nil, verdict: nil)
185
+ context ||= Guardrails::Context.new(cwd: @recorder[:cwd], session_id: @recorder[:session_id],
186
+ interface: @recorder[:frontend])
187
+ dispatched = nil
188
+ capture = Hooks::Registry.new
189
+ capture.register(:before_tool_call) { |event| dispatched = event.dup }
190
+ gate = Guardrails::Gate.new(-> { capture }, context_lookup: -> { context }, tools_lookup: -> { tools })
191
+ gate.evaluate(call, iteration: iteration, params: params, verdict: verdict || Verdict.new(call: call))
192
+ raise ArgumentError, "guardrails did not dispatch a before_tool_call event for #{call[:name].inspect}" unless dispatched
193
+
194
+ dispatched.slice(:type, :iteration, :call, :params, :blocked, :block_reason, :guardrail, :context, :targets)
195
+ end
196
+
171
197
  # One tool call through a real Guardrails::Gate: the before_tool_call
172
198
  # hooks vote on a Testing::Verdict (pass +verdict+ to start from one,
173
199
  # another voter's deny in it), with the context and targets the Gate
@@ -331,8 +357,8 @@ module Samagotchi
331
357
  result = Hooks.capture_failures(failures, &)
332
358
  if @lenient
333
359
  @errors.concat(failures)
334
- elsif (first = failures.first)
335
- raise HookFailed, first
360
+ elsif failures.any?
361
+ raise HookFailed, failures
336
362
  end
337
363
  result
338
364
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../cancellation_controller"
4
4
  require_relative "../plugin/context"
5
+ require_relative "../session_metrics/cost"
5
6
  require_relative "recorder"
6
7
 
7
8
  module Samagotchi
@@ -48,9 +49,33 @@ module Samagotchi
48
49
  toolkit: -> { recorder[:toolkit] },
49
50
  declared_tools: -> { recorder[:declared_tools] || tool_names.call },
50
51
  use_toolkit: ->(name) { recorder.use_toolkit(name, toolkits.call) },
51
- cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil }
52
+ cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
53
+ cost: -> { cost(recorder[:cost]) }
52
54
  )
53
55
  end
56
+
57
+ # The parts a test may give bundle.set(cost: {...}).
58
+ COST_PARTS = %i[reported estimated side known].freeze
59
+
60
+ # The SessionMetrics::Cost ctx.cost returns for the recorder's +cost+
61
+ # value (Recorder::DEFAULTS).
62
+ # @return [SessionMetrics::Cost]
63
+ def cost(value)
64
+ value = value.call while value.respond_to?(:call)
65
+ case value
66
+ when nil then SessionMetrics::Cost.zero
67
+ when SessionMetrics::Cost then value
68
+ when Numeric then SessionMetrics::Cost.zero.with(reported: value.to_f)
69
+ when Hash
70
+ unknown = value.keys.map(&:to_sym) - COST_PARTS
71
+ raise ArgumentError, "unknown cost parts: #{unknown.join(", ")}" unless unknown.empty?
72
+
73
+ parts = value.transform_keys(&:to_sym)
74
+ SessionMetrics::Cost.new(reported: parts.fetch(:reported, 0).to_f, estimated: parts.fetch(:estimated, 0).to_f,
75
+ side: parts.fetch(:side, 0).to_f, known: parts.fetch(:known, true))
76
+ else raise ArgumentError, "cost: a number, a Hash or a callable, not #{value.inspect}"
77
+ end
78
+ end
54
79
  end
55
80
 
56
81
  # The hook runtime (Hooks::Runtime: event[:notify] and the other
@@ -32,7 +32,10 @@ module Samagotchi
32
32
  DEFAULTS = {
33
33
  session_id: "test-session", cwd: nil, messages: [], messages_partial: false, model_name: "test:model",
34
34
  model_key: "model", toolkit: "full", declared_tools: nil, frontend: :worker, scratch: false, setup: nil,
35
- prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true
35
+ prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true,
36
+ # ctx.cost: nil (zero), a Float (reported USD), a Hash of the parts
37
+ # (reported:, estimated:, side:, known:) or a callable giving one.
38
+ cost: nil
36
39
  }.freeze
37
40
 
38
41
  attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
@@ -14,8 +14,9 @@ module Samagotchi
14
14
  # of the child's turn the last follow-up went to (Delegate.follow_up),
15
15
  # whose own reply a wait takes (ReplyWait's turn:); nil once that turn
16
16
  # was reported, or when unknown (a new child, an older worker).
17
- DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn) do
18
- def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil) = super
17
+ # +report_claim+: a report reserved until the parent's turn is settled.
18
+ DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn, :report_claim) do
19
+ def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil, report_claim: nil) = super
19
20
 
20
21
  def self.empty = new
21
22
 
@@ -33,6 +34,13 @@ module Samagotchi
33
34
  end
34
35
 
35
36
  def to_json_hash = to_h.transform_keys(&:to_s).compact
37
+
38
+ def self.from_json_hash(data)
39
+ return empty unless data.is_a?(Hash)
40
+
41
+ new(reply_file: data["reply_file"], last_turn: data["last_turn"], question_id: data["question_id"],
42
+ messages: data["messages"], turn: data["turn"], report_claim: data["report_claim"])
43
+ end
36
44
  end
37
45
 
38
46
  # The parent's cursors, one per child, in <parent dir>/delegates.json, so
@@ -68,6 +76,40 @@ module Samagotchi
68
76
  end
69
77
  end
70
78
 
79
+ # Reserve a report only if the cursor read to build it is still current.
80
+ # @return [DelegateCursor, nil] the reserved cursor, or nil if another path moved it
81
+ def claim_report(parent_id, child_id, expected:, claim:, state_dir:)
82
+ locked(parent_id, state_dir: state_dir) do
83
+ all = read(parent_id, state_dir: state_dir)
84
+ cursor = get_from(all, child_id)
85
+ next nil unless cursor == expected
86
+
87
+ claimed = cursor.with(report_claim: claim)
88
+ all[child_id] = claimed.to_json_hash
89
+ write(parent_id, all, state_dir: state_dir)
90
+ claimed
91
+ end
92
+ end
93
+
94
+ # A new owner can retry reports left reserved by a worker that exited
95
+ # before committing or releasing its turn. No cursor file means no
96
+ # claims: the lock would create the session dir and its lock file, so
97
+ # an empty session would no longer count as empty.
98
+ def clear_report_claims(parent_id, state_dir:)
99
+ return unless File.exist?(path(parent_id, state_dir: state_dir))
100
+
101
+ locked(parent_id, state_dir: state_dir) do
102
+ all = read(parent_id, state_dir: state_dir)
103
+ changed = false
104
+ all.each_value do |data|
105
+ next unless data.is_a?(Hash)
106
+
107
+ changed = true if data.delete("report_claim")
108
+ end
109
+ write(parent_id, all, state_dir: state_dir) if changed
110
+ end
111
+ end
112
+
71
113
  # Add the cursors of +entries+ (child id → cursor fields, as #read
72
114
  # gives them) that the parent has none for; one it has is kept.
73
115
  # @return [Array<String>] the child ids added
@@ -94,11 +136,7 @@ module Samagotchi
94
136
  end
95
137
 
96
138
  def get_from(all, child_id)
97
- data = all[child_id]
98
- return DelegateCursor.empty unless data.is_a?(Hash)
99
-
100
- DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
101
- question_id: data["question_id"], messages: data["messages"], turn: data["turn"])
139
+ DelegateCursor.from_json_hash(all[child_id])
102
140
  end
103
141
 
104
142
  def path(parent_id, state_dir:)
@@ -24,6 +24,7 @@ module Samagotchi
24
24
  # the wait's first look (a fast failure) still ends it. The cursors are
25
25
  # on disk (DelegateCursors), so a respawned worker doesn't repeat a reply.
26
26
  module DelegateWait
27
+ Advance = Data.define(:cursor, :already_reported)
27
28
  TIMEOUT_DEFAULT = 600
28
29
  POLL_INTERVAL = 0.5
29
30
  # Seconds with no worker before the child counts as gone: as long as
@@ -66,6 +67,11 @@ module Samagotchi
66
67
  reports = reports_mode(peers)
67
68
  others = relay && others_for(peers.session_id, child_id, relay, sd)
68
69
  cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
70
+ report_claim = cursor.report_claim
71
+ if report_claim
72
+ cursor = DelegateCursor.from_json_hash(report_claim["cursor"]).with(report_claim: report_claim)
73
+ end
74
+
69
75
  baseline = cursor.baseline
70
76
  left = timeout.to_i
71
77
  outcomes = relayed_outcomes.delete(key) || []
@@ -78,7 +84,13 @@ module Samagotchi
78
84
  left -= monotonic - started
79
85
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
80
86
  outcomes.concat(relayed_outcomes.delete(key) || [])
81
- advance(peers.session_id, child_id, wait, state_dir: sd)
87
+ movement = advance(peers.session_id, child_id, wait, state_dir: sd)
88
+ if movement&.already_reported
89
+ file = movement.cursor.report_claim&.fetch("file", nil) || wait.file
90
+ return already_reported_result(child_id, file, state_dir: sd)
91
+ end
92
+ return already_reported_result(child_id, report_claim["file"], state_dir: sd) if report_claim && wait.status == :timeout
93
+
82
94
  text = with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
83
95
  return RefText.wrap(text, reply_path(child_id, wait, state_dir: sd))
84
96
  end
@@ -159,15 +171,37 @@ module Samagotchi
159
171
  # was reported; a question keeps it. Nothing moves on a timeout or a
160
172
  # cancel.
161
173
  # @param wait [ReplyWait::Result]
162
- # @return [DelegateCursor, nil] the new cursor
174
+ # @return [Advance, nil] the cursor and whether another path reported it
163
175
  def advance(parent_id, child_id, wait, state_dir:)
164
176
  return nil unless REPORTED.include?(wait.status) && wait.session
165
177
 
166
- DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
167
- cursor = cursor.with(reply_file: wait.file) if wait.status == :done
168
- cursor = cursor.with(turn: nil) unless wait.status == :waiting_for_answer
169
- cursor.with_baseline(ReplyWait.baseline_of(wait.session))
178
+ already_reported = false
179
+ cursor = DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |current|
180
+ if (current.report_claim && current.report_claim["file"] == wait.file) ||
181
+ (wait.status == :done && current.reply_file == wait.file)
182
+ already_reported = true
183
+ next current
184
+ end
185
+
186
+ current = current.with(reply_file: wait.file) if wait.status == :done
187
+ current = current.with(turn: nil) unless wait.status == :waiting_for_answer
188
+ current.with_baseline(ReplyWait.baseline_of(wait.session))
170
189
  end
190
+ Advance.new(cursor: cursor, already_reported: already_reported)
191
+ end
192
+
193
+ def already_reported_result(child_id, file, state_dir:)
194
+ path = if file.to_s.empty?
195
+ nil
196
+ else
197
+ File.join(ReplyWait.reply_dir(child_id, state_dir: state_dir), file)
198
+ end
199
+ text = if path
200
+ "session: #{child_id}\nstatus: reported\nThe reply was already reported to this session (file: #{File.basename(file)})."
201
+ else
202
+ "session: #{child_id}\nstatus: reported\nThe child's latest status was already reported to this session."
203
+ end
204
+ RefText.wrap(text, path)
171
205
  end
172
206
 
173
207
  # The tool result for how the wait ended.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.81.0"
4
+ VERSION = "0.83.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1143,10 +1143,12 @@ function updateSessionTitle() {
1143
1143
  }
1144
1144
 
1145
1145
  // The stage takes a running turn's rows (its extras); else the history.
1146
+ // @return true when the stage took +el+ (it scrolls it into its view itself)
1146
1147
  function appendToHistory(el) {
1147
1148
  // (The stage's place first moves a turn still sliding away into the history.)
1148
- if (turnDom.place?.(el)) return;
1149
+ if (turnDom.place?.(el)) return true;
1149
1150
  appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
1151
+ return false;
1150
1152
  }
1151
1153
 
1152
1154
  // A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
@@ -26,7 +26,8 @@ import { plainText } from "./plain_line.js";
26
26
 
27
27
  // @param sessionId () => the open session's id (answers go there)
28
28
  // @param clientId this tab's id in the session's events
29
- // @param place (card) puts a new card at the end of the history
29
+ // @param place (card) puts a new card at the end of the history; true: the
30
+ // stage took it (it brings the card into its view itself, eased)
30
31
  // @param isNearBottom () => whether the history follows its end
31
32
  // @param scrollToEnd () scrolls the history to its end
32
33
  // @param removeHintIfEmpty () drops the history's placeholder hint
@@ -200,10 +201,10 @@ export function createQuestionCards({
200
201
 
201
202
  // Read before the card makes the history taller.
202
203
  const follow = isNearBottom();
203
- place(card);
204
+ const staged = place(card) === true;
204
205
  questionEl = card;
205
206
  if (follow) scrollToEnd();
206
- focusWhenIdle(card, pq.id);
207
+ focusWhenIdle(card, pq.id, { staged });
207
208
  }
208
209
 
209
210
  function isPlainEnter(e) {
@@ -213,7 +214,10 @@ export function createQuestionCards({
213
214
  // A new card takes the focus while nobody is typing: the focus is on
214
215
  // the page or an empty composer. Checked after the caller is done, so a
215
216
  // card drawn already answered (the history replay) never takes it.
216
- function focusWhenIdle(card, id) {
217
+ // +staged+: the stage holds the card and scrolls to it once it has grown
218
+ // for it (eased): the focus doesn't scroll, or the stage would snap to the
219
+ // card before its room eases in.
220
+ function focusWhenIdle(card, id, { staged = false } = {}) {
217
221
  queueMicrotask(() => {
218
222
  if (questionEl !== card || String(pending?.id) !== String(id)) return;
219
223
  const prompt = doc.getElementById?.("prompt");
@@ -221,7 +225,8 @@ export function createQuestionCards({
221
225
  const idle = !active || active === doc.body || (active === prompt && !prompt.value);
222
226
  if (!idle || (prompt && prompt.value)) return;
223
227
  const target = card.querySelector(".question-option input") || card.querySelector(".question-freeform");
224
- target?.focus();
228
+ if (staged) target?.focus({ preventScroll: true });
229
+ else target?.focus();
225
230
  });
226
231
  }
227
232