samagotchi 0.87.0 → 0.89.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 +35 -1
- data/docs/bundle-authoring.md +19 -0
- data/docs/cli.md +44 -5
- data/docs/guardrails.md +6 -1
- data/docs/internals/bundles.md +10 -0
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +9 -0
- data/lib/samagotchi/bundle_command/test_run.rb +2 -2
- data/lib/samagotchi/bundle_command/try/run.rb +162 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +159 -0
- data/lib/samagotchi/bundle_command/try/trace.rb +152 -0
- data/lib/samagotchi/bundle_command.rb +216 -10
- data/lib/samagotchi/bundle_session_settings.rb +2 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +100 -7
- data/lib/samagotchi/context_providers.rb +1 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +20 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +2 -0
- data/lib/samagotchi/installed_gem.rb +11 -0
- data/lib/samagotchi/memory_bundle/asset_installer.rb +25 -13
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +22 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +21 -6
- data/lib/samagotchi/relay.rb +34 -2
- data/lib/samagotchi/relay_desk.rb +13 -0
- data/lib/samagotchi/session_manager.rb +2 -10
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +69 -15
- data/lib/samagotchi/tools/delegate_wait.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/question_card.js +2 -0
- data/lib/samagotchi/web/public/question_cards.js +25 -18
- data/lib/samagotchi/worker.rb +51 -9
- metadata +24 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../analytics_file"
|
|
5
|
+
require_relative "../../session_metrics"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
class BundleCommand
|
|
9
|
+
module Try
|
|
10
|
+
Trace = Data.define(
|
|
11
|
+
:session_id, :exit_code, :answer, :tools, :notices, :cards, :questions, :denied,
|
|
12
|
+
:interactive_blocked, :used_memories, :tokens, :cost, :cost_is_estimate, :model, :sandbox, :stderr_tail
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
class TraceReader
|
|
16
|
+
DENIAL_TEXT = "No one to approve it (non-interactive run)."
|
|
17
|
+
SESSION_ID = /\A[A-Za-z0-9_-]+\z/
|
|
18
|
+
STDERR_LIMIT = 4000
|
|
19
|
+
|
|
20
|
+
def initialize(state_home:, session_id:)
|
|
21
|
+
@state_home = state_home
|
|
22
|
+
@session_id = session_id if session_id.to_s.match?(SESSION_ID)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read(exit_code:, answer:, stderr:, sandbox:)
|
|
26
|
+
return empty_trace(exit_code, answer, stderr, sandbox) unless @session_id
|
|
27
|
+
|
|
28
|
+
sessions_dir = File.join(@state_home, "samagotchi", "sessions")
|
|
29
|
+
session_path = File.join(sessions_dir, "#{@session_id}.json")
|
|
30
|
+
session_dir = File.join(sessions_dir, @session_id)
|
|
31
|
+
session = json_file(session_path) || {}
|
|
32
|
+
messages = Array(session["messages"]).select { |message| message.is_a?(Hash) }
|
|
33
|
+
analytics = AnalyticsFile.read(session_dir) || {}
|
|
34
|
+
entries = card_entries(session_dir)
|
|
35
|
+
tool_rows, denied = tools(messages, analytics)
|
|
36
|
+
summary = SessionMetrics.saved_summary(session_dir)
|
|
37
|
+
cost, estimate = cost_summary(summary)
|
|
38
|
+
questions = entries.select { |entry| entry["type"] == "question" }
|
|
39
|
+
notices = entries.select { |entry| entry["type"] == "hook_notice" }
|
|
40
|
+
cards = entries.select { |entry| entry["type"] == "card" }
|
|
41
|
+
blocked_count = questions.count { |question| unanswerable?(question) } + denied.size
|
|
42
|
+
|
|
43
|
+
Trace.new(
|
|
44
|
+
session_id: @session_id, exit_code: exit_code, answer: answer.to_s, tools: tool_rows, notices: notices,
|
|
45
|
+
cards: cards, questions: questions, denied: denied, interactive_blocked: blocked_count.positive?,
|
|
46
|
+
used_memories: Array(session["used_memory_names"]), tokens: {
|
|
47
|
+
prompt: summary&.prompt_sum, completion: summary&.completion_sum
|
|
48
|
+
},
|
|
49
|
+
cost: cost, cost_is_estimate: estimate, model: session["model_name"], sandbox: sandbox,
|
|
50
|
+
stderr_tail: tail(stderr)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def empty_trace(exit_code, answer, stderr, sandbox)
|
|
57
|
+
Trace.new(session_id: nil, exit_code: exit_code, answer: answer.to_s, tools: [], notices: [], cards: [],
|
|
58
|
+
questions: [], denied: [], interactive_blocked: false, used_memories: [], tokens: {
|
|
59
|
+
prompt: nil, completion: nil
|
|
60
|
+
}, cost: nil, cost_is_estimate: false, model: nil, sandbox: sandbox, stderr_tail: tail(stderr))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tools(messages, analytics)
|
|
64
|
+
records = Array(analytics["tool_records"]).select { |record| record.is_a?(Hash) }
|
|
65
|
+
.to_h do |record|
|
|
66
|
+
[[record["iteration"].to_i, record["call_index"].to_i], record]
|
|
67
|
+
end
|
|
68
|
+
tool_responses = messages.select { |message| %w[tool_response tool].include?(message["role"]) }
|
|
69
|
+
denied = []
|
|
70
|
+
index = 0
|
|
71
|
+
iteration = 0
|
|
72
|
+
rows = messages.flat_map do |message|
|
|
73
|
+
next [] unless %w[model assistant].include?(message["role"])
|
|
74
|
+
|
|
75
|
+
iteration += 1
|
|
76
|
+
Array(message["tool_calls"]).each_with_index.filter_map do |call, call_index|
|
|
77
|
+
next unless call.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
index += 1
|
|
80
|
+
record = records[[iteration, call_index + 1]]
|
|
81
|
+
id = call["id"]
|
|
82
|
+
response = tool_responses.find { |entry| entry["tool_call_id"] == id } if id
|
|
83
|
+
response ||= tool_responses[index - 1] unless id
|
|
84
|
+
is_denied = response && response["content"].to_s.include?(DENIAL_TEXT)
|
|
85
|
+
denied << index if is_denied
|
|
86
|
+
status = is_denied ? "denied" : record&.dig("status").to_s
|
|
87
|
+
status = "unknown" if status.empty?
|
|
88
|
+
{
|
|
89
|
+
index: index,
|
|
90
|
+
name: record&.[]("called_as") || record&.[]("tool") || call_name(call),
|
|
91
|
+
args: argument_text(call),
|
|
92
|
+
status: status,
|
|
93
|
+
duration_ms: record&.[]("duration_ms").to_i
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
[rows, denied]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def call_name(call)
|
|
101
|
+
function = call["function"].is_a?(Hash) ? call["function"] : {}
|
|
102
|
+
call["name"] || function["name"] || "tool"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def argument_text(call)
|
|
106
|
+
function = call["function"].is_a?(Hash) ? call["function"] : {}
|
|
107
|
+
args = call["arguments"] || function["arguments"] || {}
|
|
108
|
+
args = JSON.parse(args) if args.is_a?(String)
|
|
109
|
+
if args.is_a?(Hash)
|
|
110
|
+
preferred = %w[command content path url query].filter_map { |key| args[key] }.first
|
|
111
|
+
args = preferred || JSON.generate(args)
|
|
112
|
+
end
|
|
113
|
+
args.to_s.gsub(/\s+/, " ").strip[0, 200]
|
|
114
|
+
rescue JSON::ParserError
|
|
115
|
+
args.to_s.gsub(/\s+/, " ").strip[0, 200]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def card_entries(session_dir)
|
|
119
|
+
data = json_file(File.join(session_dir, "cards.json"))
|
|
120
|
+
entries = data.is_a?(Hash) ? data["entries"] : []
|
|
121
|
+
Array(entries).select { |entry| entry.is_a?(Hash) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def unanswerable?(question)
|
|
125
|
+
question["cancelled"] && question["reason"] == "non_interactive"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def cost_summary(summary)
|
|
129
|
+
return [nil, false] unless summary
|
|
130
|
+
|
|
131
|
+
reported = summary.cost_sum.to_f
|
|
132
|
+
estimate = summary.cost_estimate_sum.to_f
|
|
133
|
+
return [reported, false] if reported.positive?
|
|
134
|
+
return [nil, false] unless estimate.positive?
|
|
135
|
+
|
|
136
|
+
[estimate, true]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def json_file(path)
|
|
140
|
+
JSON.parse(File.read(path))
|
|
141
|
+
rescue JSON::ParserError, SystemCallError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def tail(text)
|
|
146
|
+
value = text.to_s
|
|
147
|
+
value.length > STDERR_LIMIT ? value[-STDERR_LIMIT, STDERR_LIMIT] : value
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -10,7 +10,7 @@ module Samagotchi
|
|
|
10
10
|
# subcommands have their own flags).
|
|
11
11
|
class BundleCommand
|
|
12
12
|
USAGE = <<~TEXT
|
|
13
|
-
Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check> [options]
|
|
13
|
+
Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try> [options]
|
|
14
14
|
|
|
15
15
|
install <source> [--scope system|project] [--force]
|
|
16
16
|
Install a memory bundle from a directory, zip, tar archive, or git URL.
|
|
@@ -52,17 +52,26 @@ module Samagotchi
|
|
|
52
52
|
check [PATH] [--fix] [--set KEY=VALUE]
|
|
53
53
|
Check a bundle you're writing (default: the current dir): manifest, files, sha256 lines,
|
|
54
54
|
requires_chi, and an install + load in a sandbox. --fix rewrites stale sha256 lines.
|
|
55
|
+
|
|
56
|
+
try PATH -p TEXT [options]
|
|
57
|
+
Run one non-interactive model turn with a bundle installed in an isolated sandbox.
|
|
55
58
|
TEXT
|
|
56
59
|
|
|
57
60
|
# Each subcommand's --help (and only --help: -h and help are taken as
|
|
58
61
|
# the source or name).
|
|
59
62
|
INSTALL_HELP = <<~TEXT
|
|
60
63
|
Usage: chi bundle install <source> [--scope system|project] [--force]
|
|
64
|
+
chi bundle install --dev PATH
|
|
61
65
|
|
|
62
66
|
install <source> [--scope system|project] [--force]
|
|
63
67
|
Install a memory bundle from a directory, zip, tar archive, or git URL,
|
|
64
68
|
or a bundle shipped with chi by name (see: chi bundle list).
|
|
65
69
|
--force overwrites existing entries; default skips them.
|
|
70
|
+
install --dev PATH
|
|
71
|
+
Install the bundle in the local directory PATH as a link to it: its plugin, hooks, rules and scripts
|
|
72
|
+
are PATH's own files (an edit applies at the next worker or chi web restart, with no re-install) and
|
|
73
|
+
their sha256 isn't checked, for this bundle only. Its memories are copied; re-install with --force to
|
|
74
|
+
pick up memory edits. Upgrade refuses it; uninstall removes the link, never PATH's files.
|
|
66
75
|
TEXT
|
|
67
76
|
UPGRADE_HELP = "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
|
|
68
77
|
UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
|
|
@@ -108,6 +117,19 @@ module Samagotchi
|
|
|
108
117
|
--set KEY=VALUE a setting for the load (config.yml bundles: <name>: KEY), repeatable
|
|
109
118
|
Exit status: 0 no errors, 1 errors, 2 usage.
|
|
110
119
|
TEXT
|
|
120
|
+
TRY_HELP = <<~TEXT
|
|
121
|
+
Usage: chi bundle try PATH -p TEXT [--model NAME] [--memory NAME] [--mute NAME] [--toolkit NAME]
|
|
122
|
+
[--thinking LEVEL] [--set KEY=VALUE] [--json] [--keep] [--timeout SECONDS]
|
|
123
|
+
|
|
124
|
+
Install PATH and run one non-interactive turn in an isolated temporary config, state and work directory.
|
|
125
|
+
The user's hosts, keys and ordinary runtime settings are retained; their bundles, hooks, guardrails,
|
|
126
|
+
memories, prompt additions, sessions and attached context sources are not. Shipped guardrails and a
|
|
127
|
+
$1 cost cap are installed for the run. --set KEY=VALUE sets the tried bundle's config.yml bundles entry;
|
|
128
|
+
--set cost-cap.cap=N overrides the cap (unlike chi --set BUNDLE.KEY=VALUE, these are config settings).
|
|
129
|
+
--memory and --mute accept comma-separated names and repeat. --keep retains the sandbox and debug log.
|
|
130
|
+
--json prints the experimental machine-readable trace.
|
|
131
|
+
Exit status: child turn's status; 1 on install failure or timeout; 2 for usage; 130 on Ctrl-C.
|
|
132
|
+
TEXT
|
|
111
133
|
|
|
112
134
|
# The subcommands' flags: --help only; a word not starting with -- is
|
|
113
135
|
# a positional (install's source, -h included). --scope takes whatever
|
|
@@ -115,8 +137,12 @@ module Samagotchi
|
|
|
115
137
|
INSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
116
138
|
f.value "--scope"
|
|
117
139
|
f.switch "--force"
|
|
140
|
+
f.switch "--dev"
|
|
141
|
+
end
|
|
142
|
+
UNINSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
143
|
+
f.value "--scope"
|
|
144
|
+
f.switch "--force"
|
|
118
145
|
end
|
|
119
|
-
UNINSTALL_FLAGS = INSTALL_FLAGS
|
|
120
146
|
UPGRADE_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
121
147
|
f.value "--scope"
|
|
122
148
|
f.switch "--force"
|
|
@@ -135,6 +161,19 @@ module Samagotchi
|
|
|
135
161
|
f.switch "--fix"
|
|
136
162
|
f.value "--set", key: :set, repeat: true
|
|
137
163
|
end
|
|
164
|
+
TRY_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A-/) do |f|
|
|
165
|
+
f.value "-p", "--prompt", key: :prompt
|
|
166
|
+
f.value "--model"
|
|
167
|
+
f.value "--memory", repeat: true
|
|
168
|
+
f.value "--mute", repeat: true
|
|
169
|
+
f.value "--toolkit"
|
|
170
|
+
f.value "--thinking"
|
|
171
|
+
f.value "--set", repeat: true
|
|
172
|
+
f.switch "--json"
|
|
173
|
+
f.switch "--keep"
|
|
174
|
+
f.value "--timeout"
|
|
175
|
+
f.refuse "--attach", "--shared", "chi bundle try can't use --attach or --shared"
|
|
176
|
+
end
|
|
138
177
|
TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
139
178
|
f.switch "--empty"
|
|
140
179
|
f.switch "--dry-run"
|
|
@@ -169,8 +208,9 @@ module Samagotchi
|
|
|
169
208
|
when "new" then new_bundle(@argv[1..])
|
|
170
209
|
when "test" then test_bundle(@argv[1..])
|
|
171
210
|
when "check" then check(@argv[1..])
|
|
211
|
+
when "try" then try_bundle(@argv[1..])
|
|
172
212
|
else
|
|
173
|
-
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash, new, test, check"
|
|
213
|
+
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash, new, test, check, try"
|
|
174
214
|
CLI::Exit::USAGE
|
|
175
215
|
end
|
|
176
216
|
end
|
|
@@ -185,11 +225,21 @@ module Samagotchi
|
|
|
185
225
|
source = parsed.args.last
|
|
186
226
|
scope = opts[:scope]
|
|
187
227
|
force = opts.fetch(:force, false)
|
|
228
|
+
dev = opts.fetch(:dev, false)
|
|
188
229
|
if source.nil? || source.empty?
|
|
189
230
|
@stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
|
|
190
231
|
return CLI::Exit::USAGE
|
|
191
232
|
end
|
|
192
|
-
|
|
233
|
+
if dev && (Samagotchi::MemoryBundle::SourceNormalizer.git_url?(source) || !File.directory?(source))
|
|
234
|
+
@stderr.puts "chi bundle install --dev takes a local directory (the bundle's source), not #{source}"
|
|
235
|
+
return CLI::Exit::USAGE
|
|
236
|
+
end
|
|
237
|
+
expanded_source = dev ? File.realpath(source) : expand_source(source)
|
|
238
|
+
if dev && File.dirname(expanded_source) == File.realpath(Samagotchi::MemoryBundle::SourceNormalizer::SHIPPED_DIR)
|
|
239
|
+
@stderr.puts "chi bundle install --dev cannot be used with chi-shipped bundles or profiles; " \
|
|
240
|
+
"use chi bundle install #{File.basename(expanded_source)} instead"
|
|
241
|
+
return CLI::Exit::USAGE
|
|
242
|
+
end
|
|
193
243
|
if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
|
|
194
244
|
return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
|
|
195
245
|
end
|
|
@@ -198,7 +248,7 @@ module Samagotchi
|
|
|
198
248
|
return 1 unless prepared
|
|
199
249
|
return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
|
|
200
250
|
|
|
201
|
-
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
|
|
251
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dev: dev)
|
|
202
252
|
run_installer(installer, failed: "Install") do |manifest|
|
|
203
253
|
print_hooks_and_plugin(manifest)
|
|
204
254
|
print_provenance(bundle_name, manifest)
|
|
@@ -457,7 +507,7 @@ module Samagotchi
|
|
|
457
507
|
@stdout.puts "Bundle '#{name}' not installed."
|
|
458
508
|
return 0
|
|
459
509
|
end
|
|
460
|
-
@stdout.puts "Bundle: #{name} v#{st[:provenance].version} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
|
|
510
|
+
@stdout.puts "Bundle: #{name} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
|
|
461
511
|
if st[:scope_error]
|
|
462
512
|
@stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
|
|
463
513
|
else
|
|
@@ -519,12 +569,19 @@ module Samagotchi
|
|
|
519
569
|
plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
|
|
520
570
|
includes = st[:provenance].includes
|
|
521
571
|
includes_info = includes ? " includes=#{includes.join(",")}" : ""
|
|
522
|
-
@stdout.puts " #{bname} v#{st[:provenance].version} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
|
|
572
|
+
@stdout.puts " #{bname} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
|
|
523
573
|
end
|
|
524
574
|
end
|
|
525
575
|
0
|
|
526
576
|
end
|
|
527
577
|
|
|
578
|
+
# " dev → /path (missing)" when a dev install's source isn't there, else "".
|
|
579
|
+
def dev_label(record)
|
|
580
|
+
return "" unless record.dev
|
|
581
|
+
|
|
582
|
+
" dev → #{record.source}#{" (missing)" unless File.directory?(record.source)}"
|
|
583
|
+
end
|
|
584
|
+
|
|
528
585
|
# "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
|
|
529
586
|
def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
|
|
530
587
|
|
|
@@ -844,7 +901,7 @@ module Samagotchi
|
|
|
844
901
|
parsed = parse_flags(CHECK_FLAGS, rest, "check", help: CHECK_HELP)
|
|
845
902
|
return parsed if parsed.is_a?(Integer)
|
|
846
903
|
|
|
847
|
-
settings = check_settings(parsed.options.fetch(:set, [])) or return CLI::Exit::USAGE
|
|
904
|
+
settings = check_settings(parsed.options.fetch(:set, []), label: "check") or return CLI::Exit::USAGE
|
|
848
905
|
if parsed.args.size > 1
|
|
849
906
|
@stderr.puts "chi bundle check takes one PATH (#{parsed.args.join(" ")})"
|
|
850
907
|
return CLI::Exit::USAGE
|
|
@@ -858,14 +915,163 @@ module Samagotchi
|
|
|
858
915
|
Check.new(dir, fix: parsed.options.fetch(:fix, false), settings: settings, stdout: @stdout).run
|
|
859
916
|
end
|
|
860
917
|
|
|
918
|
+
def try_bundle(rest)
|
|
919
|
+
parsed = parse_flags(TRY_FLAGS, rest, "try", help: TRY_HELP)
|
|
920
|
+
return parsed if parsed.is_a?(Integer)
|
|
921
|
+
|
|
922
|
+
options = parsed.options
|
|
923
|
+
if options[:prompt].nil? || parsed.args.size != 1
|
|
924
|
+
@stderr.puts "Usage: chi bundle try PATH -p TEXT [options]"
|
|
925
|
+
return CLI::Exit::USAGE
|
|
926
|
+
end
|
|
927
|
+
path = parsed.args.first
|
|
928
|
+
unless File.directory?(path)
|
|
929
|
+
@stderr.puts "chi bundle try: #{path} isn't a folder"
|
|
930
|
+
return CLI::Exit::USAGE
|
|
931
|
+
end
|
|
932
|
+
if options.values_at(:model, :toolkit, :thinking, :memory, :mute).flatten.compact.any?(&:empty?)
|
|
933
|
+
@stderr.puts "chi bundle try: model, toolkit, thinking, memory and mute values cannot be empty"
|
|
934
|
+
return CLI::Exit::USAGE
|
|
935
|
+
end
|
|
936
|
+
require_relative "thinking"
|
|
937
|
+
if options[:thinking] && !Thinking::LEVELS.map(&:to_s).include?(options[:thinking])
|
|
938
|
+
@stderr.puts "chi bundle try: --thinking takes #{Thinking::LEVELS.join(", ")}, not #{options[:thinking]}"
|
|
939
|
+
return CLI::Exit::USAGE
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
timeout = parse_try_timeout(options[:timeout])
|
|
943
|
+
return CLI::Exit::USAGE unless timeout
|
|
944
|
+
|
|
945
|
+
set_pairs = []
|
|
946
|
+
cost_cap = 1
|
|
947
|
+
cost_cap_override = false
|
|
948
|
+
require_relative "bundle_session_settings"
|
|
949
|
+
Array(options[:set]).each do |pair|
|
|
950
|
+
key, sep, value = pair.partition("=")
|
|
951
|
+
if sep.empty?
|
|
952
|
+
@stderr.puts "chi bundle try: --set takes KEY=VALUE, not #{pair}"
|
|
953
|
+
return CLI::Exit::USAGE
|
|
954
|
+
end
|
|
955
|
+
if key == "cost-cap.cap"
|
|
956
|
+
cost_cap = BundleSessionSettings.typed(value)
|
|
957
|
+
cost_cap_override = true
|
|
958
|
+
unless (cost_cap.is_a?(Numeric) && cost_cap >= 0) || cost_cap == false
|
|
959
|
+
@stderr.puts "chi bundle try: --set cost-cap.cap takes a non-negative number or off"
|
|
960
|
+
return CLI::Exit::USAGE
|
|
961
|
+
end
|
|
962
|
+
else
|
|
963
|
+
set_pairs << pair
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
settings = check_settings(set_pairs, label: "try")
|
|
967
|
+
return CLI::Exit::USAGE unless settings
|
|
968
|
+
|
|
969
|
+
require_relative "bundle_command/try/sandbox"
|
|
970
|
+
require_relative "bundle_command/try/run"
|
|
971
|
+
require_relative "bundle_command/try/trace"
|
|
972
|
+
sandbox = nil
|
|
973
|
+
begin
|
|
974
|
+
sandbox = Try::Sandbox.new(path, bundle_settings: settings, cost_cap: cost_cap,
|
|
975
|
+
cost_cap_override: cost_cap_override)
|
|
976
|
+
sandbox.install
|
|
977
|
+
result = Try::Run.new(sandbox).run(prompt: options[:prompt], options: options, timeout: timeout)
|
|
978
|
+
trace = Try::TraceReader.new(state_home: sandbox.state_home, session_id: result.session_id)
|
|
979
|
+
.read(exit_code: result.exit_status, answer: result.stdout, stderr: result.stderr,
|
|
980
|
+
sandbox: options[:keep] ? sandbox.root : nil)
|
|
981
|
+
render_try_trace(trace, json: options[:json], child_stderr: result.stderr)
|
|
982
|
+
@stderr.puts "chi bundle try: timed out after #{timeout} seconds" if result.timed_out
|
|
983
|
+
result.exit_status
|
|
984
|
+
rescue Try::Sandbox::Error => e
|
|
985
|
+
@stderr.puts e.message
|
|
986
|
+
e.status
|
|
987
|
+
ensure
|
|
988
|
+
report_try_sandbox(sandbox) if options[:keep] && sandbox
|
|
989
|
+
sandbox&.cleanup unless options[:keep]
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
def render_try_trace(trace, json:, child_stderr:)
|
|
994
|
+
error_lines = child_stderr.lines.reject { |line| line.start_with?("Session: ") }
|
|
995
|
+
@stderr.write(error_lines.join)
|
|
996
|
+
if json
|
|
997
|
+
render_trace_lines(trace, @stderr, include_answer: false)
|
|
998
|
+
@stdout.puts JSON.generate(trace.to_h.transform_keys(&:to_s))
|
|
999
|
+
else
|
|
1000
|
+
render_trace_lines(trace, @stdout, include_answer: true)
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def report_try_sandbox(sandbox)
|
|
1005
|
+
@stderr.puts "sandbox: #{sandbox.root}"
|
|
1006
|
+
@stderr.puts "debug log: #{File.join(sandbox.state_home, "samagotchi", "samagotchi.log")}"
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
def render_trace_lines(trace, output, include_answer:)
|
|
1010
|
+
output.puts "tools:"
|
|
1011
|
+
trace.tools.each do |tool|
|
|
1012
|
+
output.puts format(" %d %-16s %-7s %dms %s", tool[:index], tool[:name], tool[:status],
|
|
1013
|
+
tool[:duration_ms], JSON.generate(tool[:args].to_s[0, 60]))
|
|
1014
|
+
end
|
|
1015
|
+
trace.notices.each { |notice| output.puts "notices: [#{notice["hook"] || "hook"}] #{notice["text"]}" }
|
|
1016
|
+
trace.cards.each do |card|
|
|
1017
|
+
output.puts "card: #{[card["title"], card["body"]].compact.join(" — ")}"
|
|
1018
|
+
end
|
|
1019
|
+
trace.questions.each do |entry|
|
|
1020
|
+
question = entry.dig("pending_question", "question").to_s
|
|
1021
|
+
outcome = if entry["cancelled"] && entry["reason"] == "non_interactive"
|
|
1022
|
+
"unanswerable (non_interactive)"
|
|
1023
|
+
elsif entry["cancelled"]
|
|
1024
|
+
"cancelled (#{entry["reason"]})"
|
|
1025
|
+
elsif entry["answer"]
|
|
1026
|
+
"answered"
|
|
1027
|
+
else
|
|
1028
|
+
"unanswered"
|
|
1029
|
+
end
|
|
1030
|
+
output.puts "questions: #{JSON.generate(question)} -> #{outcome}"
|
|
1031
|
+
end
|
|
1032
|
+
if trace.interactive_blocked
|
|
1033
|
+
count = trace.denied.size + trace.questions.count { |question| question["cancelled"] && question["reason"] == "non_interactive" }
|
|
1034
|
+
output.puts "warn: #{count} question(s)/call(s) couldn't be answered"
|
|
1035
|
+
end
|
|
1036
|
+
if include_answer
|
|
1037
|
+
answer_lines = trace.answer.lines
|
|
1038
|
+
output.puts "answer:"
|
|
1039
|
+
answer_lines.first(10).each { |line| output.puts " #{line.chomp}" }
|
|
1040
|
+
output.puts " … (truncated)" if answer_lines.size > 10
|
|
1041
|
+
end
|
|
1042
|
+
tokens = trace.tokens
|
|
1043
|
+
prompt = tokens[:prompt] || "unknown"
|
|
1044
|
+
completion = tokens[:completion] || "unknown"
|
|
1045
|
+
cost = if trace.cost
|
|
1046
|
+
"#{"~" if trace.cost_is_estimate}$#{format("%.4f", trace.cost)}"
|
|
1047
|
+
else
|
|
1048
|
+
"unknown"
|
|
1049
|
+
end
|
|
1050
|
+
output.puts "tokens: #{prompt} in / #{completion} out cost #{cost} exit #{trace.exit_code}" \
|
|
1051
|
+
"#{" (#{trace.session_id})" if trace.session_id}"
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def parse_try_timeout(value)
|
|
1055
|
+
return 300 unless value
|
|
1056
|
+
|
|
1057
|
+
timeout = Float(value)
|
|
1058
|
+
return timeout if timeout.positive? && timeout.finite?
|
|
1059
|
+
|
|
1060
|
+
@stderr.puts "chi bundle try: --timeout must be a positive number"
|
|
1061
|
+
nil
|
|
1062
|
+
rescue ArgumentError
|
|
1063
|
+
@stderr.puts "chi bundle try: --timeout must be a positive number"
|
|
1064
|
+
nil
|
|
1065
|
+
end
|
|
1066
|
+
|
|
861
1067
|
# --set KEY=VALUE values as typed (BundleSessionSettings.typed), or nil
|
|
862
1068
|
# after saying what's wrong.
|
|
863
|
-
def check_settings(pairs)
|
|
1069
|
+
def check_settings(pairs, label:)
|
|
864
1070
|
require_relative "bundle_session_settings"
|
|
865
1071
|
pairs.each_with_object({}) do |pair, out|
|
|
866
1072
|
key, sep, value = pair.partition("=")
|
|
867
1073
|
unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
|
|
868
|
-
@stderr.puts "chi bundle
|
|
1074
|
+
@stderr.puts "chi bundle #{label}: --set takes KEY=VALUE, not #{pair}"
|
|
869
1075
|
return nil
|
|
870
1076
|
end
|
|
871
1077
|
out[key] = BundleSessionSettings.typed(value)
|
|
@@ -176,6 +176,8 @@ module Samagotchi
|
|
|
176
176
|
file = File.join(dir, name, "plugin", bundle.plugin.file.to_s)
|
|
177
177
|
return "the file is missing" unless File.file?(file)
|
|
178
178
|
|
|
179
|
+
return nil if MemoryBundle::Provenance.unchecked?(bundle.plugin.sha256)
|
|
180
|
+
|
|
179
181
|
expected = MemoryBundle::Provenance.recorded_sha(bundle.plugin.sha256)
|
|
180
182
|
return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
|
|
181
183
|
|
|
@@ -130,7 +130,13 @@ module Samagotchi
|
|
|
130
130
|
# (DelegateWait.finish, its report variant), the cursor it moves the child to,
|
|
131
131
|
# and the cursor it was read from (a commit after the model moved it
|
|
132
132
|
# itself, with delegate_result, leaves it alone).
|
|
133
|
-
|
|
133
|
+
# +relay+: the child's question when it goes to the parent's user
|
|
134
|
+
# (Tools::DelegateRelay.relay?): #resolve relays it on the turn thread
|
|
135
|
+
# and gives the report its outcome line; +text+ is the question's
|
|
136
|
+
# report until then (what a parent with no relay gets).
|
|
137
|
+
Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id, :relay) do
|
|
138
|
+
def initialize(relay: nil, **) = super
|
|
139
|
+
|
|
134
140
|
def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
|
|
135
141
|
def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
|
|
136
142
|
end
|
|
@@ -146,14 +152,19 @@ module Samagotchi
|
|
|
146
152
|
Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
|
|
147
153
|
# child id → the Report this turn took for it (rings merged).
|
|
148
154
|
@taken = {}
|
|
155
|
+
# child id → the question id whose relay card a Stop closed (#hold_back).
|
|
156
|
+
@held = {}
|
|
149
157
|
end
|
|
150
158
|
|
|
159
|
+
# The user's next input: questions held back after a Stop are offered again.
|
|
160
|
+
def release_holds = @held.clear
|
|
161
|
+
|
|
151
162
|
# Whether a ring this turn hasn't taken waits (the idle loop's check).
|
|
152
163
|
def waiting?
|
|
153
164
|
return false if ChildRing.mode == "off"
|
|
154
165
|
|
|
155
166
|
taken = taken_rings
|
|
156
|
-
ring_files.any? { |file| !taken.include?(file) }
|
|
167
|
+
ring_files.any? { |file| !taken.include?(file) && !held?(file) }
|
|
157
168
|
end
|
|
158
169
|
|
|
159
170
|
# The reports for rings this turn hasn't taken yet, recorded as taken.
|
|
@@ -164,7 +175,7 @@ module Samagotchi
|
|
|
164
175
|
return [] if ChildRing.mode == "off"
|
|
165
176
|
|
|
166
177
|
taken = taken_rings
|
|
167
|
-
fresh = ring_files.reject { |file| taken.include?(file) }
|
|
178
|
+
fresh = ring_files.reject { |file| taken.include?(file) || held?(file) }
|
|
168
179
|
by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
|
|
169
180
|
by_child.filter_map do |child_id, rings|
|
|
170
181
|
report = child_id && report_for(child_id, rings)
|
|
@@ -207,8 +218,79 @@ module Samagotchi
|
|
|
207
218
|
@taken = {}
|
|
208
219
|
end
|
|
209
220
|
|
|
221
|
+
# A report whose question goes to the parent's user, settled: relayed
|
|
222
|
+
# through +relay+ (DelegateRelay.call: blocks until the card is
|
|
223
|
+
# answered, closed or the turn stopped) and given the outcome line as
|
|
224
|
+
# its text. Runs on the turn thread, before the boundary merges it.
|
|
225
|
+
# The child's question answered since the ring was taken opens no card;
|
|
226
|
+
# nor does one already answered on a relay (RelayDesk#settled?).
|
|
227
|
+
# @param relay [#open_question, #relay_desk, nil] Engine#relay_peer;
|
|
228
|
+
# nil: the report's question text, as without P4's relay
|
|
229
|
+
# @return [Report, nil] nil when the parent's turn was stopped with the
|
|
230
|
+
# card open: the report is released (its ring and the cursor stay), so
|
|
231
|
+
# the next turn offers it again
|
|
232
|
+
def resolve(report, relay:)
|
|
233
|
+
return report unless report.relay
|
|
234
|
+
return report.with(relay: nil) unless relay
|
|
235
|
+
|
|
236
|
+
question = report.relay
|
|
237
|
+
what = question.header.to_s.empty? ? question.hook.to_s : question.header.to_s
|
|
238
|
+
line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
|
|
239
|
+
Tools::DelegateRelay.line(what, "answered on the child", hook: true)
|
|
240
|
+
elsif relay.relay_desk.settled?(report.child_id, question.id)
|
|
241
|
+
Tools::DelegateRelay.line(what, "answered on a relay already; it waits for the user on the child", hook: true)
|
|
242
|
+
else
|
|
243
|
+
outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
|
|
244
|
+
return hold_back(report) if outcome.stopped
|
|
245
|
+
|
|
246
|
+
outcome.line
|
|
247
|
+
end
|
|
248
|
+
resolved = report.with(relay: nil, text: relayed_text(report.child_id, line))
|
|
249
|
+
@taken[report.child_id] = resolved if @taken[report.child_id]&.claim_id == report.claim_id
|
|
250
|
+
resolved
|
|
251
|
+
rescue ArgumentError => e
|
|
252
|
+
# The child is gone (or a bug): its question's report, as without a relay.
|
|
253
|
+
Log.info(:worker, "delegate_relay_unresolved", child: report.child_id[0, 8], error: e.message)
|
|
254
|
+
report.with(relay: nil)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# A relay report the parent's turn stopped on, or one behind it in the
|
|
258
|
+
# same take: released (its ring and the cursor stay) and held back, so
|
|
259
|
+
# no wake turn offers that question again until the user's next input
|
|
260
|
+
# (#release_holds); other children's rings go on.
|
|
261
|
+
# @return [nil]
|
|
262
|
+
def hold_back(report)
|
|
263
|
+
@held[report.child_id] = report.relay.id.to_s
|
|
264
|
+
release_report(report)
|
|
265
|
+
end
|
|
266
|
+
|
|
210
267
|
private
|
|
211
268
|
|
|
269
|
+
# A ring of a child whose held-back question still waits.
|
|
270
|
+
def held?(file)
|
|
271
|
+
child_id = SessionInbox.read_ring(file)&.dig(:child_id)
|
|
272
|
+
question_id = @held[child_id] or return false
|
|
273
|
+
Session.load(child_id, state_dir: @state_dir).pending_question&.id.to_s == question_id
|
|
274
|
+
rescue ArgumentError
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# A relayed question's report: the outcome line, then the next reply's
|
|
279
|
+
# promise (as a report's question text ends).
|
|
280
|
+
def relayed_text(child_id, line)
|
|
281
|
+
Tools::DelegateWait.result(child_id, "question", "#{line}\n#{Tools::DelegateWait::REPORT_NEXT_REPLY}")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# One taken report back: its cursor claim goes, its rings stay untaken.
|
|
285
|
+
# @return [nil]
|
|
286
|
+
def release_report(report)
|
|
287
|
+
@taken.delete(report.child_id)
|
|
288
|
+
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
289
|
+
cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
|
|
290
|
+
end
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
|
|
212
294
|
def taken_rings
|
|
213
295
|
@taken.values.flat_map(&:rings)
|
|
214
296
|
end
|
|
@@ -260,22 +342,33 @@ module Samagotchi
|
|
|
260
342
|
# a later wait on it would repeat its reply.
|
|
261
343
|
after = after.with(report_claim: claim)
|
|
262
344
|
text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
|
|
263
|
-
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id
|
|
345
|
+
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id,
|
|
346
|
+
relay: relay?(wait) ? wait.question : nil)
|
|
264
347
|
rescue ArgumentError
|
|
265
348
|
nil # the child is gone
|
|
266
349
|
end
|
|
267
350
|
|
|
268
351
|
# A reply, a turn that left none, a crash, or a question the parent's
|
|
269
|
-
# model decides (its own ask_user_question, the step-limit continue
|
|
270
|
-
#
|
|
352
|
+
# model decides (its own ask_user_question, the step-limit continue, a
|
|
353
|
+
# hook's question). Approvals are the user's (the relay, while the
|
|
354
|
+
# parent waits; else on the child's card).
|
|
271
355
|
def reportable?(wait)
|
|
272
356
|
case wait.status
|
|
273
357
|
when :done, :no_reply, :error then true
|
|
274
|
-
when :waiting_for_answer then
|
|
358
|
+
when :waiting_for_answer then REPORTED_KINDS.include?(wait.question&.kind&.to_s)
|
|
275
359
|
else false
|
|
276
360
|
end
|
|
277
361
|
end
|
|
278
362
|
|
|
363
|
+
REPORTED_KINDS = [nil, "", "continue", Tools::DelegateRelay::HOOK_KIND].freeze
|
|
364
|
+
|
|
365
|
+
# Whether the child's question goes to this parent's user rather than
|
|
366
|
+
# its model (Tools::DelegateRelay.relay?, under the child's strategy).
|
|
367
|
+
def relay?(wait)
|
|
368
|
+
wait.status == :waiting_for_answer && wait.question.kind.to_s == Tools::DelegateRelay::HOOK_KIND &&
|
|
369
|
+
Tools::DelegateRelay.relay?(wait.question, strategy: Guardrails::ParentPolicy.for_session(wait.session))
|
|
370
|
+
end
|
|
371
|
+
|
|
279
372
|
def delete_rings(rings)
|
|
280
373
|
rings.each { |file| FileUtils.rm_f(file) }
|
|
281
374
|
end
|