samagotchi 0.85.0 → 0.87.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 +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +51 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +112 -39
- data/docs/hooks.md +19 -3
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +19 -7
- data/docs/sub-agent.md +39 -25
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge.rb +22 -11
- data/lib/samagotchi/bridge_client.rb +2 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +3 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +4 -4
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +5 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_answers_command.rb +80 -0
- data/lib/samagotchi/parent_report.rb +122 -30
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +11 -5
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +39 -25
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +14 -5
- data/lib/samagotchi/send_command.rb +103 -12
- data/lib/samagotchi/session.rb +26 -2
- data/lib/samagotchi/session_commands.rb +45 -8
- data/lib/samagotchi/session_setup.rb +10 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +20 -11
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +16 -7
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -10
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/stream_handlers.js +1 -1
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +27 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -525,8 +525,11 @@ module Samagotchi
|
|
|
525
525
|
end
|
|
526
526
|
|
|
527
527
|
# The payload a :generation_chunk event carries: llama.cpp's last chunk
|
|
528
|
-
# echoes the whole rendered prompt, which nothing reads
|
|
529
|
-
#
|
|
528
|
+
# echoes the whole rendered prompt, which nothing reads. SSE clients get
|
|
529
|
+
# no payload at all (Bridge::ClientEvent), but the event is still written
|
|
530
|
+
# to the debug log and handed to in-process observers (the ring, which
|
|
531
|
+
# `?raw=1` replays, SessionMetrics, hooks), so the prompt stays out of
|
|
532
|
+
# it (the kernel's own reads keep the full payload).
|
|
530
533
|
def event_payload(payload)
|
|
531
534
|
payload.is_a?(Hash) ? payload.except("prompt") : payload
|
|
532
535
|
end
|
|
@@ -102,7 +102,7 @@ module Samagotchi
|
|
|
102
102
|
|
|
103
103
|
# +event+ as on_delta passes it on: one that carries usage gets the
|
|
104
104
|
# usage so far in the OpenAI shape as its `usage` (SessionMetrics,
|
|
105
|
-
# ContextStatus and
|
|
105
|
+
# ContextStatus and the Bridge's chunk `counts` read that).
|
|
106
106
|
def with_usage(event)
|
|
107
107
|
return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
|
|
108
108
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module MemoryBundle
|
|
8
|
+
# A bundle manifest's sha256 lines, recomputed from the files they name:
|
|
9
|
+
# `rake bundles:sha` for the shipped bundles and `chi bundle check --fix`
|
|
10
|
+
# for anyone's. Plain Ruby (no chi requires), so the release tools load
|
|
11
|
+
# it on their own.
|
|
12
|
+
module Shas
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
# A sha line that doesn't match its file: +old+ is the hex the line
|
|
16
|
+
# holds ("" for a placeholder `sha256: sha256:`), nil when the entry
|
|
17
|
+
# has no sha line at all (a plugin:, hooks: or scripts: entry the
|
|
18
|
+
# installer then takes unchecked; .refresh can't write one).
|
|
19
|
+
Stale = Data.define(:file, :old, :new)
|
|
20
|
+
|
|
21
|
+
# What may follow a sha value on its line: a comment, then the line end
|
|
22
|
+
# (\r\n kept as it was).
|
|
23
|
+
TAIL = /(?:[ \t]+#[^\r\n]*)?[ \t]*\r?\n?\z/
|
|
24
|
+
# A sha value with what follows it, for the rewrite.
|
|
25
|
+
VALUE = /sha256:(\h*)((?:[ \t]+#[^\r\n]*)?)[ \t]*(\r?\n?)\z/
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# The manifest text with every sha256 line recomputed from the file it
|
|
30
|
+
# names, the layout otherwise untouched. The shapes:
|
|
31
|
+
# files: a.md: sha256:<hex> (dir/a.md)
|
|
32
|
+
# a.md:\n sha256: sha256:<hex> (dir/a.md, with description:)
|
|
33
|
+
# hooks: h.rb:\n sha256: sha256:<hex> (dir/hooks/h.rb)
|
|
34
|
+
# plugin: file: p.rb\n sha256: sha256:<hex> (dir/p.rb)
|
|
35
|
+
# scripts: s.rb: sha256:<hex> (dir/scripts/s.rb)
|
|
36
|
+
# Returns [text, [[relative file, old sha, new sha], ...]] for the lines
|
|
37
|
+
# that changed. +text+ defaults to dir/manifest.yml. A named file that's
|
|
38
|
+
# missing raises, and so does a files: entry none of those shapes
|
|
39
|
+
# matched (a quoted sha, a flow mapping), which would otherwise be
|
|
40
|
+
# skipped without a word.
|
|
41
|
+
def refresh(dir, text = File.read(File.join(dir, "manifest.yml")))
|
|
42
|
+
text, changes, = scan(dir, text)
|
|
43
|
+
[text, changes]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The stale sha lines of the bundle at +dir+, then the plugin:, hooks:
|
|
47
|
+
# and scripts: entries with no sha line, as [Stale].
|
|
48
|
+
def stale(dir, text = File.read(File.join(dir, "manifest.yml")))
|
|
49
|
+
_, changes, unlined = scan(dir, text)
|
|
50
|
+
changes.map { |file, old, new| Stale.new(file, old, new) } +
|
|
51
|
+
unlined.map { |file| Stale.new(file, nil, digest(dir, file)) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def scan(dir, text)
|
|
55
|
+
parsed = YAML.safe_load(parseable(text)) || {}
|
|
56
|
+
plugin_file = parsed.dig("plugin", "file")
|
|
57
|
+
top = sub = nil
|
|
58
|
+
changes = []
|
|
59
|
+
seen = []
|
|
60
|
+
lines = text.lines.map do |line|
|
|
61
|
+
if line.match?(/\A[^\s#]/)
|
|
62
|
+
top = line[/\A([^\s#][^:]*):/, 1]
|
|
63
|
+
sub = nil
|
|
64
|
+
end
|
|
65
|
+
sub = line[/\A ([^\s#][^:]*):/, 1] if line.match?(/\A [^\s#]/)
|
|
66
|
+
file, prefix = sha_line(line, top, sub, plugin_file)
|
|
67
|
+
next line unless file
|
|
68
|
+
|
|
69
|
+
seen << file
|
|
70
|
+
_, old, comment, eol = line.match(VALUE).to_a
|
|
71
|
+
new = digest(dir, file)
|
|
72
|
+
changes << [file, old, new] unless old == new
|
|
73
|
+
"#{prefix}sha256:#{new}#{comment}#{eol}"
|
|
74
|
+
end
|
|
75
|
+
unread = keys(parsed["files"]) - seen
|
|
76
|
+
unless unread.empty?
|
|
77
|
+
raise Error, "#{File.basename(dir)}/manifest.yml: no sha line to refresh for files: #{unread.join(", ")} " \
|
|
78
|
+
"(write `name: sha256:<hex>`, or a mapping with an unquoted `sha256: sha256:<hex>` line)"
|
|
79
|
+
end
|
|
80
|
+
[lines.join, changes, coded(parsed, plugin_file) - seen]
|
|
81
|
+
end
|
|
82
|
+
private_class_method :scan
|
|
83
|
+
|
|
84
|
+
# +text+ with each placeholder `sha256: sha256:` given a hex digit: a
|
|
85
|
+
# value that ends in a colon isn't YAML. Manifest readers that should
|
|
86
|
+
# take a placeholder (`chi bundle check`) parse this text.
|
|
87
|
+
def parseable(text) = text.gsub(/^(\s*(?:["']?[^"':\s][^"':]*["']?: )?)sha256:(?=[ \t]*(?:#[^\r\n]*)?\r?$)/, "\\1sha256:0")
|
|
88
|
+
|
|
89
|
+
# The code files the manifest names (plugin, hooks, scripts), as paths
|
|
90
|
+
# relative to the bundle dir.
|
|
91
|
+
def coded(parsed, plugin_file)
|
|
92
|
+
(plugin_file.is_a?(String) ? [plugin_file] : []) +
|
|
93
|
+
keys(parsed["hooks"]).map { |h| File.join("hooks", h) } +
|
|
94
|
+
keys(parsed["scripts"]).map { |s| File.join("scripts", s) }
|
|
95
|
+
end
|
|
96
|
+
private_class_method :coded
|
|
97
|
+
|
|
98
|
+
def keys(section) = section.is_a?(Hash) ? section.keys.map(&:to_s) : []
|
|
99
|
+
private_class_method :keys
|
|
100
|
+
|
|
101
|
+
def digest(dir, file)
|
|
102
|
+
path = File.join(dir, file)
|
|
103
|
+
raise Error, "#{File.basename(dir)}/manifest.yml names #{file}, which isn't there" unless File.file?(path)
|
|
104
|
+
|
|
105
|
+
Digest::SHA256.hexdigest(File.binread(path))
|
|
106
|
+
end
|
|
107
|
+
private_class_method :digest
|
|
108
|
+
|
|
109
|
+
# [file relative to the bundle dir, the line up to its sha256: value] for a
|
|
110
|
+
# sha line, nil for any other line.
|
|
111
|
+
def sha_line(line, top, sub, plugin_file)
|
|
112
|
+
case top
|
|
113
|
+
when "files"
|
|
114
|
+
m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and return sub && [sub.delete("\"'"), m[1]]
|
|
115
|
+
m = line.match(/\A( ["']?([^"':]+)["']?: )sha256:\h*#{TAIL}/) and [m[2], m[1]]
|
|
116
|
+
when "hooks"
|
|
117
|
+
m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and sub and [File.join("hooks", sub.delete("\"'")), m[1]]
|
|
118
|
+
when "plugin"
|
|
119
|
+
m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and plugin_file and [plugin_file, m[1]]
|
|
120
|
+
when "scripts"
|
|
121
|
+
m = line.match(/\A( ["']?([^"':]+)["']?: )sha256:\h*#{TAIL}/) and [File.join("scripts", m[2]), m[1]]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
private_class_method :sha_line
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/parent_policy"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# /parent-answers, the session's own guardrails.parent_answers
|
|
7
|
+
# (Session#parent_answers): with no argument, the level that applies, the
|
|
8
|
+
# session's own and config.yml's; `/parent-answers LEVEL` (hard, standard,
|
|
9
|
+
# soft) or `default` (none of its own) sets it, saved at once, for the next
|
|
10
|
+
# answer a parent gives. It may only tighten config.yml's
|
|
11
|
+
# (Guardrails::ParentPolicy.effective). Tightening goes from anyone;
|
|
12
|
+
# loosening (toward soft, or default) only from a user at a UI: the REPL,
|
|
13
|
+
# an attached TUI, the web (+user_ui+). A parent agent's line (chi send,
|
|
14
|
+
# a piped or marked attach, a delegate's follow-up) is refused.
|
|
15
|
+
class ParentAnswersCommand
|
|
16
|
+
NAME = "/parent-answers"
|
|
17
|
+
USAGE = "usage: #{NAME} [hard|standard|soft|default]".freeze
|
|
18
|
+
REFUSED = "loosening parent_answers is the user's: #{NAME} in chi --attach, the REPL or the web " \
|
|
19
|
+
"(not chi send or a parent agent)".freeze
|
|
20
|
+
|
|
21
|
+
# What +args+ (the text after the name) asks for.
|
|
22
|
+
# @return [Array] [:show], [:set, level or nil (default)] or [:usage]
|
|
23
|
+
def self.parse(args)
|
|
24
|
+
words = args.to_s.split
|
|
25
|
+
return [:show] if words.empty?
|
|
26
|
+
return [:usage] if words.size > 1
|
|
27
|
+
|
|
28
|
+
word = words.first.downcase
|
|
29
|
+
return [:set, nil] if word == Guardrails::ParentPolicy::UNSET
|
|
30
|
+
|
|
31
|
+
Guardrails::ParentPolicy::ORDER.include?(word) ? [:set, word] : [:usage]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether the command line +line+ loosens a session whose own value is
|
|
35
|
+
# +current+ (chi send's early refusal, before it sends the line).
|
|
36
|
+
def self.loosens?(line, current)
|
|
37
|
+
kind, value = parse(line.to_s.strip.delete_prefix(NAME))
|
|
38
|
+
kind == :set && Guardrails::ParentPolicy.looser?(current, value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param engine [Engine] #session
|
|
42
|
+
# @param save [#call] saves the session after a change
|
|
43
|
+
def initialize(engine:, save:)
|
|
44
|
+
@engine = engine
|
|
45
|
+
@save = save
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param args [String] what follows /parent-answers
|
|
49
|
+
# @param user_ui [Boolean] a user at a UI typed it: it may loosen
|
|
50
|
+
# @return [Array(String, Boolean, Symbol)] the reply, whether the
|
|
51
|
+
# session's value changed, and :ok or :error
|
|
52
|
+
def run(args, user_ui:)
|
|
53
|
+
session = @engine.session or return ["no session yet", false, :error]
|
|
54
|
+
kind, value = self.class.parse(args)
|
|
55
|
+
return [USAGE, false, :error] if kind == :usage
|
|
56
|
+
return [listing(session), false, :ok] if kind == :show
|
|
57
|
+
return ["#{listing(session)}\n(no change)", false, :ok] if value == session.parent_answers
|
|
58
|
+
return [REFUSED, false, :error] if Guardrails::ParentPolicy.looser?(session.parent_answers, value) && !user_ui
|
|
59
|
+
|
|
60
|
+
session.parent_answers = value
|
|
61
|
+
@save.call(session)
|
|
62
|
+
capped = Guardrails::ParentPolicy.capped_note(value)
|
|
63
|
+
[[listing(session), ("(#{capped})" if capped)].compact.join("\n"), true, :ok]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# "parent answers: standard (this session: standard; config.yml
|
|
69
|
+
# guardrails.parent_answers: soft)"
|
|
70
|
+
def listing(session)
|
|
71
|
+
config = Guardrails::ParentPolicy.current
|
|
72
|
+
effective = Guardrails::ParentPolicy.for_session(session)
|
|
73
|
+
own = session.parent_answers || "none"
|
|
74
|
+
# A hand-edited value that isn't a level: fail-closed, said here.
|
|
75
|
+
own = "#{own} (unreadable value, read as hard)" if session.parent_answers &&
|
|
76
|
+
!Guardrails::ParentPolicy::ORDER.include?(session.parent_answers)
|
|
77
|
+
"parent answers: #{effective.name} (this session: #{own}; config.yml #{config.label})"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -5,6 +5,8 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
require_relative "cli/exit"
|
|
7
7
|
require_relative "pending_question"
|
|
8
|
+
require_relative "session"
|
|
9
|
+
require_relative "guardrails/parent_policy"
|
|
8
10
|
|
|
9
11
|
module Samagotchi
|
|
10
12
|
# How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
|
|
@@ -76,18 +78,20 @@ module Samagotchi
|
|
|
76
78
|
# @param extra [Hash] more keys for the object (chi answer's
|
|
77
79
|
# answered_here: false)
|
|
78
80
|
# @return [Hash] the --format json object
|
|
79
|
-
|
|
81
|
+
# @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
|
|
82
|
+
# may pick on the waited-for session (#strategy_for); nil: config.yml's
|
|
83
|
+
def json(result, session_id:, timeout: nil, extra: {}, strategy: nil)
|
|
80
84
|
report = { status: status(result), session_id: session_id }
|
|
81
85
|
report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
|
|
82
86
|
case report[:status]
|
|
83
87
|
when "answered" then report[:text] = result.text.to_s
|
|
84
88
|
when "question"
|
|
85
|
-
question = question(result.question)
|
|
89
|
+
question = question(result.question, strategy: strategy)
|
|
86
90
|
report[:question] = question
|
|
87
91
|
report[:answer_with] = answer_with(session_id, question)
|
|
88
92
|
else
|
|
89
93
|
report.merge!(stop_facts(result))
|
|
90
|
-
report[:detail] = detail(result, session_id: session_id, timeout: timeout)
|
|
94
|
+
report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
|
|
91
95
|
end
|
|
92
96
|
report.merge(extra)
|
|
93
97
|
end
|
|
@@ -105,8 +109,8 @@ module Samagotchi
|
|
|
105
109
|
end
|
|
106
110
|
|
|
107
111
|
# @return [String] the JSON object as one line
|
|
108
|
-
def json_line(result, session_id:, timeout: nil, extra: {})
|
|
109
|
-
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
112
|
+
def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
|
|
113
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
110
114
|
end
|
|
111
115
|
|
|
112
116
|
# Print a wait's end the way the commands do: the JSON object on
|
|
@@ -115,17 +119,17 @@ module Samagotchi
|
|
|
115
119
|
# @param command [String] "chi send", "chi answer"
|
|
116
120
|
# @param extra [Hash] more keys for the JSON object (#json)
|
|
117
121
|
# @return [Integer] the exit status
|
|
118
|
-
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
|
|
122
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil)
|
|
119
123
|
if json
|
|
120
|
-
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
124
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
121
125
|
elsif result.status == :done
|
|
122
126
|
stdout.puts(result.text)
|
|
123
127
|
else
|
|
124
128
|
stdout.flush
|
|
125
129
|
text = if result.status == :waiting_for_answer
|
|
126
|
-
question_text(result.question, session_id: session_id)
|
|
130
|
+
question_text(result.question, session_id: session_id, strategy: strategy)
|
|
127
131
|
else
|
|
128
|
-
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
|
|
132
|
+
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy)}\n"
|
|
129
133
|
end
|
|
130
134
|
stderr.print("#{command}: #{text}")
|
|
131
135
|
end
|
|
@@ -144,38 +148,102 @@ module Samagotchi
|
|
|
144
148
|
# parent sees it.
|
|
145
149
|
# kind is "question" for the model's own (the desk stores none),
|
|
146
150
|
# "hook", "approval" or "continue" (the step-limit question, with the
|
|
147
|
-
# turn's limit).
|
|
151
|
+
# turn's limit). allowed_options: the options a parent may pick, by
|
|
152
|
+
# +strategy+ (nil: this process's guardrails.parent_answers;
|
|
153
|
+
# Guardrails::ParentPolicy), when that is not all of them.
|
|
148
154
|
# @return [Hash]
|
|
149
|
-
def question(pending)
|
|
150
|
-
pending
|
|
155
|
+
def question(pending, strategy: nil)
|
|
156
|
+
pending = PendingQuestion.from_file(pending) || PendingQuestion.new
|
|
151
157
|
approval = pending.approval.is_a?(Hash) ? symbolize(pending.approval) : nil
|
|
158
|
+
options = Array(pending.options).map(&:to_s)
|
|
159
|
+
allowed = allowed_options(pending, strategy: strategy)
|
|
152
160
|
{
|
|
153
161
|
id: pending.id.to_s,
|
|
154
162
|
kind: (pending.kind || "question").to_s,
|
|
155
163
|
header: blank(pending.header),
|
|
156
164
|
text: pending.question.to_s,
|
|
157
|
-
options:
|
|
165
|
+
options: options,
|
|
158
166
|
multi_select: !!pending.multi_select,
|
|
159
167
|
allow_freeform: !!pending.allow_freeform,
|
|
168
|
+
allowed_options: (allowed unless allowed == options),
|
|
160
169
|
limit: pending.limit,
|
|
161
170
|
approval: approval&.slice(*APPROVAL_KEYS)
|
|
162
171
|
}.compact
|
|
163
172
|
end
|
|
164
173
|
|
|
165
174
|
# The command that answers +question+; N is the parent's to fill in.
|
|
166
|
-
# An approval's is a deny:
|
|
167
|
-
#
|
|
168
|
-
# turn
|
|
175
|
+
# An approval's is a deny: a wider allow is the user's (#question_text
|
|
176
|
+
# lists an Allow once a parent may give). A step-limit question's
|
|
177
|
+
# continues the turn, or stops it where a parent may not continue (its
|
|
178
|
+
# other form is in #question_text).
|
|
169
179
|
def answer_with(session_id, question)
|
|
170
180
|
return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
|
|
181
|
+
return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
|
|
171
182
|
return "chi answer #{session_id} --question #{question[:id]} --option Continue" if continue?(question)
|
|
172
183
|
|
|
184
|
+
# The one option a parent may pick (cost-cap's Stop under hard).
|
|
185
|
+
only = Array(question[:allowed_options])
|
|
186
|
+
return "chi answer #{session_id} --question #{question[:id]} --option #{option_arg(only.first)}" if only.size == 1
|
|
187
|
+
|
|
173
188
|
"chi answer #{session_id} --question #{question[:id]} --option N"
|
|
174
189
|
end
|
|
175
190
|
|
|
176
|
-
# What a parent does with an approval.
|
|
191
|
+
# What a parent does with an approval it may not allow.
|
|
177
192
|
DENY_AND_TELL = "deny it, and tell your user"
|
|
178
193
|
|
|
194
|
+
# The labels a parent may pick on +pending+ (a PendingQuestion, or a
|
|
195
|
+
# Hash with its fields) by +strategy+ (nil: this process's).
|
|
196
|
+
# @return [Array<String>]
|
|
197
|
+
def allowed_options(pending, strategy: nil)
|
|
198
|
+
pending = PendingQuestion.from_file(pending) || PendingQuestion.new
|
|
199
|
+
Guardrails::ParentPolicy.allowed_options(pending, strategy: policy(strategy))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# What a parent may pick on the session +session_id+: config.yml's
|
|
203
|
+
# level tightened by the session file's own parent_answers
|
|
204
|
+
# (Guardrails::ParentPolicy.for_session); config.yml's when there is no
|
|
205
|
+
# such session, hard (the strictest) when its file can't be read.
|
|
206
|
+
# @return [Guardrails::ParentPolicy::Strategy]
|
|
207
|
+
def strategy_for(session_id, state_dir:)
|
|
208
|
+
return Guardrails::ParentPolicy.current unless Session.exist?(session_id, state_dir: state_dir)
|
|
209
|
+
|
|
210
|
+
Guardrails::ParentPolicy.for_session(Session.load(session_id, state_dir: state_dir))
|
|
211
|
+
rescue ArgumentError, KeyError, TypeError, NoMethodError, SystemCallError, JSON::ParserError
|
|
212
|
+
Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# +strategy+, or this process's config.yml's.
|
|
216
|
+
def policy(strategy) = strategy || Guardrails::ParentPolicy.current
|
|
217
|
+
|
|
218
|
+
# An approval's options a parent may allow (Allow once under soft).
|
|
219
|
+
def allowed_allows(question, strategy: nil)
|
|
220
|
+
question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
|
|
221
|
+
deny = Guardrails::Approval::DENY
|
|
222
|
+
question.fetch(:allowed_options, question[:options]).reject { |label| label == deny }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# What a parent does with an approval: deny it and tell its user, or
|
|
226
|
+
# give the Allow once its strategy lets it.
|
|
227
|
+
def approval_advice(question, strategy: nil)
|
|
228
|
+
allows = allowed_allows(question, strategy: strategy)
|
|
229
|
+
return DENY_AND_TELL if allows.empty?
|
|
230
|
+
|
|
231
|
+
"#{allows.join(" or ")} is yours to give (#{policy(strategy).label}); " \
|
|
232
|
+
"anything wider is up to your user: #{DENY_AND_TELL}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Whether a parent may answer Continue to a step-limit question.
|
|
236
|
+
def may_continue?(question, strategy: nil)
|
|
237
|
+
question = question(question, strategy: strategy) unless question.is_a?(Hash) && question.key?(:text)
|
|
238
|
+
question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def stop_command(session_id, question)
|
|
242
|
+
"chi answer #{session_id} --question #{question[:id]} --option Stop --text WHY"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def option_arg(label) = label.match?(/\A[\w.-]+\z/) ? label : label.inspect
|
|
246
|
+
|
|
179
247
|
# The other way: the user answers it in chi (the web's bell and badge
|
|
180
248
|
# say it waits), and a wait with no message waits past it.
|
|
181
249
|
def leave_open(session_id)
|
|
@@ -203,20 +271,23 @@ module Samagotchi
|
|
|
203
271
|
end
|
|
204
272
|
|
|
205
273
|
# What a wait that ended without an answer says, one line.
|
|
206
|
-
def detail(result, session_id:, timeout: nil, turn_id: nil)
|
|
274
|
+
def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
|
|
207
275
|
attach = "chi --attach #{session_id}"
|
|
208
276
|
case result.status
|
|
209
277
|
when :waiting_for_answer
|
|
210
|
-
q = question(result.question)
|
|
278
|
+
q = question(result.question, strategy: strategy)
|
|
211
279
|
if approval?(q)
|
|
212
|
-
"waiting for an approval: #{first_line(q[:text])}; #{
|
|
280
|
+
"waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
|
|
281
|
+
elsif continue?(q) && !may_continue?(q)
|
|
282
|
+
"waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
|
|
283
|
+
"(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
|
|
213
284
|
elsif continue?(q)
|
|
214
285
|
"waiting at the step limit: #{first_line(q[:text])}; " \
|
|
215
286
|
"answer Continue or Stop: chi answer #{session_id} --question #{q[:id]} --option Continue"
|
|
216
287
|
else
|
|
217
288
|
"waiting for an answer: #{first_line(q[:text])}; open it: #{attach} or the web"
|
|
218
289
|
end
|
|
219
|
-
when :no_reply then "#{no_reply_line(result, session_id)}; #{attach} shows it"
|
|
290
|
+
when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{attach} shows it"
|
|
220
291
|
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
221
292
|
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
222
293
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
@@ -230,40 +301,61 @@ module Samagotchi
|
|
|
230
301
|
# The whole question for stderr: header, text, numbered options, what
|
|
231
302
|
# else it takes, and the commands that answer it.
|
|
232
303
|
# @return [String] lines, newline-terminated
|
|
233
|
-
def question_text(pending, session_id:)
|
|
234
|
-
q = question(pending)
|
|
304
|
+
def question_text(pending, session_id:, strategy: nil)
|
|
305
|
+
q = question(pending, strategy: strategy)
|
|
235
306
|
lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
|
|
236
307
|
text_lines = q[:text].strip.lines.map(&:rstrip)
|
|
237
308
|
text_lines = text_lines.drop(1) unless q[:header]
|
|
238
309
|
lines.concat(text_lines.map { |line| " #{line}" })
|
|
239
310
|
q[:options].each_with_index { |option, i| lines << " #{i + 1}. #{option}" }
|
|
240
311
|
if approval?(q)
|
|
241
|
-
|
|
312
|
+
allows = allowed_allows(q)
|
|
313
|
+
if allows.empty?
|
|
314
|
+
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
315
|
+
else
|
|
316
|
+
lines << " #{approval_advice(q, strategy: strategy)}"
|
|
317
|
+
allows.each { |label| lines << " allow: chi answer #{session_id} --question #{q[:id]} --option #{option_arg(label)}" }
|
|
318
|
+
end
|
|
242
319
|
lines << " deny: #{answer_with(session_id, q)}"
|
|
243
320
|
lines << " #{leave_open(session_id)}"
|
|
244
321
|
elsif continue?(q)
|
|
245
|
-
lines <<
|
|
246
|
-
|
|
322
|
+
lines << if may_continue?(q)
|
|
323
|
+
" continue: #{answer_with(session_id, q)}"
|
|
324
|
+
else
|
|
325
|
+
" continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
|
|
326
|
+
end
|
|
327
|
+
lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
|
|
247
328
|
lines << " #{message_drops(session_id)}"
|
|
248
329
|
else
|
|
249
330
|
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
250
331
|
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
332
|
+
if q[:allowed_options]
|
|
333
|
+
lines << " a parent may pick #{q[:allowed_options].map { |o| option_arg(o) }.join(", ")}; the others are up to your user"
|
|
334
|
+
end
|
|
251
335
|
lines << " answer: #{answer_with(session_id, q)}"
|
|
252
336
|
lines << " or open it: chi --attach #{session_id} or the web"
|
|
253
337
|
end
|
|
254
338
|
lines.map { |line| "#{line}\n" }.join
|
|
255
339
|
end
|
|
256
340
|
|
|
257
|
-
def no_reply_line(result, session_id = nil)
|
|
341
|
+
def no_reply_line(result, session_id = nil, strategy: nil)
|
|
258
342
|
case result.outcome
|
|
259
343
|
when "failed" then failed_line(result)
|
|
260
|
-
when "canceled"
|
|
344
|
+
when "canceled"
|
|
345
|
+
# A turn its worker died in, settled by the next (TurnCheckpoint.settle).
|
|
346
|
+
result.cancel_reason == "worker_stopped" ? "the turn was canceled: its worker exited mid-turn" : "the turn was canceled"
|
|
261
347
|
when "completed" then "the turn ended with no visible answer"
|
|
262
348
|
when "not_continued" then "the turn was not continued (Stop); its work so far stays"
|
|
263
349
|
when "removed" then "the queued message was removed before it ran; no turn ran for it"
|
|
264
350
|
when "exhausted"
|
|
265
|
-
"the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered
|
|
266
|
-
|
|
351
|
+
ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
|
|
352
|
+
strategy = policy(strategy)
|
|
353
|
+
if strategy.allow?(Guardrails::ParentPolicy::SPEND)
|
|
354
|
+
"#{ran_out}; chi send #{session_id} -m '/continue yes' continues it, a message drops it"
|
|
355
|
+
else
|
|
356
|
+
# No /continue yes hint: a parent may not continue it here.
|
|
357
|
+
"#{ran_out}; continuing it is up to your user (#{strategy.label}), a message drops it"
|
|
358
|
+
end
|
|
267
359
|
else "the turn ended without a reply (canceled, failed or empty)"
|
|
268
360
|
end
|
|
269
361
|
end
|
|
@@ -7,8 +7,12 @@ module Samagotchi
|
|
|
7
7
|
# step-limit question (kind "continue").
|
|
8
8
|
#
|
|
9
9
|
# id: the question's uuid; question, options, header, multi_select,
|
|
10
|
-
# allow_freeform: what the UIs show;
|
|
11
|
-
#
|
|
10
|
+
# allow_freeform: what the UIs show; effects: each option's effect for
|
|
11
|
+
# a parent agent's answer, in option order (Guardrails::ParentPolicy;
|
|
12
|
+
# nil: untagged); user_only: only the user may pick an option that isn't
|
|
13
|
+
# a deny or a plain answer (an approval on chi's own config); kind: nil
|
|
14
|
+
# for the model's own, "hook", "approval" or "continue"; hook: the asking
|
|
15
|
+
# hook's name;
|
|
12
16
|
# approval: the approval's facts (Guardrails::Approval.payload); relay: a
|
|
13
17
|
# relayed approval's facts (Relay.card); limit, context: the step-limit
|
|
14
18
|
# question's turn limit and offer context (ContinueOffer); status:
|
|
@@ -20,8 +24,8 @@ module Samagotchi
|
|
|
20
24
|
# Crosses the process as a Hash: #to_h for an event, a Bridge snapshot or
|
|
21
25
|
# a UI (symbol keys), #to_file for session.json (string keys); both in
|
|
22
26
|
# member order with unset fields left out. .from_file reads either back.
|
|
23
|
-
PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :
|
|
24
|
-
:approval, :relay, :limit, :context, :status, :created_at, :relayed_to) do
|
|
27
|
+
PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :effects, :user_only,
|
|
28
|
+
:kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to) do
|
|
25
29
|
def initialize(**fields)
|
|
26
30
|
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
27
31
|
end
|
|
@@ -18,6 +18,22 @@ module Samagotchi
|
|
|
18
18
|
ANNOTATION_LABEL_MAX = 40
|
|
19
19
|
ANNOTATIONS_MAX = 5
|
|
20
20
|
|
|
21
|
+
# The first chi release with each call a plugin may make (from git:
|
|
22
|
+
# the first release tag holding it), for `chi bundle check`'s
|
|
23
|
+
# requires-chi-floor: a bundle whose requires_chi admits an older chi.
|
|
24
|
+
# A call not listed gets no floor check: it came before the first tag
|
|
25
|
+
# (v0.2.0), or isn't listed yet. Keys have no "?" (API_CALL drops it).
|
|
26
|
+
SINCE = {
|
|
27
|
+
"ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
|
|
28
|
+
"Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
|
|
29
|
+
"ctx.context" => "0.32.0", "ctx.delegate" => "0.32.0", "ctx.scratch" => "0.32.0",
|
|
30
|
+
"fallback_for:" => "0.35.0", "ctx.fork" => "0.45.0", "ctx.frontend" => "0.49.0",
|
|
31
|
+
"ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
|
|
32
|
+
"ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
|
|
33
|
+
"chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
|
|
34
|
+
"ctx.cost" => "0.83.0"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
21
37
|
# @param bundle [String]
|
|
22
38
|
# @param label [String] "<file> (bundle <name>)": what hooks and
|
|
23
39
|
# failures are named by
|
|
@@ -29,7 +29,7 @@ module Samagotchi
|
|
|
29
29
|
# The Engine's side of a Context: callables, so the Engine itself is
|
|
30
30
|
# never handed out. +messages+ returns the conversation, +notify+ takes
|
|
31
31
|
# (text, level, label, fallback_for:), +ask_user+ takes (question:, options:, header:,
|
|
32
|
-
# allow_freeform:, hook:) like the hook runtime's, +card+ takes
|
|
32
|
+
# allow_freeform:, hook:, and effects: when the plugin gave them) like the hook runtime's, +card+ takes
|
|
33
33
|
# Engine#show_card's keywords and returns the id, +ask_model+ takes
|
|
34
34
|
# (chat messages, timeout:, max_tokens:, cancel_controller:, model:: a
|
|
35
35
|
# configured model ref, nil for the session's) and returns the answer
|
|
@@ -355,15 +355,21 @@ module Samagotchi
|
|
|
355
355
|
|
|
356
356
|
# A single-select question through the question flow. Inside a
|
|
357
357
|
# stream hook's handler (generation_progress) it asks no one (nil).
|
|
358
|
+
# @param effects [Hash{String => Symbol}, nil] what an option does, by
|
|
359
|
+
# its label, for a parent agent's answer (Guardrails::ParentPolicy):
|
|
360
|
+
# :spend (more time or money: a Continue), :permit (one call),
|
|
361
|
+
# :permit_wide, :deny (a Stop); a label left out is a plain :answer.
|
|
362
|
+
# Tag every option that spends or permits.
|
|
358
363
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
|
|
359
|
-
# (no one to ask, cancelled, bad options)
|
|
360
|
-
def ask_user(question:, options:, header: nil, allow_freeform: false)
|
|
364
|
+
# (no one to ask, cancelled, bad options or effects)
|
|
365
|
+
def ask_user(question:, options:, header: nil, allow_freeform: false, effects: nil)
|
|
366
|
+
tags = effects.nil? ? {} : { effects: effects }
|
|
361
367
|
if (helper = event_helper(:ask_user))
|
|
362
|
-
return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform)
|
|
368
|
+
return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform, **tags)
|
|
363
369
|
end
|
|
364
370
|
|
|
365
371
|
@host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
|
|
366
|
-
hook: @label)
|
|
372
|
+
hook: @label, **tags)
|
|
367
373
|
end
|
|
368
374
|
|
|
369
375
|
# Whether the running turn was cancelled (a long tool should stop).
|
|
@@ -4,6 +4,7 @@ require_relative "api"
|
|
|
4
4
|
require_relative "context"
|
|
5
5
|
require_relative "../hooks"
|
|
6
6
|
require_relative "../log"
|
|
7
|
+
require_relative "../guardrails/load_failures"
|
|
7
8
|
require_relative "../version"
|
|
8
9
|
require_relative "../memory_bundle/manifest"
|
|
9
10
|
require_relative "../memory_bundle/provenance"
|
|
@@ -71,7 +72,8 @@ module Samagotchi
|
|
|
71
72
|
rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
|
|
72
73
|
raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
|
|
73
74
|
|
|
74
|
-
failed(bundle_name, basename, "#{e.class}: #{e.message}", failures
|
|
75
|
+
failed(bundle_name, basename, "#{e.class}: #{e.message}", failures,
|
|
76
|
+
location: file && Guardrails::LoadFailures.location(e, File.dirname(file)))
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
# Build +file+'s plugin with +settings+ and commit what its #register
|
|
@@ -136,10 +138,10 @@ module Samagotchi
|
|
|
136
138
|
end
|
|
137
139
|
end
|
|
138
140
|
|
|
139
|
-
def failed(bundle_name, basename, reason, failures)
|
|
141
|
+
def failed(bundle_name, basename, reason, failures, location: nil)
|
|
140
142
|
Log.warn(TAG, "plugin_not_loaded", echo: "[samagotchi:plugins] bundle '#{bundle_name}' plugin '#{basename}' not loaded: #{reason}",
|
|
141
143
|
bundle: bundle_name, file: basename)
|
|
142
|
-
failures&.add("plugin #{basename} (bundle #{bundle_name})", reason, required: false)
|
|
144
|
+
failures&.add("plugin #{basename} (bundle #{bundle_name})", reason, required: false, location: location)
|
|
143
145
|
false
|
|
144
146
|
end
|
|
145
147
|
end
|