samagotchi 0.90.0 → 0.91.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 +22 -1
- data/docs/architecture.md +3 -3
- data/docs/bundle-authoring.md +4 -10
- data/docs/cli.md +4 -1
- data/docs/configuration.md +5 -3
- data/docs/internals/background-tasks.md +27 -2
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/prompt-caching.md +4 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge.rb +7 -2
- data/lib/samagotchi/bundle_command/try/trace.rb +25 -8
- data/lib/samagotchi/bundle_command.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +5 -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 +9 -3
- data/lib/samagotchi/last_turn.rb +3 -2
- data/lib/samagotchi/task_finish_note.rb +90 -0
- data/lib/samagotchi/task_watch.rb +185 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/tool_declarations.rb +16 -0
- data/lib/samagotchi/tools/builtins.rb +5 -1
- data/lib/samagotchi/tools/task_record.rb +9 -0
- data/lib/samagotchi/tools/task_wait.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/notify.js +4 -4
- data/lib/samagotchi/web/public/turn_events.js +13 -1
- data/lib/samagotchi/worker.rb +177 -7
- data/lib/samagotchi/worker_idle_exit.rb +11 -3
- data/lib/samagotchi/worker_inbound.rb +18 -4
- data/lib/samagotchi/worker_wakes.rb +14 -4
- metadata +3 -1
|
@@ -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,185 @@
|
|
|
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
|
+
Entry = Data.define(:id, :limit_sec, :note_only) do
|
|
23
|
+
def initialize(id:, limit_sec: nil, note_only: false) = super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The cancel reasons that are the user's own Stop (Bridge::CANCEL_REASONS
|
|
27
|
+
# less session_stopped, and the TUI's ctrl_c).
|
|
28
|
+
USER_CANCEL_REASONS = %i[manual user ctrl_c].freeze
|
|
29
|
+
|
|
30
|
+
# The first line of a task tool's result, after a gate-corrected call's
|
|
31
|
+
# "ran as: …" line and the optional "[tool]" lead.
|
|
32
|
+
TASK_LINE = /\A#{Tools::TaskRuntime::RAN_AS_LINE}(?:\[task_(?:wait|get|stop)\]\n)?task_id: (\S+)/
|
|
33
|
+
LOOK_TOOLS = %w[task_wait task_get task_stop].freeze
|
|
34
|
+
HANDED_OVER_LIMIT = /#{Regexp.escape(Tools::Execute::HANDED_OVER)} (\d+)s limit/
|
|
35
|
+
|
|
36
|
+
MODE_KEY = "session.task_wakes"
|
|
37
|
+
MODES = %w[wake queue off].freeze
|
|
38
|
+
|
|
39
|
+
# session.task_wakes: wake (default), queue or off; anything else is wake.
|
|
40
|
+
def self.mode
|
|
41
|
+
value = Config.get(MODE_KEY).to_s
|
|
42
|
+
MODES.include?(value) ? value : "wake"
|
|
43
|
+
rescue StandardError
|
|
44
|
+
"wake"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
@entries = {}
|
|
50
|
+
@turn_ids = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A raw Engine event: a result that started a task adds it, one that
|
|
54
|
+
# shows the model the task's end removes it (#seen_id).
|
|
55
|
+
def observe(event)
|
|
56
|
+
return unless event[:type] == :tool_call_completed
|
|
57
|
+
|
|
58
|
+
tool = event[:tool].to_s
|
|
59
|
+
output = event[:output].to_s
|
|
60
|
+
output = output.scrub unless output.valid_encoding?
|
|
61
|
+
if (id = Tools::TaskRuntime.created_id(tool, output))
|
|
62
|
+
add(id, limit_sec: tool == Tools::Execute::NAME ? output[HANDED_OVER_LIMIT, 1]&.to_i : nil)
|
|
63
|
+
elsif LOOK_TOOLS.include?(tool) && (id = output[TASK_LINE, 1])
|
|
64
|
+
touched(id)
|
|
65
|
+
remove(id) if seen?(tool, output)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add(id, limit_sec: nil)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@entries[id] = Entry.new(id: id, limit_sec: limit_sec)
|
|
72
|
+
@turn_ids << id
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def remove(id)
|
|
77
|
+
@mutex.synchronize { @entries.delete(id) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A turn begins: the tasks it starts or waits on are its own (#turn_canceled).
|
|
81
|
+
def turn_started
|
|
82
|
+
@mutex.synchronize { @turn_ids = [] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A turn ended canceled for +reason+: the user's Stop makes that turn's
|
|
86
|
+
# tasks note-only (M5); a signal's, a hook's or a session stop's don't.
|
|
87
|
+
def turn_canceled(reason)
|
|
88
|
+
return unless USER_CANCEL_REASONS.include?(reason&.to_sym)
|
|
89
|
+
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@turn_ids.each { |id| @entries[id] &&= @entries[id].with(note_only: true) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ids = @mutex.synchronize { @entries.keys }
|
|
96
|
+
|
|
97
|
+
def entry(id) = @mutex.synchronize { @entries[id] }
|
|
98
|
+
|
|
99
|
+
def any? = @mutex.synchronize { @entries.any? }
|
|
100
|
+
|
|
101
|
+
# The watched tasks that finished, taken out of the watch with their
|
|
102
|
+
# records ([Entry, TaskRecord], oldest first). One the user stopped (the
|
|
103
|
+
# web's stop-task button) or whose record is gone is dropped, not
|
|
104
|
+
# returned. +keep+: the ids the conversation still holds
|
|
105
|
+
# (TaskRuntime.created_ids_in): a !rollback or a restored turn may have
|
|
106
|
+
# dropped the result that added one, which is dropped too. One `ps` per
|
|
107
|
+
# running task (TaskRuntime.get_record); nothing with an empty watch.
|
|
108
|
+
# @return [Array<Array(Entry, Tools::TaskRecord)>]
|
|
109
|
+
def take_finished(keep: nil)
|
|
110
|
+
entries = @mutex.synchronize { @entries.values }
|
|
111
|
+
return [] if entries.empty?
|
|
112
|
+
|
|
113
|
+
entries.filter_map do |entry|
|
|
114
|
+
# Safe for any llm_context strategy: stale and forget stubs are drawn
|
|
115
|
+
# in the view sent to the model (LLMContextView, the entry's edits),
|
|
116
|
+
# never written over the saved tool_response text; only a !rollback
|
|
117
|
+
# or a restored turn takes the result that names the id out.
|
|
118
|
+
next drop(entry, log: "not_in_conversation") if keep && !keep.include?(entry.id)
|
|
119
|
+
|
|
120
|
+
record, error = Tools::TaskRuntime.get_record(entry.id)
|
|
121
|
+
next drop(entry) if error || record.nil?
|
|
122
|
+
next nil if record.running?
|
|
123
|
+
# Removed by a look meanwhile (a task_wait on an anytime command's thread).
|
|
124
|
+
next nil unless drop(entry, kept: true)
|
|
125
|
+
next nil if record.stopped_by_user?
|
|
126
|
+
|
|
127
|
+
[entry, record]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# +entries+ taken by #take_finished whose note couldn't be written:
|
|
132
|
+
# watched again (an id watched meanwhile keeps its own entry).
|
|
133
|
+
def put_back(entries)
|
|
134
|
+
@mutex.synchronize { entries.each { |entry| @entries[entry.id] ||= entry } }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Whether a watched task has finished (#take_finished would take it):
|
|
138
|
+
# Engine#next_turn_waiting asks at a turn's end. Takes nothing.
|
|
139
|
+
def finished?
|
|
140
|
+
ids.any? do |id|
|
|
141
|
+
record, = Tools::TaskRuntime.get_record(id)
|
|
142
|
+
record && !record.running?
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The watched tasks still running ({id:, command:}), for the idle exit
|
|
147
|
+
# and /exit (WorkerIdleExit's running_tasks).
|
|
148
|
+
# @return [Array<Hash>]
|
|
149
|
+
def running
|
|
150
|
+
ids.filter_map do |id|
|
|
151
|
+
record, = Tools::TaskRuntime.get_record(id)
|
|
152
|
+
{ id: id, command: record.command.to_s } if record&.running?
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
# Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
|
|
159
|
+
# was there. +log+: why a task leaves without a note (debug).
|
|
160
|
+
def drop(entry, kept: false, log: nil)
|
|
161
|
+
removed = !remove(entry.id).nil?
|
|
162
|
+
Log.debug(:worker, "task_watch_dropped", task_id: entry.id, reason: log) if log && removed
|
|
163
|
+
kept ? removed : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def touched(id)
|
|
167
|
+
@mutex.synchronize { @turn_ids << id unless @turn_ids.include?(id) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Whether +tool+'s +output+ for a task shows the model its end:
|
|
171
|
+
# task_wait's final status or a done_pattern match (D5), task_get's
|
|
172
|
+
# final status (the status alone is enough), any task_stop.
|
|
173
|
+
def seen?(tool, output)
|
|
174
|
+
status = output[/^status: (\S+)/, 1]
|
|
175
|
+
case tool
|
|
176
|
+
when "task_stop" then true
|
|
177
|
+
when "task_get" then final?(status)
|
|
178
|
+
when "task_wait" then final?(status) || output.match?(/^wait_result: pattern_matched$/)
|
|
179
|
+
else false
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def final?(status) = !status.nil? && status != "running"
|
|
184
|
+
end
|
|
185
|
+
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
|
|
@@ -518,6 +518,22 @@ module Samagotchi
|
|
|
518
518
|
|
|
519
519
|
GEMMA_QUOTE = '<|"|>'
|
|
520
520
|
|
|
521
|
+
# What a worker's finish wake (session.task_wakes not off) lets the
|
|
522
|
+
# model know: a task it leaves running is reported when it ends.
|
|
523
|
+
TASK_FINISH_LINE = "if you end your turn before it finishes, chi tells you when it does."
|
|
524
|
+
|
|
525
|
+
# +schema+ with TASK_FINISH_LINE in execute's hand-over sentence and at
|
|
526
|
+
# task_create's end; any other schema as given.
|
|
527
|
+
def with_task_finish_line(schema)
|
|
528
|
+
case schema[:name]
|
|
529
|
+
when "execute"
|
|
530
|
+
schema.merge(description: schema[:description].sub("and task_wait waits for it.",
|
|
531
|
+
"and task_wait waits for it; #{TASK_FINISH_LINE}"))
|
|
532
|
+
when "task_create" then schema.merge(description: "#{schema[:description]} #{TASK_FINISH_LINE.sub(/\Ai/, "I")}")
|
|
533
|
+
else schema
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
521
537
|
# +schema+ without execute's description property: what a session
|
|
522
538
|
# with execute.description off declares (the parameter isn't offered
|
|
523
539
|
# at all). Any other schema as given.
|
|
@@ -185,13 +185,17 @@ module Samagotchi
|
|
|
185
185
|
# description parameter (execute.description off)
|
|
186
186
|
# @param policy [String, nil] llm_context.policy, the sentence
|
|
187
187
|
# forget_outputs' description carries
|
|
188
|
+
# @param task_finish_notes [Boolean] a worker's session whose finish
|
|
189
|
+
# wake reports a task left running (session.task_wakes not off):
|
|
190
|
+
# execute and task_create say so (ToolDeclarations::TASK_FINISH_LINE)
|
|
188
191
|
# @return [Registry] a new registry with the built-ins, in
|
|
189
192
|
# TOOL_SCHEMAS order (an Engine's own, which bundles add to), then
|
|
190
193
|
# forget_outputs, offered only under the forget layer
|
|
191
|
-
def registry(command_description: true, policy: ToolDeclarations::FORGET_OUTPUTS_POLICY)
|
|
194
|
+
def registry(command_description: true, policy: ToolDeclarations::FORGET_OUTPUTS_POLICY, task_finish_notes: false)
|
|
192
195
|
by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
|
|
193
196
|
registry = ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, built|
|
|
194
197
|
schema = ToolDeclarations.without_command_description(schema) unless command_description
|
|
198
|
+
schema = ToolDeclarations.with_task_finish_line(schema) if task_finish_notes
|
|
195
199
|
klass = by_name.fetch(schema[:name])
|
|
196
200
|
handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
|
|
197
201
|
built.register(klass::NAME, schema: schema, handler: handler)
|
|
@@ -44,6 +44,15 @@ module Samagotchi
|
|
|
44
44
|
to_h.reject { |key, value| value.nil? && self.class::OPTIONAL.include?(key) }.transform_keys(&:to_s)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# A task id as people read it: the random part a task_create id ends
|
|
48
|
+
# with (TaskRuntime.generate_task_id starts it with the time, the same
|
|
49
|
+
# for every task that second), else its first 8 chars. The web's
|
|
50
|
+
# turn_events.js has the JS twin (shortTaskId).
|
|
51
|
+
def self.short_id(id)
|
|
52
|
+
id = id.to_s
|
|
53
|
+
id[/-(\h{8})\z/, 1] || id[0, 8]
|
|
54
|
+
end
|
|
55
|
+
|
|
47
56
|
def running? = status == "running"
|
|
48
57
|
|
|
49
58
|
def stopped_by_user? = stop_reason == "stopped_by_user"
|
|
@@ -80,12 +80,19 @@ module Samagotchi
|
|
|
80
80
|
end
|
|
81
81
|
private_class_method :cancelled_response
|
|
82
82
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
83
|
+
# A finished task's text, as task_wait gives it: its status lines and
|
|
84
|
+
# always the log's last lines (a test run's summary, an error), then a
|
|
85
|
+
# stderr_tail when the record has a non-empty one. A task the user
|
|
86
|
+
# stopped also says so. Public: a task finish wake's note
|
|
87
|
+
# (TaskFinishNote) carries the same text.
|
|
88
|
+
# @param record [TaskRecord] a finished one
|
|
89
|
+
# @return [String] a RefText of its output_path when it has one
|
|
90
|
+
def self.finished_text(record, tail_lines: TAIL_LINES_DEFAULT)
|
|
86
91
|
extra = record.stopped_by_user? ? [TaskRuntime::USER_STOP_NOTE] : []
|
|
87
92
|
format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines, extra: extra)
|
|
88
93
|
end
|
|
94
|
+
|
|
95
|
+
def self.finished_response(record, tail_lines) = finished_text(record, tail_lines: tail_lines)
|
|
89
96
|
private_class_method :finished_response
|
|
90
97
|
|
|
91
98
|
def self.output_tail(record, tail_lines)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -15,11 +15,11 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// A finished turn shorter than this was watched: no "done". Not a turn
|
|
18
|
-
// chi ran for a delegate child's report (last_turn origin delegate_report)
|
|
19
|
-
//
|
|
20
|
-
// watched it.
|
|
18
|
+
// chi ran for a delegate child's report (last_turn origin delegate_report),
|
|
19
|
+
// an attached context change (context) or a finished background task
|
|
20
|
+
// (task): nobody started it, so nobody watched it.
|
|
21
21
|
const DONE_MIN_SECONDS = 10;
|
|
22
|
-
const UNWATCHED_ORIGINS = ["delegate_report", "context"];
|
|
22
|
+
const UNWATCHED_ORIGINS = ["delegate_report", "context", "task"];
|
|
23
23
|
|
|
24
24
|
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
25
25
|
// and asks whether to go on) has its own reason: "out of steps".
|
|
@@ -107,13 +107,25 @@ export function isReportClient(clientId) {
|
|
|
107
107
|
|
|
108
108
|
// A turn chi ran because an attached context source changed: context:<name>.
|
|
109
109
|
const CONTEXT_CLIENT = "context:";
|
|
110
|
+
// A turn chi ran because a background task the session started finished
|
|
111
|
+
// unseen: task:<task id> (the Worker's finish wake).
|
|
112
|
+
const TASK_CLIENT = "task:";
|
|
113
|
+
|
|
114
|
+
// A task id as people read it: the random part a task_create id ends with
|
|
115
|
+
// (it starts with the time), else its first 8 chars (TaskRecord.short_id).
|
|
116
|
+
function shortTaskId(id) {
|
|
117
|
+
const m = /-([0-9a-f]{8})$/i.exec(id);
|
|
118
|
+
return m ? m[1] : id.slice(0, 8);
|
|
119
|
+
}
|
|
110
120
|
|
|
111
121
|
// The label on another client's prompt, by its client_id prefix; a context
|
|
112
|
-
// change's turn as "context <name> changed"
|
|
122
|
+
// change's turn as "context <name> changed", a finished task's as
|
|
123
|
+
// "task <id8> finished".
|
|
113
124
|
export function clientLabel(clientId) {
|
|
114
125
|
if (!clientId) return null;
|
|
115
126
|
const id = String(clientId);
|
|
116
127
|
if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
|
|
128
|
+
if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
|
|
117
129
|
const colon = id.indexOf(":");
|
|
118
130
|
return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
|
|
119
131
|
}
|