samagotchi 0.82.0 → 0.84.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +2 -2
  4. data/bin/chi +27 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +1 -0
  7. data/docs/configuration.md +52 -9
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/memory.md +1 -1
  11. data/docs/plugins.md +85 -3
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +13 -1
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/bundle_session_settings.rb +212 -0
  16. data/lib/samagotchi/bundles/core/manifest.yml +4 -2
  17. data/lib/samagotchi/bundles/cost-cap/manifest.yml +13 -0
  18. data/lib/samagotchi/bundles/cost-cap/plugin.rb +235 -0
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/child_reports.rb +25 -8
  21. data/lib/samagotchi/engine.rb +2 -1
  22. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  23. data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
  24. data/lib/samagotchi/memory_bundle/installer.rb +2 -1
  25. data/lib/samagotchi/memory_bundle/manifest.rb +24 -2
  26. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  27. data/lib/samagotchi/plugin/context.rb +40 -3
  28. data/lib/samagotchi/reply_wait.rb +13 -15
  29. data/lib/samagotchi/send_command.rb +24 -7
  30. data/lib/samagotchi/session.rb +16 -1
  31. data/lib/samagotchi/session_metrics/cost.rb +37 -0
  32. data/lib/samagotchi/session_metrics.rb +9 -0
  33. data/lib/samagotchi/session_setup.rb +14 -6
  34. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -5
  35. data/lib/samagotchi/terminal_ui.rb +7 -2
  36. data/lib/samagotchi/testing/bundle.rb +31 -5
  37. data/lib/samagotchi/testing/host.rb +26 -1
  38. data/lib/samagotchi/testing/recorder.rb +4 -1
  39. data/lib/samagotchi/tools/delegate_cursor.rb +45 -7
  40. data/lib/samagotchi/tools/delegate_wait.rb +40 -6
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +13 -4
  43. metadata +5 -1
@@ -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.82.0"
4
+ VERSION = "0.84.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -649,6 +649,15 @@ module Samagotchi
649
649
  thinking, error = request_thinking(body["thinking"])
650
650
  return error if error
651
651
 
652
+ # bundle_settings: the session's own bundle settings (chi --set:
653
+ # {bundle => {key => scalar}}), checked against the installed
654
+ # bundles the same way.
655
+ begin
656
+ bundle_settings = BundleSessionSettings.from_request(body["bundle_settings"])
657
+ rescue BundleSessionSettings::Error => e
658
+ return error_response(400, "invalid_bundle_settings", e.message)
659
+ end
660
+
652
661
  folder = dir ? { working_directory: dir } : {}
653
662
  # model: the model the session starts on (the start page's picker,
654
663
  # spelled as GET /api/models lists it); blank means the default.
@@ -659,7 +668,7 @@ module Samagotchi
659
668
  # until that turn is saved.
660
669
  preview = idle ? body["preview"].to_s.strip : ""
661
670
  folder[:title] = preview unless preview.empty?
662
- setup = SessionSetup.new(llm_context: llm_context, thinking: thinking)
671
+ setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, bundle_settings: bundle_settings)
663
672
  folder[:setup] = setup unless setup.empty?
664
673
  begin
665
674
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
@@ -679,7 +688,7 @@ module Samagotchi
679
688
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
680
689
  # link of that session's chain (SessionManager.continue_session), in its
681
690
  # folder and with its own setup (SessionSetup: llm_context, thinking
682
- # and reasoning_history levels), so dir, llm_context and thinking are
691
+ # and reasoning_history levels, bundle settings), so dir, llm_context, thinking and bundle_settings are
683
692
  # refused beside it (a create takes no reasoning_history). It runs on
684
693
  # model (or model_name) when given, else on the previous link's: one
685
694
  # whose host is gone falls back to the default, and the 201's
@@ -694,9 +703,9 @@ module Samagotchi
694
703
  unless given.is_a?(String) && !given.strip.empty?
695
704
  return error_response(400, "invalid_continues", "continues is a session id")
696
705
  end
697
- if (fields = %w[dir llm_context thinking].select { |key| body.key?(key) }).any?
706
+ if (fields = %w[dir llm_context thinking bundle_settings].select { |key| body.key?(key) }).any?
698
707
  return error_response(400, "invalid_continues",
699
- "continues takes the previous session's folder, llm_context and thinking; " \
708
+ "continues takes the previous session's folder, llm_context, thinking and bundle_settings; " \
700
709
  "leave out #{fields.join(", ")}")
701
710
  end
702
711
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.82.0
4
+ version: 0.84.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -180,6 +180,7 @@ files:
180
180
  - lib/samagotchi/broadcast_command.rb
181
181
  - lib/samagotchi/bundle_command.rb
182
182
  - lib/samagotchi/bundle_needs.rb
183
+ - lib/samagotchi/bundle_session_settings.rb
183
184
  - lib/samagotchi/bundles/agents-md/manifest.yml
184
185
  - lib/samagotchi/bundles/agents-md/plugin.rb
185
186
  - lib/samagotchi/bundles/btw/manifest.yml
@@ -200,6 +201,8 @@ files:
200
201
  - lib/samagotchi/bundles/coordinator/plugin.rb
201
202
  - lib/samagotchi/bundles/coordinator/skill_coordinator.md
202
203
  - lib/samagotchi/bundles/core/manifest.yml
204
+ - lib/samagotchi/bundles/cost-cap/manifest.yml
205
+ - lib/samagotchi/bundles/cost-cap/plugin.rb
203
206
  - lib/samagotchi/bundles/dev/manifest.yml
204
207
  - lib/samagotchi/bundles/github-pr/manifest.yml
205
208
  - lib/samagotchi/bundles/github-pr/plugin.rb
@@ -469,6 +472,7 @@ files:
469
472
  - lib/samagotchi/session_inbox.rb
470
473
  - lib/samagotchi/session_manager.rb
471
474
  - lib/samagotchi/session_metrics.rb
475
+ - lib/samagotchi/session_metrics/cost.rb
472
476
  - lib/samagotchi/session_metrics/model_segment.rb
473
477
  - lib/samagotchi/session_metrics/side_totals.rb
474
478
  - lib/samagotchi/session_metrics/totals.rb