samagotchi 0.58.0 → 0.59.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 +38 -1
- data/docs/architecture.md +3 -3
- data/docs/cli.md +5 -4
- data/docs/configuration.md +35 -20
- data/docs/hooks.md +2 -2
- data/docs/internals/background-tasks.md +7 -0
- data/docs/internals/engine.md +5 -5
- data/docs/internals/prompt-caching.md +2 -2
- data/docs/internals/tool-guardrails.md +2 -1
- data/docs/plugins.md +26 -6
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +6 -2
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +11 -5
- data/lib/samagotchi/engine.rb +26 -1
- data/lib/samagotchi/host_config.rb +3 -1
- data/lib/samagotchi/host_registry.rb +2 -1
- data/lib/samagotchi/model_settings.rb +4 -2
- data/lib/samagotchi/process_group.rb +76 -3
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_commands.rb +15 -3
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/steer_cut_after.rb +69 -0
- data/lib/samagotchi/system_prompt.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
- data/lib/samagotchi/terminal_ui.rb +1 -1
- data/lib/samagotchi/tool_activity.rb +18 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/execute.rb +318 -70
- data/lib/samagotchi/tools/task_get.rb +2 -0
- data/lib/samagotchi/tools/task_runtime.rb +96 -13
- data/lib/samagotchi/tools/task_wait.rb +23 -5
- data/lib/samagotchi/utf8_default.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/read_view.js +3 -1
- data/lib/samagotchi/web/public/task_stop.js +25 -7
- metadata +2 -1
data/lib/samagotchi/steer_cut.rb
CHANGED
|
@@ -30,19 +30,23 @@ module Samagotchi
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# +clock+: monotonic seconds; +controller+: the running turn's cancel
|
|
33
|
-
# controller (nil between turns), read only when a cut is due
|
|
34
|
-
|
|
33
|
+
# controller (nil between turns), read only when a cut is due;
|
|
34
|
+
# +cut_after+: seconds a thinking-only generation may stream before a
|
|
35
|
+
# cut, called on every check (the Engine passes the model's own
|
|
36
|
+
# SteerCutAfter; the global steer.cut_after here).
|
|
37
|
+
def initialize(clock:, controller:, cut_after: -> { self.class.cut_after })
|
|
35
38
|
# What the running generation streams: whether a steer may cut it.
|
|
36
39
|
@phase = GenerationPhase.new(clock: clock)
|
|
37
40
|
# A message that may cut it but came too early: it cuts later.
|
|
38
41
|
@waiting = WaitingSteer.new
|
|
42
|
+
@cut_after = cut_after
|
|
39
43
|
@controller = controller
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
# A message for the running turn from +source+ (Steer.source_for_client:
|
|
43
47
|
# nil for the user, "chi_send", "parent_agent") cuts the streaming
|
|
44
|
-
# generation when it has streamed only thinking for
|
|
45
|
-
#
|
|
48
|
+
# generation when it has streamed only thinking for cut_after seconds
|
|
49
|
+
# (GenerationPhase): the loops' cut path (CutPolicy) then starts
|
|
46
50
|
# the step again with the message, no nudge, no retry spent. Too early,
|
|
47
51
|
# the message waits (WaitingSteer) and cuts once the thinking passes it
|
|
48
52
|
# (#recheck, on each thinking chunk), unless a boundary hands it to the
|
|
@@ -52,13 +56,13 @@ module Samagotchi
|
|
|
52
56
|
# before queueing it. Outside any lock; a cut that lands just after the
|
|
53
57
|
# generation ended is harmless (the loop re-asks).
|
|
54
58
|
# @return [Symbol] :now (a generation was cut now), :waits (the message
|
|
55
|
-
# waits for the thinking to pass
|
|
59
|
+
# waits for the thinking to pass cut_after), or :off (cutting
|
|
56
60
|
# off for this source: a plugin, no turn, cut_after 0, a generation
|
|
57
61
|
# that isn't thinking-only, or a drain already took the message)
|
|
58
62
|
def cut_for_steer(source, epoch: nil)
|
|
59
63
|
return :off unless Steer.cuts?(source)
|
|
60
64
|
|
|
61
|
-
after =
|
|
65
|
+
after = @cut_after.call.to_i
|
|
62
66
|
return :off unless after.positive?
|
|
63
67
|
|
|
64
68
|
ctrl = @controller.call
|
|
@@ -107,11 +111,11 @@ module Samagotchi
|
|
|
107
111
|
private
|
|
108
112
|
|
|
109
113
|
# A thinking chunk streamed: a waiting message cuts once the thinking
|
|
110
|
-
# passes
|
|
114
|
+
# passes cut_after.
|
|
111
115
|
def recheck
|
|
112
116
|
return unless @waiting.waiting?
|
|
113
117
|
|
|
114
|
-
after =
|
|
118
|
+
after = @cut_after.call.to_i
|
|
115
119
|
return unless after.positive? && @phase.cuttable?(after)
|
|
116
120
|
|
|
117
121
|
source = @waiting.take
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# How long a message sent as a cut waits for the generation it came during:
|
|
7
|
+
# the seconds a generation must have streamed only thinking before a cut may
|
|
8
|
+
# land (SteerCut), one per model, host or run, like Thinking for the level.
|
|
9
|
+
#
|
|
10
|
+
# Order (#resolve): the env (SAMAGOTCHI_STEER_CUT_AFTER), then
|
|
11
|
+
# models.<key>.steer_cut_after, hosts.<name>.steer_cut_after,
|
|
12
|
+
# steer.cut_after in config.yml, then 20. 0 = never cut (a slow local
|
|
13
|
+
# thinker is left alone); it is a value, not an unset.
|
|
14
|
+
module SteerCutAfter
|
|
15
|
+
KEY = "steer_cut_after"
|
|
16
|
+
ENV_KEY = "SAMAGOTCHI_STEER_CUT_AFTER"
|
|
17
|
+
DEFAULT = 20
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# A `steer_cut_after:` setting (hosts: entry or models: entry): whole
|
|
22
|
+
# seconds ≥ 0 (0 = never), nil when unset; anything else warns once,
|
|
23
|
+
# naming +where+, and counts as unset.
|
|
24
|
+
# @return [Integer, nil]
|
|
25
|
+
def seconds(value, where)
|
|
26
|
+
return nil if value.nil?
|
|
27
|
+
|
|
28
|
+
text = value.to_s.strip
|
|
29
|
+
return text.to_i if text.match?(/\A\d+\z/)
|
|
30
|
+
|
|
31
|
+
ConfigFile.warn_once "Warning: #{where}: steer_cut_after must be whole seconds (0 = never); ignored"
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The seconds for +target+ and where they came from
|
|
36
|
+
# ("SAMAGOTCHI_STEER_CUT_AFTER", "models: qwen", "hosts.splash",
|
|
37
|
+
# "steer.cut_after"; nil for the default).
|
|
38
|
+
# @param target [HostRegistry::ModelTarget]
|
|
39
|
+
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
40
|
+
# @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
|
|
41
|
+
# @return [Array(Integer, String|nil)]
|
|
42
|
+
def resolve(target, names:, models: nil)
|
|
43
|
+
value, origin = Config.get_with_origin("steer.cut_after")
|
|
44
|
+
return [value.to_i, ENV_KEY] if origin == :env
|
|
45
|
+
|
|
46
|
+
models ||= begin
|
|
47
|
+
ConfigFile.model_settings
|
|
48
|
+
rescue StandardError
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
setting = ConfigFile.model_setting(names, :steer_cut_after, models: models)
|
|
52
|
+
return [setting.value, "models: #{setting.key}"] if setting
|
|
53
|
+
|
|
54
|
+
host = target.entry.steer_cut_after
|
|
55
|
+
return [host, "hosts.#{target.entry.name}"] unless host.nil?
|
|
56
|
+
|
|
57
|
+
return [value.to_i, "steer.cut_after"] if origin == :file
|
|
58
|
+
|
|
59
|
+
[DEFAULT, nil]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# "after 60 s (models: qwen)"; 0 or less is "never (hosts.splash)".
|
|
63
|
+
# No source: just the seconds.
|
|
64
|
+
def label(seconds, source)
|
|
65
|
+
text = seconds.to_i <= 0 ? "never" : "after #{seconds} s"
|
|
66
|
+
source ? "#{text} (#{source})" : text
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# The system prompt an Engine gives its model: the base prompt (tool
|
|
17
17
|
# declarations and call syntax for the raw-prompt loop, none for the chat
|
|
18
18
|
# loop, the shared guidance) and around it the thinking token, rg
|
|
19
|
-
# guidance, the identity memory, the model notes, the preloaded memories,
|
|
19
|
+
# guidance, the identity memory, the model notes, the preloaded memories, AGENTS.md, the
|
|
20
20
|
# memory indexes and, last, the model, where the session runs and the
|
|
21
21
|
# session (#system_prompt_with_index has the order and why).
|
|
22
22
|
#
|
|
@@ -24,7 +24,7 @@ module Samagotchi
|
|
|
24
24
|
# tools a plugin replaces, the attached session, the thinking level) is
|
|
25
25
|
# read at build time through the lookups; the memory lists are fixed.
|
|
26
26
|
class SystemPrompt
|
|
27
|
-
AGENT_DESCRIPTION_FILE = "
|
|
27
|
+
AGENT_DESCRIPTION_FILE = "AGENTS.md"
|
|
28
28
|
|
|
29
29
|
# The model a session runs on, as the prompt names it: the resolved
|
|
30
30
|
# ref, its host ("name, host:port" or url), the memory overlay key, and
|
|
@@ -282,7 +282,7 @@ module Samagotchi
|
|
|
282
282
|
# Ordered for the servers' prompt caches (they reuse only an exact token
|
|
283
283
|
# prefix): what every session shares first, what changes least before
|
|
284
284
|
# what changes more, and the per-session lines last. The global parts
|
|
285
|
-
# (identity, preloads) come before the project's
|
|
285
|
+
# (identity, preloads) come before the project's AGENTS.md, the memory
|
|
286
286
|
# indexes (a memory write changes them) after both; the model, the
|
|
287
287
|
# location and the session close the prompt (Gemma's tool declarations
|
|
288
288
|
# still follow them).
|
|
@@ -464,7 +464,7 @@ module Samagotchi
|
|
|
464
464
|
|
|
465
465
|
# ── Project / rg helpers ───────────────────────────────────────────────────
|
|
466
466
|
|
|
467
|
-
#
|
|
467
|
+
# AGENTS.md in the cwd, else at the top of its git work tree.
|
|
468
468
|
def project_specific_description
|
|
469
469
|
return nil if skip_agent_description?
|
|
470
470
|
|
|
@@ -566,7 +566,7 @@ module Samagotchi
|
|
|
566
566
|
end
|
|
567
567
|
|
|
568
568
|
def skip_agent_description?
|
|
569
|
-
Config.get("
|
|
569
|
+
Config.get("skip_agents_md") == true
|
|
570
570
|
end
|
|
571
571
|
|
|
572
572
|
def rg_available?
|
|
@@ -99,8 +99,9 @@ module Samagotchi
|
|
|
99
99
|
# What a line submitted at the open prompt says became of it: a plain
|
|
100
100
|
# line goes in at the running turn's next step boundary (nothing cuts
|
|
101
101
|
# unless the message asked for it), /cut's cutting now or once the
|
|
102
|
-
# thinking passes
|
|
103
|
-
#
|
|
102
|
+
# thinking passes the model's cut wait (the run's cut_after; the global
|
|
103
|
+
# steer.cut_after when the worker didn't say), and /queue's waiting for
|
|
104
|
+
# the end. Both TUIs and chi send --cut print the same words
|
|
104
105
|
# (SteerCut#cut_for_steer's answer).
|
|
105
106
|
NEXT_STEP_NOTE = "(goes in at the next step)"
|
|
106
107
|
CUT_NOW_NOTE = "(cut in now)"
|
|
@@ -116,17 +117,20 @@ module Samagotchi
|
|
|
116
117
|
STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
|
|
117
118
|
|
|
118
119
|
# @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
|
|
120
|
+
# @param after [Integer, nil] the run's cut wait (the model's own
|
|
121
|
+
# SteerCutAfter): the seconds a :waits waits for; the global
|
|
122
|
+
# steer.cut_after when not given.
|
|
119
123
|
# @return [String] the dim line to show for a /cut
|
|
120
|
-
def self.steer_cut_note(outcome)
|
|
124
|
+
def self.steer_cut_note(outcome, after = nil)
|
|
121
125
|
case outcome
|
|
122
126
|
when :now then CUT_NOW_NOTE
|
|
123
|
-
when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
|
|
127
|
+
when :waits then format(CUT_WAITS_NOTE, after || SteerCut.cut_after)
|
|
124
128
|
else CUT_OFF_NOTE
|
|
125
129
|
end
|
|
126
130
|
end
|
|
127
131
|
|
|
128
132
|
# The same words, for a UI that includes the module.
|
|
129
|
-
def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
|
|
133
|
+
def steer_cut_note(outcome, after = nil) = Formatting.steer_cut_note(outcome, after)
|
|
130
134
|
|
|
131
135
|
# The choice a mid-turn line carries, as [:cut, "TEXT"] or
|
|
132
136
|
# [:queue, "TEXT"]; nil for any other line. The text is "" when the
|
|
@@ -1199,7 +1199,7 @@ module Samagotchi
|
|
|
1199
1199
|
@pending_input_queue.push(text)
|
|
1200
1200
|
# With no session running the cut is :off; the text still waits for
|
|
1201
1201
|
# the running turn's next step.
|
|
1202
|
-
@surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
|
|
1202
|
+
@surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
|
|
1203
1203
|
true
|
|
1204
1204
|
end
|
|
1205
1205
|
|
|
@@ -46,7 +46,11 @@ module Samagotchi
|
|
|
46
46
|
status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
|
|
47
47
|
}
|
|
48
48
|
# A grep that found nothing: ok, and the web row says "no match".
|
|
49
|
-
|
|
49
|
+
# Not a hand-over: an "exit: 1" in its output tail is the command's.
|
|
50
|
+
if event[:status] == "ok" && tool_name == Tools::Execute::NAME && !handed_over?(tool_name, result) &&
|
|
51
|
+
execute_failed?(result.to_s)
|
|
52
|
+
event[:no_match] = true
|
|
53
|
+
end
|
|
50
54
|
title = tool_title(tool_name, call, cwd: cwd)
|
|
51
55
|
event[:title] = title if title
|
|
52
56
|
# The name the model used when an alias ran a built-in (called_as: "bash"
|
|
@@ -214,6 +218,8 @@ module Samagotchi
|
|
|
214
218
|
return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
|
|
215
219
|
return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
|
|
216
220
|
return "error" if text.start_with?("Error:")
|
|
221
|
+
# Still running, as a task: its output tail may hold any "exit: N".
|
|
222
|
+
return "ok" if handed_over?(tool_name, text)
|
|
217
223
|
if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
218
224
|
return no_match?(text, command) ? "ok" : "error"
|
|
219
225
|
end
|
|
@@ -222,6 +228,17 @@ module Samagotchi
|
|
|
222
228
|
"ok"
|
|
223
229
|
end
|
|
224
230
|
|
|
231
|
+
# An execute still running at its limit, handed over as a task
|
|
232
|
+
# (Tools::Execute::HANDED_OVER).
|
|
233
|
+
def handed_over?(tool_name, text)
|
|
234
|
+
return false unless tool_name == Tools::Execute::NAME && text.to_s.start_with?(Tools::Execute::HANDED_OVER)
|
|
235
|
+
|
|
236
|
+
# The first line only, scrubbed: a regexp raises on bytes that aren't UTF-8.
|
|
237
|
+
text.to_s.each_line.first.to_s.scrub.match?(HANDED_OVER_START)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
HANDED_OVER_START = /\A#{Regexp.escape(Tools::Execute::HANDED_OVER)} \d+s limit:/
|
|
241
|
+
|
|
225
242
|
def execute_failed?(text)
|
|
226
243
|
code = execute_exit_code(text)
|
|
227
244
|
!code.nil? && code != "0"
|
|
@@ -20,7 +20,7 @@ module Samagotchi
|
|
|
20
20
|
TOOL_SCHEMAS = [
|
|
21
21
|
{
|
|
22
22
|
name: "execute",
|
|
23
|
-
description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. Don't background with &; use task_create.",
|
|
23
|
+
description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. A command still running at the time limit keeps running as a background task (by default): the result gives its task_id, and task_wait waits for it. Don't background with &; use task_create.",
|
|
24
24
|
parameters: {
|
|
25
25
|
type: "object",
|
|
26
26
|
properties: {
|