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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +2 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +1 -1
- data/docs/configuration.md +54 -10
- data/docs/hooks.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +58 -1
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +2 -1
- data/docs/sub-agent.md +23 -8
- data/lib/samagotchi/bundles/core/manifest.yml +4 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +11 -0
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +228 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +25 -8
- data/lib/samagotchi/cli/parent_wait.rb +9 -3
- data/lib/samagotchi/engine.rb +3 -2
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
- data/lib/samagotchi/idle_client.rb +2 -7
- data/lib/samagotchi/llm/http.rb +7 -2
- data/lib/samagotchi/parent_report.rb +7 -3
- data/lib/samagotchi/plugin/context.rb +8 -1
- data/lib/samagotchi/reply_wait.rb +66 -2
- data/lib/samagotchi/send_command.rb +47 -8
- data/lib/samagotchi/session_metrics/cost.rb +37 -0
- data/lib/samagotchi/session_metrics/side_totals.rb +16 -9
- data/lib/samagotchi/session_metrics.rb +12 -0
- data/lib/samagotchi/testing/boundary.rb +15 -0
- data/lib/samagotchi/testing/bundle.rb +35 -9
- data/lib/samagotchi/testing/host.rb +26 -1
- data/lib/samagotchi/testing/recorder.rb +4 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +45 -7
- data/lib/samagotchi/tools/delegate_wait.rb +40 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +3 -1
- data/lib/samagotchi/web/public/question_cards.js +10 -5
- 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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
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
|
|
335
|
-
raise HookFailed,
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|