samagotchi 0.65.0 → 0.67.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 +107 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +50 -28
- data/docs/cli.md +14 -2
- data/docs/configuration.md +101 -16
- data/docs/context.md +39 -2
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +75 -30
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +36 -13
- data/docs/memory.md +1 -1
- data/docs/plugins.md +289 -25
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +16 -6
- data/docs/sub-agent.md +6 -1
- data/docs/testing.md +17 -3
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +23 -8
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/engine.rb +120 -107
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +138 -156
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +5 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +41 -15
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -9
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +52 -43
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/commands.js +6 -4
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +17 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_model.js +2 -2
- data/lib/samagotchi/web/public/stream_handlers.js +13 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +13 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +23 -1
|
@@ -49,8 +49,11 @@ module Samagotchi
|
|
|
49
49
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
50
50
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
51
51
|
# The gate runs first, so tool_call_started shows the call that runs.
|
|
52
|
+
# A tool the session's toolkit leaves out skips it: no before_tool_call
|
|
53
|
+
# hook, no approval card for a call that can't run (KernelLoop#dispatch
|
|
54
|
+
# answers it with the not-loaded error).
|
|
52
55
|
original = call.dup
|
|
53
|
-
verdict = evaluate(call, iteration, params)
|
|
56
|
+
verdict = left_out?(call) ? Guardrails::Verdict.new(call: call) : evaluate(call, iteration, params)
|
|
54
57
|
call = verdict.call
|
|
55
58
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
56
59
|
label = plugin_label(call[:name])
|
|
@@ -265,6 +268,15 @@ module Samagotchi
|
|
|
265
268
|
# The kernel's tools, for the activity line of a tool that isn't built in.
|
|
266
269
|
def tools = @kernel.tools
|
|
267
270
|
|
|
271
|
+
# A real tool the session's toolkit leaves out of this turn.
|
|
272
|
+
def left_out?(call)
|
|
273
|
+
registry = tools
|
|
274
|
+
return false unless registry.respond_to?(:left_out) && !call[:name].nil?
|
|
275
|
+
|
|
276
|
+
layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
|
|
277
|
+
!registry.left_out(call[:name], layers: layers).nil?
|
|
278
|
+
end
|
|
279
|
+
|
|
268
280
|
# A tool the registry has from a plugin (not core, not unknown).
|
|
269
281
|
def plugin_tool?(name)
|
|
270
282
|
entry = tools && !name.nil? ? tools[name] : nil
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session's toolkit: which of the registered tools its prompts declare
|
|
7
|
+
# (docs/sessions.md, "Toolkits"). Saved in the session file as given
|
|
8
|
+
# ({"name" => "lean", "add" => [], "remove" => []}) when the session is
|
|
9
|
+
# created, kept by continues and forks, and never resolved again from
|
|
10
|
+
# config: a respawned worker declares what the session was created with.
|
|
11
|
+
# A session without one (a file from before toolkits) is on FULL.
|
|
12
|
+
#
|
|
13
|
+
# +name+ names a Kit (the built-ins, config's toolkit.kits, a plugin's
|
|
14
|
+
# chi.toolkit); +add+ and +remove+ are the session's own changes on top
|
|
15
|
+
# (--tool / --no-tool, /toolkit add|remove). #resolve against the kits
|
|
16
|
+
# known gives the Resolved the Registry filters by.
|
|
17
|
+
Toolkit = Data.define(:name, :add, :remove) do
|
|
18
|
+
def initialize(name:, add: [], remove: [])
|
|
19
|
+
super(name: name.to_s.strip, add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Hash] the session file's "toolkit"
|
|
23
|
+
def to_file = { "name" => name, "add" => add, "remove" => remove }
|
|
24
|
+
|
|
25
|
+
# The plain form the snapshot and the web carry.
|
|
26
|
+
def to_h = { name: name, add: add, remove: remove }
|
|
27
|
+
|
|
28
|
+
# This toolkit with +tool+ declared (/toolkit add): a tool it removed
|
|
29
|
+
# comes out of +remove+ (back to its kit's), any other goes into +add+.
|
|
30
|
+
def adding(tool)
|
|
31
|
+
tool = tool.to_s
|
|
32
|
+
remove.include?(tool) ? with(remove: remove - [tool]) : with(add: (add + [tool]).uniq)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# This toolkit with +tool+ left out (/toolkit remove), the other way.
|
|
36
|
+
def removing(tool)
|
|
37
|
+
tool = tool.to_s
|
|
38
|
+
add.include?(tool) ? with(add: add - [tool]) : with(remove: (remove + [tool]).uniq)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# How the UIs name it: "lean", "lean +web_fetch -memory_write".
|
|
42
|
+
def label = [name, *add.map { |tool| "+#{tool}" }, *remove.map { |tool| "-#{tool}" }].join(" ")
|
|
43
|
+
|
|
44
|
+
def full? = name == Toolkit::FULL && add.empty? && remove.empty?
|
|
45
|
+
|
|
46
|
+
# The kit +name+ names, from +kits+ (Toolkit.kits), with the session's
|
|
47
|
+
# own +add+ and +remove+ on top. An unknown name, a base that is, or a
|
|
48
|
+
# cycle falls back to FULL with the reason in Resolved#fallback; the
|
|
49
|
+
# saved toolkit is untouched, so a reinstalled bundle gets it back.
|
|
50
|
+
# @param kits [Hash{String => Kit}]
|
|
51
|
+
# @return [Resolved]
|
|
52
|
+
def resolve(kits)
|
|
53
|
+
Toolkit.resolve_kit(name, kits).changed(add: add, remove: remove)
|
|
54
|
+
rescue Toolkit::Unresolved => e
|
|
55
|
+
Toolkit.resolve_kit(Toolkit::FULL, kits).changed(add: add, remove: remove).with(fallback: e.message)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Toolkit
|
|
60
|
+
FULL = "full"
|
|
61
|
+
LEAN = "lean"
|
|
62
|
+
CHILD = "child"
|
|
63
|
+
|
|
64
|
+
# A name that can't be resolved (unknown, a cycle).
|
|
65
|
+
class Unresolved < StandardError; end
|
|
66
|
+
|
|
67
|
+
# A toolkit as a session's next turn would declare it (Engine
|
|
68
|
+
# #toolkit_explained): +toolkit+ as saved, +resolved+, the Registry
|
|
69
|
+
# +declared+ entries and the names it leaves out (+left_out+), and
|
|
70
|
+
# whether that isn't what the Registry declares now (+pending+: it
|
|
71
|
+
# applies at the next turn's start).
|
|
72
|
+
Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :pending) do
|
|
73
|
+
# What the prompt carries of it: the declared names and, when it
|
|
74
|
+
# leaves tools out, the kit name the not-loaded line gives. Two equal
|
|
75
|
+
# keys build the same prompt (/toolkit's "no change", its cost).
|
|
76
|
+
def prompt_key = [declared.map(&:name), left_out.empty? ? nil : resolved.name]
|
|
77
|
+
|
|
78
|
+
# What the UIs show (the snapshot, command_ran): nil when it declares
|
|
79
|
+
# every tool on full (full, full +delegate), which has no chip.
|
|
80
|
+
def summary
|
|
81
|
+
return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil?
|
|
82
|
+
|
|
83
|
+
{ name: toolkit.name, label: toolkit.label, left_out: left_out, fallback: resolved.fallback }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Tool names as a list of unique, stripped strings.
|
|
88
|
+
def self.tool_names(names) = Array(names).map { |tool| tool.to_s.strip }.reject(&:empty?).uniq
|
|
89
|
+
|
|
90
|
+
# A named list of declared tools: +tools+ (:all, every built-in; or the
|
|
91
|
+
# built-in names) when it has no +base+, else its +base+'s with +add+
|
|
92
|
+
# and +remove+. +source+: "core", "config", or the bundle that declared
|
|
93
|
+
# it (chi.toolkit).
|
|
94
|
+
Kit = Data.define(:name, :base, :tools, :add, :remove, :source) do
|
|
95
|
+
def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core")
|
|
96
|
+
super(name: name.to_s, base: base&.to_s, tools: tools == :all ? :all : Toolkit.tool_names(tools),
|
|
97
|
+
add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove), source: source.to_s)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The built-ins `lean` declares: the file and shell tools, memories,
|
|
102
|
+
# the task tools (execute's hand-over, turn notes and task_wait's tail
|
|
103
|
+
# name the trio), questions, web_fetch, and context_read (core notes
|
|
104
|
+
# tell the model to call it).
|
|
105
|
+
LEAN_TOOLS = %w[
|
|
106
|
+
execute read write edit memory_read memory_write task_create task_wait task_get task_list task_stop
|
|
107
|
+
ask_user_question web_fetch context_read
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
BUILTIN_KITS = [
|
|
111
|
+
Kit.new(name: FULL, tools: :all),
|
|
112
|
+
Kit.new(name: LEAN, tools: LEAN_TOOLS),
|
|
113
|
+
# A delegate child: it reports to its parent (send_note; the
|
|
114
|
+
# delegated-by line names it) and leaves memory writes to it.
|
|
115
|
+
Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write])
|
|
116
|
+
].to_h { |kit| [kit.name, kit] }.freeze
|
|
117
|
+
|
|
118
|
+
# What a toolkit declares, the Registry's filter: every built-in
|
|
119
|
+
# (+all_core+) or the ones in +core+, every plugin tool and every tool
|
|
120
|
+
# an LLM context layer gates (forget_outputs), but for +removed+;
|
|
121
|
+
# +added+ declares any registered tool. +fallback+: why the session's
|
|
122
|
+
# own toolkit couldn't be resolved (this is FULL then), nil when it was.
|
|
123
|
+
Resolved = Data.define(:name, :all_core, :core, :added, :removed, :fallback) do
|
|
124
|
+
def initialize(name:, all_core: false, core: [], added: [], removed: [], fallback: nil)
|
|
125
|
+
super(name: name.to_s, all_core: all_core, core: core.dup.freeze, added: added.dup.freeze,
|
|
126
|
+
removed: removed.dup.freeze, fallback: fallback)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Every built-in and nothing removed: what no toolkit declares.
|
|
130
|
+
def everything? = all_core && removed.empty?
|
|
131
|
+
|
|
132
|
+
# @param entry [Tools::Registry::Entry]
|
|
133
|
+
def declares?(entry)
|
|
134
|
+
return false if removed.include?(entry.name)
|
|
135
|
+
return true if added.include?(entry.name) || !entry.core? || !entry.layer.nil?
|
|
136
|
+
|
|
137
|
+
all_core || core.include?(entry.name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# This with +add+ declared and +remove+ left out, in that order of
|
|
141
|
+
# the kit's own words: a later change wins.
|
|
142
|
+
def changed(add: [], remove: [])
|
|
143
|
+
added = self.added - remove
|
|
144
|
+
removed = self.removed - add
|
|
145
|
+
with(added: (added + add).uniq, removed: (removed + remove).uniq)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The session file's "toolkit": a Toolkit, nil for none (a file from
|
|
150
|
+
# before toolkits, or one that isn't a toolkit), which reads as FULL.
|
|
151
|
+
def self.from_file(value)
|
|
152
|
+
case value
|
|
153
|
+
when Hash
|
|
154
|
+
name = value["name"] || value[:name]
|
|
155
|
+
return nil if name.to_s.strip.empty?
|
|
156
|
+
|
|
157
|
+
new(name: name, add: value["add"] || value[:add], remove: value["remove"] || value[:remove])
|
|
158
|
+
when String, Symbol
|
|
159
|
+
value.to_s.strip.empty? ? nil : new(name: value)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The toolkit a new session is stored on: config's toolkit.default, or
|
|
164
|
+
# toolkit.child for a delegate child; FULL when unset. As named: the
|
|
165
|
+
# worker resolves it (plugins' kits load there).
|
|
166
|
+
def self.configured(delegate: false)
|
|
167
|
+
name = Config.get(delegate ? "toolkit.child" : "toolkit.default").to_s.strip
|
|
168
|
+
new(name: name.empty? ? FULL : name)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# config.yml's toolkit.kits (a map of name => {base:, add:, remove:}),
|
|
172
|
+
# nil when it has none.
|
|
173
|
+
def self.config_kits
|
|
174
|
+
section = ConfigFile.section("toolkit")
|
|
175
|
+
section.is_a?(Hash) ? section["kits"] : nil
|
|
176
|
+
rescue StandardError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A new session's toolkit from chi --toolkit NAME / --tool / --no-tool
|
|
181
|
+
# (each repeatable, a comma list too): NAME (config's toolkit.default
|
|
182
|
+
# without one) with those tools added and left out; nil when none was
|
|
183
|
+
# given. A tool in both is left out.
|
|
184
|
+
def self.from_flags(name: nil, add: [], remove: [])
|
|
185
|
+
add = split_names(add)
|
|
186
|
+
remove = split_names(remove)
|
|
187
|
+
return nil if name.to_s.strip.empty? && add.empty? && remove.empty?
|
|
188
|
+
|
|
189
|
+
base = name.to_s.strip.empty? ? configured.name : name
|
|
190
|
+
new(name: base, add: add - remove, remove: remove)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.split_names(values) = tool_names(Array(values).flat_map { |value| value.to_s.split(",") })
|
|
194
|
+
private_class_method :split_names
|
|
195
|
+
|
|
196
|
+
# The toolkit a session is on: its own, or FULL without one.
|
|
197
|
+
def self.of(toolkit) = toolkit || new(name: FULL)
|
|
198
|
+
|
|
199
|
+
# What a toolkit change costs the prompt cache (CacheConfirm): the
|
|
200
|
+
# declared tools are the front of every request, so any change to them
|
|
201
|
+
# (or to the not-loaded line) is :full; nil when they stay the same.
|
|
202
|
+
# @param before [Object] what the prompt carries now (Explained#prompt_key)
|
|
203
|
+
# @param after [Object] what it would carry after the change
|
|
204
|
+
def self.cache_cost(before, after) = before == after ? nil : :full
|
|
205
|
+
|
|
206
|
+
# @return [Resolved] +name+'s kit through its bases
|
|
207
|
+
# @raise [Unresolved] unknown, or a cycle
|
|
208
|
+
def self.resolve_kit(name, kits, seen = [])
|
|
209
|
+
raise Unresolved, "no toolkit named #{name}" unless (kit = kits[name])
|
|
210
|
+
raise Unresolved, "toolkit #{name}'s bases make a cycle (#{(seen + [name]).join(" → ")})" if seen.include?(name)
|
|
211
|
+
|
|
212
|
+
base = if kit.base
|
|
213
|
+
resolve_kit(kit.base, kits, seen + [name])
|
|
214
|
+
else
|
|
215
|
+
Resolved.new(name: name, all_core: kit.tools == :all, core: kit.tools == :all ? [] : kit.tools)
|
|
216
|
+
end
|
|
217
|
+
base.changed(add: kit.add, remove: kit.remove).with(name: name)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The kits known: the built-ins, then config's toolkit.kits, then the
|
|
221
|
+
# plugins' (chi.toolkit). A config kit with a built-in's name is left
|
|
222
|
+
# out (one it can't redefine) with a line in +errors+, and so is one
|
|
223
|
+
# that isn't a {base:, add:, remove:} map.
|
|
224
|
+
# @param config [Hash, nil] toolkit.kits
|
|
225
|
+
# @param plugins [Array<Kit>]
|
|
226
|
+
# @param errors [Array<String>] gets a line per kit left out
|
|
227
|
+
# @return [Hash{String => Kit}]
|
|
228
|
+
def self.kits(config: nil, plugins: [], errors: [])
|
|
229
|
+
kits = BUILTIN_KITS.dup
|
|
230
|
+
(config.is_a?(Hash) ? config : {}).each do |name, spec|
|
|
231
|
+
name = name.to_s
|
|
232
|
+
if kits.key?(name)
|
|
233
|
+
errors << "toolkit.kits.#{name}: #{name} is a built-in toolkit"
|
|
234
|
+
elsif !spec.is_a?(Hash)
|
|
235
|
+
errors << "toolkit.kits.#{name}: expected {base:, add:, remove:}"
|
|
236
|
+
else
|
|
237
|
+
spec = spec.transform_keys(&:to_s)
|
|
238
|
+
kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
|
|
239
|
+
source: "config")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
plugins.each { |kit| kits[kit.name] ||= kit }
|
|
243
|
+
kits
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "log"
|
|
4
|
+
require_relative "toolkit"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# An Engine's session toolkit: the kits it resolves against, what the
|
|
8
|
+
# session's next turn declares (#explained, the UIs' summary), a plugin's
|
|
9
|
+
# ctx.use_toolkit (#use) and the turn-start apply into the tool Registry
|
|
10
|
+
# (#apply!), with its load warnings said once per process.
|
|
11
|
+
#
|
|
12
|
+
# What it needs from the Engine comes through lookups, called at use.
|
|
13
|
+
class ToolkitBinding
|
|
14
|
+
# @param tools [#call] → Tools::Registry
|
|
15
|
+
# @param session [#call] → Session, nil
|
|
16
|
+
# @param state_dir [#call] → the sessions' state dir (#use saves there)
|
|
17
|
+
# @param can_stage [#call] → whether a plugin may change the toolkit now
|
|
18
|
+
# (no turn runs, no model reply yet)
|
|
19
|
+
# @param reset_prompt [#call] → build the system prompts again
|
|
20
|
+
# @param emit [#call] (sink, event) → emits to a turn's sink + observers
|
|
21
|
+
# @param notify [#call] (text, level, source, extra:) → a notice
|
|
22
|
+
def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:)
|
|
23
|
+
@tools = tools
|
|
24
|
+
@session = session
|
|
25
|
+
@state_dir = state_dir
|
|
26
|
+
@can_stage = can_stage
|
|
27
|
+
@reset_prompt = reset_prompt
|
|
28
|
+
@emit = emit
|
|
29
|
+
@notify = notify
|
|
30
|
+
# The toolkits plugins declare (chi.toolkit), beside the built-in and
|
|
31
|
+
# config ones (#kits); Plugin::Registries appends to it.
|
|
32
|
+
@plugin_kits = []
|
|
33
|
+
@warned = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Array<Toolkit::Kit>] the plugins' kits (Plugin::Registries' toolkits:)
|
|
37
|
+
attr_reader :plugin_kits
|
|
38
|
+
|
|
39
|
+
# The kits a session's toolkit resolves against: the built-ins, config's
|
|
40
|
+
# toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
|
|
41
|
+
# process, like the plugins': an edit to toolkit.kits never changes a
|
|
42
|
+
# live session's tools mid-session (a new worker reads it).
|
|
43
|
+
# @param errors [Array<String>] gets a line per config kit left out
|
|
44
|
+
# @return [Hash{String => Toolkit::Kit}]
|
|
45
|
+
def kits(errors = [])
|
|
46
|
+
@config_kits = Toolkit.config_kits unless defined?(@config_kits)
|
|
47
|
+
Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The session's toolkit resolved (Toolkit::Resolved): what its next
|
|
51
|
+
# turn declares. A plain full toolkit is nil, the Registry's "every tool".
|
|
52
|
+
def resolved(session, errors = [])
|
|
53
|
+
resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
|
|
54
|
+
resolved.everything? ? nil : resolved
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# +toolkit+ (the session's by default) as its next turn would declare
|
|
58
|
+
# it (/toolkit, the UIs' chip).
|
|
59
|
+
# @return [Toolkit::Explained]
|
|
60
|
+
def explained(toolkit = nil)
|
|
61
|
+
toolkit ||= Toolkit.of(@session.call&.toolkit)
|
|
62
|
+
resolved = toolkit.resolve(kits)
|
|
63
|
+
filter = resolved.everything? ? nil : resolved
|
|
64
|
+
tools = @tools.call
|
|
65
|
+
Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: tools.entries(toolkit: filter),
|
|
66
|
+
left_out: tools.left_out_names(toolkit: filter), pending: filter != tools.toolkit)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The session's toolkit for the UIs (the snapshot, command_ran): nil on
|
|
70
|
+
# full, or with no session.
|
|
71
|
+
def summary
|
|
72
|
+
@session.call && explained.summary
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The session's toolkit name, nil with no session (ctx.toolkit).
|
|
76
|
+
def name
|
|
77
|
+
session = @session.call
|
|
78
|
+
session && Toolkit.of(session.toolkit).name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A plugin's ctx.use_toolkit: put the session on toolkit +name+, saved,
|
|
82
|
+
# when it has no model reply yet and no turn runs (D3: no confirm from
|
|
83
|
+
# plugins), and tell the user as /toolkit would; it applies at the next
|
|
84
|
+
# turn's start.
|
|
85
|
+
# @return [Boolean] whether it was staged
|
|
86
|
+
# @raise [ArgumentError] an unknown toolkit
|
|
87
|
+
def use(name)
|
|
88
|
+
raise ArgumentError, "no toolkit named #{name}" unless kits.key?(name)
|
|
89
|
+
|
|
90
|
+
session = @session.call
|
|
91
|
+
return false if session.nil? || !@can_stage.call
|
|
92
|
+
|
|
93
|
+
toolkit = Toolkit.new(name: name)
|
|
94
|
+
return true if session.toolkit == toolkit
|
|
95
|
+
|
|
96
|
+
session.toolkit = toolkit
|
|
97
|
+
session.save(state_dir: @state_dir.call)
|
|
98
|
+
@notify.call("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: summary })
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Resolve the session's toolkit into the Registry, on the turn thread at
|
|
103
|
+
# a turn's start (before its prompt is built), and build the prompts
|
|
104
|
+
# again when what it declares changed. A name that doesn't resolve (a
|
|
105
|
+
# typo, an uninstalled bundle) declares everything for now and says so
|
|
106
|
+
# once; the saved toolkit stays as it is.
|
|
107
|
+
# @return [Boolean] whether the declared tools changed
|
|
108
|
+
def apply!(session, on_event = nil)
|
|
109
|
+
errors = []
|
|
110
|
+
resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
|
|
111
|
+
warn_unresolved(session, resolved, errors, on_event)
|
|
112
|
+
resolved = nil if resolved.everything?
|
|
113
|
+
tools = @tools.call
|
|
114
|
+
return false if resolved == tools.toolkit
|
|
115
|
+
|
|
116
|
+
tools.toolkit = resolved
|
|
117
|
+
@reset_prompt.call
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# The toolkit's load warnings, each once per process: a name that
|
|
124
|
+
# didn't resolve, config kits left out.
|
|
125
|
+
def warn_unresolved(session, resolved, errors, on_event)
|
|
126
|
+
lines = errors.dup
|
|
127
|
+
if resolved.fallback
|
|
128
|
+
lines << "#{Toolkit.of(session&.toolkit).label}: #{resolved.fallback}; this session declares every tool (full) " \
|
|
129
|
+
"until it resolves"
|
|
130
|
+
end
|
|
131
|
+
(lines - @warned).each do |line|
|
|
132
|
+
@warned << line
|
|
133
|
+
Log.warn(:config, "toolkit_unresolved", message: line)
|
|
134
|
+
@emit.call(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "cache_confirm"
|
|
5
|
+
require_relative "toolkit"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# /toolkit, the session's toolkit (Session#toolkit): with no argument,
|
|
9
|
+
# the tools its next turn declares with their estimated tokens, the
|
|
10
|
+
# toolkit and the tools it leaves out; `/toolkit NAME` puts the session
|
|
11
|
+
# on another toolkit (its own adds and removes go), `/toolkit add TOOL`
|
|
12
|
+
# and `/toolkit remove TOOL` change one tool. A change is saved at once
|
|
13
|
+
# and applies at the next turn's start (Engine#apply_toolkit!). The
|
|
14
|
+
# declared tools are the front of every request, so once the session has
|
|
15
|
+
# a model reply a change asks first (CacheConfirm); --yes applies it.
|
|
16
|
+
class ToolkitCommand
|
|
17
|
+
NAME = "/toolkit"
|
|
18
|
+
USAGE = "usage: #{NAME} [NAME | add TOOL | remove TOOL]".freeze
|
|
19
|
+
# Characters per token for the estimates (context.chars_per_token's
|
|
20
|
+
# default; a listing, not a meter).
|
|
21
|
+
CHARS_PER_TOKEN = 4.0
|
|
22
|
+
|
|
23
|
+
# @param engine [Engine] #session, #toolkit_explained, #toolkit_kits,
|
|
24
|
+
# #tools, #history?
|
|
25
|
+
# @param save [#call] saves the session after a change
|
|
26
|
+
def initialize(engine:, save:)
|
|
27
|
+
@engine = engine
|
|
28
|
+
@save = save
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param args [String] what follows /toolkit (--yes anywhere)
|
|
32
|
+
# @param confirm [Boolean] ask before a change once the session has a
|
|
33
|
+
# model reply (CacheConfirm); false applies it at once
|
|
34
|
+
# @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
|
|
35
|
+
# whether the toolkit changed, and the confirm it asks for (nothing
|
|
36
|
+
# changed then)
|
|
37
|
+
# @raise [ArgumentError] an unknown toolkit or tool, or bad words
|
|
38
|
+
def run(args, confirm: true)
|
|
39
|
+
words, yes = CacheConfirm.split(args, downcase: false)
|
|
40
|
+
return [listing(@engine.toolkit_explained), false, nil] if words.empty?
|
|
41
|
+
|
|
42
|
+
session = @engine.session or raise ArgumentError, "no session yet"
|
|
43
|
+
before = @engine.toolkit_explained
|
|
44
|
+
after = @engine.toolkit_explained(changed(before.toolkit, words.split))
|
|
45
|
+
# Nothing the prompt carries changes (full +delegate on full): not saved.
|
|
46
|
+
return [listing(before) + "\n(no change to what the next turn declares)", false, nil] if after.prompt_key == before.prompt_key
|
|
47
|
+
|
|
48
|
+
cost = Toolkit.cache_cost(before.prompt_key, after.prompt_key)
|
|
49
|
+
if confirm && !yes && CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: !cost.nil?)
|
|
50
|
+
step = CacheConfirm.confirm(NAME, words)
|
|
51
|
+
return [[change_line(before, after), "The tools are the front of every request: the whole prompt is read " \
|
|
52
|
+
"again from them on.", step.text].join("\n"), false, step]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
session.toolkit = after.toolkit
|
|
56
|
+
@save.call(session)
|
|
57
|
+
[[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The toolkit, its declared tools with tokens, and the ones left out.
|
|
61
|
+
def listing(explained)
|
|
62
|
+
toolkit = explained.toolkit
|
|
63
|
+
lines = ["toolkit: #{toolkit.label}#{" (applies at the next turn's start)" if explained.pending}"]
|
|
64
|
+
if (fallback = explained.resolved.fallback)
|
|
65
|
+
lines << " #{fallback}: every tool is declared (full) until it resolves"
|
|
66
|
+
end
|
|
67
|
+
declared = explained.declared
|
|
68
|
+
lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
|
|
69
|
+
"#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
|
|
70
|
+
left_out = explained.left_out
|
|
71
|
+
lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
|
|
72
|
+
lines << " toolkits: #{@engine.toolkit_kits.keys.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
|
|
73
|
+
lines.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# +toolkit+ after the words: NAME, add TOOL…, remove TOOL….
|
|
79
|
+
def changed(toolkit, words)
|
|
80
|
+
verb, *tools = words
|
|
81
|
+
case verb.downcase
|
|
82
|
+
when "add", "remove"
|
|
83
|
+
raise ArgumentError, "#{NAME} #{verb} needs a tool name\n#{USAGE}" if tools.empty?
|
|
84
|
+
|
|
85
|
+
tools.reduce(toolkit) do |kit, tool|
|
|
86
|
+
raise ArgumentError, "no tool named #{tool} in this session" unless @engine.tools.key?(tool)
|
|
87
|
+
|
|
88
|
+
verb.casecmp?("add") ? kit.adding(tool) : kit.removing(tool)
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
raise ArgumentError, "#{NAME} takes one toolkit name\n#{USAGE}" unless tools.empty?
|
|
92
|
+
|
|
93
|
+
kits = @engine.toolkit_kits
|
|
94
|
+
raise ArgumentError, "no toolkit named #{verb} (toolkits: #{kits.keys.join(", ")})" unless kits.key?(verb)
|
|
95
|
+
|
|
96
|
+
Toolkit.new(name: verb)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def change_line(before, after)
|
|
101
|
+
added = names(after) - names(before)
|
|
102
|
+
removed = names(before) - names(after)
|
|
103
|
+
parts = []
|
|
104
|
+
parts << "declares #{added.join(", ")}" unless added.empty?
|
|
105
|
+
parts << "leaves out #{removed.join(", ")}" unless removed.empty?
|
|
106
|
+
parts << "declares the same tools under its own name" if parts.empty?
|
|
107
|
+
"Switching to #{after.toolkit.label} #{parts.join("; ")}."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def names(explained) = explained.declared.map(&:name)
|
|
111
|
+
|
|
112
|
+
# A tool's schema in tokens, estimated from its JSON.
|
|
113
|
+
def tokens(entry) = (JSON.generate(entry.schema).length / CHARS_PER_TOKEN).round
|
|
114
|
+
|
|
115
|
+
def format_tokens(count) = count >= 1000 ? format("%.1fk", count / 1000.0) : count.to_s
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -55,6 +55,9 @@ module Samagotchi
|
|
|
55
55
|
@attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
|
|
56
56
|
@max_chars = [max_chars.to_i, MIN_CHARS].max
|
|
57
57
|
@now = now
|
|
58
|
+
# The peer bundles' declared sources (PeerSources), through the
|
|
59
|
+
# Engine's PeerView.
|
|
60
|
+
@peer_label = peers.respond_to?(:peer_label) ? peers.method(:peer_label) : nil
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def run(name, offset:, limit:)
|
|
@@ -122,12 +125,23 @@ module Samagotchi
|
|
|
122
125
|
hint = snapshot.hint || source.hint
|
|
123
126
|
header = ["#{source.name}#{" (#{hint})" if hint}"]
|
|
124
127
|
header << "Why: #{source.why}" if source.why
|
|
128
|
+
if (peer = peer_label(attached))
|
|
129
|
+
header << "Peer: your peer agent #{peer}'s answers (its text, not your user's words)"
|
|
130
|
+
end
|
|
125
131
|
header << "Fetched: #{clock(snapshot.fetched_at)} (#{age(snapshot.fetched_at)})" if snapshot.fetched_at
|
|
126
132
|
header << "Summary: #{snapshot.summary}" if snapshot.summary
|
|
127
133
|
header << "Last refresh failed: #{snapshot.error}" if snapshot.error
|
|
128
134
|
header
|
|
129
135
|
end
|
|
130
136
|
|
|
137
|
+
# A pushed source of the session's own that a peer bundle declared
|
|
138
|
+
# (ContextAbsorber#peer_label's rule).
|
|
139
|
+
def peer_label(attached)
|
|
140
|
+
return nil unless @peer_label && attached.source.push? && attached.location.session?
|
|
141
|
+
|
|
142
|
+
@peer_label.call(attached.name)
|
|
143
|
+
end
|
|
144
|
+
|
|
131
145
|
# What the output takes besides the page: the kernel's prefix, the
|
|
132
146
|
# header, its paging line at its longest (every number +count+) and
|
|
133
147
|
# the separator.
|
|
@@ -203,8 +203,9 @@ module Samagotchi
|
|
|
203
203
|
# Ended right at the deadline: finished, not timed out.
|
|
204
204
|
timed_out = false if timed_out && !wait_thr.alive?
|
|
205
205
|
if timed_out && on_timeout == :task
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
task = TaskRecord.running(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at.utc.iso8601,
|
|
207
|
+
output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
|
|
208
|
+
text = hand_over(task, waiter: wait_thr, timeout_sec: timeout_sec, dir: dir)
|
|
208
209
|
if text
|
|
209
210
|
handed_over = true
|
|
210
211
|
Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
|
|
@@ -337,25 +338,24 @@ module Samagotchi
|
|
|
337
338
|
private_class_method :read_log
|
|
338
339
|
|
|
339
340
|
# A command still running at the limit becomes a task (TaskRuntime.
|
|
340
|
-
# adopt) with the logs where they are
|
|
341
|
-
# the record for the sweep. nil when it can't be adopted (the caller
|
|
341
|
+
# adopt +task+, a TaskRecord.running) with the logs where they are
|
|
342
|
+
# (+dir+); a back-pointer in that dir names the record for the sweep. nil when it can't be adopted (the caller
|
|
342
343
|
# then kills it as in :kill mode) or when it ended meanwhile (the
|
|
343
344
|
# caller then reads it as finished).
|
|
344
345
|
# @return [String, nil] the HANDED_OVER result
|
|
345
|
-
def self.hand_over(
|
|
346
|
+
def self.hand_over(task, waiter:, timeout_sec:, dir:)
|
|
346
347
|
return nil unless waiter.alive?
|
|
347
348
|
|
|
348
|
-
record, error = TaskRuntime.adopt(
|
|
349
|
-
output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
|
|
349
|
+
record, error = TaskRuntime.adopt(task)
|
|
350
350
|
return nil if error
|
|
351
351
|
|
|
352
|
-
File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record
|
|
353
|
-
write_missing_exit_code(waiter, exit_code_path)
|
|
352
|
+
File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record.id)))
|
|
353
|
+
write_missing_exit_code(waiter, record.exit_code_path)
|
|
354
354
|
# Ended between the deadline and the adopt: the record says how.
|
|
355
|
-
TaskRuntime.get_record(id) unless waiter.alive?
|
|
356
|
-
lines = [handed_over_line(timeout_sec), "task_id: #{id}",
|
|
355
|
+
TaskRuntime.get_record(record.id) unless waiter.alive?
|
|
356
|
+
lines = [handed_over_line(timeout_sec), "task_id: #{record.id}",
|
|
357
357
|
"Wait for it with task_wait (it may take minutes); task_stop stops it."]
|
|
358
|
-
{ "stdout" =>
|
|
358
|
+
{ "stdout" => record.output_path, "stderr" => record.stderr_path }.each do |label, path|
|
|
359
359
|
tail = TaskRuntime.output_tail_lines(path, HAND_OVER_TAIL_LINES)
|
|
360
360
|
lines << "#{label} (so far, tail):\n#{tail.chomp}" unless tail.empty?
|
|
361
361
|
end
|