samagotchi 0.90.0 → 0.92.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 +79 -1
- data/README.md +5 -2
- data/bin/chi +4 -4
- data/docs/architecture.md +17 -13
- data/docs/bundle-authoring.md +6 -11
- data/docs/cli.md +7 -4
- data/docs/configuration.md +8 -4
- data/docs/context.md +5 -3
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +27 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +21 -8
- data/docs/internals/prompt-caching.md +4 -0
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/lib/samagotchi/bridge.rb +11 -7
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +26 -15
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +5 -0
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/client_id.rb +3 -0
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_note.rb +13 -6
- data/lib/samagotchi/engine.rb +31 -15
- data/lib/samagotchi/hooks/registry.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/last_turn.rb +3 -2
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command.rb +3 -3
- data/lib/samagotchi/session_manager.rb +12 -20
- data/lib/samagotchi/task_finish_note.rb +90 -0
- data/lib/samagotchi/task_watch.rb +228 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/testing/bundle.rb +32 -19
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_declarations.rb +16 -0
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/builtins.rb +5 -1
- data/lib/samagotchi/tools/task_record.rb +9 -0
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/tools/task_wait.rb +10 -3
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +24 -14
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/notify.js +4 -4
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/turn_events.js +13 -1
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +104 -42
- data/lib/samagotchi/worker_idle_exit.rb +26 -3
- data/lib/samagotchi/worker_inbound.rb +18 -4
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +11 -4
- metadata +8 -1
|
@@ -154,7 +154,7 @@ module Samagotchi
|
|
|
154
154
|
# source, recorded as DEV_SHA (no sha256 check) with "dev": true
|
|
155
155
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
156
156
|
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil,
|
|
157
|
-
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], dev: false)
|
|
157
|
+
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, dev: false)
|
|
158
158
|
FileUtils.mkdir_p(@bundle_dir)
|
|
159
159
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
160
160
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -255,6 +255,7 @@ module Samagotchi
|
|
|
255
255
|
end
|
|
256
256
|
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
257
257
|
manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
258
|
+
manifest_data["session_setting_types"] = session_setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless session_setting_types.to_h.empty?
|
|
258
259
|
|
|
259
260
|
write_manifest(manifest_data)
|
|
260
261
|
end
|
|
@@ -6,6 +6,7 @@ require "json"
|
|
|
6
6
|
require_relative "cli/exit"
|
|
7
7
|
require_relative "pending_question"
|
|
8
8
|
require_relative "session"
|
|
9
|
+
require_relative "turn_checkpoint"
|
|
9
10
|
require_relative "guardrails/parent_policy"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
@@ -339,6 +340,8 @@ module Samagotchi
|
|
|
339
340
|
end
|
|
340
341
|
|
|
341
342
|
def no_reply_line(result, session_id = nil, strategy: nil)
|
|
343
|
+
return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
|
|
344
|
+
|
|
342
345
|
case result.outcome
|
|
343
346
|
when "failed" then failed_line(result)
|
|
344
347
|
when "canceled"
|
|
@@ -360,6 +363,15 @@ module Samagotchi
|
|
|
360
363
|
end
|
|
361
364
|
end
|
|
362
365
|
|
|
366
|
+
# A file-delivered session command's outcome: no turn ran for it.
|
|
367
|
+
def command_line(result)
|
|
368
|
+
case result.outcome
|
|
369
|
+
when "completed" then "the command ran (no turn)"
|
|
370
|
+
when "failed" then "the command failed"
|
|
371
|
+
else "the command was refused (#{result.cancel_reason || "canceled"})"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
363
375
|
# A failed turn's line; one whose work stayed says so, so the task isn't
|
|
364
376
|
# sent again over what it changed.
|
|
365
377
|
def failed_line(result)
|
|
@@ -59,8 +59,11 @@ module Samagotchi
|
|
|
59
59
|
# :canceled and :timeout
|
|
60
60
|
# @!attribute turn_id [String, nil] the id of the turn this result is
|
|
61
61
|
# about, when known
|
|
62
|
+
# @!attribute origin [String, nil] "command" for a :no_reply that a
|
|
63
|
+
# file-delivered session command settled (no turn ran:
|
|
64
|
+
# TurnCheckpoint.record_command_outcome); nil otherwise
|
|
62
65
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
63
|
-
:stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
|
|
66
|
+
:stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
|
|
64
67
|
|
|
65
68
|
module_function
|
|
66
69
|
|
|
@@ -163,7 +166,8 @@ module Samagotchi
|
|
|
163
166
|
# command finished without a turn. After the reply check: a worker
|
|
164
167
|
# killed between writing the reply and its idle save left the reply.
|
|
165
168
|
if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
|
|
166
|
-
return Result.new(status: :no_reply, outcome: settled.outcome, session: session,
|
|
169
|
+
return Result.new(status: :no_reply, outcome: settled.outcome, session: session, origin: settled.origin,
|
|
170
|
+
**settled.stop_facts)
|
|
167
171
|
end
|
|
168
172
|
|
|
169
173
|
case session.status
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require_relative "paths"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# The temp folders a sandboxed chi runs in, shared by `chi bundle try`'s
|
|
10
|
+
# child (BundleCommand::Try::Sandbox) and the test kit
|
|
11
|
+
# (Testing::Sandbox): one realpath'd root holding config/, state/,
|
|
12
|
+
# work/ and whatever else the sandbox wants, the XDG pair pointing at
|
|
13
|
+
# them, and the switches every sandbox turns off. Where the environment
|
|
14
|
+
# goes is each sandbox's own: try builds its child's Hash, the kit
|
|
15
|
+
# changes ENV.
|
|
16
|
+
class SandboxLayout
|
|
17
|
+
DIRS = %w[config state work].freeze
|
|
18
|
+
# No recap request and no cache warm-up request in a sandbox.
|
|
19
|
+
QUIET_ENV = { "SAMAGOTCHI_RECAP_ENABLED" => "false", "SAMAGOTCHI_CACHE_WARMUP" => "off" }.freeze
|
|
20
|
+
|
|
21
|
+
# A new temp root (Dir.mktmpdir +prefix+, its realpath) with DIRS and
|
|
22
|
+
# +extra+ folders in it.
|
|
23
|
+
# @return [SandboxLayout]
|
|
24
|
+
def self.create(prefix, extra: [])
|
|
25
|
+
root = File.realpath(Dir.mktmpdir(prefix))
|
|
26
|
+
(DIRS + extra).each { |dir| FileUtils.mkdir_p(File.join(root, dir)) }
|
|
27
|
+
new(root)
|
|
28
|
+
rescue StandardError
|
|
29
|
+
FileUtils.rm_rf(root) if root
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :root
|
|
35
|
+
|
|
36
|
+
def initialize(root)
|
|
37
|
+
@root = root
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dir(name) = File.join(root, name)
|
|
41
|
+
|
|
42
|
+
def config_home = dir("config")
|
|
43
|
+
|
|
44
|
+
def state_home = dir("state")
|
|
45
|
+
|
|
46
|
+
def work_dir = dir("work")
|
|
47
|
+
|
|
48
|
+
# XDG_CONFIG_HOME and XDG_STATE_HOME at the sandbox's, and QUIET_ENV.
|
|
49
|
+
# @return [Hash{String=>String}]
|
|
50
|
+
def env = { "XDG_CONFIG_HOME" => config_home, "XDG_STATE_HOME" => state_home }.merge(QUIET_ENV)
|
|
51
|
+
|
|
52
|
+
# config.yml under +config_home+ (default the sandbox's): a Hash (as
|
|
53
|
+
# YAML) or YAML text.
|
|
54
|
+
# @return [String] its path
|
|
55
|
+
def write_config(data, config_home: self.config_home)
|
|
56
|
+
path = File.join(config_home, Paths::APP_DIR, "config.yml")
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
58
|
+
File.write(path, data.is_a?(String) ? data : YAML.dump(data))
|
|
59
|
+
path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def remove
|
|
63
|
+
FileUtils.rm_rf(root)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -93,9 +93,9 @@ module Samagotchi
|
|
|
93
93
|
--set BUNDLE.KEY=VALUE
|
|
94
94
|
(--new) a bundle setting for this session, over
|
|
95
95
|
config.yml's bundles: (repeatable; a key its bundle's
|
|
96
|
-
manifest lists in session_settings:; 3,
|
|
97
|
-
|
|
98
|
-
--set cost-cap.cap=3)
|
|
96
|
+
manifest lists in session_settings:; numbers (3,
|
|
97
|
+
.5) and booleans (true, off, no) are typed, the
|
|
98
|
+
rest is text: --set cost-cap.cap=3)
|
|
99
99
|
--parent-answers LEVEL
|
|
100
100
|
(--new) what a parent agent may answer on its
|
|
101
101
|
questions: hard, standard or soft, only stricter than
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "time"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "rbconfig"
|
|
8
|
+
require_relative "child_env"
|
|
8
9
|
require_relative "client_id"
|
|
9
10
|
require_relative "analytics_file"
|
|
10
11
|
require_relative "atomic_file"
|
|
@@ -210,7 +211,7 @@ module Samagotchi
|
|
|
210
211
|
# is when the worker starts); opts[:env] adds to it (merged, not
|
|
211
212
|
# replaced) what it can't read there: this `chi`'s CLI settings
|
|
212
213
|
# (Config.cli_env) and the absolute log file. It unsets
|
|
213
|
-
# SAMAGOTCHI_GUARDRAILS_
|
|
214
|
+
# what ChildEnv lists (SAMAGOTCHI_GUARDRAILS_*, the session markers).
|
|
214
215
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
215
216
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
216
217
|
# XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
|
|
@@ -244,18 +245,15 @@ module Samagotchi
|
|
|
244
245
|
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
245
246
|
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
246
247
|
child_env.merge!(Paths.absolute_xdg)
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
# older or future env-exposed one out as
|
|
251
|
-
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
|
|
257
|
-
# A smoke's fake "newest installed" is for this process's notices only.
|
|
258
|
-
child_env[InstalledVersions::ENV_KEY] = nil
|
|
248
|
+
# Unset (Process.spawn unsets a key mapped to nil) what no child chi
|
|
249
|
+
# takes from its spawner (ChildEnv): the spawner's guardrails settings
|
|
250
|
+
# (a parent agent starting or waking a session; config.yml no longer
|
|
251
|
+
# reads them, this keeps an older or future env-exposed one out as
|
|
252
|
+
# well); the session markers, a session's own (a worker started from
|
|
253
|
+
# its commands, chi send, is another session: its execute and tasks
|
|
254
|
+
# set its own; nothing else it runs has one); and a smoke's fake
|
|
255
|
+
# "newest installed", for this process's notices only.
|
|
256
|
+
child_env.merge!(ChildEnv.unset)
|
|
259
257
|
# A worker process: run_session_loop traps signals, and leaves this
|
|
260
258
|
# process's tree.
|
|
261
259
|
child_env[WORKER_PROCESS_ENV] = "1"
|
|
@@ -263,17 +261,11 @@ module Samagotchi
|
|
|
263
261
|
# A gem-mode worker activates the newest installed chi itself: under
|
|
264
262
|
# `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
|
|
265
263
|
# hold it to the bundle's version, and its badge would never clear.
|
|
266
|
-
child_env.merge!(
|
|
264
|
+
child_env.merge!(ChildEnv.unbundled)
|
|
267
265
|
opts[:env] = child_env unless child_env.empty?
|
|
268
266
|
opts
|
|
269
267
|
end
|
|
270
268
|
|
|
271
|
-
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
272
|
-
WORKER_ENV_PREFIX = "SAMAGOTCHI_WORKER_"
|
|
273
|
-
# Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
|
|
274
|
-
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
|
|
275
|
-
SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
|
|
276
|
-
|
|
277
269
|
# List all sessions, reading status from persisted session.json files.
|
|
278
270
|
# +project_root+: only that project's sessions (nil: every session).
|
|
279
271
|
# +include_archived+: archived sessions too (Session#archived says which).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "context_note"
|
|
5
|
+
require_relative "tool_output_cut"
|
|
6
|
+
require_relative "tools/task_wait"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# The note a finished background task leaves in its session (TaskWatch,
|
|
10
|
+
# the Worker's finish wake): a context note under its own header
|
|
11
|
+
# (ContextNote::TASK_HEADER), as Engine#add_context_note takes it. The
|
|
12
|
+
# wake variant opens a wake turn (turn_start, turn_id) and ends with the
|
|
13
|
+
# ask (WAKE_TEXT); the plain one joins the next turn (queue mode, a spent
|
|
14
|
+
# budget, a note-only task, the unmark after a failed wake).
|
|
15
|
+
module TaskFinishNote
|
|
16
|
+
WAKE_TEXT = "chi started this turn because a background task you started finished; your user didn't write " \
|
|
17
|
+
"anything.\nCarry on with what you planned to do with its result. If you planned nothing, tell your " \
|
|
18
|
+
"user in a sentence or two how it went."
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param record [Tools::TaskRecord] the finished task
|
|
23
|
+
# @param limit_sec [Integer, nil] the limit an execute handed it over at
|
|
24
|
+
# @param wake [Boolean] the wake variant
|
|
25
|
+
# @param cap [Integer] max_tool_output_chars: task_wait's text is cut to
|
|
26
|
+
# it as ToolRunner cuts the tool's result
|
|
27
|
+
# @return [Hash] the note (ContextNote.message's keys)
|
|
28
|
+
def note(record, limit_sec:, wake:, cap:, turn_id: nil, now: Time.now)
|
|
29
|
+
note = { note_id: "task-#{record.id}", source: "task #{short_id(record.id)}", header: ContextNote::TASK_HEADER,
|
|
30
|
+
text: text(record, limit_sec: limit_sec, wake: wake, cap: cap), created_at: now.iso8601 }
|
|
31
|
+
wake ? note.merge(turn_start: true, turn_id: turn_id) : note
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def text(record, limit_sec:, wake:, cap:)
|
|
35
|
+
[headline(record, limit_sec), "command: #{record.command}", body(record, cap), (WAKE_TEXT if wake)].compact.join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def headline(record, limit_sec)
|
|
39
|
+
seconds = duration(record)
|
|
40
|
+
outcome = record.exit_code.nil? ? record.status.to_s : "exit #{record.exit_code}"
|
|
41
|
+
handed = " (handed over by execute at its #{limit_sec}s limit)" if limit_sec
|
|
42
|
+
"Background task #{short_id(record.id)} finished: #{outcome}#{" after #{duration_text(seconds)}" if seconds}#{handed}."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# task_wait's own text for the task, cut to +cap+ as its result would be
|
|
46
|
+
# (ToolOutputCut's :head_tail_ref, naming the logs).
|
|
47
|
+
def body(record, cap)
|
|
48
|
+
text = Tools::TaskWait.finished_text(record).to_s
|
|
49
|
+
return text if text.length <= cap
|
|
50
|
+
|
|
51
|
+
ToolOutputCut.apply(text, cap: cap, tool: Tools::TaskWait::NAME, ref: record.output_path,
|
|
52
|
+
ref_stderr: record.stderr_path).first
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# From started_at to the exit_code file's mtime (the shell's EXIT trap
|
|
56
|
+
# wrote it as it ended), else finished_at: that is when a refresh first
|
|
57
|
+
# noticed the end, which may be long after. nil when neither is known.
|
|
58
|
+
# @return [Float, nil] seconds
|
|
59
|
+
def duration(record)
|
|
60
|
+
started = time(record.started_at)
|
|
61
|
+
return nil unless started
|
|
62
|
+
|
|
63
|
+
ended = exit_code_mtime(record) || time(record.finished_at)
|
|
64
|
+
ended && [ended - started, 0].max
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def duration_text(seconds)
|
|
68
|
+
seconds = seconds.round
|
|
69
|
+
return "#{seconds}s" if seconds < 60
|
|
70
|
+
return format("%dm%02ds", seconds / 60, seconds % 60) if seconds < 3600
|
|
71
|
+
|
|
72
|
+
format("%dh%02dm", seconds / 3600, (seconds % 3600) / 60)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def short_id(id) = Tools::TaskRecord.short_id(id)
|
|
76
|
+
|
|
77
|
+
def exit_code_mtime(record)
|
|
78
|
+
path = record.exit_code_path.to_s
|
|
79
|
+
path.empty? || !File.file?(path) ? nil : File.mtime(path)
|
|
80
|
+
rescue SystemCallError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def time(value)
|
|
85
|
+
value && Time.iso8601(value.to_s)
|
|
86
|
+
rescue ArgumentError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "tools/task_runtime"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The background tasks a worker's session started that the model hasn't
|
|
9
|
+
# seen finish (docs/internals/background-tasks.md, Finish wake). Held by
|
|
10
|
+
# the Worker (the --no-shared REPL has none) and fed by the Engine's raw
|
|
11
|
+
# :tool_call_completed events (#observe), the input Bridge#own_task_ids
|
|
12
|
+
# reads too; the idle loop takes the finished ones (#take_finished) for a
|
|
13
|
+
# wake turn or a plain note. In memory only: a crash loses them, and they
|
|
14
|
+
# behave as before (the model sees them at its next look).
|
|
15
|
+
#
|
|
16
|
+
# Thread-safe: the turn thread feeds it, the loop's thread takes from it,
|
|
17
|
+
# and anytime commands run on threads of their own.
|
|
18
|
+
class TaskWatch
|
|
19
|
+
# A watched task: +limit_sec+, the limit an execute handed it over at
|
|
20
|
+
# (nil for task_create's); +note_only+, the user stopped the turn that
|
|
21
|
+
# started or waited on it (M5): its finish is a plain note, no wake;
|
|
22
|
+
# +failures+, how often its note couldn't be written (#put_back) or the
|
|
23
|
+
# look for it failed (#look_failed).
|
|
24
|
+
Entry = Data.define(:id, :limit_sec, :note_only, :failures) do
|
|
25
|
+
def initialize(id:, limit_sec: nil, note_only: false, failures: 0) = super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A task whose note or look failed this often still gets tried at each
|
|
29
|
+
# look, but no longer holds the worker up (#holding): a failure that
|
|
30
|
+
# never passes must not keep it alive for good.
|
|
31
|
+
MAX_FAILURES = 3
|
|
32
|
+
|
|
33
|
+
# The cancel reasons that are the user's own Stop (Bridge::CANCEL_REASONS
|
|
34
|
+
# less session_stopped, and the TUI's ctrl_c).
|
|
35
|
+
USER_CANCEL_REASONS = %i[manual user ctrl_c].freeze
|
|
36
|
+
|
|
37
|
+
# The first line of a task tool's result, after a gate-corrected call's
|
|
38
|
+
# "ran as: …" line and the optional "[tool]" lead.
|
|
39
|
+
TASK_LINE = /\A#{Tools::TaskRuntime::RAN_AS_LINE}(?:\[task_(?:wait|get|stop)\]\n)?task_id: (\S+)/
|
|
40
|
+
LOOK_TOOLS = %w[task_wait task_get task_stop].freeze
|
|
41
|
+
HANDED_OVER_LIMIT = /#{Regexp.escape(Tools::Execute::HANDED_OVER)} (\d+)s limit/
|
|
42
|
+
|
|
43
|
+
MODE_KEY = "session.task_wakes"
|
|
44
|
+
MODES = %w[wake queue off].freeze
|
|
45
|
+
|
|
46
|
+
# session.task_wakes: wake (default), queue or off; anything else is
|
|
47
|
+
# wake, and so is a config that can't be read (logged once per error
|
|
48
|
+
# class: it is read at every idle pass).
|
|
49
|
+
def self.mode
|
|
50
|
+
value = Config.get(MODE_KEY).to_s
|
|
51
|
+
MODES.include?(value) ? value : "wake"
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
@mode_errors ||= {}
|
|
54
|
+
unless @mode_errors[e.class]
|
|
55
|
+
@mode_errors[e.class] = true
|
|
56
|
+
Log.warn(:worker, "task_wakes_unreadable", error: e.class.name, msg: e.message)
|
|
57
|
+
end
|
|
58
|
+
"wake"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize
|
|
62
|
+
@mutex = Mutex.new
|
|
63
|
+
@entries = {}
|
|
64
|
+
@turn_ids = []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A raw Engine event: a result that started a task adds it, one that
|
|
68
|
+
# shows the model the task's end removes it (#seen_id).
|
|
69
|
+
def observe(event)
|
|
70
|
+
return unless event[:type] == :tool_call_completed
|
|
71
|
+
|
|
72
|
+
tool = event[:tool].to_s
|
|
73
|
+
output = event[:output].to_s
|
|
74
|
+
output = output.scrub unless output.valid_encoding?
|
|
75
|
+
if (id = Tools::TaskRuntime.created_id(tool, output))
|
|
76
|
+
add(id, limit_sec: tool == Tools::Execute::NAME ? output[HANDED_OVER_LIMIT, 1]&.to_i : nil)
|
|
77
|
+
elsif LOOK_TOOLS.include?(tool) && (id = output[TASK_LINE, 1])
|
|
78
|
+
touched(id)
|
|
79
|
+
remove(id) if seen?(tool, output)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def add(id, limit_sec: nil)
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
@entries[id] = Entry.new(id: id, limit_sec: limit_sec)
|
|
86
|
+
@turn_ids << id
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def remove(id)
|
|
91
|
+
@mutex.synchronize { @entries.delete(id) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A turn begins: the tasks it starts or waits on are its own (#turn_canceled).
|
|
95
|
+
def turn_started
|
|
96
|
+
@mutex.synchronize { @turn_ids = [] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A turn ended canceled for +reason+: the user's Stop makes that turn's
|
|
100
|
+
# tasks note-only (M5); a signal's, a hook's or a session stop's don't.
|
|
101
|
+
def turn_canceled(reason)
|
|
102
|
+
return unless USER_CANCEL_REASONS.include?(reason&.to_sym)
|
|
103
|
+
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
@turn_ids.each { |id| @entries[id] &&= @entries[id].with(note_only: true) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ids = @mutex.synchronize { @entries.keys }
|
|
110
|
+
|
|
111
|
+
# The watched tasks, oldest first (a copy).
|
|
112
|
+
# @return [Array<Entry>]
|
|
113
|
+
def entries = @mutex.synchronize { @entries.values }
|
|
114
|
+
|
|
115
|
+
def any? = @mutex.synchronize { @entries.any? }
|
|
116
|
+
|
|
117
|
+
# The watched tasks that finished, taken out of the watch with their
|
|
118
|
+
# records ([Entry, TaskRecord], oldest first). One the user stopped (the
|
|
119
|
+
# web's stop-task button) or whose record is gone is dropped, not
|
|
120
|
+
# returned. +keep+: the ids the conversation still holds
|
|
121
|
+
# (TaskRuntime.created_ids_in): a !rollback or a restored turn may have
|
|
122
|
+
# dropped the result that added one, which is dropped too. One `ps` per
|
|
123
|
+
# running task (TaskRuntime.get_record); nothing with an empty watch.
|
|
124
|
+
# @return [Array<Array(Entry, Tools::TaskRecord)>]
|
|
125
|
+
def take_finished(keep: nil)
|
|
126
|
+
watched = entries
|
|
127
|
+
return [] if watched.empty?
|
|
128
|
+
|
|
129
|
+
watched.filter_map do |entry|
|
|
130
|
+
# Safe for any llm_context strategy: stale and forget stubs are drawn
|
|
131
|
+
# in the view sent to the model (LLMContextView, the entry's edits),
|
|
132
|
+
# never written over the saved tool_response text; only a !rollback
|
|
133
|
+
# or a restored turn takes the result that names the id out.
|
|
134
|
+
next drop(entry, log: "not_in_conversation") if keep && !keep.include?(entry.id)
|
|
135
|
+
|
|
136
|
+
record, error = Tools::TaskRuntime.get_record(entry.id)
|
|
137
|
+
if error || record.nil?
|
|
138
|
+
# Gone (its task dir deleted) or unreadable: no note can be written.
|
|
139
|
+
next drop(entry, log: "record_unreadable", level: :info, error: error.to_s.lines.first&.strip)
|
|
140
|
+
end
|
|
141
|
+
next nil if record.running?
|
|
142
|
+
# Removed by a look meanwhile (a task_wait on an anytime command's thread).
|
|
143
|
+
next nil unless drop(entry, kept: true)
|
|
144
|
+
next nil if record.stopped_by_user?
|
|
145
|
+
|
|
146
|
+
[entry, record]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# +entries+ taken by #take_finished whose note couldn't be written:
|
|
151
|
+
# watched again, the failure counted (an id watched meanwhile keeps its
|
|
152
|
+
# own entry).
|
|
153
|
+
def put_back(entries)
|
|
154
|
+
@mutex.synchronize do
|
|
155
|
+
entries.each { |entry| @entries[entry.id] ||= entry.with(failures: entry.failures + 1) }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The look for finished tasks failed before it could tell which ended
|
|
160
|
+
# (the conversation's task ids couldn't be read): every watched task
|
|
161
|
+
# counts a failure.
|
|
162
|
+
def look_failed
|
|
163
|
+
@mutex.synchronize { @entries.transform_values! { |entry| entry.with(failures: entry.failures + 1) } }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whether a watched task has finished (#take_finished would take it):
|
|
167
|
+
# Engine#next_turn_waiting asks at a turn's end. Takes nothing. A record
|
|
168
|
+
# that can't be read counts as not finished here (and not running in
|
|
169
|
+
# #running): #take_finished drops it at the next look, logged.
|
|
170
|
+
def finished?
|
|
171
|
+
ids.any? do |id|
|
|
172
|
+
record, = Tools::TaskRuntime.get_record(id)
|
|
173
|
+
record && !record.running?
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The watched tasks a finish would wake for, running or finished and
|
|
178
|
+
# not yet taken (#take_finished takes them at the idle loop's next
|
|
179
|
+
# look): they hold the worker's idle exit (WorkerIdleExit's
|
|
180
|
+
# watched_tasks). Not a note-only one (no wake would come for it), nor
|
|
181
|
+
# one whose note or look failed MAX_FAILURES times. No `ps`: in memory.
|
|
182
|
+
# @return [Array<Entry>]
|
|
183
|
+
def holding = entries.reject { |entry| entry.note_only || entry.failures >= MAX_FAILURES }
|
|
184
|
+
|
|
185
|
+
# The #holding tasks still running ({id:, command:}), for /exit
|
|
186
|
+
# (WorkerIdleExit's watched_running): one finished meanwhile is noted
|
|
187
|
+
# as the worker leaves. One `ps` per task.
|
|
188
|
+
# @return [Array<Hash>]
|
|
189
|
+
def running
|
|
190
|
+
holding.filter_map do |entry|
|
|
191
|
+
record, = Tools::TaskRuntime.get_record(entry.id)
|
|
192
|
+
{ id: entry.id, command: record.command.to_s } if record&.running?
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
# Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
|
|
199
|
+
# was there. +log+: why a task leaves without a note, at +level+ (debug
|
|
200
|
+
# by default), with +error+ when there is one.
|
|
201
|
+
def drop(entry, kept: false, log: nil, level: :debug, error: nil)
|
|
202
|
+
removed = !remove(entry.id).nil?
|
|
203
|
+
if log && removed
|
|
204
|
+
Log.public_send(level, :worker, "task_watch_dropped", **{ task_id: entry.id, reason: log, error: error }.compact)
|
|
205
|
+
end
|
|
206
|
+
kept ? removed : nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def touched(id)
|
|
210
|
+
@mutex.synchronize { @turn_ids << id unless @turn_ids.include?(id) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Whether +tool+'s +output+ for a task shows the model its end:
|
|
214
|
+
# task_wait's final status or a done_pattern match (D5), task_get's
|
|
215
|
+
# final status (the status alone is enough), any task_stop.
|
|
216
|
+
def seen?(tool, output)
|
|
217
|
+
status = output[/^status: (\S+)/, 1]
|
|
218
|
+
case tool
|
|
219
|
+
when "task_stop" then true
|
|
220
|
+
when "task_get" then final?(status)
|
|
221
|
+
when "task_wait" then final?(status) || output.match?(/^wait_result: pattern_matched$/)
|
|
222
|
+
else false
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def final?(status) = !status.nil? && status != "running"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -60,8 +60,12 @@ module Samagotchi
|
|
|
60
60
|
HELD_REASONS = {
|
|
61
61
|
"turn_running" => "a turn is running", "input_queued" => "prompts are queued",
|
|
62
62
|
"continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
|
|
63
|
-
"reminders" => "reminders are set", "starting" => "the worker is still starting"
|
|
63
|
+
"reminders" => "reminders are set", "starting" => "the worker is still starting",
|
|
64
|
+
"background_tasks" => "background tasks it started are still running"
|
|
64
65
|
}.freeze
|
|
66
|
+
# How to end a background task that holds the worker up (an older
|
|
67
|
+
# worker's 409 has no hint of its own).
|
|
68
|
+
TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
|
|
65
69
|
# How much of the last answer a join shows.
|
|
66
70
|
JOIN_ANSWER_LINES = 12
|
|
67
71
|
JOIN_ANSWER_CHARS = 1200
|
|
@@ -556,7 +560,8 @@ module Samagotchi
|
|
|
556
560
|
"Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
|
|
557
561
|
when 409
|
|
558
562
|
reason = reply.json&.fetch("reason", nil).to_s
|
|
559
|
-
"Detached; the session keeps running (#{
|
|
563
|
+
"Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
|
|
564
|
+
"Re-attach with: chi --attach #{id}"
|
|
560
565
|
when 0 then exit_failed_line("no reply")
|
|
561
566
|
when 408 then exit_failed_line(LATE)
|
|
562
567
|
else
|
|
@@ -567,6 +572,14 @@ module Samagotchi
|
|
|
567
572
|
end
|
|
568
573
|
end
|
|
569
574
|
|
|
575
|
+
# Why the worker stayed up; the background tasks it waits for by
|
|
576
|
+
# their short id and command (a finish wake's, D3), and how to stop one.
|
|
577
|
+
def held_words(reason, tasks)
|
|
578
|
+
words = HELD_REASONS.fetch(reason, reason)
|
|
579
|
+
listed = Array(tasks).filter_map { |t| "#{Tools::TaskRecord.short_id(t["id"])} (#{t["command"]})" if t.is_a?(Hash) }
|
|
580
|
+
listed.empty? ? words : "#{words}: #{listed.join(", ")}; #{TASK_STOP_HINT}"
|
|
581
|
+
end
|
|
582
|
+
|
|
570
583
|
# An older worker leaves the field out.
|
|
571
584
|
def discards?(reply) = reply.json&.fetch("discard", nil) == true
|
|
572
585
|
|
|
@@ -1628,8 +1641,10 @@ module Samagotchi
|
|
|
1628
1641
|
def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
|
|
1629
1642
|
def report_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CHILD_PREFIX)
|
|
1630
1643
|
def context_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CONTEXT_PREFIX)
|
|
1631
|
-
|
|
1632
|
-
|
|
1644
|
+
def task_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::TASK_PREFIX)
|
|
1645
|
+
# A turn with no prompt: "(continuing)"; a context wake's or a task
|
|
1646
|
+
# finish wake's label alone.
|
|
1647
|
+
def continuing_text(origin) = context_origin?(origin) || task_origin?(origin) ? nil : "(continuing)"
|
|
1633
1648
|
end
|
|
1634
1649
|
end
|
|
1635
1650
|
end
|
|
@@ -9,6 +9,7 @@ require_relative "../steer_cut"
|
|
|
9
9
|
require_relative "../memory_bundle/index_size"
|
|
10
10
|
require_relative "../prompt_note"
|
|
11
11
|
require_relative "../prompt_files"
|
|
12
|
+
require_relative "../tools/task_record"
|
|
12
13
|
require_relative "model_segments_text"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
@@ -207,8 +208,9 @@ module Samagotchi
|
|
|
207
208
|
|
|
208
209
|
# Who sent a line, by its client_id: CLIENT_LABELS by the whole id or
|
|
209
210
|
# its prefix, a turn an attached context source started
|
|
210
|
-
# (context:<name>) as "context <name> changed",
|
|
211
|
-
#
|
|
211
|
+
# (context:<name>) as "context <name> changed", a finished background
|
|
212
|
+
# task's (task:<id>) as "task <id8> finished", AUTOMATIC_LABEL for an
|
|
213
|
+
# id chi doesn't know; +fallback+ for none (the user's own).
|
|
212
214
|
def client_label(client_id, fallback = "user")
|
|
213
215
|
return fallback unless client_id
|
|
214
216
|
|
|
@@ -216,6 +218,9 @@ module Samagotchi
|
|
|
216
218
|
name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
|
|
217
219
|
return "context #{name} changed" if id.start_with?(ClientId::CONTEXT_PREFIX) && !name.empty?
|
|
218
220
|
|
|
221
|
+
task = id.delete_prefix(ClientId::TASK_PREFIX)
|
|
222
|
+
return "task #{Tools::TaskRecord.short_id(task)} finished" if id.start_with?(ClientId::TASK_PREFIX) && !task.empty?
|
|
223
|
+
|
|
219
224
|
prefix = id[/\A[^:]*:/]
|
|
220
225
|
CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
|
|
221
226
|
end
|