samagotchi 0.79.0 → 0.80.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 +30 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +7 -4
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +144 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +32 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +92 -15
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +40 -37
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +53 -8
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +138 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +163 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +19 -14
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- metadata +21 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Testing
|
|
5
|
+
# What a bundle's Ruby may reach of chi (docs/plugins.md, "Stable for
|
|
6
|
+
# bundles"): ALLOWED is the checked copy of that list. #scan finds a
|
|
7
|
+
# bundle's other references to Samagotchi:: and requires of chi's files;
|
|
8
|
+
# EXCEPTIONS is what the shipped bundles reach today anyway, each with
|
|
9
|
+
# why: the APIs to design before that bundle could leave chi's repo
|
|
10
|
+
# (spec/samagotchi/testing/boundary_spec.rb keeps both exact).
|
|
11
|
+
module Boundary
|
|
12
|
+
# A name a bundle may use, and the file that defines it (a require of
|
|
13
|
+
# it is fine too). A name allows itself, its methods (".x") and what
|
|
14
|
+
# it holds ("::X"); one ending in a method or constant allows only that.
|
|
15
|
+
Allowed = Data.define(:name, :file)
|
|
16
|
+
|
|
17
|
+
ALLOWED = [
|
|
18
|
+
Allowed.new(name: "Samagotchi::Plugin::Api", file: "samagotchi/plugin/api"),
|
|
19
|
+
Allowed.new(name: "Samagotchi::Plugin::ToolResult", file: "samagotchi/plugin/tool_result"),
|
|
20
|
+
Allowed.new(name: "Samagotchi::Plugin::ModelError", file: "samagotchi/plugin/context"),
|
|
21
|
+
Allowed.new(name: "Samagotchi::Plugin::ModelCancelled", file: "samagotchi/plugin/context"),
|
|
22
|
+
Allowed.new(name: "Samagotchi::Plugin::HttpError", file: "samagotchi/plugin/http"),
|
|
23
|
+
Allowed.new(name: "Samagotchi::Plugin::HttpCancelled", file: "samagotchi/plugin/http"),
|
|
24
|
+
Allowed.new(name: "Samagotchi::Plugin::Http::Response", file: "samagotchi/plugin/http"),
|
|
25
|
+
Allowed.new(name: "Samagotchi::Plugin::Service::Stopped", file: "samagotchi/plugin/service"),
|
|
26
|
+
Allowed.new(name: "Samagotchi::Plugin::Sessions::Error", file: "samagotchi/plugin/sessions"),
|
|
27
|
+
Allowed.new(name: "Samagotchi::Plugin::AttachedContext::Error", file: "samagotchi/plugin/attached_context"),
|
|
28
|
+
Allowed.new(name: "Samagotchi::Plugin::Peer", file: "samagotchi/plugin/peer"),
|
|
29
|
+
Allowed.new(name: "Samagotchi::Tools::ToolIndex::Tool", file: "samagotchi/tools/tool_index"),
|
|
30
|
+
Allowed.new(name: "Samagotchi::Tools::ToolIndex::Group", file: "samagotchi/tools/tool_index"),
|
|
31
|
+
Allowed.new(name: "Samagotchi::Tools::ToolIndex.schema_note", file: "samagotchi/tools/tool_index"),
|
|
32
|
+
Allowed.new(name: "Samagotchi::Tools::ToolIndex.spec_tokens", file: "samagotchi/tools/tool_index"),
|
|
33
|
+
Allowed.new(name: "Samagotchi::Tools::Execute::NAME", file: "samagotchi/tools/execute"),
|
|
34
|
+
Allowed.new(name: "Samagotchi::Tools::Execute::TIMEOUT_PREFIX", file: "samagotchi/tools/execute"),
|
|
35
|
+
Allowed.new(name: "Samagotchi::Tools::Execute::HANDED_OVER", file: "samagotchi/tools/execute"),
|
|
36
|
+
Allowed.new(name: "Samagotchi::Tools::Execute.timeout_seconds", file: "samagotchi/tools/execute"),
|
|
37
|
+
Allowed.new(name: "Samagotchi::Tools::TaskRuntime::TASKS_DIR", file: "samagotchi/tools/task_runtime"),
|
|
38
|
+
Allowed.new(name: "Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME", file: "samagotchi/tools/task_runtime"),
|
|
39
|
+
Allowed.new(name: "Samagotchi::AtomicFile.write", file: "samagotchi/atomic_file"),
|
|
40
|
+
Allowed.new(name: "Samagotchi::Log.warn", file: "samagotchi/log"),
|
|
41
|
+
Allowed.new(name: "Samagotchi::Log.debug", file: "samagotchi/log"),
|
|
42
|
+
Allowed.new(name: "Samagotchi::Config.get", file: "samagotchi/config"),
|
|
43
|
+
Allowed.new(name: "Samagotchi::VERSION", file: "samagotchi/version"),
|
|
44
|
+
# The kit itself, for a bundle's own specs.
|
|
45
|
+
Allowed.new(name: "Samagotchi::Testing", file: "samagotchi/testing")
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
# The kit's adapters, required by a bundle's specs.
|
|
49
|
+
ALLOWED_REQUIRES = (ALLOWED.map(&:file) + %w[samagotchi/testing/rspec samagotchi/testing/minitest]).uniq.freeze
|
|
50
|
+
|
|
51
|
+
SESSION = "a saved session (context sources are keyed by its id): needs a kit helper that saves one"
|
|
52
|
+
SOURCES = "the session's context sources (the peer's inbox / outbox): needs a kit helper that attaches one"
|
|
53
|
+
REF_LIMITS = "a ref's kinds and limits (event[:refs] checks them too): could be stable constants"
|
|
54
|
+
# The peers' specs pair a session through its context sources.
|
|
55
|
+
PEER_SPEC = {
|
|
56
|
+
'require "samagotchi/context_sources"' => "spec: #{SOURCES}",
|
|
57
|
+
"Samagotchi::Session.new_session" => "spec: #{SESSION}",
|
|
58
|
+
"Samagotchi::ContextSources.session_location" => "spec: #{SOURCES}",
|
|
59
|
+
"Samagotchi::ContextSources::Source.new" => "spec: #{SOURCES}",
|
|
60
|
+
"Samagotchi::ContextSources::Fetched.new" => "spec: an outbox answer as the context poller records it"
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# bundle => {reference => why}: what each shipped bundle reaches
|
|
64
|
+
# beyond ALLOWED today, its specs' (why "spec: …") included.
|
|
65
|
+
EXCEPTIONS = {
|
|
66
|
+
"agents-md" => {
|
|
67
|
+
'require "samagotchi/guardrails/context"' => "for GitInfo: a ctx helper for the checkout's files could replace it",
|
|
68
|
+
"Samagotchi::Guardrails::GitInfo.new" => "the work tree's top for the AGENTS.md chain (ctx.repo_root covers the top only)"
|
|
69
|
+
},
|
|
70
|
+
"claude-peer" => PEER_SPEC,
|
|
71
|
+
"codex-peer" => PEER_SPEC,
|
|
72
|
+
"coordinator" => {
|
|
73
|
+
"Samagotchi::MemoryPaths.scope_dir" => "where the coordinator's memories live: needs a ctx memory-path API",
|
|
74
|
+
"Samagotchi::MemoryPaths" => "spec: checks /coordinate end reads no memories (MemoryPaths.scope_dir isn't called)"
|
|
75
|
+
},
|
|
76
|
+
"github-pr" => {
|
|
77
|
+
"Samagotchi::Session.new_session" => "spec: #{SESSION} (ctx.delegate?, ctx.fork?, ctx.context read it)",
|
|
78
|
+
"Samagotchi::ContextSources.session_location" => "spec: reads and removes the session's sources (added_by), " \
|
|
79
|
+
"which ctx.context.list doesn't show",
|
|
80
|
+
'require "samagotchi/memory_bundle/installer"' => "spec: for Installer",
|
|
81
|
+
"Samagotchi::MemoryBundle::Installer.new" => "spec: installs the bundle so its context provider resolves: " \
|
|
82
|
+
"needs a kit install helper",
|
|
83
|
+
'require "samagotchi/context_providers"' => "spec: for ContextProviders",
|
|
84
|
+
"Samagotchi::ContextProviders.resolve" => "spec: the manifest's URL provider through chi's own resolver",
|
|
85
|
+
'require "samagotchi/answer_display"' => "spec: for AnswerDisplay",
|
|
86
|
+
"Samagotchi::AnswerDisplay.new" => "spec: after_turn's present: helper, as chi's fire site builds it: " \
|
|
87
|
+
"the kit could supply after_turn's fire-site keys",
|
|
88
|
+
'require "samagotchi/answer_refs"' => "spec: for AnswerRefs",
|
|
89
|
+
"Samagotchi::AnswerRefs.new" => "spec: after_turn's refs: helper, as chi's fire site builds it",
|
|
90
|
+
"Samagotchi::Log" => "spec: stubs Log to check what ctx.log recorded: a kit log recorder could replace it"
|
|
91
|
+
},
|
|
92
|
+
"loop-guard" => {
|
|
93
|
+
'require "samagotchi/llm/native_tool_normalizer"' => "spec: for NativeToolNormalizer",
|
|
94
|
+
"Samagotchi::LLM::NativeToolNormalizer.normalize" => "spec: replays recorded calls in the shape chi's parser gives",
|
|
95
|
+
"Samagotchi::Hooks::StreamWatch::EVERY_CHARS" => "spec: the stream watch's batch size (a ctx or kit value later)"
|
|
96
|
+
},
|
|
97
|
+
"skills" => {
|
|
98
|
+
"Samagotchi::Tools::MemoryRead.memories_dir" => "lists the skill memories: needs a ctx memory-path API"
|
|
99
|
+
},
|
|
100
|
+
"mcp" => {
|
|
101
|
+
'require "samagotchi/process_group"' => "for ProcessGroup.stop",
|
|
102
|
+
"Samagotchi::ProcessGroup.stop" => "stops a server's process group: needs a ctx process API",
|
|
103
|
+
'require "samagotchi/token_usage"' => "for CHARS_PER_TOKEN",
|
|
104
|
+
"Samagotchi::TokenUsage::CHARS_PER_TOKEN" => "estimates a tool's tokens: could be a stable constant",
|
|
105
|
+
'require "samagotchi/tool_declarations"' => "loaded for Api.tool_spec's schemas"
|
|
106
|
+
},
|
|
107
|
+
"slow-commands" => {
|
|
108
|
+
'require "samagotchi/guardrails/shell_lex"' => "for ShellLex",
|
|
109
|
+
"Samagotchi::Guardrails::ShellLex" => "splits a command into its parts: needs a stable shell-parsing helper",
|
|
110
|
+
"Samagotchi::Tools::Execute.const_defined?" => "reads execute's limits that older chis lack, by reflection",
|
|
111
|
+
"Samagotchi::Tools::Execute.const_get" => "reads execute's limits that older chis lack, by reflection"
|
|
112
|
+
},
|
|
113
|
+
"source-links" => {
|
|
114
|
+
'require "samagotchi/ref"' => "for the Ref / RefAction limits",
|
|
115
|
+
"Samagotchi::Ref::FILE" => REF_LIMITS,
|
|
116
|
+
"Samagotchi::Ref::KIND.match?" => REF_LIMITS,
|
|
117
|
+
"Samagotchi::Ref::MAX_LABEL" => REF_LIMITS,
|
|
118
|
+
"Samagotchi::RefAction::MAX_LABEL" => REF_LIMITS,
|
|
119
|
+
"Samagotchi::RefAction.http_url?" => "which URLs an action may open: could be a stable helper"
|
|
120
|
+
}
|
|
121
|
+
}.freeze
|
|
122
|
+
|
|
123
|
+
# A Samagotchi:: name with what follows it (::X, .method).
|
|
124
|
+
REFERENCE = /\bSamagotchi(?:::[A-Z]\w*)+(?:\.[a-z_]\w*[?!]?)?/
|
|
125
|
+
# A require of one of chi's files.
|
|
126
|
+
REQUIRE = %r{\brequire\s*\(?\s*["'](samagotchi(?:/[\w/]+)?)["']}
|
|
127
|
+
|
|
128
|
+
# One reference outside ALLOWED, in a file of the bundle.
|
|
129
|
+
Finding = Data.define(:reference, :file)
|
|
130
|
+
|
|
131
|
+
module_function
|
|
132
|
+
|
|
133
|
+
# The bundle's references to chi outside ALLOWED (its plugin, hooks,
|
|
134
|
+
# scripts and specs: every .rb under +dir+), one per reference and
|
|
135
|
+
# file, sorted.
|
|
136
|
+
# @param dir [String] the bundle's folder
|
|
137
|
+
# @return [Array<Finding>]
|
|
138
|
+
def scan(dir)
|
|
139
|
+
Dir.glob(File.join(dir, "**", "*.rb")).sort.flat_map do |path|
|
|
140
|
+
text = File.read(path)
|
|
141
|
+
refs = text.scan(REFERENCE).reject { |name| allowed?(name) }
|
|
142
|
+
refs += text.scan(REQUIRE).flatten.reject { |file| ALLOWED_REQUIRES.include?(file) }.map { |file| %(require "#{file}") }
|
|
143
|
+
file = path.delete_prefix("#{File.expand_path(dir)}/")
|
|
144
|
+
refs.uniq.map { |reference| Finding.new(reference: reference, file: file) }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Whether a bundle may use +name+ ("Samagotchi::X::Y", maybe ".method").
|
|
149
|
+
def allowed?(name)
|
|
150
|
+
ALLOWED.any? do |entry|
|
|
151
|
+
name == entry.name || name.start_with?("#{entry.name}.", "#{entry.name}::")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../plugin/loader"
|
|
4
|
+
require_relative "../plugin/tool_spec"
|
|
5
|
+
require_relative "../hooks"
|
|
6
|
+
require_relative "../commands/registry"
|
|
7
|
+
require_relative "../session_commands"
|
|
8
|
+
require_relative "../tools/builtins"
|
|
9
|
+
require_relative "../tools/tool_index"
|
|
10
|
+
require_relative "../peer_sources"
|
|
11
|
+
require_relative "../toolkit"
|
|
12
|
+
require_relative "../guardrails/gate"
|
|
13
|
+
require_relative "../memory_bundle/manifest"
|
|
14
|
+
require_relative "sandbox"
|
|
15
|
+
require_relative "recorder"
|
|
16
|
+
require_relative "host"
|
|
17
|
+
require_relative "sessions"
|
|
18
|
+
require_relative "fake_http"
|
|
19
|
+
require_relative "verdict"
|
|
20
|
+
require_relative "recording_api"
|
|
21
|
+
|
|
22
|
+
module Samagotchi
|
|
23
|
+
module Testing
|
|
24
|
+
# A hook (a plugin's chi.on block or a hook file) raised while the kit
|
|
25
|
+
# fired it: chi would log it and go on, a test fails with it.
|
|
26
|
+
class HookFailed < StandardError
|
|
27
|
+
# @return [Hooks::Failure]
|
|
28
|
+
attr_reader :failure
|
|
29
|
+
|
|
30
|
+
def initialize(failure)
|
|
31
|
+
@failure = failure
|
|
32
|
+
error = failure.error
|
|
33
|
+
super("#{failure.hook} raised on #{failure.event}: #{error.class}: #{error.message}")
|
|
34
|
+
set_backtrace(error.backtrace) if error.backtrace
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cause = @failure.error
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A chi.init the plugin registered: its arguments and block.
|
|
41
|
+
Init = Data.define(:label, :plugin_label, :provides_tools, :quiet, :timeout, :failed, :block)
|
|
42
|
+
# A chi.replace_tools set, staged as chi stages it for the turn thread.
|
|
43
|
+
StagedTools = Data.define(:specs, :context)
|
|
44
|
+
|
|
45
|
+
# One bundle loaded for a test through chi's own seams: its plugin
|
|
46
|
+
# through Plugin::Loader.register_file (Plugin::Api, a real
|
|
47
|
+
# Plugin::Context) and its hook files through
|
|
48
|
+
# Hooks::BundleLoader.load_hook_file, into real registries (commands
|
|
49
|
+
# and tools with chi's built-ins in them, so a name clash fails as in
|
|
50
|
+
# chi). Only the edges are recorded: the Host and hook Runtime
|
|
51
|
+
# (Recorder), ctx.sessions (Testing::Sessions), chi.init tasks and
|
|
52
|
+
# staged tool sets. No sha256 or requires_chi check: it loads the
|
|
53
|
+
# source as it is.
|
|
54
|
+
#
|
|
55
|
+
# bundle = Samagotchi::Testing::Bundle.load("path/to/bundle", settings: { "every" => 3 })
|
|
56
|
+
# bundle.fire(:after_turn, status: "completed")
|
|
57
|
+
# bundle.command("/checkin stop")
|
|
58
|
+
# bundle.tool_call({ name: "execute", content: "ls" }) # → Verdict
|
|
59
|
+
# bundle.notices, bundle.cards, bundle.steers …
|
|
60
|
+
# bundle.shutdown
|
|
61
|
+
#
|
|
62
|
+
# A hook that raises while #fire, #tool_call, #command or #tool runs
|
|
63
|
+
# raises HookFailed after the call (chi would log it and go on);
|
|
64
|
+
# #lenient collects them in #errors instead.
|
|
65
|
+
class Bundle
|
|
66
|
+
SHIPPED_DIR = File.expand_path("../bundles", __dir__)
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# @param dir [String] the bundle's folder (its manifest.yml)
|
|
70
|
+
# @param settings [Hash] config.yml `bundles: <name>:`
|
|
71
|
+
# @param host [Hash] session values (Recorder::DEFAULTS' keys)
|
|
72
|
+
# @param sandbox [Sandbox, nil] default Sandbox.current, else one of
|
|
73
|
+
# its own, left at #shutdown
|
|
74
|
+
# @return [Bundle]
|
|
75
|
+
def load(dir, settings: {}, host: {}, sandbox: nil)
|
|
76
|
+
new(dir, settings: settings, host: host, sandbox: sandbox).tap(&:load!)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A bundle chi ships (lib/samagotchi/bundles/<name>), as #load.
|
|
80
|
+
def shipped(name, **) = load(File.join(SHIPPED_DIR, name.to_s), **)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [String] the bundle's name (manifest.yml)
|
|
84
|
+
attr_reader :name
|
|
85
|
+
# @return [MemoryBundle::Manifest]
|
|
86
|
+
attr_reader :manifest
|
|
87
|
+
# @return [Plugin::Registries]
|
|
88
|
+
attr_reader :registries
|
|
89
|
+
# @return [Recorder]
|
|
90
|
+
attr_reader :recorder
|
|
91
|
+
# @return [Sessions] ctx.sessions
|
|
92
|
+
attr_reader :sessions
|
|
93
|
+
# @return [FakeHttp] what answers ctx.http (stub, requests, unmatched)
|
|
94
|
+
attr_reader :http
|
|
95
|
+
# @return [Sandbox]
|
|
96
|
+
attr_reader :sandbox
|
|
97
|
+
# @return [Plugin::Api, nil] the plugin's committed Api (nil without one)
|
|
98
|
+
attr_reader :api
|
|
99
|
+
# @return [Object, nil] the plugin's instance (the object #register ran
|
|
100
|
+
# on), for specs of its own methods
|
|
101
|
+
attr_reader :plugin
|
|
102
|
+
# @return [Module, nil] the module the plugin was evaluated in
|
|
103
|
+
# (bundle.namespace::ThinkingWatch)
|
|
104
|
+
attr_reader :namespace
|
|
105
|
+
# @return [Module] the module the hook files were evaluated in
|
|
106
|
+
attr_reader :hooks_namespace
|
|
107
|
+
# @return [Array<Init>]
|
|
108
|
+
attr_reader :inits
|
|
109
|
+
# @return [Array<StagedTools>]
|
|
110
|
+
attr_reader :staged_tools
|
|
111
|
+
# @return [Array<Hooks::Failure>] what raised under #lenient
|
|
112
|
+
attr_reader :errors
|
|
113
|
+
# @return [Integer] how often chi was told the tools changed
|
|
114
|
+
attr_reader :tools_changes
|
|
115
|
+
|
|
116
|
+
def initialize(dir, settings: {}, host: {}, sandbox: nil)
|
|
117
|
+
@dir = File.expand_path(dir)
|
|
118
|
+
@manifest = MemoryBundle::Manifest.read(dir: @dir)
|
|
119
|
+
@name = @manifest.name
|
|
120
|
+
@settings = stringify(settings || {})
|
|
121
|
+
@own_sandbox = sandbox.nil? && Sandbox.current.nil?
|
|
122
|
+
@sandbox = sandbox || Sandbox.current || Sandbox.enter
|
|
123
|
+
@recorder = Recorder.new(cwd: @sandbox.work_dir, **host)
|
|
124
|
+
@sessions = Sessions.new
|
|
125
|
+
@http = FakeHttp.new
|
|
126
|
+
@inits = []
|
|
127
|
+
@staged_tools = []
|
|
128
|
+
@errors = []
|
|
129
|
+
@lenient = false
|
|
130
|
+
@tools_changes = 0
|
|
131
|
+
@contexts = {}
|
|
132
|
+
@registries = build_registries
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @api private (Bundle.load)
|
|
136
|
+
def load!
|
|
137
|
+
if (plugin = @manifest.plugin)
|
|
138
|
+
@namespace = Plugin::Loader.fresh_namespace(@name)
|
|
139
|
+
@api = Plugin::Loader.register_file(@name, File.join(@dir, plugin[:file]), @registries, settings: @settings,
|
|
140
|
+
namespace: @namespace,
|
|
141
|
+
api_class: RecordingApi) do |built|
|
|
142
|
+
@plugin = built
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
@hooks_namespace = Hooks::BundleLoader.namespace_for(@name).const_set(:"KitHooks#{object_id}", Module.new)
|
|
146
|
+
@manifest.hooks.each do |basename, hook|
|
|
147
|
+
next if hook.event.empty?
|
|
148
|
+
|
|
149
|
+
Hooks::BundleLoader.load_hook_file(@name, basename, File.join(@dir, "hooks", basename), hook, hooks,
|
|
150
|
+
settings: @settings, namespace: @hooks_namespace)
|
|
151
|
+
end
|
|
152
|
+
self
|
|
153
|
+
rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
|
|
154
|
+
shutdown
|
|
155
|
+
raise
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ── Firing ──────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
# Fire hook +event+ through the registry, as chi's fire sites do: the
|
|
161
|
+
# event is {type: event, **fields} (a fire site's own keys, such as
|
|
162
|
+
# after_turn's present:/refs:, come from +fields+), with the runtime
|
|
163
|
+
# helpers added. Returns the event after the hooks.
|
|
164
|
+
# @return [Hash]
|
|
165
|
+
def fire(event, **fields)
|
|
166
|
+
payload = { type: event.to_sym, **fields }
|
|
167
|
+
failing { hooks.fire(event.to_sym, payload) }
|
|
168
|
+
payload
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# One tool call through a real Guardrails::Gate: the before_tool_call
|
|
172
|
+
# hooks vote on a Testing::Verdict (pass +verdict+ to start from one,
|
|
173
|
+
# another voter's deny in it), with the context and targets the Gate
|
|
174
|
+
# computes for the call (cwd: the session's by default).
|
|
175
|
+
# @param call [Hash] the parsed call ({name:, content:/path:/args:})
|
|
176
|
+
# @param context [Guardrails::Context, nil]
|
|
177
|
+
# @return [Verdict]
|
|
178
|
+
def tool_call(call, params: nil, cwd: nil, context: nil, verdict: nil, iteration: 1)
|
|
179
|
+
context ||= Guardrails::Context.new(cwd: cwd || @recorder[:cwd], session_id: @recorder[:session_id],
|
|
180
|
+
interface: @recorder[:frontend])
|
|
181
|
+
gate = Guardrails::Gate.new(-> { hooks }, context_lookup: -> { context }, tools_lookup: -> { tools },
|
|
182
|
+
checks_lookup: -> { [] })
|
|
183
|
+
verdict ||= Verdict.new(call: call)
|
|
184
|
+
params ||= "#{call[:name]} #{call[:content] || call[:path]}".strip
|
|
185
|
+
failing { gate.evaluate(call, iteration: iteration, params: params, verdict: verdict) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Run a command line ("/name args") as chi does: the first matching
|
|
189
|
+
# command, given the text after its name. Returns the handler's value
|
|
190
|
+
# (a raise propagates: chi would show it as an error result).
|
|
191
|
+
def command(line)
|
|
192
|
+
entry = commands.lookup(line)
|
|
193
|
+
raise ArgumentError, "no command matches #{line.inspect}" unless entry
|
|
194
|
+
raise ArgumentError, "#{entry.name} is chi's own command, not the bundle's" if entry.source == "core"
|
|
195
|
+
|
|
196
|
+
failing { entry.handler.call(line.to_s.strip.delete_prefix(entry.name).strip) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Call a registered tool as chi's dispatch does: its handler gets the
|
|
200
|
+
# call ({name:, args:}), types the args by the tool's schema
|
|
201
|
+
# (ToolSpec.args_of) and returns the text the model reads.
|
|
202
|
+
# @return [String]
|
|
203
|
+
def tool(name, args = {})
|
|
204
|
+
entry = tools[name.to_s]
|
|
205
|
+
raise ArgumentError, "no tool #{name}" unless entry
|
|
206
|
+
|
|
207
|
+
failing { entry.handler.call({ name: name.to_s, args: stringify(args) }, nil) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Run the chi.init blocks in order, as chi's init tasks do.
|
|
211
|
+
# @return [Array] their results
|
|
212
|
+
def run_inits = failing { @inits.map { |init| init.block.call } }
|
|
213
|
+
|
|
214
|
+
# Apply the staged chi.replace_tools sets, as chi does before a turn
|
|
215
|
+
# (ToolSpec.apply). @return [Boolean] whether the tools changed
|
|
216
|
+
def apply_staged_tools!
|
|
217
|
+
sets = @staged_tools.dup
|
|
218
|
+
@staged_tools.clear
|
|
219
|
+
sets.map { |set| Plugin::ToolSpec.apply(tools, @name, set.specs, set.context)[:changed] }.any?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Collect what raises in the block's fires in #errors instead of
|
|
223
|
+
# raising HookFailed.
|
|
224
|
+
def lenient
|
|
225
|
+
was = @lenient
|
|
226
|
+
@lenient = true
|
|
227
|
+
yield
|
|
228
|
+
ensure
|
|
229
|
+
@lenient = was
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# ── What it registered ─────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
# @return [Commands::Registry] chi's built-ins and the bundle's
|
|
235
|
+
def commands = @registries.commands
|
|
236
|
+
# @return [Tools::Registry] chi's built-ins and the bundle's
|
|
237
|
+
def tools = @registries.tools
|
|
238
|
+
# @return [Hooks::Registry]
|
|
239
|
+
def hooks = @registries.hooks
|
|
240
|
+
# @return [Array<Toolkit::Kit>]
|
|
241
|
+
def toolkits = @registries.toolkits
|
|
242
|
+
# @return [Tools::ToolIndex]
|
|
243
|
+
def tool_index = @registries.tool_index
|
|
244
|
+
# @return [PeerSources]
|
|
245
|
+
def peer_sources = @registries.peer_sources
|
|
246
|
+
|
|
247
|
+
# The bundle's own command entries.
|
|
248
|
+
def bundle_commands = commands.entries.select { |entry| entry.source == @name }
|
|
249
|
+
# The bundle's own tool entries.
|
|
250
|
+
def bundle_tools = tools.by_source(@name)
|
|
251
|
+
# Its hooks for +event+ as [bundle, hook_name, priority], fire order.
|
|
252
|
+
def hook_names(event) = hooks.bundle_hook_names(event.to_sym)
|
|
253
|
+
# The annotations its commands carry.
|
|
254
|
+
def annotations = bundle_commands.flat_map(&:annotations)
|
|
255
|
+
|
|
256
|
+
# The chi.* calls #register made, in order, as made (RecordingApi).
|
|
257
|
+
# @return [Array<Declaration>]
|
|
258
|
+
def declarations = @api ? @api.declarations : []
|
|
259
|
+
|
|
260
|
+
# The plugin's ctx (a real Plugin::Context; nil without a plugin).
|
|
261
|
+
def ctx = @contexts.values.first
|
|
262
|
+
|
|
263
|
+
# ── Recorders ──────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
def notices = @recorder.notices
|
|
266
|
+
def cards = @recorder.cards
|
|
267
|
+
def steers = @recorder.steers
|
|
268
|
+
# Every stop_turn and stop_generation, in order (Stop#kind).
|
|
269
|
+
def stops = @recorder.stops
|
|
270
|
+
def turn_stops = @recorder.turn_stops
|
|
271
|
+
def generation_stops = @recorder.generation_stops
|
|
272
|
+
def questions = @recorder.questions
|
|
273
|
+
def model_requests = @recorder.model_requests
|
|
274
|
+
def toolkit_uses = @recorder.toolkit_uses
|
|
275
|
+
# Queue ask_model answers: a String, an exception, or a callable.
|
|
276
|
+
def model_answers = @recorder.model_answers
|
|
277
|
+
|
|
278
|
+
# Queue ask_user answers ({selected:, freeform:, selected_indices:},
|
|
279
|
+
# or a callable given the Question).
|
|
280
|
+
def answer(*answers)
|
|
281
|
+
@recorder.answers.concat(answers)
|
|
282
|
+
self
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Change the session's values (cwd:, messages:, running: …).
|
|
286
|
+
def set(**values)
|
|
287
|
+
@recorder.set(**values)
|
|
288
|
+
self
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Stop the plugin's services; leave the sandbox it entered.
|
|
292
|
+
def shutdown
|
|
293
|
+
@registries&.services&.stop_all
|
|
294
|
+
return unless @own_sandbox
|
|
295
|
+
|
|
296
|
+
@own_sandbox = false
|
|
297
|
+
@sandbox.leave
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
def build_registries
|
|
303
|
+
builtin_tools = Tools::Builtins.registry
|
|
304
|
+
toolkits = []
|
|
305
|
+
host = Host.build(@recorder, toolkits: -> { Toolkit::BUILTIN_KITS.keys + toolkits.map(&:name) },
|
|
306
|
+
tool_names: -> { @registries.tools.names })
|
|
307
|
+
Plugin::Registries.new(
|
|
308
|
+
commands: SessionCommands.register_builtins(Commands::Registry.new),
|
|
309
|
+
tools: builtin_tools,
|
|
310
|
+
hooks: Hooks::Registry.new.tap { |registry| registry.runtime = Runtime.build(@recorder) },
|
|
311
|
+
context_for: lambda { |bundle, settings, label|
|
|
312
|
+
@contexts[label] = Plugin::Context.new(bundle: bundle, label: label, settings: settings, host: host,
|
|
313
|
+
env: ENV, sessions: @sessions, http_transport: @http)
|
|
314
|
+
},
|
|
315
|
+
tools_changed: -> { @tools_changes += 1 },
|
|
316
|
+
services: Plugin::Services.new,
|
|
317
|
+
stage_tools: ->(_bundle, specs, context) { @staged_tools << StagedTools.new(specs: specs, context: context) },
|
|
318
|
+
init: lambda { |_bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
|
|
319
|
+
@inits << Init.new(label: label, plugin_label: plugin_label, provides_tools: provides_tools, quiet: quiet,
|
|
320
|
+
timeout: timeout, failed: failed, block: block)
|
|
321
|
+
},
|
|
322
|
+
toolkits: toolkits,
|
|
323
|
+
tool_index: Tools::ToolIndex.new,
|
|
324
|
+
peer_sources: PeerSources.new
|
|
325
|
+
)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Run the block catching the hooks that raise in it.
|
|
329
|
+
def failing(&)
|
|
330
|
+
failures = []
|
|
331
|
+
result = Hooks.capture_failures(failures, &)
|
|
332
|
+
if @lenient
|
|
333
|
+
@errors.concat(failures)
|
|
334
|
+
elsif (first = failures.first)
|
|
335
|
+
raise HookFailed, first
|
|
336
|
+
end
|
|
337
|
+
result
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def stringify(value)
|
|
341
|
+
case value
|
|
342
|
+
when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
|
|
343
|
+
when Array then value.map { |item| stringify(item) }
|
|
344
|
+
else value
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Testing
|
|
8
|
+
# Fake commands for a test's PATH (Sandbox#fake_executable), cheap to
|
|
9
|
+
# exec: macOS checks a new executable file on its first exec (about
|
|
10
|
+
# 0.5–1 s, once per file). A fake is a symlink to one launcher, made
|
|
11
|
+
# once per process, that runs the fake's body (<name>.sh beside the
|
|
12
|
+
# link) with /bin/sh: writing a new body costs nothing.
|
|
13
|
+
module FakeExecutable
|
|
14
|
+
LAUNCHER = "#!/bin/sh\nexec /bin/sh \"$0.sh\" \"$@\"\n"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Writes +dir+/+name+, which runs +body+ (sh) with its arguments;
|
|
19
|
+
# again for the same name replaces the body.
|
|
20
|
+
# @return [String] the command's path
|
|
21
|
+
def write(dir, name, body)
|
|
22
|
+
FileUtils.mkdir_p(dir)
|
|
23
|
+
path = File.join(dir, name)
|
|
24
|
+
File.write("#{path}.sh", body.end_with?("\n") ? body : "#{body}\n")
|
|
25
|
+
FileUtils.ln_sf(launcher, path)
|
|
26
|
+
path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def launcher
|
|
30
|
+
@launcher ||= begin
|
|
31
|
+
dir = Dir.mktmpdir("chi-fake-exec")
|
|
32
|
+
owner = Process.pid
|
|
33
|
+
at_exit { FileUtils.rm_rf(dir) if Process.pid == owner }
|
|
34
|
+
File.join(dir, "launcher").tap do |path|
|
|
35
|
+
File.write(path, LAUNCHER)
|
|
36
|
+
File.chmod(0o755, path)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|