samagotchi 0.12.0 → 0.13.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 +62 -1
- data/README.md +20 -7
- data/bin/chi +5 -2
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +40 -14
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +0 -1
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +34 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +15 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +16 -4
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -3
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- metadata +7 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../paths"
|
|
5
|
+
require_relative "../hooks/loader"
|
|
6
|
+
require_relative "approval"
|
|
7
|
+
require_relative "protected_paths"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Guardrails
|
|
11
|
+
# What a parent agent may allow on an approval (guardrails.parent_approvals,
|
|
12
|
+
# config.yml only): off, a deny only; once, "Allow once" too, never a
|
|
13
|
+
# wider scope. `chi answer` checks it before it posts; the worker checks
|
|
14
|
+
# it again for an answer marked as chi answer's (CLIENT_ID), with its own
|
|
15
|
+
# config. A convention for an honest but eager parent, not a security
|
|
16
|
+
# boundary: any local process can post to the Bridge, with or without
|
|
17
|
+
# the marker.
|
|
18
|
+
#
|
|
19
|
+
# An option allows when Approval.settle would allow it: its index is
|
|
20
|
+
# below the approval's scopes. The scope is read by index, never by
|
|
21
|
+
# label. When the scopes are missing or don't fit the options, it fails
|
|
22
|
+
# closed: only a last option labelled Deny denies.
|
|
23
|
+
#
|
|
24
|
+
# Whatever the setting, an approval for a call on chi's own config dir
|
|
25
|
+
# (config.yml, memories/.bundles' rules), the hooks dir or the approval
|
|
26
|
+
# store is the user's alone (:protected): "Allow once" on a config.yml
|
|
27
|
+
# rewrite would be "allow everything" from then on.
|
|
28
|
+
module ParentApprovals
|
|
29
|
+
KEY = "guardrails.parent_approvals"
|
|
30
|
+
# The client id chi answer posts its answers with.
|
|
31
|
+
CLIENT_ID = "cli:answer"
|
|
32
|
+
# Set by chi's execute and task_create for the commands they run: a
|
|
33
|
+
# chi started there answers as a parent (#parent_process?).
|
|
34
|
+
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
35
|
+
# Environment variables that say an agent runs this process: Claude
|
|
36
|
+
# Code's, and chi's own. They survive a PTY wrapper (script, expect).
|
|
37
|
+
AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", PARENT_SESSION_ENV].freeze
|
|
38
|
+
# Rules that ask about chi's own config and hooks: the core
|
|
39
|
+
# ProtectedPaths asks and the guardrails bundle's shell rule.
|
|
40
|
+
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
|
|
41
|
+
# A shell command naming chi's config, hooks or guardrails (the
|
|
42
|
+
# guardrails bundle's shell-touches-chi, less .git/hooks).
|
|
43
|
+
CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Whether an answer typed into this process is a parent agent's (so
|
|
48
|
+
# this setting applies to it): stdin isn't a terminal (a pipe, a file,
|
|
49
|
+
# /dev/null), or an agent marker is set. A person at chi --attach or
|
|
50
|
+
# the REPL has a terminal and no marker.
|
|
51
|
+
def parent_process?(env: ENV, stdin: $stdin)
|
|
52
|
+
return true if AGENT_MARKERS.any? { |key| !env.fetch(key, "").to_s.strip.empty? }
|
|
53
|
+
|
|
54
|
+
!(stdin.respond_to?(:tty?) && stdin.tty?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# This process's setting: "once" or "off" (anything else is off).
|
|
58
|
+
def setting
|
|
59
|
+
Config.get(KEY).to_s == "once" ? "once" : "off"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Why a parent may not give this answer, or nil.
|
|
63
|
+
# @param pending [Hash] the pending question (symbol or string keys)
|
|
64
|
+
# @param indices [Array<Integer, nil>] the selected options' places
|
|
65
|
+
# @param setting [String, nil] "off" or "once"
|
|
66
|
+
# @return [Symbol, nil] :protected (chi's own config: the user's
|
|
67
|
+
# alone), :off (no allow at all), :once_only, or nil
|
|
68
|
+
def refusal(pending, indices, setting:)
|
|
69
|
+
return nil unless approval?(pending)
|
|
70
|
+
|
|
71
|
+
scopes = scopes(pending)
|
|
72
|
+
allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
|
|
73
|
+
return nil if allows.empty?
|
|
74
|
+
return :protected if protected?(pending)
|
|
75
|
+
return :off unless setting.to_s == "once"
|
|
76
|
+
return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
|
|
77
|
+
|
|
78
|
+
:once_only
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether +pending+ asks about a call on chi's own config, hooks,
|
|
82
|
+
# guardrail rules or approvals: by its rule, its paths or its command
|
|
83
|
+
# (the dirs as this process's environment and config.yml name them).
|
|
84
|
+
def protected?(pending)
|
|
85
|
+
facts = fetch(pending, :approval)
|
|
86
|
+
return false unless facts.is_a?(Hash)
|
|
87
|
+
return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
|
|
88
|
+
return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
|
|
89
|
+
|
|
90
|
+
dirs = chi_dirs
|
|
91
|
+
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
92
|
+
return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
|
|
93
|
+
|
|
94
|
+
command = fetch(facts, :command).to_s
|
|
95
|
+
return false if command.empty?
|
|
96
|
+
|
|
97
|
+
command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# chi's config dir, the hooks dir and the approval store's dir.
|
|
101
|
+
def chi_dirs
|
|
102
|
+
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
103
|
+
[ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails")]
|
|
104
|
+
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The refusal as a line for the parent: deny, and tell the user.
|
|
108
|
+
# Never how to allow it (the web, chi --attach): that is the user's.
|
|
109
|
+
# @param reason [Symbol] what #refusal returned
|
|
110
|
+
# @param typed [Boolean] the answer was typed at a ? prompt (the
|
|
111
|
+
# REPL), not given to chi answer
|
|
112
|
+
def message(reason, typed: false)
|
|
113
|
+
deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
|
|
114
|
+
case reason
|
|
115
|
+
when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
|
|
116
|
+
when :off then "allowing a tool call is up to the user: #{deny}"
|
|
117
|
+
else "only Allow once (#{KEY}: once) can be given here: #{deny}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# An approval: its kind says so, or it carries approval facts.
|
|
122
|
+
def approval?(pending)
|
|
123
|
+
fetch(pending, :kind).to_s == Approval::KIND || !fetch(pending, :approval).nil?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The offered scopes, in option order, or nil when they are missing,
|
|
127
|
+
# empty, not strings, or leave no option after them for Deny.
|
|
128
|
+
def scopes(pending)
|
|
129
|
+
facts = fetch(pending, :approval)
|
|
130
|
+
scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
|
|
131
|
+
return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
|
|
132
|
+
return nil unless scopes.size < options(pending).size
|
|
133
|
+
|
|
134
|
+
scopes
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def deny?(pending, scopes, index)
|
|
138
|
+
return false unless index.is_a?(Integer) && index >= 0
|
|
139
|
+
|
|
140
|
+
options = options(pending)
|
|
141
|
+
return index >= scopes.size && index < options.size if scopes
|
|
142
|
+
|
|
143
|
+
index == options.size - 1 && options[index].to_s == Approval::DENY
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def options(pending) = Array(fetch(pending, :options))
|
|
147
|
+
|
|
148
|
+
def fetch(hash, key)
|
|
149
|
+
return nil unless hash.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -4,6 +4,8 @@ require_relative "registry"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../log"
|
|
6
6
|
require_relative "../memory_bundle/provenance"
|
|
7
|
+
require_relative "../memory_bundle/manifest"
|
|
8
|
+
require_relative "../version"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
# Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
|
|
@@ -28,7 +30,8 @@ module Samagotchi
|
|
|
28
30
|
# fails to load, or its file's sha256 differs from the one recorded at
|
|
29
31
|
# install, the failure is reported and the gate denies every tool call.
|
|
30
32
|
# Any hook whose sha256 differs is not loaded (reinstall the bundle
|
|
31
|
-
# after editing a hook by hand)
|
|
33
|
+
# after editing a hook by hand), nor any of a bundle whose requires_chi
|
|
34
|
+
# this chi doesn't meet.
|
|
32
35
|
#
|
|
33
36
|
# The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
|
|
34
37
|
# - "fail_closed" (on :before_tool_call): a raising guardrail denies
|
|
@@ -48,10 +51,14 @@ module Samagotchi
|
|
|
48
51
|
# @param settings [Hash] the bundle's section of config.yml
|
|
49
52
|
# `bundles:` (string keys); a hook class whose initialize takes an
|
|
50
53
|
# argument gets it
|
|
54
|
+
# @param requires_chi [String, nil] the bundle's requirement; when
|
|
55
|
+
# this chi doesn't meet it no hook loads (each is reported, as the
|
|
56
|
+
# plugin loader does)
|
|
51
57
|
# @return [Integer] number of hooks successfully registered
|
|
52
|
-
def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {})
|
|
58
|
+
def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, requires_chi: nil)
|
|
53
59
|
return 0 unless metadata.is_a?(Hash)
|
|
54
60
|
|
|
61
|
+
too_old = MemoryBundle::Manifest.requires_chi_failure(requires_chi, Samagotchi::VERSION)
|
|
55
62
|
loaded = 0
|
|
56
63
|
metadata.each do |raw_basename, raw_meta|
|
|
57
64
|
basename = raw_basename.to_s
|
|
@@ -72,7 +79,7 @@ module Samagotchi
|
|
|
72
79
|
failures&.add(what, "the file is missing", required: true) if fail_closed
|
|
73
80
|
next
|
|
74
81
|
end
|
|
75
|
-
if (mismatch = sha_mismatch(file, meta.(:sha256), required: fail_closed))
|
|
82
|
+
if (mismatch = too_old || sha_mismatch(file, meta.(:sha256), required: fail_closed))
|
|
76
83
|
Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
|
|
77
84
|
failures&.add(what, mismatch, required: fail_closed)
|
|
78
85
|
next
|
|
@@ -21,7 +21,6 @@ module Samagotchi
|
|
|
21
21
|
class HostRegistry
|
|
22
22
|
CACHE_TTL_SECONDS = 60
|
|
23
23
|
REMOTE_CACHE_TTL_SECONDS = 600
|
|
24
|
-
LIST_TIMEOUT_SECONDS = 3
|
|
25
24
|
# Seconds a remote host's stream may take to show something (a queued
|
|
26
25
|
# free model on OpenRouter can send only keep-alives for minutes).
|
|
27
26
|
REMOTE_FIRST_TOKEN_TIMEOUT = 120
|
|
@@ -245,12 +245,19 @@ module Samagotchi
|
|
|
245
245
|
function = call["function"] || {}
|
|
246
246
|
ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
|
|
247
247
|
end
|
|
248
|
-
ChatResponse.new(text: message["content"].to_s,
|
|
248
|
+
ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
|
|
249
249
|
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
250
250
|
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
251
251
|
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
|
|
252
252
|
end
|
|
253
253
|
|
|
254
|
+
# Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
|
|
255
|
+
# writes after </think> at the start of the answer
|
|
256
|
+
# (https://github.com/incoai/splash/issues/254). Remove once Splash
|
|
257
|
+
# strips it; llama.cpp already does. Streamed, it applies to the chunks
|
|
258
|
+
# until the first text.
|
|
259
|
+
def self.drop_leading_newlines(text) = text.sub(/\A[\r\n]+/, "")
|
|
260
|
+
|
|
254
261
|
def self.served_model(payload)
|
|
255
262
|
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
256
263
|
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
@@ -372,6 +379,7 @@ module Samagotchi
|
|
|
372
379
|
@finish_reason = choice["finish_reason"] if choice["finish_reason"]
|
|
373
380
|
delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
|
|
374
381
|
content = delta["content"].to_s
|
|
382
|
+
content = OpenAIChat.drop_leading_newlines(content) if @text.empty?
|
|
375
383
|
reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
|
|
376
384
|
@text << content
|
|
377
385
|
@reasoning << reasoning
|
|
@@ -265,6 +265,7 @@ module Samagotchi
|
|
|
265
265
|
# ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
|
|
266
266
|
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
267
267
|
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
268
|
+
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
268
269
|
|
|
269
270
|
# Files the previous version shipped and this one doesn't.
|
|
270
271
|
if @upgrade && existing_provenance && existing_provenance[:files]
|
|
@@ -467,6 +468,17 @@ module Samagotchi
|
|
|
467
468
|
end
|
|
468
469
|
end
|
|
469
470
|
|
|
471
|
+
# The hook loader skips every hook of a bundle whose requires_chi this
|
|
472
|
+
# chi doesn't meet (Hooks::BundleLoader); say so at install, as
|
|
473
|
+
# install_plugin does for the plugin.
|
|
474
|
+
def warn_hooks_requires_chi(manifest, hook_files)
|
|
475
|
+
return if manifest.nil? || hook_files.empty?
|
|
476
|
+
|
|
477
|
+
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
478
|
+
@warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
470
482
|
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
471
483
|
# warning when its sha256 differs from the declared one or this chi
|
|
472
484
|
# doesn't meet requires_chi (the Engine then won't load it).
|
|
@@ -43,7 +43,15 @@ module Samagotchi
|
|
|
43
43
|
}
|
|
44
44
|
end
|
|
45
45
|
{ provenance: data, scope: scope, target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
|
|
46
|
-
needs: needs_status(data) }
|
|
46
|
+
hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Why none of the bundle's hooks load (this chi doesn't meet its
|
|
50
|
+
# requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
|
|
51
|
+
def self.hooks_requires_failure(data)
|
|
52
|
+
return nil if (data[:hooks] || {}).empty?
|
|
53
|
+
|
|
54
|
+
Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
# The stored needs, each with found: from this process's PATH (the
|
|
@@ -51,6 +51,14 @@ module Samagotchi
|
|
|
51
51
|
File.basename(common) == ".git" ? File.dirname(common) : common
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The top of the git work tree holding +cwd+: the directory with the
|
|
55
|
+
# nearest .git (a linked worktree's own checkout, unlike #project_root);
|
|
56
|
+
# nil outside any repository.
|
|
57
|
+
def work_tree_root(cwd = Dir.pwd)
|
|
58
|
+
dot_git = find_dot_git(cwd)
|
|
59
|
+
dot_git && File.dirname(dot_git)
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
# Is +cwd+ inside a git repository (a .git somewhere above it)?
|
|
55
63
|
def in_repo?(cwd = Dir.pwd)
|
|
56
64
|
!find_dot_git(cwd).nil?
|
|
@@ -18,11 +18,18 @@ module Samagotchi
|
|
|
18
18
|
# so every surface that reports a missing model reports it the same way).
|
|
19
19
|
UnknownHost = Class.new(MissingModel)
|
|
20
20
|
|
|
21
|
+
# Gemma 4 thinks in a channel: `<|channel>thought` … `<channel|>`. The
|
|
22
|
+
# one source for the parser, the stream splitter, the literal guard and
|
|
23
|
+
# the web's saved-message reader.
|
|
24
|
+
GEMMA_THOUGHT_CHANNEL_OPEN = "<|channel>thought"
|
|
25
|
+
GEMMA_THOUGHT_CHANNEL_CLOSE = "<channel|>"
|
|
26
|
+
|
|
21
27
|
attr_reader :name, :turn_start, :turn_end,
|
|
22
28
|
:tool_call_open, :tool_call_close,
|
|
23
29
|
:tool_response_open, :tool_response_close,
|
|
24
30
|
:string_delim,
|
|
25
31
|
:thought_open, :thought_close,
|
|
32
|
+
:thought_channel_open, :thought_channel_close,
|
|
26
33
|
:system_prefix, :user_prefix, :assistant_prefix,
|
|
27
34
|
:model_prefix,
|
|
28
35
|
:stop_sequences, :tool_decl_format,
|
|
@@ -39,6 +46,9 @@ module Samagotchi
|
|
|
39
46
|
@string_delim = config[:string_delim]
|
|
40
47
|
@thought_open = config[:thought_open]
|
|
41
48
|
@thought_close = config[:thought_close]
|
|
49
|
+
# A thinking block besides thought_open's (Gemma's channel), or nil.
|
|
50
|
+
@thought_channel_open = config[:thought_channel_open]
|
|
51
|
+
@thought_channel_close = config[:thought_channel_close]
|
|
42
52
|
@system_prefix = config[:system_prefix]
|
|
43
53
|
@user_prefix = config[:user_prefix]
|
|
44
54
|
@assistant_prefix = config[:assistant_prefix]
|
|
@@ -68,6 +78,8 @@ module Samagotchi
|
|
|
68
78
|
string_delim: '<|"|>',
|
|
69
79
|
thought_open: "<|think|>",
|
|
70
80
|
thought_close: nil,
|
|
81
|
+
thought_channel_open: GEMMA_THOUGHT_CHANNEL_OPEN,
|
|
82
|
+
thought_channel_close: GEMMA_THOUGHT_CHANNEL_CLOSE,
|
|
71
83
|
system_prefix: "",
|
|
72
84
|
user_prefix: "",
|
|
73
85
|
assistant_prefix: "",
|
|
@@ -131,8 +143,9 @@ module Samagotchi
|
|
|
131
143
|
# qualified with a host that isn't configured, instead of sending the
|
|
132
144
|
# whole ref to the default host as a model id, and when an alias after
|
|
133
145
|
# a host prefix names another host ("openrouter:tiny" with tiny:
|
|
134
|
-
# box:…)
|
|
135
|
-
# spawned session)
|
|
146
|
+
# box:…), or with a host config.yml has with enabled: false. Where a
|
|
147
|
+
# model comes in (an Engine starting or switching, a spawned session)
|
|
148
|
+
# calls it.
|
|
136
149
|
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
137
150
|
# entries); config.yml's by default
|
|
138
151
|
# @return [String] +model_name+
|
|
@@ -145,6 +158,9 @@ module Samagotchi
|
|
|
145
158
|
"use #{parsed.alias_name} or #{other}:#{parsed.alias_name}"
|
|
146
159
|
end
|
|
147
160
|
ref = parsed.ref
|
|
161
|
+
if !parsed.host_name && (disabled = disabled_host_prefix(ref, hosts, env))
|
|
162
|
+
raise UnknownHost, "host '#{disabled}' is disabled (enabled: false in config.yml)"
|
|
163
|
+
end
|
|
148
164
|
warn_host_slash(ref, hosts) unless parsed.host_name
|
|
149
165
|
host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
|
|
150
166
|
return model_name unless host
|
|
@@ -155,6 +171,18 @@ module Samagotchi
|
|
|
155
171
|
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
|
|
156
172
|
end
|
|
157
173
|
|
|
174
|
+
# The prefix of "box:x" when box is a host config.yml has with
|
|
175
|
+
# enabled: false (left out of +hosts+), else nil.
|
|
176
|
+
def self.disabled_host_prefix(ref, hosts, env)
|
|
177
|
+
prefix, rest = ref.to_s.split(":", 2)
|
|
178
|
+
return nil if rest.to_s.strip.empty?
|
|
179
|
+
|
|
180
|
+
prefix = prefix.strip.downcase
|
|
181
|
+
return nil if hosts.keys.any? { |k| k.to_s.downcase == prefix }
|
|
182
|
+
|
|
183
|
+
Samagotchi::ConfigFile.disabled_host_names(env: env).include?(prefix) ? prefix : nil
|
|
184
|
+
end
|
|
185
|
+
|
|
158
186
|
# "box/x" named host box until '/' stopped naming a host: a saved
|
|
159
187
|
# session or a hand-written ref says so once, then goes to the default host.
|
|
160
188
|
def self.warn_host_slash(ref, hosts)
|
|
@@ -193,10 +221,6 @@ module Samagotchi
|
|
|
193
221
|
DEFAULT_NAME
|
|
194
222
|
end
|
|
195
223
|
|
|
196
|
-
def self.from_env
|
|
197
|
-
from_model_name(required_model_name(nil))
|
|
198
|
-
end
|
|
199
|
-
|
|
200
224
|
# ── Resolution: which profile a model gets ───────────────────────────
|
|
201
225
|
|
|
202
226
|
NAMES = %w[qwen36 gemma4].freeze
|
|
@@ -328,16 +352,6 @@ module Samagotchi
|
|
|
328
352
|
end
|
|
329
353
|
private_class_method :probe?
|
|
330
354
|
|
|
331
|
-
# ── Thought channel support ──────────────────────────────────────────
|
|
332
|
-
|
|
333
|
-
def thought_channel_open
|
|
334
|
-
nil
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def uses_channel_thoughts?
|
|
338
|
-
false
|
|
339
|
-
end
|
|
340
|
-
|
|
341
355
|
def uses_role_prefixes?
|
|
342
356
|
!system_prefix.empty? && !user_prefix.empty?
|
|
343
357
|
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "cli/exit"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
|
|
10
|
+
# sub-agent: `chi send --wait` and `chi answer`. One JSON object for
|
|
11
|
+
# --format json, or a line (a block for a question) for stderr, and the
|
|
12
|
+
# exit status. A question carries everything needed to answer it, so a
|
|
13
|
+
# parent agent can answer from the output alone. Kept apart from the
|
|
14
|
+
# commands so a later delegate_answer tool reports the same shape.
|
|
15
|
+
module ParentReport
|
|
16
|
+
# The approval facts a parent needs; the rest (preview, branch) is the
|
|
17
|
+
# card's.
|
|
18
|
+
APPROVAL_KEYS = %i[tool label command paths args cwd rule source reason scopes].freeze
|
|
19
|
+
|
|
20
|
+
# stderr that keeps its last line: with --format json a failure before
|
|
21
|
+
# any wait is reported from it (#error_line).
|
|
22
|
+
class LastLine < SimpleDelegator
|
|
23
|
+
attr_reader :last
|
|
24
|
+
|
|
25
|
+
def puts(*lines)
|
|
26
|
+
@last = lines.last.to_s.chomp unless lines.empty?
|
|
27
|
+
__getobj__.puts(*lines)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param result [ReplyWait::Result]
|
|
34
|
+
# @return [String] answered, question, failed, canceled, no_answer,
|
|
35
|
+
# error, worker_gone, stopped or running
|
|
36
|
+
def status(result)
|
|
37
|
+
case result.status
|
|
38
|
+
when :done then "answered"
|
|
39
|
+
when :waiting_for_answer then "question"
|
|
40
|
+
when :no_reply
|
|
41
|
+
case result.outcome
|
|
42
|
+
when "failed" then "failed"
|
|
43
|
+
when "canceled" then "canceled"
|
|
44
|
+
else "no_answer"
|
|
45
|
+
end
|
|
46
|
+
when :error then "error"
|
|
47
|
+
when :worker_gone then "worker_gone"
|
|
48
|
+
when :stopped then "stopped"
|
|
49
|
+
# ReplyWait's own :canceled is the caller giving up (its cancelled:),
|
|
50
|
+
# and :timeout the deadline: the turn goes on either way.
|
|
51
|
+
else "running"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer] 0 answered, 3 a question waits, 4 still running,
|
|
56
|
+
# 1 anything else
|
|
57
|
+
def exit_status(result)
|
|
58
|
+
case status(result)
|
|
59
|
+
when "answered" then CLI::Exit::OK
|
|
60
|
+
when "question" then CLI::Exit::QUESTION
|
|
61
|
+
when "running" then CLI::Exit::RUNNING
|
|
62
|
+
else CLI::Exit::FAILED
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param session_id [String]
|
|
67
|
+
# @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
|
|
68
|
+
# @return [Hash] the --format json object
|
|
69
|
+
def json(result, session_id:, timeout: nil)
|
|
70
|
+
report = { status: status(result), session_id: session_id }
|
|
71
|
+
case report[:status]
|
|
72
|
+
when "answered" then report[:text] = result.text.to_s
|
|
73
|
+
when "question"
|
|
74
|
+
question = question(result.question)
|
|
75
|
+
report[:question] = question
|
|
76
|
+
report[:answer_with] = answer_with(session_id, question)
|
|
77
|
+
else report[:detail] = detail(result, session_id: session_id, timeout: timeout)
|
|
78
|
+
end
|
|
79
|
+
report
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String] the JSON object as one line
|
|
83
|
+
def json_line(result, session_id:, timeout: nil)
|
|
84
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Print a wait's end the way the commands do: the JSON object on
|
|
88
|
+
# stdout, or the reply on stdout and anything else on stderr (a
|
|
89
|
+
# question in full) after "chi send: ".
|
|
90
|
+
# @param command [String] "chi send", "chi answer"
|
|
91
|
+
# @return [Integer] the exit status
|
|
92
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
|
|
93
|
+
if json
|
|
94
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
|
|
95
|
+
elsif result.status == :done
|
|
96
|
+
stdout.puts(result.text)
|
|
97
|
+
else
|
|
98
|
+
stdout.flush
|
|
99
|
+
text = if result.status == :waiting_for_answer
|
|
100
|
+
question_text(result.question, session_id: session_id)
|
|
101
|
+
else
|
|
102
|
+
"#{detail(result, session_id: session_id, timeout: timeout)}\n"
|
|
103
|
+
end
|
|
104
|
+
stderr.print("#{command}: #{text}")
|
|
105
|
+
end
|
|
106
|
+
stdout.flush
|
|
107
|
+
exit_status(result)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A failure before any wait (no such session, a busy one, a message
|
|
111
|
+
# that didn't go in) as the JSON object.
|
|
112
|
+
# @param session_id [String, nil]
|
|
113
|
+
def error_line(detail, session_id: nil)
|
|
114
|
+
JSON.generate({ status: "error", session_id: session_id, detail: detail.to_s })
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A pending question (Session#pending_question) as a parent sees it.
|
|
118
|
+
# kind is "question" for the model's own (the desk stores none),
|
|
119
|
+
# "hook" or "approval".
|
|
120
|
+
# @return [Hash]
|
|
121
|
+
def question(pending)
|
|
122
|
+
pending ||= {}
|
|
123
|
+
approval = pending[:approval].is_a?(Hash) ? symbolize(pending[:approval]) : nil
|
|
124
|
+
{
|
|
125
|
+
id: pending[:id].to_s,
|
|
126
|
+
kind: (pending[:kind] || "question").to_s,
|
|
127
|
+
header: blank(pending[:header]),
|
|
128
|
+
text: pending[:question].to_s,
|
|
129
|
+
options: Array(pending[:options]).map(&:to_s),
|
|
130
|
+
multi_select: !!pending[:multi_select],
|
|
131
|
+
allow_freeform: !!pending[:allow_freeform],
|
|
132
|
+
approval: approval&.slice(*APPROVAL_KEYS)
|
|
133
|
+
}.compact
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The command that answers +question+; N is the parent's to fill in.
|
|
137
|
+
# An approval's is a deny: allowing it is the user's
|
|
138
|
+
# (Guardrails::ParentApprovals).
|
|
139
|
+
def answer_with(session_id, question)
|
|
140
|
+
return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
|
|
141
|
+
|
|
142
|
+
"chi answer #{session_id} --question #{question[:id]} --option N"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# What a parent does with an approval.
|
|
146
|
+
DENY_AND_TELL = "deny it, and tell your user"
|
|
147
|
+
|
|
148
|
+
def approval?(question)
|
|
149
|
+
question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# What a wait that ended without an answer says, one line.
|
|
153
|
+
def detail(result, session_id:, timeout: nil)
|
|
154
|
+
attach = "chi --attach #{session_id}"
|
|
155
|
+
case result.status
|
|
156
|
+
when :waiting_for_answer
|
|
157
|
+
if approval?(result.question)
|
|
158
|
+
"waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
|
|
159
|
+
else
|
|
160
|
+
"waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
|
|
161
|
+
end
|
|
162
|
+
when :no_reply then "#{no_reply_line(result)}; #{attach} shows it"
|
|
163
|
+
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
164
|
+
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
165
|
+
when :stopped then "the session was stopped (chi sessions stop)"
|
|
166
|
+
else timeout ? "still running after #{format("%g", timeout)} s: #{attach}" : "still running: #{attach}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The whole question for stderr: header, text, numbered options, what
|
|
171
|
+
# else it takes, and the commands that answer it.
|
|
172
|
+
# @return [String] lines, newline-terminated
|
|
173
|
+
def question_text(pending, session_id:)
|
|
174
|
+
q = question(pending)
|
|
175
|
+
lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
|
|
176
|
+
text_lines = q[:text].strip.lines.map(&:rstrip)
|
|
177
|
+
text_lines = text_lines.drop(1) unless q[:header]
|
|
178
|
+
lines.concat(text_lines.map { |line| " #{line}" })
|
|
179
|
+
q[:options].each_with_index { |option, i| lines << " #{i + 1}. #{option}" }
|
|
180
|
+
if approval?(q)
|
|
181
|
+
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
182
|
+
lines << " deny: #{answer_with(session_id, q)}"
|
|
183
|
+
else
|
|
184
|
+
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
185
|
+
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
186
|
+
lines << " answer: #{answer_with(session_id, q)}"
|
|
187
|
+
lines << " or open it: chi --attach #{session_id} or the web"
|
|
188
|
+
end
|
|
189
|
+
lines.map { |line| "#{line}\n" }.join
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def no_reply_line(result)
|
|
193
|
+
case result.outcome
|
|
194
|
+
when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
|
|
195
|
+
when "canceled" then "the turn was canceled"
|
|
196
|
+
when "completed" then "the turn ended with no visible answer"
|
|
197
|
+
else "the turn ended without a reply (canceled, failed or empty)"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def first_line(text)
|
|
202
|
+
text.to_s.strip.lines.first.to_s.strip
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def blank(value)
|
|
206
|
+
value.to_s.strip.empty? ? nil : value.to_s
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def symbolize(hash)
|
|
210
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require_relative "../log"
|
|
4
5
|
require_relative "../hooks/registry"
|
|
5
6
|
require_relative "../tools/args"
|
|
@@ -298,14 +299,26 @@ module Samagotchi
|
|
|
298
299
|
schema: spec[:schema], label: spec[:label],
|
|
299
300
|
handler: lambda { |call, _kctx|
|
|
300
301
|
result = block.call(Api.args_of(call, parameters), context)
|
|
301
|
-
|
|
302
|
-
result.is_a?(String) ? result : result.to_s
|
|
302
|
+
Api.result_text(result)
|
|
303
303
|
},
|
|
304
304
|
preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
|
|
305
305
|
targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
|
|
306
306
|
}
|
|
307
307
|
end
|
|
308
308
|
|
|
309
|
+
# A tool's result as the model reads it: a String (a ToolResult too,
|
|
310
|
+
# with its images) as it is, a Hash or Array as JSON (its #to_s is
|
|
311
|
+
# Hash#inspect, which changes with the Ruby version), else #to_s.
|
|
312
|
+
def self.result_text(result)
|
|
313
|
+
case result
|
|
314
|
+
when String then result
|
|
315
|
+
when Hash, Array then JSON.generate(result)
|
|
316
|
+
else result.to_s
|
|
317
|
+
end
|
|
318
|
+
rescue JSON::GeneratorError
|
|
319
|
+
result.to_s
|
|
320
|
+
end
|
|
321
|
+
|
|
309
322
|
# A tool call's arguments as a plugin sees them: the parsers' args:
|
|
310
323
|
# (a call built without one, in a spec say: the call without its
|
|
311
324
|
# name), string keys, typed by +parameters+, frozen.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "model_profile"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# Escapes literal control-token text when arbitrary source/output is embedded
|
|
5
7
|
# inside prompts, then restores those placeholders when model text is used.
|
|
@@ -13,8 +15,8 @@ module Samagotchi
|
|
|
13
15
|
["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "<tool_response|>"],
|
|
14
16
|
["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
|
|
15
17
|
["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
|
|
16
|
-
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]",
|
|
17
|
-
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]",
|
|
18
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
|
|
19
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
|
|
18
20
|
].freeze
|
|
19
21
|
|
|
20
22
|
QWEN_TOKEN_PAIRS = [
|