samagotchi 0.63.0 → 0.64.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 +46 -1
- data/docs/architecture.md +12 -2
- data/docs/cli.md +16 -8
- data/docs/configuration.md +15 -8
- data/docs/internals/engine.md +10 -3
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +11 -8
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +18 -4
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +6 -3
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_config.rb +6 -6
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/terminal_ui/attached_loop.rb +47 -26
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +6 -0
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/timing.js +2 -1
- data/lib/samagotchi/web/public/turn_model.js +13 -3
- data/lib/samagotchi/worker.rb +7 -5
- metadata +2 -1
|
@@ -57,7 +57,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
|
|
|
57
57
|
**Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
|
|
58
58
|
|
|
59
59
|
- `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
|
|
60
|
-
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`;
|
|
60
|
+
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; a worker reads `hosts:` from the file as it starts (`SAMAGOTCHI_HOSTS_JSON` replaces it for a process it is set on; `SessionManager.spawn_options` unsets it for workers).
|
|
61
61
|
- `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
62
62
|
- `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
|
|
63
63
|
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.64.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:99ad8a596cc0f5129caf82cc00314d597f0ec309be96e2d48ca4fa936b4ae343
|
|
10
10
|
memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -231,11 +231,16 @@ module Samagotchi
|
|
|
231
231
|
return nil unless child.parent_id == @session_id
|
|
232
232
|
|
|
233
233
|
before = @taken[child_id]&.after || Tools::DelegateCursors.get(@session_id, child_id, state_dir: @state_dir)
|
|
234
|
+
# A follow-up's turn (DelegateCursor#turn) is reported by its own
|
|
235
|
+
# reply, as delegate_result takes it, not a newer queued turn's.
|
|
234
236
|
wait = ReplyWait.call(child_id, state_dir: @state_dir, cursor: before.reply_file, baseline: before.baseline,
|
|
235
|
-
timeout: 0)
|
|
237
|
+
timeout: 0, turn: before.turn)
|
|
236
238
|
return nil unless reportable?(wait)
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
# A reported end is the follow-up turn's too (DelegateCursor#turn):
|
|
241
|
+
# a later wait on it would repeat its reply.
|
|
242
|
+
after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
243
|
+
turn: wait.status == :waiting_for_answer ? before.turn : nil)
|
|
239
244
|
.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
240
245
|
text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
|
|
241
246
|
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
|
|
@@ -57,10 +57,17 @@ module Samagotchi
|
|
|
57
57
|
# report it. Ctrl-C leaves the turn running (130).
|
|
58
58
|
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
59
59
|
# it counts as gone; nil waits for whatever wakes one
|
|
60
|
+
# @param turn [String, nil] the enqueued_id of the turn waited for
|
|
61
|
+
# (ReplyWait's turn:): a queued message's, or the turn an answer or
|
|
62
|
+
# a --wait with no message is about (ReplyWait.own_turn)
|
|
63
|
+
# @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
|
|
64
|
+
# queued:)
|
|
60
65
|
# @return [Integer] the exit status
|
|
61
|
-
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER
|
|
66
|
+
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
|
|
67
|
+
queued: false)
|
|
62
68
|
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
63
|
-
owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL
|
|
69
|
+
owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
|
|
70
|
+
queued: queued)
|
|
64
71
|
result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
65
72
|
@reported = true
|
|
66
73
|
ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "delivery"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Where a line typed at a terminal's prompt goes, for both terminals (the
|
|
7
|
+
# REPL's TerminalUI#steer_line / #run_input_line, the attached
|
|
8
|
+
# AttachedLoop#submit). It only classifies; each UI keeps what it does
|
|
9
|
+
# with the answer (the REPL's mid-turn command policy and image lines, the
|
|
10
|
+
# attached TUI's continue offer and question). The web's composer.js
|
|
11
|
+
# routeComposerLine decides the same; spec/fixtures/composer_routes.json
|
|
12
|
+
# holds the cases both answer (docs/testing.md).
|
|
13
|
+
#
|
|
14
|
+
# In order:
|
|
15
|
+
# 1. /cut TEXT, /queue TEXT (.steer_choice), before every command check:
|
|
16
|
+
# the bare word is :usage. While a turn runs TEXT is a :deliver with
|
|
17
|
+
# its delivery (Delivery::CUT, Delivery::QUEUE); whether the worker
|
|
18
|
+
# can queue is the UI's to check. With none running TEXT is routed
|
|
19
|
+
# on as a line (step 2), its own /cut not read again.
|
|
20
|
+
# 2. A line: :empty; a terminal's own word (:local, the registry's
|
|
21
|
+
# lookup_local id: :exit, :archive, :detach, :stats, :recap…); a
|
|
22
|
+
# session command (:command); a typo of one (:typo, the hint in
|
|
23
|
+
# +reply+); else :prompt. A line with images (+images+) is a message:
|
|
24
|
+
# no command or typo check.
|
|
25
|
+
module ComposerRoute
|
|
26
|
+
CUT_COMMAND = "/cut"
|
|
27
|
+
QUEUE_COMMAND = "/queue"
|
|
28
|
+
# The word, then any whitespace and the text (as the registry's
|
|
29
|
+
# entries match them, so a line Tab or /help knows is never a prompt).
|
|
30
|
+
STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
|
|
31
|
+
|
|
32
|
+
KINDS = %i[empty usage deliver local command typo prompt].freeze
|
|
33
|
+
|
|
34
|
+
# @!attribute kind [Symbol] one of KINDS
|
|
35
|
+
# @!attribute text [String, nil] the line stripped: a /cut's or
|
|
36
|
+
# /queue's own text, not the word
|
|
37
|
+
# @!attribute delivery [String, nil] a :deliver's Delivery value
|
|
38
|
+
# @!attribute reply [String, nil] a :typo's hint
|
|
39
|
+
# @!attribute local [Symbol, nil] a :local's registry id
|
|
40
|
+
# @!attribute choice [Symbol, nil] :cut or :queue when the line was
|
|
41
|
+
# typed with that word (an idle one's text routed on keeps it)
|
|
42
|
+
Route = Data.define(:kind, :text, :delivery, :reply, :local, :choice) do
|
|
43
|
+
def initialize(kind:, text: nil, delivery: nil, reply: nil, local: nil, choice: nil)
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param line [String, nil] as typed
|
|
49
|
+
# @param running [Boolean] a turn runs
|
|
50
|
+
# @param registry [Commands::Registry] the session's commands
|
|
51
|
+
# @param images [Integer] the attachments not in the text (the web's
|
|
52
|
+
# chips); a terminal's @path images are its own to handle (0)
|
|
53
|
+
# @return [Route]
|
|
54
|
+
def self.call(line, running:, registry:, images: 0)
|
|
55
|
+
text = line.to_s.strip
|
|
56
|
+
choice, message = steer_choice(text)
|
|
57
|
+
return line_route(text, registry: registry, images: images) unless choice
|
|
58
|
+
return Route.new(kind: :usage, choice: choice) if message.empty?
|
|
59
|
+
return line_route(message, registry: registry, images: images).with(choice: choice) unless running
|
|
60
|
+
|
|
61
|
+
Route.new(kind: :deliver, text: message, delivery: choice == :cut ? Delivery::CUT : Delivery::QUEUE, choice: choice)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The choice a line carries, as [:cut, "TEXT"] or [:queue, "TEXT"]; nil
|
|
65
|
+
# for any other line. The text is "" when the line named the choice
|
|
66
|
+
# without one.
|
|
67
|
+
# @param line [String, nil]
|
|
68
|
+
# @return [Array(Symbol, String), nil]
|
|
69
|
+
def self.steer_choice(line)
|
|
70
|
+
match = STEER_CHOICE.match(line.to_s.strip) or return nil
|
|
71
|
+
|
|
72
|
+
[match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.line_route(text, registry:, images:)
|
|
76
|
+
return Route.new(kind: :empty, text: text) if text.empty?
|
|
77
|
+
|
|
78
|
+
if images.zero?
|
|
79
|
+
local = registry.lookup_local(text)&.id
|
|
80
|
+
return Route.new(kind: :local, text: text, local: local) if local
|
|
81
|
+
return Route.new(kind: :command, text: text) if registry.command?(text)
|
|
82
|
+
|
|
83
|
+
hint = registry.unknown_command_hint(text)
|
|
84
|
+
return Route.new(kind: :typo, text: text, reply: hint) if hint
|
|
85
|
+
end
|
|
86
|
+
Route.new(kind: :prompt, text: text)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :line_route
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -857,7 +857,7 @@ module Samagotchi
|
|
|
857
857
|
HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
|
|
858
858
|
ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
859
859
|
|
|
860
|
-
# config.yml's hosts: (or a
|
|
860
|
+
# config.yml's hosts: (or a SAMAGOTCHI_HOSTS_JSON override) by lowercased
|
|
861
861
|
# name; an entry that is disabled or invalid (warned) is left out. No
|
|
862
862
|
# entry left: "default", from server.host/port/transport.
|
|
863
863
|
# @return [Hash{String => HostConfig}]
|
|
@@ -914,7 +914,7 @@ module Samagotchi
|
|
|
914
914
|
|
|
915
915
|
# The names (lowercased) of the hosts entries with enabled: false, which
|
|
916
916
|
# hosts_config leaves out. Read from the same source: config.yml's
|
|
917
|
-
# hosts:, or SAMAGOTCHI_HOSTS_JSON
|
|
917
|
+
# hosts:, or a SAMAGOTCHI_HOSTS_JSON override.
|
|
918
918
|
# @return [Array<String>]
|
|
919
919
|
def disabled_host_names(env: ENV, path: global_path(env: env))
|
|
920
920
|
raw = hosts_source(read_yaml(env: env, path: path), env)
|
|
@@ -926,7 +926,8 @@ module Samagotchi
|
|
|
926
926
|
end
|
|
927
927
|
|
|
928
928
|
# config.yml's hosts: as written, or the SAMAGOTCHI_HOSTS_JSON override
|
|
929
|
-
#
|
|
929
|
+
# set on this process (specs; SessionManager.spawn_options unsets it for
|
|
930
|
+
# a worker, which reads the file).
|
|
930
931
|
def hosts_source(data, env)
|
|
931
932
|
raw = data[HOSTS_KEY] if data.is_a?(Hash)
|
|
932
933
|
env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
|
|
@@ -1041,6 +1042,8 @@ module Samagotchi
|
|
|
1041
1042
|
hosts.keys.map { |k| k.to_s.downcase }.include?(prefix) ? nil : prefix
|
|
1042
1043
|
end
|
|
1043
1044
|
|
|
1045
|
+
# hosts_config as a SAMAGOTCHI_HOSTS_JSON override (workers no longer
|
|
1046
|
+
# get one: they read config.yml).
|
|
1044
1047
|
def hosts_json_for_env(env: ENV, path: global_path(env: env))
|
|
1045
1048
|
hosts = hosts_config(env: env, path: path)
|
|
1046
1049
|
# Only serialize if non-default or explicitly configured hosts:
|
|
@@ -85,12 +85,17 @@ module Samagotchi
|
|
|
85
85
|
# Called with the event log held, before the command's command_ran. A
|
|
86
86
|
# Stop runs no turn: last_turn says so (not_continued), so a wait on
|
|
87
87
|
# it ends (chi answer --option Stop).
|
|
88
|
+
# It carries the offer turn's enqueued_id, so a wait on that turn
|
|
89
|
+
# (ReplyWait's turn:) ends on it too.
|
|
88
90
|
def announce_resolved(result, command)
|
|
89
91
|
@engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
|
|
90
92
|
return if result.resume
|
|
91
93
|
|
|
92
94
|
session = @engine.session
|
|
93
|
-
|
|
95
|
+
return unless session
|
|
96
|
+
|
|
97
|
+
session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3),
|
|
98
|
+
enqueued_id: offer_turn_id)
|
|
94
99
|
end
|
|
95
100
|
|
|
96
101
|
# After a command's command_ran and save: a /continue that answered the
|
|
@@ -117,9 +122,13 @@ module Samagotchi
|
|
|
117
122
|
# asks again, one before the turn began too (logged, no :turn_failed).
|
|
118
123
|
# The answer's text is carried into the turn as it begins; a turn that
|
|
119
124
|
# never began leaves it to no other.
|
|
125
|
+
# The continue turn is the offer turn's: its origin carries that turn's
|
|
126
|
+
# enqueued_id, so its last_turn, its reply file and its checkpoint mark
|
|
127
|
+
# do too, and a wait on that turn (chi answer) gets its reply.
|
|
120
128
|
def run_continue_turn(command)
|
|
121
129
|
offer = @turn_flow.offer
|
|
122
130
|
resume_from = @turn_flow.resume_from
|
|
131
|
+
enqueued_id = offer_turn_id
|
|
123
132
|
close("answered")
|
|
124
133
|
steer = @continue_steer
|
|
125
134
|
@continue_steer = nil
|
|
@@ -131,8 +140,8 @@ module Samagotchi
|
|
|
131
140
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
132
141
|
return announce_offer(offer)
|
|
133
142
|
end
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
origin = { client_id: command.client_id, enqueued_id: enqueued_id }.compact
|
|
144
|
+
@run_turn.call(nil, continue: true, origin: origin, resumed: true, resume_from: resume_from,
|
|
136
145
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
137
146
|
if error
|
|
138
147
|
announce_offer(offer)
|
|
@@ -156,6 +165,11 @@ module Samagotchi
|
|
|
156
165
|
|
|
157
166
|
private
|
|
158
167
|
|
|
168
|
+
# The enqueued_id of the turn the offer stands for: the exhausted
|
|
169
|
+
# turn's last_turn (a canceled continue turn's carries the same id);
|
|
170
|
+
# nil when it ran no input (a reminder turn, a delegate's first).
|
|
171
|
+
def offer_turn_id = @engine.session&.last_turn&.enqueued_id
|
|
172
|
+
|
|
159
173
|
def announce_offer(offer = @turn_flow.offer)
|
|
160
174
|
@engine.announce(type: :continue_offered, context: offer[:context], no_interrupt: offer[:no_interrupt])
|
|
161
175
|
ask
|
data/lib/samagotchi/delivery.rb
CHANGED
|
@@ -23,25 +23,30 @@ module Samagotchi
|
|
|
23
23
|
# Why a request body's delivery field can't be taken, as {error:,
|
|
24
24
|
# detail:} for a 400, or nil when it can. The Bridge and the web's
|
|
25
25
|
# POST /turn both ask this, so they refuse the same values. A missing
|
|
26
|
-
# or unknown string is not refused: it is the default (#parse).
|
|
27
|
-
# is refused until a worker runs it (no worker reads it yet, so it
|
|
28
|
-
# would merge at the next step while the ack said queue).
|
|
26
|
+
# or unknown string is not refused: it is the default (#parse).
|
|
29
27
|
# @param raw [Object] the wire value
|
|
30
28
|
# @return [Hash, nil]
|
|
31
29
|
def self.refusal(raw)
|
|
32
30
|
return nil if raw.nil?
|
|
33
31
|
return { error: "bad_delivery", detail: "delivery must be a string" } unless raw.is_a?(String)
|
|
34
|
-
return QUEUE_UNAVAILABLE if raw == QUEUE
|
|
35
32
|
|
|
36
33
|
nil
|
|
37
34
|
end
|
|
38
35
|
|
|
36
|
+
# The worker sidecar feature (Bridge::FEATURES) of a worker that runs
|
|
37
|
+
# all three values. One without it (before 0.64) ignores QUEUE, or
|
|
38
|
+
# refuses it with a 400 delivery_unavailable, so a sender checks it
|
|
39
|
+
# before sending a queue (SessionManager.deliver_turn, the attached TUI).
|
|
40
|
+
QUEUE_FEATURE = "delivery"
|
|
41
|
+
|
|
42
|
+
# The refusal for a queue a worker without QUEUE_FEATURE would steer
|
|
43
|
+
# (SessionManager.deliver_turn answers it as a 409). P0-P2 workers
|
|
44
|
+
# answer the same error code for a queue, with their own detail.
|
|
39
45
|
QUEUE_UNAVAILABLE = {
|
|
40
|
-
error
|
|
41
|
-
detail
|
|
42
|
-
|
|
46
|
+
"error" => "delivery_unavailable",
|
|
47
|
+
"detail" => "this session's worker predates queueing, so the message would steer the running turn; " \
|
|
48
|
+
"restart it (chi sessions restart ID, or the web's update badge) and send again"
|
|
43
49
|
}.freeze
|
|
44
|
-
private_constant :QUEUE_UNAVAILABLE
|
|
45
50
|
|
|
46
51
|
# Whether it is the one every sender defaulting to a step boundary uses.
|
|
47
52
|
def self.next_step?(value) = parse(value) == NEXT_STEP
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1433,8 +1433,10 @@ module Samagotchi
|
|
|
1433
1433
|
elsif client_id == ClientId::REMINDER then "reminder"
|
|
1434
1434
|
else "client"
|
|
1435
1435
|
end
|
|
1436
|
+
enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
|
|
1436
1437
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1437
1438
|
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
1439
|
+
enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
|
|
1438
1440
|
**stop_facts(event))
|
|
1439
1441
|
end
|
|
1440
1442
|
|
|
@@ -9,9 +9,9 @@ require_relative "reasoning_history"
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
# One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
|
|
11
11
|
# holds them by lowercased name; HostRegistry builds its HostEntry from
|
|
12
|
-
# one).
|
|
13
|
-
# SAMAGOTCHI_HOSTS_JSON
|
|
14
|
-
# it back to an equal HostConfig.
|
|
12
|
+
# one). #to_env_h writes every field in the shape of the
|
|
13
|
+
# SAMAGOTCHI_HOSTS_JSON override (ConfigFile.hosts_json_for_env), and
|
|
14
|
+
# .parse reads it back to an equal HostConfig.
|
|
15
15
|
#
|
|
16
16
|
# name: the lowercased entry name; host, port, scheme: where it is (from
|
|
17
17
|
# url: when given); url: the configured url, nil when host/port; transport,
|
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# A hosts: entry as written (YAML, or a
|
|
29
|
+
# A hosts: entry as written (YAML, or a SAMAGOTCHI_HOSTS_JSON override),
|
|
30
30
|
# or nil when it is disabled (enabled: false) or invalid. An invalid
|
|
31
31
|
# one warns once, naming the entry, the way each reader of a setting
|
|
32
32
|
# does for a bad value (that value is then unset).
|
|
@@ -160,10 +160,10 @@ module Samagotchi
|
|
|
160
160
|
end
|
|
161
161
|
private_class_method :invalid
|
|
162
162
|
|
|
163
|
-
# The entry as a
|
|
163
|
+
# The entry as a SAMAGOTCHI_HOSTS_JSON override carries it (string keys,
|
|
164
164
|
# unset fields left out); .parse(name, to_env_h) gives it back. A url
|
|
165
165
|
# entry travels as its url (host/port come from it); the API key stays
|
|
166
|
-
# in the environment
|
|
166
|
+
# in the environment.
|
|
167
167
|
# @return [Hash{String => Object}]
|
|
168
168
|
def to_env_h
|
|
169
169
|
location = url ? { "url" => url } : { "host" => host, "port" => port }
|
|
@@ -104,8 +104,8 @@ module Samagotchi
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
# The entry as written back to config (
|
|
108
|
-
#
|
|
107
|
+
# The entry as written back to config (HostConfig#to_env_h): nil for a
|
|
108
|
+
# bare id.
|
|
109
109
|
def to_config
|
|
110
110
|
config = { "price" => price&.to_config, "served" => served }.compact
|
|
111
111
|
config unless config.empty?
|
|
@@ -9,9 +9,13 @@ module Samagotchi
|
|
|
9
9
|
#
|
|
10
10
|
# id: the turn's id (nil when unknown); started_at / at: iso8601, when the
|
|
11
11
|
# turn began and when this checkpoint was taken; iteration: the loop
|
|
12
|
-
# boundaries it had passed (Integer)
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
# boundaries it had passed (Integer); enqueued_id: the input the turn
|
|
13
|
+
# runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
|
|
14
|
+
# carries its offer turn's), so any process that loads the session can
|
|
15
|
+
# tell which turn is running (ReplyWait.own_turn); nil when it ran none,
|
|
16
|
+
# or a worker from before it wrote one.
|
|
17
|
+
InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id) do
|
|
18
|
+
def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil) = super
|
|
15
19
|
|
|
16
20
|
# A saved record (string or symbol keys), an InterruptedTurn as it is;
|
|
17
21
|
# nil for anything else.
|
data/lib/samagotchi/last_turn.rb
CHANGED
|
@@ -12,9 +12,12 @@ module Samagotchi
|
|
|
12
12
|
# children's reports) or "context" (one an attached context source's
|
|
13
13
|
# change started); exhausted/limit: the turn ran out of iterations at
|
|
14
14
|
# limit. The rest are its stop facts (LastTurn::STOP_FACTS), only what is
|
|
15
|
-
# known.
|
|
15
|
+
# known. enqueued_id: the input's id when the turn ran one (a POST /turn
|
|
16
|
+
# ack's, an input file's), so a wait for a queued message (`chi send
|
|
17
|
+
# --queue --wait`, ReplyWait's turn:) tells its own turn's end from
|
|
18
|
+
# another's. A field may be nil (unset; left out of the file).
|
|
16
19
|
LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
|
|
17
|
-
:error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by) do
|
|
20
|
+
:error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id) do
|
|
18
21
|
def initialize(**fields)
|
|
19
22
|
super(**members.to_h { |m| [m, nil] }, **fields)
|
|
20
23
|
end
|
|
@@ -19,17 +19,24 @@ module Samagotchi
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Append a message to the queue. Nil/empty strings are ignored.
|
|
22
|
-
|
|
22
|
+
# +seq+ is the sender's own order number for it (the REPL numbers every
|
|
23
|
+
# line it takes during a turn, so its leftovers keep send order across
|
|
24
|
+
# this queue and its after-turn list: #drain_entries).
|
|
25
|
+
def push(text, seq: nil)
|
|
23
26
|
text = text.to_s
|
|
24
27
|
return if text.empty?
|
|
25
28
|
|
|
26
|
-
@mutex.synchronize { @messages << text }
|
|
29
|
+
@mutex.synchronize { @messages << [seq, text] }
|
|
27
30
|
nil
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
# Remove and return all queued messages in FIFO order. Non-blocking.
|
|
31
34
|
# @return [Array<String>]
|
|
32
|
-
def drain
|
|
35
|
+
def drain = drain_entries.map(&:last)
|
|
36
|
+
|
|
37
|
+
# #drain with each message's +seq+ (nil when pushed without one).
|
|
38
|
+
# @return [Array(Integer|nil, String)]
|
|
39
|
+
def drain_entries
|
|
33
40
|
@mutex.synchronize do
|
|
34
41
|
drained = @messages
|
|
35
42
|
@messages = []
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../client_id"
|
|
4
|
+
require_relative "../delivery"
|
|
4
5
|
require_relative "../session"
|
|
5
6
|
require_relative "../bridge_client"
|
|
6
7
|
require_relative "../bridge/turn_accumulator"
|
|
@@ -62,18 +63,28 @@ module Samagotchi
|
|
|
62
63
|
# Send +text+ to session +id+ as a user message (it runs as a turn;
|
|
63
64
|
# a stopped session is woken). Waits up to 5 s for its worker, so call
|
|
64
65
|
# it from an anytime command or your own thread, never a tool or hook
|
|
65
|
-
# of a running turn.
|
|
66
|
+
# of a running turn. A running turn takes it at its next step; with
|
|
67
|
+
# +delivery: :queue+ it runs as a turn of its own after that one
|
|
68
|
+
# instead. A plugin never cuts the running turn's thinking.
|
|
69
|
+
# @param delivery [Symbol, String, nil] nil or :next_step (default),
|
|
70
|
+
# :queue
|
|
66
71
|
# @return [String] the id of the session it went to
|
|
67
|
-
# @raise [Error] no such session, a REPL owns it,
|
|
68
|
-
|
|
72
|
+
# @raise [Error] no such session, a REPL owns it, it wasn't taken
|
|
73
|
+
# (a worker older than queueing refuses a queue), or delivery is
|
|
74
|
+
# :cut or unknown
|
|
75
|
+
def send(id, text, delivery: nil)
|
|
69
76
|
raise Error, "nothing to send" if text.to_s.strip.empty?
|
|
70
77
|
|
|
78
|
+
wire = plugin_delivery(delivery)
|
|
71
79
|
state_dir = self.state_dir
|
|
72
80
|
sid = resolve(id, state_dir)
|
|
73
|
-
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir
|
|
81
|
+
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir,
|
|
82
|
+
delivery: wire)
|
|
74
83
|
case delivered[:status]
|
|
75
84
|
when :accepted then sid
|
|
76
|
-
when :refused
|
|
85
|
+
when :refused
|
|
86
|
+
ack = delivered[:ack] || {}
|
|
87
|
+
raise Error, "session #{sid[0, 8]} refused the message (#{ack["error"]}#{": #{ack["detail"]}" if ack["detail"]})"
|
|
77
88
|
when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
|
|
78
89
|
else raise Error, "the message to session #{sid[0, 8]} could not be written"
|
|
79
90
|
end
|
|
@@ -81,6 +92,17 @@ module Samagotchi
|
|
|
81
92
|
raise Error, "session #{sid[0, 8]} is open in a chi REPL, which takes no messages from others"
|
|
82
93
|
end
|
|
83
94
|
|
|
95
|
+
# The wire value for send's delivery: (nil for the default).
|
|
96
|
+
def plugin_delivery(delivery)
|
|
97
|
+
case delivery&.to_s
|
|
98
|
+
when nil, "", Delivery::NEXT_STEP then nil
|
|
99
|
+
when Delivery::QUEUE then Delivery::QUEUE
|
|
100
|
+
when Delivery::CUT then raise Error, "plugins never cut: send it plain (the next step) or delivery: :queue"
|
|
101
|
+
else raise Error, "delivery takes :next_step or :queue, not #{delivery.inspect}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
private :plugin_delivery
|
|
105
|
+
|
|
84
106
|
# Session +id+ now: from its worker when one runs (with a running turn
|
|
85
107
|
# so far), else as saved.
|
|
86
108
|
# @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
|