terret-tools-std 0.1.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 +7 -0
- data/lib/terret/tools_std/bash.rb +226 -0
- data/lib/terret/tools_std/files.rb +229 -0
- data/lib/terret/tools_std/jobs.rb +255 -0
- data/lib/terret/tools_std/task.rb +127 -0
- data/lib/terret/tools_std/terminals.rb +195 -0
- data/lib/terret/tools_std/todo.rb +158 -0
- data/lib/terret/tools_std/web_fetch.rb +472 -0
- data/lib/terret/tools_std.rb +15 -0
- metadata +86 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module ToolsStd
|
|
5
|
+
# `job_start` / `job_collect` / `job_stop` (docs/subagents.md §6) — work
|
|
6
|
+
# that outlives the tool call that started it, over ctx[:jobs]. snake_case
|
|
7
|
+
# because these three have no Claude Code equivalent to be verbatim with,
|
|
8
|
+
# the same rule that produced `terminal_*` in M7.
|
|
9
|
+
#
|
|
10
|
+
# The split in the metadata is the whole design in one table. Starting and
|
|
11
|
+
# stopping put a process into the world or take one out of it, so both are
|
|
12
|
+
# mutating and both are governed by policy; collecting reads a buffer and
|
|
13
|
+
# asks nobody. That is what lets a delegated agent watch a job it could
|
|
14
|
+
# never have started: a subagent's approval can never be answered
|
|
15
|
+
# (docs/subagents.md §2), so a `:policy` call from one is denied, while
|
|
16
|
+
# `job_collect` runs for anybody.
|
|
17
|
+
#
|
|
18
|
+
# Neither `cwd` nor `env` is a tool argument, for the reason the terminal
|
|
19
|
+
# tools give: a cwd the model chose is contained by nothing, and an env it
|
|
20
|
+
# writes is a place for a credential to be laundered into a child process.
|
|
21
|
+
# Both stay the row's decision, where a deployment can see them.
|
|
22
|
+
class Jobs < Hames::Service
|
|
23
|
+
service_key :tools_std_jobs
|
|
24
|
+
inject :tools, :jobs, :sandbox
|
|
25
|
+
config_schema max_output: { type: Integer, default: 30_000,
|
|
26
|
+
doc: "bytes of collected job output returned to the model before truncation" }
|
|
27
|
+
|
|
28
|
+
# The same literal Bash, WebFetch and Task separate their output with,
|
|
29
|
+
# and it carries the same caveats: a readability device rather than a
|
|
30
|
+
# security boundary — a job can print the line itself — whose actual
|
|
31
|
+
# delivery is that the genuine remarks are always last and always
|
|
32
|
+
# advisory data that nothing downstream acts on.
|
|
33
|
+
LEDGER = "--- terret ---"
|
|
34
|
+
|
|
35
|
+
# What one collect may show. The seam has its own cap
|
|
36
|
+
# (Jobs::DEFAULT_MAX_OUTPUT, a mebibyte) and that one is a memory bound;
|
|
37
|
+
# this is a display decision, the tool's own honest cap, exactly as
|
|
38
|
+
# Bash's is.
|
|
39
|
+
DEFAULT_MAX_OUTPUT = 30_000
|
|
40
|
+
|
|
41
|
+
START_DESCRIPTION =
|
|
42
|
+
"Start a shell command in the background and get back a job id. The command keeps " \
|
|
43
|
+
"running after this call returns and after the turn that started it ends; read what " \
|
|
44
|
+
"it has written with job_collect, and end it with job_stop. Each job is a fresh " \
|
|
45
|
+
"shell, not this session's persistent bash, so a `cd` or an `export` from a Bash " \
|
|
46
|
+
"call is not in effect here. A job does not survive a restart of the harness. If " \
|
|
47
|
+
"this call is interrupted before its result is recorded, a resume of the turn runs " \
|
|
48
|
+
"it again and starts a SECOND job — the first may still be running, with output " \
|
|
49
|
+
"nobody will collect — so after a resume, use job_collect to check for two ids " \
|
|
50
|
+
"where you expected one."
|
|
51
|
+
|
|
52
|
+
COLLECT_DESCRIPTION =
|
|
53
|
+
"Read whatever a job has written since the last time it was collected, and find out " \
|
|
54
|
+
"whether it is still running. The output is drained: the same bytes are never " \
|
|
55
|
+
"handed back twice, so keep what you are given."
|
|
56
|
+
|
|
57
|
+
STOP_DESCRIPTION =
|
|
58
|
+
"End a job: it is sent SIGTERM, then SIGKILL if it does not leave. Collect the job " \
|
|
59
|
+
"once more afterwards for anything it wrote on its way out."
|
|
60
|
+
|
|
61
|
+
def start(ctx)
|
|
62
|
+
@ctx = ctx
|
|
63
|
+
register_collect
|
|
64
|
+
register_stop
|
|
65
|
+
# job_start's approval is derived from sandbox isolation the way Bash's
|
|
66
|
+
# is (docs/exec.md §5, §13): job_start runs `bash -lc <cmd>` in a fresh
|
|
67
|
+
# shell, so unsandboxed it is arbitrary shell execution and needs a human
|
|
68
|
+
# every time, while inside a sandbox the container is the backstop and it
|
|
69
|
+
# is governed like any other mutating tool. The verdict is captured at
|
|
70
|
+
# registration, so a hot sandbox swap re-derives it through the
|
|
71
|
+
# config/updated listener rather than leaving a stale value in front of a
|
|
72
|
+
# shell whose isolation changed underneath it.
|
|
73
|
+
#
|
|
74
|
+
# The effect frame owns whichever registration is current: the loader
|
|
75
|
+
# emits config/updated OUTSIDE with_owner, so a registration made from
|
|
76
|
+
# the listener would belong to no row — and an ownerless job_start would
|
|
77
|
+
# outlive the row that mounted it, holding process-spawning authority
|
|
78
|
+
# nothing in the harness could still take away.
|
|
79
|
+
@ctx.effect do
|
|
80
|
+
register_start
|
|
81
|
+
-> { @start_registration&.call }
|
|
82
|
+
end
|
|
83
|
+
@ctx.on("config/updated") { |_id, _config| refresh_start! }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# max_output is read at call time, so a swapped row governs the very next
|
|
87
|
+
# call; every other knob these tools reach (the job cap, the cwd) belongs
|
|
88
|
+
# to the jobs row and is read there. job_start's approval, by contrast, IS
|
|
89
|
+
# a registration-time capture — the config/updated listener above, not a
|
|
90
|
+
# remount, is what keeps it current.
|
|
91
|
+
def reconfigure(_config); end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# `ctx:` is passed explicitly: the registry would otherwise record the
|
|
96
|
+
# frame on the context it was started in (the root), so a roster mounted
|
|
97
|
+
# into a forked agent scope would leave registrations behind that outlive
|
|
98
|
+
# the fork — a disposed agent with a tool of its own that can still spawn
|
|
99
|
+
# processes.
|
|
100
|
+
def tool(name, description, params, mutating:, approval:, concurrency:, &handler)
|
|
101
|
+
@ctx[:tools].register(name: name, description: description, params: params,
|
|
102
|
+
mutating: mutating, approval: approval,
|
|
103
|
+
concurrency: concurrency, ctx: @ctx, &handler)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def object_schema(properties, required)
|
|
107
|
+
{ type: "object", properties: properties, required: required }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def id_property
|
|
111
|
+
{ type: "string", description: "The job id job_start handed back" }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def register_start
|
|
115
|
+
params = object_schema(
|
|
116
|
+
{ command: { type: "string",
|
|
117
|
+
description: "The shell command line to run in the background" } },
|
|
118
|
+
%w[command]
|
|
119
|
+
)
|
|
120
|
+
# `command` is required in the schema and defaulted here: a model that
|
|
121
|
+
# omits it has made a mistake, and an omitted keyword would cost a
|
|
122
|
+
# whole turn to an ArgumentError where a defaulted one costs a result
|
|
123
|
+
# the model can read and correct.
|
|
124
|
+
@start_approval = derive_approval
|
|
125
|
+
@start_registration = tool("job_start", START_DESCRIPTION, params, mutating: true,
|
|
126
|
+
approval: @start_approval, concurrency: :serial) do |session_id:, command: nil|
|
|
127
|
+
started(@ctx[:jobs].start(command!(command), session: session_id))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# §13 / docs/exec.md §5: the sandbox's own verdict, never a config knob of
|
|
132
|
+
# this row's — an isolation claim belongs to the thing doing the isolating.
|
|
133
|
+
def derive_approval = @ctx[:sandbox].isolated? ? :policy : :always
|
|
134
|
+
|
|
135
|
+
def refresh_start!
|
|
136
|
+
return if derive_approval == @start_approval
|
|
137
|
+
|
|
138
|
+
@start_registration&.call
|
|
139
|
+
register_start
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def register_collect
|
|
143
|
+
# :parallel because a collect is a read of a buffer with no ordering
|
|
144
|
+
# against its siblings — watching four jobs in one message is the case
|
|
145
|
+
# the barrier was declared for.
|
|
146
|
+
tool("job_collect", COLLECT_DESCRIPTION, object_schema({ id: id_property }, %w[id]),
|
|
147
|
+
mutating: false, approval: :never, concurrency: :parallel) do |session_id:, id: nil|
|
|
148
|
+
render(@ctx[:jobs].collect(id!(id, "job_collect"), session: session_id))
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def register_stop
|
|
153
|
+
tool("job_stop", STOP_DESCRIPTION, object_schema({ id: id_property }, %w[id]),
|
|
154
|
+
mutating: true, approval: :policy, concurrency: :serial) do |session_id:, id: nil|
|
|
155
|
+
stopped(@ctx[:jobs].stop(id!(id, "job_stop"), session: session_id))
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Both refusals name the argument rather than the Ruby that would
|
|
160
|
+
# otherwise report it. An array here is a model reaching for the
|
|
161
|
+
# terminal_open convention, and stringifying it would start a job around
|
|
162
|
+
# a command nobody wrote.
|
|
163
|
+
def command!(command)
|
|
164
|
+
return command if command.is_a?(String) && !command.strip.empty?
|
|
165
|
+
|
|
166
|
+
raise Terret::Tools::Failure,
|
|
167
|
+
"job_start needs a command: one shell command line, as a string. Nothing " \
|
|
168
|
+
"was started."
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def id!(id, tool)
|
|
172
|
+
return id if id.is_a?(String) && !id.strip.empty?
|
|
173
|
+
|
|
174
|
+
raise Terret::Tools::Failure,
|
|
175
|
+
"#{tool} needs the job id job_start handed back, as a string"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The ledger line is what the model will address the job by for the rest
|
|
179
|
+
# of its life, so it is never omitted.
|
|
180
|
+
def started(id) = "The job is running in the background.\n#{LEDGER}\njob #{id}"
|
|
181
|
+
|
|
182
|
+
def stopped(id)
|
|
183
|
+
"Stopped job #{id}.\n#{LEDGER}\ncollect it once more for anything it wrote on its way out"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def render(result)
|
|
187
|
+
body, dropped = cap(scrub(result[:output]))
|
|
188
|
+
remarks = remarks_for(result, body, dropped)
|
|
189
|
+
"#{body.empty? ? '(no new output)' : body}\n#{LEDGER}\n#{remarks.join("\n")}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Unlike Bash's, the status remark is never silent. "Still running" and
|
|
193
|
+
# "finished" are the two facts a collect exists to establish, and a model
|
|
194
|
+
# left to infer them from an empty result will either poll a job that has
|
|
195
|
+
# been over for minutes or walk away from one that has not started
|
|
196
|
+
# writing yet.
|
|
197
|
+
def remarks_for(result, body, dropped)
|
|
198
|
+
remarks = [status_remark(result)]
|
|
199
|
+
# The dropped bytes are gone, not held back: this collect drained the
|
|
200
|
+
# buffer, so what did not fit the cap is not waiting for the next one.
|
|
201
|
+
# Saying "truncated" without saying that invites a model to collect
|
|
202
|
+
# again for the rest of a result nothing can hand it.
|
|
203
|
+
if dropped.positive?
|
|
204
|
+
remarks << "output truncated at max_output: kept the first #{body.bytesize} bytes " \
|
|
205
|
+
"of rendered output and dropped #{dropped} more, which are gone rather " \
|
|
206
|
+
"than waiting for the next collect"
|
|
207
|
+
end
|
|
208
|
+
# The seam's own cap, hit before this tool ever saw the bytes. Reported
|
|
209
|
+
# separately because it is a different loss: those bytes are gone from
|
|
210
|
+
# the buffer, not merely from this result.
|
|
211
|
+
if result[:truncated]
|
|
212
|
+
remarks << "some of the job's output was dropped before it could be collected; it " \
|
|
213
|
+
"was writing faster than the buffer's cap allows"
|
|
214
|
+
end
|
|
215
|
+
remarks
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def status_remark(result)
|
|
219
|
+
return "the job is still running" unless result[:status] == :exited
|
|
220
|
+
return "the job has exited with status #{result[:exit_status]}" if result[:exit_status]
|
|
221
|
+
|
|
222
|
+
"the job was stopped before it could report an exit status"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Clamped rather than trusted: a row carrying a negative cap would
|
|
226
|
+
# otherwise byteslice its way to nil and raise on every call, turning one
|
|
227
|
+
# bad config value into a tool that never works.
|
|
228
|
+
def max_output = [config[:max_output] || DEFAULT_MAX_OUTPUT, 0].max
|
|
229
|
+
|
|
230
|
+
# A job's bytes are not guaranteed to be text. The seam preserves
|
|
231
|
+
# whatever the job wrote (that is its job) and the session log refuses
|
|
232
|
+
# invalid UTF-8 at the durable append boundary, so this is the layer
|
|
233
|
+
# where they have to become storable.
|
|
234
|
+
def scrub(output) = output.to_s.scrub
|
|
235
|
+
|
|
236
|
+
def cap(text)
|
|
237
|
+
limit = max_output
|
|
238
|
+
return [text, 0] if text.bytesize <= limit
|
|
239
|
+
|
|
240
|
+
kept = whole_characters(text.byteslice(0, limit))
|
|
241
|
+
[kept, text.bytesize - kept.bytesize]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Cutting at a byte offset can split a character in half, and those
|
|
245
|
+
# halves are bytes this file manufactured — a durable append JSON-encodes
|
|
246
|
+
# the payload, so a manufactured half raises a layer away from the code
|
|
247
|
+
# that broke it. Belt and braces after #scrub, and kept anyway, for the
|
|
248
|
+
# same reason Bash keeps its copy.
|
|
249
|
+
def whole_characters(text)
|
|
250
|
+
text = text.byteslice(0, text.bytesize - 1) until text.valid_encoding?
|
|
251
|
+
text
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module ToolsStd
|
|
5
|
+
# `Task` (docs/subagents.md §4) — Claude Code's name verbatim, per the M7
|
|
6
|
+
# rule: allow lists in the wild are already written against `Task`, and a
|
|
7
|
+
# Terret-native name would buy a translation layer that does nothing but
|
|
8
|
+
# rename, forever. The tool is thin on purpose: it talks to
|
|
9
|
+
# `ctx[:subagents].run` and knows nothing else about what a subagent is.
|
|
10
|
+
#
|
|
11
|
+
# `approval: :never` on a tool that can obviously mutate the world is the
|
|
12
|
+
# one entry here that looks wrong and is not. The metadata describes what
|
|
13
|
+
# THIS call does directly, and this call starts a conversation. Everything
|
|
14
|
+
# the child then does passes the child's own pipeline: its own allow list,
|
|
15
|
+
# its own approvals gate, one decision per actual effect. Gating `Task`
|
|
16
|
+
# itself would ask a human to approve a call whose effects are not knowable
|
|
17
|
+
# until after the approval is granted — the worst possible moment to ask —
|
|
18
|
+
# and would then ask again, correctly, for each real effect inside.
|
|
19
|
+
#
|
|
20
|
+
# `concurrency: :parallel` because a Task call is a whole turn of latency,
|
|
21
|
+
# which is exactly the case the barrier was declared for.
|
|
22
|
+
class Task < Hames::Service
|
|
23
|
+
service_key :tools_std_task
|
|
24
|
+
inject :tools, :loop, :subagents
|
|
25
|
+
config_schema({}) # the Task tool takes no config (see the :subagents seam)
|
|
26
|
+
|
|
27
|
+
# The same literal Bash and WebFetch separate their output with, and it
|
|
28
|
+
# carries the same caveats: a readability device rather than a security
|
|
29
|
+
# boundary — a child could print the line itself — whose actual delivery
|
|
30
|
+
# is that the genuine remarks are always last and always advisory data
|
|
31
|
+
# that nothing downstream acts on.
|
|
32
|
+
LEDGER = "--- terret ---"
|
|
33
|
+
|
|
34
|
+
DESCRIPTION = "Delegate a whole task to a fresh subagent and get back its final answer. " \
|
|
35
|
+
"The subagent starts with an empty conversation: the prompt is everything " \
|
|
36
|
+
"it will see, so state the goal, the context it needs, and what to report " \
|
|
37
|
+
"back. It runs with the same tools and the same permissions you have, and " \
|
|
38
|
+
"its own work is logged in its own session rather than in yours."
|
|
39
|
+
|
|
40
|
+
def start(ctx)
|
|
41
|
+
@ctx = ctx
|
|
42
|
+
register_task
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Nothing is captured from config; there is no knob on this row.
|
|
46
|
+
def reconfigure(_config); end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# `ctx:` is passed explicitly: the registry would otherwise record the
|
|
51
|
+
# frame on the context it was started in (the root), so a roster mounted
|
|
52
|
+
# into a forked agent scope would leave a registration behind that
|
|
53
|
+
# outlives the fork.
|
|
54
|
+
def tool(name, description, params, mutating:, approval:, concurrency:, &handler)
|
|
55
|
+
@ctx[:tools].register(name: name, description: description, params: params,
|
|
56
|
+
mutating: mutating, approval: approval,
|
|
57
|
+
concurrency: concurrency, ctx: @ctx, &handler)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def register_task
|
|
61
|
+
params = {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
description: { type: "string",
|
|
65
|
+
description: "A short label for the delegation, three to five words" },
|
|
66
|
+
prompt: { type: "string",
|
|
67
|
+
description: "The subagent's whole instruction; it sees nothing else" }
|
|
68
|
+
},
|
|
69
|
+
required: %w[description prompt]
|
|
70
|
+
}
|
|
71
|
+
# Both are required in the schema and both are defaulted here. A
|
|
72
|
+
# delegation nobody can name is one nobody can follow, and a model
|
|
73
|
+
# writing one without a prompt has made a mistake — but an omitted
|
|
74
|
+
# keyword would cost a whole turn to an ArgumentError, where a
|
|
75
|
+
# defaulted one costs a result the model can read and correct.
|
|
76
|
+
# `description` is a label for a log or a UI line; the child never
|
|
77
|
+
# sees it, so nothing here uses it.
|
|
78
|
+
tool("Task", DESCRIPTION, params, mutating: false, approval: :never,
|
|
79
|
+
concurrency: :parallel) do |session_id:, description: nil, prompt: nil|
|
|
80
|
+
render(delegate(prompt, session_id))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The load-bearing line of this tool. Task is registered on the ROOT
|
|
85
|
+
# context like the rest of the roster, so its closure captures the root
|
|
86
|
+
# and not any agent; what makes the child inherit the CALLER's roster and
|
|
87
|
+
# policy floor is looking the caller up here. `session_id` is injected by
|
|
88
|
+
# the registry and merged last, so a model that writes one into its
|
|
89
|
+
# arguments is naming somebody else's context and simply loses.
|
|
90
|
+
def delegate(prompt, session_id)
|
|
91
|
+
if prompt.nil? || (prompt.is_a?(String) && prompt.strip.empty?)
|
|
92
|
+
raise Terret::Tools::Failure,
|
|
93
|
+
"Task needs a prompt: it is everything the subagent will see. " \
|
|
94
|
+
"Nothing was delegated."
|
|
95
|
+
end
|
|
96
|
+
unless prompt.is_a?(String)
|
|
97
|
+
raise Terret::Tools::Failure,
|
|
98
|
+
"prompt must be a string; got #{prompt.class}. Nothing was delegated."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
agent = @ctx[:loop].agent_for_session(session_id)
|
|
102
|
+
unless agent
|
|
103
|
+
raise Terret::Tools::Failure,
|
|
104
|
+
"no live agent owns this session, so Task has no context to delegate from"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@ctx[:subagents].run(prompt: prompt, ctx: agent.ctx)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The ledger is not cosmetic and it is never omitted: nothing in the
|
|
111
|
+
# parent's log links it to the child's session except this line.
|
|
112
|
+
#
|
|
113
|
+
# A turn that did not complete is reported too. "Had nothing to say" and
|
|
114
|
+
# "was stopped part-way" are different facts about a delegation, and a
|
|
115
|
+
# model shown only the child's last sentence would summarize the second
|
|
116
|
+
# as if it were an answer.
|
|
117
|
+
def render(result)
|
|
118
|
+
text = result.text.to_s
|
|
119
|
+
remarks = ["child session #{result.session_id}"]
|
|
120
|
+
unless result.status == :completed
|
|
121
|
+
remarks << "the subagent's turn ended #{result.status} rather than completing"
|
|
122
|
+
end
|
|
123
|
+
"#{text.empty? ? '(no reply)' : text}\n#{LEDGER}\n#{remarks.join("\n")}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module ToolsStd
|
|
5
|
+
# `terminal_open` / `terminal_input` / `terminal_read` / `terminal_close`
|
|
6
|
+
# (docs/exec.md §5) — the four std tools with no Claude Code equivalent,
|
|
7
|
+
# over ctx[:terminals]'s named long-lived PTYs. A REPL or a dev server
|
|
8
|
+
# stays addressable across a whole turn, which is the difference between
|
|
9
|
+
# this seam and the one-shot spawn behind `Bash`.
|
|
10
|
+
#
|
|
11
|
+
# Every call passes its own session as the owner. Names are scoped per
|
|
12
|
+
# owner on the seam, so that argument is the whole reason one agent
|
|
13
|
+
# cannot read — or close — a live process belonging to another; a
|
|
14
|
+
# constant here would quietly merge every agent's terminals into the one
|
|
15
|
+
# namespace the seam was built to keep apart.
|
|
16
|
+
#
|
|
17
|
+
# Neither `cwd` nor `env` is a tool argument, deliberately. A terminal is
|
|
18
|
+
# spawned directly rather than through ctx[:fs], so nothing would contain
|
|
19
|
+
# a cwd the model chose, and an env the model writes is a place for a
|
|
20
|
+
# credential to be laundered into a child process. Both stay the row's
|
|
21
|
+
# decision, where a deployment can see them.
|
|
22
|
+
class Terminals < Hames::Service
|
|
23
|
+
service_key :tools_std_terminals
|
|
24
|
+
inject :tools, :terminals
|
|
25
|
+
config_schema({}) # the terminal tools take no config (see the :terminals seam)
|
|
26
|
+
|
|
27
|
+
def start(ctx)
|
|
28
|
+
@ctx = ctx
|
|
29
|
+
register_open
|
|
30
|
+
register_input
|
|
31
|
+
register_read
|
|
32
|
+
register_close
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Nothing is captured from config here: every knob these tools reach
|
|
36
|
+
# (the cap, the read timeout, the cwd) belongs to the terminals row and
|
|
37
|
+
# is read there, at call time. Saying so beats letting the base class
|
|
38
|
+
# warn that this row needs a remount when it does not.
|
|
39
|
+
def reconfigure(_config); end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# `ctx:` is passed explicitly: the registry would otherwise record the
|
|
44
|
+
# frame on the context it was started in (the root), so a roster
|
|
45
|
+
# mounted into a forked agent scope would leave registrations behind
|
|
46
|
+
# that outlive the fork — a disposed agent with a tool of its own that
|
|
47
|
+
# can still spawn processes.
|
|
48
|
+
# All four are `mutating: true`, `terminal_read` included, and that is
|
|
49
|
+
# not a rounding-up. Reading a PTY CONSUMES the stream: the bytes it
|
|
50
|
+
# returns are gone for every later read, so two identical calls give
|
|
51
|
+
# different answers and neither can be replayed. It is not reading in
|
|
52
|
+
# the sense the Read tool means, where the file is still there
|
|
53
|
+
# afterwards. The cost is that a deployment gating mutations asks about
|
|
54
|
+
# terminal_read too, which is the right trade — an approvals deployment
|
|
55
|
+
# is interactive by definition, and the alternative is a tool that
|
|
56
|
+
# quietly drains another tool's output while claiming to observe it.
|
|
57
|
+
def tool(name, description, params, &handler)
|
|
58
|
+
@ctx[:tools].register(name: name, description: description, params: params,
|
|
59
|
+
mutating: true, approval: :policy, concurrency: :serial,
|
|
60
|
+
ctx: @ctx, &handler)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def object_schema(properties, required)
|
|
64
|
+
{ type: "object", properties: properties, required: required }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def name_property = { type: "string", description: "The terminal's name within this session" }
|
|
68
|
+
|
|
69
|
+
def register_open
|
|
70
|
+
params = object_schema(
|
|
71
|
+
{ name: name_property,
|
|
72
|
+
argv: { type: "array", items: { type: "string" },
|
|
73
|
+
description: "The command and its arguments, e.g. [\"python3\", \"-i\"]" } },
|
|
74
|
+
%w[name argv]
|
|
75
|
+
)
|
|
76
|
+
description = "Start a long-lived terminal (a PTY) that stays open across calls. No shell " \
|
|
77
|
+
"interprets the argv, so use [\"bash\", \"-lc\", \"...\"] for pipelines or " \
|
|
78
|
+
"redirection. The terminal keeps running until terminal_close."
|
|
79
|
+
tool("terminal_open", description, params) do |name:, argv:, session_id:|
|
|
80
|
+
open_terminal(name, argv!(argv), session_id)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def register_input
|
|
85
|
+
params = object_schema(
|
|
86
|
+
{ name: name_property,
|
|
87
|
+
text: { type: "string",
|
|
88
|
+
description: "Text to type; include a trailing newline to submit a line" } },
|
|
89
|
+
%w[name text]
|
|
90
|
+
)
|
|
91
|
+
tool("terminal_input", "Type text into an open terminal. Nothing is submitted until a " \
|
|
92
|
+
"newline is sent, exactly as at a keyboard.", params) do |name:, text:, session_id:|
|
|
93
|
+
@ctx[:terminals].input(name, text, session: session_id)
|
|
94
|
+
"Typed #{text.to_s.bytesize} bytes into terminal #{name}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def register_read
|
|
99
|
+
params = object_schema(
|
|
100
|
+
{ name: name_property,
|
|
101
|
+
timeout: { type: "integer",
|
|
102
|
+
description: "Optional milliseconds to wait for output before giving up" } },
|
|
103
|
+
%w[name]
|
|
104
|
+
)
|
|
105
|
+
tool("terminal_read", "Read whatever an open terminal has said since the last read. " \
|
|
106
|
+
"Returns empty-handed rather than waiting for a terminal that has " \
|
|
107
|
+
"nothing to say.", params) do |name:, session_id:, timeout: nil|
|
|
108
|
+
read_terminal(name, session_id, timeout)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def register_close
|
|
113
|
+
tool("terminal_close", "Close a terminal and reap its process, freeing the name.",
|
|
114
|
+
object_schema({ name: name_property }, %w[name])) do |name:, session_id:|
|
|
115
|
+
# The seam makes closing a name that is not open a no-op, because
|
|
116
|
+
# disposal runs over sets that may already be partly closed. The
|
|
117
|
+
# tool says which of the two happened rather than inventing a
|
|
118
|
+
# failure the seam deliberately does not raise.
|
|
119
|
+
if @ctx[:terminals].close(name, session: session_id).nil?
|
|
120
|
+
"No terminal named #{name} was open"
|
|
121
|
+
else
|
|
122
|
+
"Closed terminal #{name}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Named away from `open` and `read`: Kernel's own methods carry those
|
|
128
|
+
# names, and Kernel#open runs a command when handed a string starting
|
|
129
|
+
# with a pipe. A private helper that shadows it is one rename away from
|
|
130
|
+
# falling through to it, in the one class where that would hand a model
|
|
131
|
+
# a spawn nobody gated.
|
|
132
|
+
# argv arrives as whatever JSON the model wrote, and two shapes of it
|
|
133
|
+
# reach the kernel as something worse than an error. An empty list
|
|
134
|
+
# becomes a nil program and surfaces as "TypeError: no implicit
|
|
135
|
+
# conversion of nil into String" from inside exec; a nil element
|
|
136
|
+
# stringifies into an empty argument the kernel accepts happily, so the
|
|
137
|
+
# terminal opens around a command nobody wrote. Both are refused rather
|
|
138
|
+
# than repaired — repairing an argv is a guess about what the caller
|
|
139
|
+
# meant, made at the moment it is spawning a process.
|
|
140
|
+
def argv!(argv)
|
|
141
|
+
list = argv.is_a?(Array) ? argv : [argv]
|
|
142
|
+
raise Terret::Tools::Failure, "argv must name a command to run" if list.empty?
|
|
143
|
+
|
|
144
|
+
bad = list.reject { |arg| arg.is_a?(String) }
|
|
145
|
+
return list if bad.empty?
|
|
146
|
+
|
|
147
|
+
raise Terret::Tools::Failure,
|
|
148
|
+
"every argv element must be a string; got #{bad.first.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def open_terminal(name, argv, session_id)
|
|
152
|
+
terminal = @ctx[:terminals].open(name, argv, session: session_id)
|
|
153
|
+
"Opened terminal #{terminal.name} (pid #{terminal.pid})"
|
|
154
|
+
rescue Errno::ENOENT
|
|
155
|
+
# The three errnos a spawn raises for an argv that cannot become a
|
|
156
|
+
# process. Left alone the pipeline renders Ruby's own wording, and
|
|
157
|
+
# EACCES's is actively misleading: "Permission denied - fork failed"
|
|
158
|
+
# names the one call that did NOT fail — the fork worked, the exec
|
|
159
|
+
# did not — so a model reads it as a broken harness and retries
|
|
160
|
+
# instead of fixing the argv it wrote. A Failure renders message-only,
|
|
161
|
+
# and the seam registers nothing before the spawn returns, so there is
|
|
162
|
+
# never a half-open terminal to mention.
|
|
163
|
+
#
|
|
164
|
+
# Deliberately not `SystemCallError`: EMFILE and ENFILE mean the
|
|
165
|
+
# harness is out of descriptors, which is not the caller's mistake to
|
|
166
|
+
# fix and must keep crashing loudly rather than being explained to a
|
|
167
|
+
# model as a bad command.
|
|
168
|
+
raise Terret::Tools::Failure,
|
|
169
|
+
"could not start #{argv.first.inspect}: no such command, or the terminal's " \
|
|
170
|
+
"working directory is gone; nothing was opened"
|
|
171
|
+
rescue Errno::EACCES, Errno::ENOEXEC
|
|
172
|
+
raise Terret::Tools::Failure,
|
|
173
|
+
"could not start #{argv.first.inspect}: not executable — a directory, or a file " \
|
|
174
|
+
"without the execute bit; nothing was opened"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def read_terminal(name, session_id, timeout)
|
|
178
|
+
chunk = @ctx[:terminals].read(name, session: session_id,
|
|
179
|
+
timeout: timeout.nil? ? nil : timeout / 1000.0)
|
|
180
|
+
# nil is the terminal's process being gone, "" is it being alive with
|
|
181
|
+
# nothing to say — two different answers, and a model that cannot
|
|
182
|
+
# tell them apart will either keep polling a dead terminal or close a
|
|
183
|
+
# live one. The name stays open in both cases; reading is not
|
|
184
|
+
# disposal.
|
|
185
|
+
return "(the terminal's process has ended; terminal_close frees the name)" if chunk.nil?
|
|
186
|
+
return "(nothing to read)" if chunk.empty?
|
|
187
|
+
|
|
188
|
+
# Same stance as Bash's: the seam preserves whatever the child wrote,
|
|
189
|
+
# the session log refuses invalid UTF-8 at the durable append
|
|
190
|
+
# boundary, so this is the layer that has to make it storable.
|
|
191
|
+
chunk.scrub
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|