samagotchi 0.33.0 → 0.34.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 +47 -1
- data/README.md +4 -1
- data/docs/context.md +5 -0
- data/docs/guardrails.md +31 -1
- data/docs/hooks.md +1 -1
- data/docs/memory.md +17 -1
- data/docs/plugins.md +77 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +9 -3
- data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
- data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/system/delegated.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/children_status.rb +157 -0
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +16 -103
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/guardrail_wiring.rb +27 -2
- data/lib/samagotchi/guardrails/approvals.rb +7 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
- data/lib/samagotchi/guardrails/context.rb +32 -0
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/memory_bundle/builder.rb +14 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +45 -1
- data/lib/samagotchi/process_group.rb +2 -5
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +20 -8
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +103 -14
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/worker.rb +7 -2
- metadata +10 -1
|
@@ -5,6 +5,6 @@ Another chi session (the parent) gave you this task. It reads only your last mes
|
|
|
5
5
|
- Finish the task in this turn if you can. End with one reply the parent can act on without asking back: the result first, then evidence (paths, commands run and what they printed), then anything you could not verify or finish.
|
|
6
6
|
- Do only what the task asks. A question ("why does this spec fail?", "what calls X?", "check whether…") gets a report, not file changes: say what you found and what change you would make, and leave making it to the parent. Edit files only when the task asks you to change, fix, add or write something, and then only the files that takes.
|
|
7
7
|
- The parent may send follow-up messages later; each one is a new turn in this same session, with what you did so far.
|
|
8
|
-
- Work in the directory you were started in. Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
|
|
8
|
+
- Work in the directory you were started in: every cd, read, edit, test and commit stays in it (in a linked worktree, never the repository's other checkouts). Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
|
|
9
9
|
- You can't delegate further. Don't start other chi sessions, and send notes only to the parent.
|
|
10
10
|
- If you are blocked, stop and say so in the reply with the exact question. Use ask_user_question only when the task says the user is watching.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.34.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
|
-
self_map.md: sha256:
|
|
8
|
+
self_map.md: sha256:eb017702746f9aad96404d9982a3c27bec717906fa2a14175ab207ad6f843f72
|
|
9
9
|
config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
|
|
10
|
-
memory_guide.md: sha256:
|
|
11
|
-
delegated.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:63d5d7f7d704e6ce7e28dd99e532b714c1b9f7acf548092404248d8ca5385b80
|
|
11
|
+
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -15,7 +15,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
|
|
|
15
15
|
| `system` | `$XDG_CONFIG_HOME/samagotchi/memories/`, default `~/.config/samagotchi/memories/` (`MemoryPaths.system_dir`) | User-wide preferences, identity, cross-project knowledge |
|
|
16
16
|
| `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
|
|
17
17
|
|
|
18
|
-
- The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the
|
|
18
|
+
- The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows that folder (not the root: in a linked worktree, work stays in the worktree). Outside a repository the root is the working directory. A separate clone is a different project.
|
|
19
19
|
- `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the description. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
|
|
20
20
|
|
|
21
21
|
## Tools you have
|
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
`chi send --new --wait -m TEXT` starts a session the user can watch in the web and prints its
|
|
31
31
|
answer (exit 3: it waits for the user's answer); see "Starting a session".
|
|
32
32
|
- `delegate` hands a task to a child session that runs in parallel and returns only its final
|
|
33
|
-
reply (`
|
|
34
|
-
as a delegate report when the child ends its turn
|
|
33
|
+
reply (`wait: false` is the usual way: I end my turn or keep talking with my user, and chi
|
|
34
|
+
brings the reply to me by itself as a delegate report when the child ends its turn, starting a
|
|
35
|
+
turn for it if I'm idle; `delegate_result` only when this turn can't go on without the reply;
|
|
36
|
+
`session:` sends a follow-up to a child). A child is a
|
|
35
37
|
normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
|
|
36
38
|
with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
|
|
37
39
|
approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "archive_store"
|
|
6
|
+
require_relative "memory_paths"
|
|
7
|
+
require_relative "reply_wait"
|
|
8
|
+
require_relative "tools/delegate"
|
|
9
|
+
require_relative "tools/delegate_cursor"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
13
|
+
# requires KernelLoop and so the tools (a require cycle otherwise).
|
|
14
|
+
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
15
|
+
|
|
16
|
+
# A parent's children as `/children` and the glance views show them: one
|
|
17
|
+
# Child per child session, read from the files layer 1 already writes
|
|
18
|
+
# (the session JSON, its output/ replies, the parent's delegates.json).
|
|
19
|
+
# Nothing here runs git or a subprocess.
|
|
20
|
+
module ChildrenStatus
|
|
21
|
+
# One child of a session.
|
|
22
|
+
# @!attribute state [String] waiting, running, failed, stopped, done or
|
|
23
|
+
# idle (STATES, in the order they are decided)
|
|
24
|
+
# @!attribute waiting [String, nil] what it waits for: question,
|
|
25
|
+
# approval, hook or continue
|
|
26
|
+
# @!attribute delegate [Boolean] a `delegate` child; false for a fork
|
|
27
|
+
# @!attribute branch [String, nil] the branch checked out in its folder
|
|
28
|
+
# (a short sha when detached); nil outside git
|
|
29
|
+
# @!attribute last_reply [String, nil] the first line of its newest reply
|
|
30
|
+
# @!attribute reported [Boolean] the parent was already given that reply
|
|
31
|
+
# (a delegate report or a wait); false when there is none
|
|
32
|
+
Child = Data.define(:id, :short_id, :title, :state, :waiting, :live, :delegate, :cwd, :branch,
|
|
33
|
+
:last_reply, :last_reply_at, :reported, :updated_at, :archived)
|
|
34
|
+
|
|
35
|
+
# The glance views' numbers: children running, waiting for an answer,
|
|
36
|
+
# and with a reply the parent wasn't given yet.
|
|
37
|
+
Counts = Data.define(:running, :waiting, :unreported) do
|
|
38
|
+
def self.none = new(running: 0, waiting: 0, unreported: 0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
STATES = %w[waiting running failed stopped done idle].freeze
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# Every child of +parent_id+, newest first: delegates and forks (a fork
|
|
46
|
+
# is delegate: false), archived ones only with +include_archived+. Scans
|
|
47
|
+
# every session (SessionManager.children_of): for on-demand use.
|
|
48
|
+
# @return [Array<Child>]
|
|
49
|
+
def of(parent_id, state_dir:, include_archived: false)
|
|
50
|
+
cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
|
|
51
|
+
SessionManager.children_of(parent_id, state_dir: state_dir).filter_map do |row|
|
|
52
|
+
next if row[:archived] && !include_archived
|
|
53
|
+
|
|
54
|
+
session = load(row[:id], state_dir: state_dir)
|
|
55
|
+
session && child(session, row, cursors, state_dir: state_dir)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The cheap path for the glance views: only the children in the
|
|
60
|
+
# parent's delegates.json (one Session.load and an owner check each, no
|
|
61
|
+
# scan). Its keys can also name a fork the model passed to delegate
|
|
62
|
+
# session: or a deleted child: only live delegates of this parent count,
|
|
63
|
+
# archived ones don't.
|
|
64
|
+
# @return [Counts]
|
|
65
|
+
def counts(parent_id, state_dir:)
|
|
66
|
+
cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
|
|
67
|
+
children = cursors.keys.filter_map do |id|
|
|
68
|
+
session = load(id, state_dir: state_dir)
|
|
69
|
+
next unless session&.delegate? && session.parent_id == parent_id
|
|
70
|
+
next if ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
|
|
71
|
+
|
|
72
|
+
child(session, summary_of(session, state_dir: state_dir), cursors, state_dir: state_dir)
|
|
73
|
+
end
|
|
74
|
+
Counts.new(running: children.count { |c| c.state == "running" },
|
|
75
|
+
waiting: children.count { |c| c.state == "waiting" },
|
|
76
|
+
unreported: children.count { |c| c.last_reply && !c.reported })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param row [Hash] a children_of row, or summary_of's keys
|
|
80
|
+
# @param cursors [Hash] the parent's delegates.json
|
|
81
|
+
def child(session, row, cursors, state_dir:)
|
|
82
|
+
reply = ReplyWait.newest_reply(session.id, state_dir: state_dir)
|
|
83
|
+
cursor = Tools::DelegateCursors.get_from(cursors, session.id)
|
|
84
|
+
Child.new(id: session.id, short_id: session.id[0, 8], title: row[:preview].to_s,
|
|
85
|
+
state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
|
|
86
|
+
delegate: session.delegate?, cwd: session.working_directory, branch: branch(session.working_directory),
|
|
87
|
+
last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
|
|
88
|
+
last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
|
|
89
|
+
updated_at: session.updated_at, archived: !!row[:archived])
|
|
90
|
+
end
|
|
91
|
+
private_class_method :child
|
|
92
|
+
|
|
93
|
+
def state_of(session, row, reply)
|
|
94
|
+
return "waiting" if row[:waiting]
|
|
95
|
+
return "running" if Tools::Delegate.running?(row)
|
|
96
|
+
return "stopped" if session.status == Session::STATUS_STOPPED
|
|
97
|
+
|
|
98
|
+
outcome = session.last_turn.is_a?(Hash) ? session.last_turn["outcome"] : nil
|
|
99
|
+
return "failed" if session.status == Session::STATUS_ERROR || outcome == "failed"
|
|
100
|
+
return "done" if outcome == "completed" && reply
|
|
101
|
+
|
|
102
|
+
"idle"
|
|
103
|
+
end
|
|
104
|
+
private_class_method :state_of
|
|
105
|
+
|
|
106
|
+
# The keys of a children_of row that state_of and child read, for a
|
|
107
|
+
# session counts loaded itself.
|
|
108
|
+
def summary_of(session, state_dir:)
|
|
109
|
+
live = SessionManager.worker_live?(session.id, state_dir: state_dir)
|
|
110
|
+
{ live: live, busy: live && session.status == Session::STATUS_RUNNING, status: session.status,
|
|
111
|
+
updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
|
|
112
|
+
preview: session.last_prompt.to_s }
|
|
113
|
+
end
|
|
114
|
+
private_class_method :summary_of
|
|
115
|
+
|
|
116
|
+
# The branch from the folder's own HEAD file (a linked worktree's is
|
|
117
|
+
# under .git/worktrees/<name>), read without git.
|
|
118
|
+
# @return [String, nil]
|
|
119
|
+
def branch(cwd)
|
|
120
|
+
dir = MemoryPaths.git_dir(cwd.to_s)
|
|
121
|
+
head = dir && File.read(File.join(dir, "HEAD"), 512).strip
|
|
122
|
+
return nil if head.nil? || head.empty?
|
|
123
|
+
|
|
124
|
+
head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
|
|
125
|
+
rescue SystemCallError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
private_class_method :branch
|
|
129
|
+
|
|
130
|
+
def first_line(id, file, state_dir:)
|
|
131
|
+
text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
|
|
132
|
+
text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
|
|
133
|
+
rescue SystemCallError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
private_class_method :first_line
|
|
137
|
+
|
|
138
|
+
# Reply files are named by the local time they were written
|
|
139
|
+
# (SessionInbox.write_output, %Y%m%d%H%M%S then nanoseconds).
|
|
140
|
+
# @return [Time, nil] nil for a name that isn't one
|
|
141
|
+
def reply_time(file)
|
|
142
|
+
Time.strptime(File.basename(file, ".txt")[0, 14], "%Y%m%d%H%M%S")
|
|
143
|
+
rescue ArgumentError, TypeError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
private_class_method :reply_time
|
|
147
|
+
|
|
148
|
+
def load(id, state_dir:)
|
|
149
|
+
return nil unless Session.exist?(id, state_dir: state_dir)
|
|
150
|
+
|
|
151
|
+
Session.load(id, state_dir: state_dir)
|
|
152
|
+
rescue ArgumentError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
private_class_method :load
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "turn_note"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# What a turn does after a generation was cut (stop_generation: a
|
|
7
|
+
# plugin's cut, or Engine#cut_for_steer's), shared by the native loop
|
|
8
|
+
# (KernelLoop) and the chat loop (ChatLoop::Run); each maps the outcome to
|
|
9
|
+
# its own ending.
|
|
10
|
+
#
|
|
11
|
+
# A generation a plugin cut is an empty answer made early. Queued input
|
|
12
|
+
# (a user's line, a plugin's steer) goes in first, with or without a
|
|
13
|
+
# retry left, and spends no attempt: the model answers it. A steer's cut
|
|
14
|
+
# with nothing queued (its message was taken at a boundary already) is
|
|
15
|
+
# asked again as is. Else it is asked again with its own nudge while the
|
|
16
|
+
# retry budget lasts, else the turn ends as cancelled (hook), with
|
|
17
|
+
# nothing salvaged and without the spent nudge. A Stop that came right
|
|
18
|
+
# after the cut is a plain cancel.
|
|
19
|
+
module CutPolicy
|
|
20
|
+
# :stopped (a Stop after the cut), :again (the turn goes on) or :hook
|
|
21
|
+
# (the turn ends as cancelled by the cut's bundle).
|
|
22
|
+
Outcome = Data.define(:kind) do
|
|
23
|
+
def stopped? = kind == :stopped
|
|
24
|
+
def again? = kind == :again
|
|
25
|
+
def hook? = kind == :hook
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
STOPPED = Outcome.new(kind: :stopped)
|
|
29
|
+
AGAIN = Outcome.new(kind: :again)
|
|
30
|
+
HOOK = Outcome.new(kind: :hook)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# +cut+: the cut's detail (the stop_generation payload). +emit+ takes an
|
|
35
|
+
# event Hash; +inject+ puts queued input in (true when there was any).
|
|
36
|
+
# +finish_reason+ and +thinking_chars+: the cut generation's, for the
|
|
37
|
+
# retry's row.
|
|
38
|
+
# On :hook the turn controller is cancelled (:hook, +cut+) already.
|
|
39
|
+
def decide(cut:, cancel_controller:, empty_retry:, conversation:, iteration:, emit:, inject:, finish_reason:,
|
|
40
|
+
thinking_chars:)
|
|
41
|
+
return STOPPED if cancel_controller.cancelled?
|
|
42
|
+
|
|
43
|
+
# The row under the cut step, above the message it was cut for.
|
|
44
|
+
emit.call({ type: :steer_cut, iteration: iteration, source: cut[:source].to_s }) if cut[:steer]
|
|
45
|
+
return AGAIN if inject.call || cut[:steer]
|
|
46
|
+
|
|
47
|
+
if empty_retry.left?
|
|
48
|
+
empty_retry.nudge!(conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
|
|
49
|
+
emit: emit, iteration: iteration, finish_reason: finish_reason,
|
|
50
|
+
thinking_chars: thinking_chars, stopped_by: cut[:by])
|
|
51
|
+
return AGAIN
|
|
52
|
+
end
|
|
53
|
+
empty_retry.drop_nudge!(conversation)
|
|
54
|
+
cancel_controller.cancel!(:hook, cut)
|
|
55
|
+
HOOK
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -49,8 +49,8 @@ require_relative "tools/memory"
|
|
|
49
49
|
require_relative "muted_memories"
|
|
50
50
|
require_relative "used_memories"
|
|
51
51
|
require_relative "turn_state"
|
|
52
|
-
require_relative "
|
|
53
|
-
require_relative "
|
|
52
|
+
require_relative "steer_cut"
|
|
53
|
+
require_relative "engine_peers"
|
|
54
54
|
require_relative "bundle_needs"
|
|
55
55
|
require_relative "model_overlay"
|
|
56
56
|
require_relative "served_model"
|
|
@@ -109,10 +109,8 @@ module Samagotchi
|
|
|
109
109
|
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
110
110
|
# may steer or ask whether a turn runs while it loads.
|
|
111
111
|
@turn_state = TurnState.new(clock: -> { monotonic_now })
|
|
112
|
-
#
|
|
113
|
-
@
|
|
114
|
-
# A message that may cut it but came too early: it cuts later.
|
|
115
|
-
@waiting_steer = WaitingSteer.new
|
|
112
|
+
# A message for the running turn cutting its generation.
|
|
113
|
+
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller })
|
|
116
114
|
@scratch = scratch
|
|
117
115
|
@no_interrupt = no_interrupt
|
|
118
116
|
@chat_backend = nil
|
|
@@ -343,69 +341,15 @@ module Samagotchi
|
|
|
343
341
|
@turn_state.steer(text, source: source)
|
|
344
342
|
end
|
|
345
343
|
|
|
346
|
-
# A message for the running turn from +source+
|
|
347
|
-
#
|
|
348
|
-
#
|
|
349
|
-
# seconds (GenerationPhase): the loops' cut path then starts the step
|
|
350
|
-
# again with the message, no nudge, no retry spent. Too early, the
|
|
351
|
-
# message waits (WaitingSteer) and cuts once the thinking passes it
|
|
352
|
-
# (#recheck_waiting_steer, on each thinking chunk), unless a boundary
|
|
353
|
-
# hands it to the model first. A plugin's message never cuts, nor an
|
|
354
|
-
# unknown client's (automatic:<id>).
|
|
355
|
-
# Call it after the message is queued, with the #input_epoch read
|
|
356
|
-
# before queueing it. Outside any lock; a cut that lands just after the
|
|
357
|
-
# generation ended is harmless (the loop re-asks).
|
|
344
|
+
# A message for the running turn from +source+ cuts the generation
|
|
345
|
+
# streaming only thinking (SteerCut#cut_for_steer). Call it after the
|
|
346
|
+
# message is queued, with the #input_epoch read before queueing it.
|
|
358
347
|
# @return [Boolean] whether a generation was cut now
|
|
359
|
-
def cut_for_steer(source, epoch: nil)
|
|
360
|
-
return false unless Steer.cuts?(source)
|
|
348
|
+
def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
|
|
361
349
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
ctrl = active_cancel_controller
|
|
366
|
-
return false unless ctrl
|
|
367
|
-
return steer_cut!(ctrl, source.to_s) if @generation_phase.cuttable?(after)
|
|
368
|
-
|
|
369
|
-
Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting_steer.wait!(source, epoch: epoch)
|
|
370
|
-
false
|
|
371
|
-
rescue StandardError
|
|
372
|
-
false
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# The drains that took input so far (WaitingSteer#epoch): read it before
|
|
376
|
-
# queueing a message for #cut_for_steer.
|
|
377
|
-
def input_epoch
|
|
378
|
-
@waiting_steer.epoch
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
# A thinking chunk streamed: a waiting message cuts once the thinking
|
|
382
|
-
# passes steer.cut_after.
|
|
383
|
-
def recheck_waiting_steer
|
|
384
|
-
return unless @waiting_steer.waiting?
|
|
385
|
-
|
|
386
|
-
after = Config.get("steer.cut_after").to_i
|
|
387
|
-
return unless after.positive? && @generation_phase.cuttable?(after)
|
|
388
|
-
|
|
389
|
-
source = @waiting_steer.take
|
|
390
|
-
ctrl = active_cancel_controller
|
|
391
|
-
steer_cut!(ctrl, source, waited: true) if source && ctrl
|
|
392
|
-
rescue StandardError
|
|
393
|
-
nil
|
|
394
|
-
end
|
|
395
|
-
private :recheck_waiting_steer
|
|
396
|
-
|
|
397
|
-
def steer_cut!(ctrl, source, waited: false)
|
|
398
|
-
age = @generation_phase.age
|
|
399
|
-
cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
|
|
400
|
-
if cut
|
|
401
|
-
@waiting_steer.clear!
|
|
402
|
-
fields = { source: source, age: age&.round(1) }
|
|
403
|
-
fields[:waited] = true if waited
|
|
404
|
-
Log.info(:turn, "steer_cut", **fields)
|
|
405
|
-
end
|
|
406
|
-
cut
|
|
407
|
-
end
|
|
408
|
-
private :steer_cut!
|
|
350
|
+
# The drains that took input so far: read it before queueing a message
|
|
351
|
+
# for #cut_for_steer.
|
|
352
|
+
def input_epoch = @steer_cut.input_epoch
|
|
409
353
|
|
|
410
354
|
# Like #steer, for the turn that begins next (a continue turn answered
|
|
411
355
|
# with a text): it joins that turn at its first boundary. Callable from
|
|
@@ -432,7 +376,7 @@ module Samagotchi
|
|
|
432
376
|
lambda do |at_answer: false|
|
|
433
377
|
lines = pending_input ? Array(pending_input.call) : []
|
|
434
378
|
# The input went to the model: a message that waited to cut is in.
|
|
435
|
-
@
|
|
379
|
+
@steer_cut.delivered! unless lines.empty?
|
|
436
380
|
lines + take_steers(at_answer)
|
|
437
381
|
end
|
|
438
382
|
end
|
|
@@ -1459,28 +1403,6 @@ module Samagotchi
|
|
|
1459
1403
|
# @return [Session] current session (Engine owns create/resume)
|
|
1460
1404
|
attr_reader :session
|
|
1461
1405
|
|
|
1462
|
-
# The kernel's Tools::Peers, following the current session. cancelled?
|
|
1463
|
-
# is the running turn's cancel, for a tool that waits (delegate_result):
|
|
1464
|
-
# the controller is set from another thread and a tool gets no other
|
|
1465
|
-
# way to see it.
|
|
1466
|
-
PeerView = Struct.new(:engine) do
|
|
1467
|
-
def session_id = engine.session&.id
|
|
1468
|
-
def cwd = engine.session&.working_directory
|
|
1469
|
-
def project_root = engine.session&.project_root
|
|
1470
|
-
def state_dir = engine.peer_state_dir
|
|
1471
|
-
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
1472
|
-
def relay = engine.relay_peer
|
|
1473
|
-
def model_ref = engine.effective_model_ref
|
|
1474
|
-
end
|
|
1475
|
-
|
|
1476
|
-
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
1477
|
-
# its own question flow, which every UI attached to it answers.
|
|
1478
|
-
RelayPeer = Struct.new(:engine) do
|
|
1479
|
-
def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
|
|
1480
|
-
def interface = engine.interface
|
|
1481
|
-
def relay_desk = engine.relay_desk
|
|
1482
|
-
end
|
|
1483
|
-
|
|
1484
1406
|
# This worker's relays of its delegates' approvals (memory only).
|
|
1485
1407
|
# @return [RelayDesk]
|
|
1486
1408
|
attr_reader :relay_desk
|
|
@@ -2015,8 +1937,7 @@ module Samagotchi
|
|
|
2015
1937
|
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
2016
1938
|
# treats the just-finished turn as activity and re-arms its window.
|
|
2017
1939
|
left = @turn_state.finish!
|
|
2018
|
-
@
|
|
2019
|
-
@waiting_steer.clear!
|
|
1940
|
+
@steer_cut.finished!
|
|
2020
1941
|
Client.swap_probe_cancel(probe_cancel_before)
|
|
2021
1942
|
# The next turn's window and served model are asked again: the cache
|
|
2022
1943
|
# (this host's, process-wide) is dropped here, at the turn's end,
|
|
@@ -2473,21 +2394,13 @@ module Samagotchi
|
|
|
2473
2394
|
def build_stream_event_handler(on_event, cancel_controller: nil)
|
|
2474
2395
|
watch = stream_watch(cancel_controller)
|
|
2475
2396
|
proc do |event|
|
|
2476
|
-
progress =
|
|
2397
|
+
progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
|
|
2398
|
+
@steer_cut.observe(event, progress)
|
|
2477
2399
|
case event[:type]
|
|
2478
2400
|
when :generation_started
|
|
2479
|
-
@generation_phase.started!
|
|
2480
2401
|
@generation_thought = nil
|
|
2481
2402
|
watch&.started(event[:iteration])
|
|
2482
|
-
when :generation_chunk
|
|
2483
|
-
# A chunk without the lanes (no loop of ours sends one) counts as text.
|
|
2484
|
-
text = event.key?(:text) ? event[:text] : event[:content]
|
|
2485
|
-
progress = { thinking: event[:thinking].to_s, text: text.to_s }
|
|
2486
|
-
@generation_phase.chunk!(**progress, tool_call: event[:tool_call])
|
|
2487
|
-
recheck_waiting_steer unless progress[:thinking].empty?
|
|
2488
|
-
note_generation_thought(progress[:thinking])
|
|
2489
|
-
when :generation_retrying then @generation_phase.retrying!
|
|
2490
|
-
when :generation_completed, :generation_cancelled then @generation_phase.finished!
|
|
2403
|
+
when :generation_chunk then note_generation_thought(progress[:thinking])
|
|
2491
2404
|
end
|
|
2492
2405
|
# The chat loop asked again without the thinking fields: a notice,
|
|
2493
2406
|
# not an event of its own.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The views of an Engine its peers get: the kernel's tools
|
|
5
|
+
# (Engine#initialize sets @kernel.peers) and a delegate's approval relay
|
|
6
|
+
# (Engine#relay_peer).
|
|
7
|
+
class Engine
|
|
8
|
+
# The kernel's Tools::Peers, following the current session. cancelled?
|
|
9
|
+
# is the running turn's cancel, for a tool that waits (delegate_result):
|
|
10
|
+
# the controller is set from another thread and a tool gets no other
|
|
11
|
+
# way to see it.
|
|
12
|
+
PeerView = Struct.new(:engine) do
|
|
13
|
+
def session_id = engine.session&.id
|
|
14
|
+
def cwd = engine.session&.working_directory
|
|
15
|
+
def project_root = engine.session&.project_root
|
|
16
|
+
def state_dir = engine.peer_state_dir
|
|
17
|
+
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
18
|
+
def relay = engine.relay_peer
|
|
19
|
+
def model_ref = engine.effective_model_ref
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
23
|
+
# its own question flow, which every UI attached to it answers.
|
|
24
|
+
RelayPeer = Struct.new(:engine) do
|
|
25
|
+
def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
|
|
26
|
+
def interface = engine.interface
|
|
27
|
+
def relay_desk = engine.relay_desk
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -104,11 +104,26 @@ module Samagotchi
|
|
|
104
104
|
)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
# The gate's core checks, in order.
|
|
107
|
+
# The gate's core checks, in order. A delegate child's boundary comes
|
|
108
|
+
# before the rules, so its ask keeps the child-boundary rule id (and
|
|
109
|
+
# its once/session scopes) when a strict rule asks too.
|
|
108
110
|
def checks
|
|
109
111
|
rules = self.rules
|
|
110
112
|
[@failures, rules.hook_asks, protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
|
|
111
|
-
rules].compact
|
|
113
|
+
child_boundary, rules].compact
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The delegate child's boundary (Guardrails::ChildBoundary); nil for
|
|
117
|
+
# any other session.
|
|
118
|
+
def child_boundary
|
|
119
|
+
return nil unless @session_lookup.call&.delegate?
|
|
120
|
+
|
|
121
|
+
@child_boundary ||= Guardrails::ChildBoundary.new(
|
|
122
|
+
root: -> { boundary_root },
|
|
123
|
+
worktrees: -> { (root = boundary_root) ? @git.worktrees(root) : [] },
|
|
124
|
+
common_dir: -> { (root = boundary_root) && @git.common_dir(root) },
|
|
125
|
+
branch: -> { (root = boundary_root) && @git.branch(root) }
|
|
126
|
+
)
|
|
112
127
|
end
|
|
113
128
|
|
|
114
129
|
# The YAML rules: config.yml's `guardrails:` section (rules, disable) and
|
|
@@ -221,6 +236,16 @@ module Samagotchi
|
|
|
221
236
|
|
|
222
237
|
private
|
|
223
238
|
|
|
239
|
+
# The work tree top of the session's working_directory (the folder
|
|
240
|
+
# itself outside a repo, or when it's gone); never Dir.pwd, which is
|
|
241
|
+
# the waker's cwd for a worker whose folder is gone.
|
|
242
|
+
def boundary_root
|
|
243
|
+
dir = @session_lookup.call&.working_directory.to_s
|
|
244
|
+
return nil if dir.empty?
|
|
245
|
+
|
|
246
|
+
@git.root(dir) || File.expand_path(dir)
|
|
247
|
+
end
|
|
248
|
+
|
|
224
249
|
# The rules of a bundle that needs a newer chi: the ones last loaded
|
|
225
250
|
# from it, with one notice per bundle version (restart the session to
|
|
226
251
|
# load the new ones). Never loaded (a chi older than the bundle it
|
|
@@ -26,6 +26,10 @@ module Samagotchi
|
|
|
26
26
|
class Approvals
|
|
27
27
|
FILE = "approvals.json"
|
|
28
28
|
STORED_SCOPES = %w[session repo rule].freeze
|
|
29
|
+
# Rules a stored answer allows only for its session: their asks offer
|
|
30
|
+
# once/session, and a repo entry for the same call (from another
|
|
31
|
+
# rule) would hold in every worktree, for every child.
|
|
32
|
+
SESSION_ONLY_RULES = %w[child-boundary].freeze
|
|
29
33
|
|
|
30
34
|
# @param sessions_dir [String] Session's state dir
|
|
31
35
|
# ($XDG_STATE_HOME/samagotchi/sessions): the store sits beside it,
|
|
@@ -69,7 +73,10 @@ module Samagotchi
|
|
|
69
73
|
repo = verdict.targets&.repo
|
|
70
74
|
here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
|
|
71
75
|
session_id = verdict.context&.session_id
|
|
76
|
+
session_only = SESSION_ONLY_RULES.include?(verdict.rule.to_s)
|
|
72
77
|
list.find do |e|
|
|
78
|
+
next false if session_only && e["scope"] != "session"
|
|
79
|
+
|
|
73
80
|
case e["scope"]
|
|
74
81
|
when "session" then session_id && e["session_id"] == session_id && e["key"] == key
|
|
75
82
|
when "repo" then here.call(e) && e["key"] == key
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "outside"
|
|
4
|
+
require_relative "protected_paths"
|
|
5
|
+
require_relative "shell_lex"
|
|
6
|
+
require_relative "shell_paths"
|
|
7
|
+
require_relative "shared_git"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Guardrails
|
|
11
|
+
# A delegate child asks before it changes anything outside its folder
|
|
12
|
+
# (+root+: the work tree top of its working_directory, never Dir.pwd,
|
|
13
|
+
# so a child whose worktree is gone still has one). A core check, on in
|
|
14
|
+
# every guardrails mode and with guardrails.enabled: false, for delegate
|
|
15
|
+
# sessions only (GuardrailWiring#checks), so ordinary sessions and a
|
|
16
|
+
# parent's own flows never get it. It asks, once or for the session
|
|
17
|
+
# (a repo or rule answer would hold in every worktree, for every child),
|
|
18
|
+
# when:
|
|
19
|
+
# write, edit: a path is outside root (Outside: tmp dirs and memory
|
|
20
|
+
# files never are);
|
|
21
|
+
# execute, task_create: mutating git runs in a dir outside root
|
|
22
|
+
# (ShellGitDirs), or a command that isn't read-only starts outside
|
|
23
|
+
# root (its cwd:, else Dir.pwd) or names a path in the repository's
|
|
24
|
+
# other checkouts (the main one and sibling worktrees) or its
|
|
25
|
+
# common git dir; or it runs git that changes what all checkouts
|
|
26
|
+
# share, from anywhere (SharedGit: config, update-ref, worktree add,
|
|
27
|
+
# stash clear, tags, another branch's delete).
|
|
28
|
+
# Paths elsewhere in a command (/usr/bin, ~/.gem) don't count, and
|
|
29
|
+
# read-only commands never ask. Text heuristics: sh -c bodies, scripts
|
|
30
|
+
# and $(…) aren't looked into.
|
|
31
|
+
class ChildBoundary
|
|
32
|
+
RULE = "child-boundary"
|
|
33
|
+
SOURCE = "core"
|
|
34
|
+
SCOPES = %w[once session].freeze
|
|
35
|
+
SHELL_TOOLS = %w[execute task_create].freeze
|
|
36
|
+
|
|
37
|
+
# @param root [#call] → String, nil: the child's folder (nil: no check)
|
|
38
|
+
# @param worktrees [#call] → Array<String>: every checkout of root's
|
|
39
|
+
# repository (GitInfo#worktrees); root's own is left out here
|
|
40
|
+
# @param common_dir [#call] → String, nil: root's common git dir (in
|
|
41
|
+
# a bare layout it is in no checkout)
|
|
42
|
+
# @param branch [#call] → String, nil: the branch checked out in root
|
|
43
|
+
def initialize(root:, worktrees: -> { [] }, common_dir: -> {}, branch: -> {})
|
|
44
|
+
@root = root
|
|
45
|
+
@worktrees = worktrees
|
|
46
|
+
@common_dir = common_dir
|
|
47
|
+
@branch = branch
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Vote on +verdict+ (its targets).
|
|
51
|
+
def check(verdict)
|
|
52
|
+
targets = verdict.targets
|
|
53
|
+
root = targets && @root.call
|
|
54
|
+
return verdict unless root && !root.to_s.empty?
|
|
55
|
+
|
|
56
|
+
crossing = crosses?(targets, root) or return verdict
|
|
57
|
+
|
|
58
|
+
what = crossing == :shared ? "git state every checkout of the repository shares, from its folder" : "something outside its folder"
|
|
59
|
+
verdict.ask!("a delegate child changes #{what} #{root}: ask the user",
|
|
60
|
+
scopes: SCOPES, rule: RULE, source: SOURCE, decided_by: "core")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def crosses?(targets, root)
|
|
66
|
+
case targets.tool
|
|
67
|
+
when "write", "edit" then targets.paths.any? { |path| Outside.outside?(path, root: root) }
|
|
68
|
+
when *SHELL_TOOLS then shell_crosses?(targets, root)
|
|
69
|
+
else false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shell_crosses?(targets, root)
|
|
74
|
+
return true if targets.git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: root) }
|
|
75
|
+
return :shared if SharedGit.changes?(targets.command, own_branch: @branch.call)
|
|
76
|
+
return false if targets.read_only?
|
|
77
|
+
return true if targets.cwd && Outside.outside?(targets.cwd, root: root)
|
|
78
|
+
|
|
79
|
+
names_other_checkout?(targets.command, targets.cwd, root)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether a word of +command+ resolves into another checkout of the
|
|
83
|
+
# repository (and not into root, which may sit inside the main one).
|
|
84
|
+
def names_other_checkout?(command, cwd, root)
|
|
85
|
+
real_root = ProtectedPaths.real(root)
|
|
86
|
+
others = [*@worktrees.call, @common_dir.call].compact.map { |dir| ProtectedPaths.real(dir) } - [real_root]
|
|
87
|
+
return false if others.empty?
|
|
88
|
+
|
|
89
|
+
walk = ShellPaths::Walk.new(cwd, Dir.home, ENV)
|
|
90
|
+
ShellLex.simple_commands(ShellLex.lex(command.to_s)).any? do |words|
|
|
91
|
+
walk.step(words).any? do |_word, path|
|
|
92
|
+
next false unless path
|
|
93
|
+
|
|
94
|
+
real = ProtectedPaths.real(path)
|
|
95
|
+
!ShellPaths.within?(real, real_root) && others.any? { |dir| ShellPaths.within?(real, dir) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
rescue ArgumentError, EncodingError
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|