1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
|
-- Run a shell command and return its merged stdout+stderr output.
--
-- The command is executed via `/bin/sh -c <command>`, so shell
-- features (pipes, redirections, globs, `&&`, etc.) all work the
-- usual way. Stdin is closed; the command cannot read from the
-- terminal.
--
-- Context-budget discipline:
-- - Output is capped at 50 KB (matching `read`'s cap), keeping the
-- **last** 50 KB on overflow. Compiler errors, test failures, and
-- stack traces all put the important content at the end, so a
-- head-keeping shell tool routinely truncates exactly the bytes
-- the agent needed.
-- - Once overflow is detected, the *complete* transcript is
-- streamed to a spill file under the panto data home's `shell-output/`.
-- The agent can then `read` it with start_line/end_line to inspect
-- specific regions — including the head that fell out of the
-- tail buffer.
--
-- Timeout: defaults to 30s. When the timer fires the child gets
-- SIGTERM; if it still hasn't exited 250 ms later, SIGKILL.
--
-- Stdout and stderr are merged into a single stream (in arrival
-- order). Distinguishing them would require either prefixing every
-- chunk (visually noisy) or surfacing two separate strings (not how
-- a string-returning tool result is shaped). Most shell commands the
-- agent runs interleave the two anyway.
local std = require("_std")
local uv = require("luv")
local MAX_BYTES = 50 * 1024 -- 50 KB, same as `read`
local DEFAULT_TIMEOUT_S = 300 -- 5 minutes
local SIGKILL_GRACE_MS = 250
local tool = {
name = "std.shell",
description = string.format("Execute a shell command via `/bin/sh -c`. Returns merged stdout+stderr after the command exits, prefixed with an exit-status header. Output is truncated to the last %dKB; on truncation the full transcript is saved under the panto data home's `shell-output/` and its path is included for follow-up `read` calls. Default timeout 5 minutes.", MAX_BYTES / 1024),
schema = {
type = "object",
properties = {
command = { type = "string", description = "Shell command. Passed to `sh -c` verbatim; pipes, redirections, etc. work." },
cwd = { type = "string", description = "Working directory (default: cwd)." },
timeout = { type = "integer", description = "Hard timeout in seconds (default: " .. tostring(DEFAULT_TIMEOUT_S) .. ").", minimum = 1 },
},
required = { "command" },
},
}
-- ---------------------------------------------------------------------------
-- Tool body
-- ---------------------------------------------------------------------------
tool.handler = function(input)
local command = input.command
if type(command) ~= "string" or command == "" then
return "Error: `command` must be a non-empty string."
end
local cwd = input.cwd
if cwd ~= nil and type(cwd) ~= "string" then
return "Error: `cwd` must be a string if provided."
end
local timeout_s = input.timeout or DEFAULT_TIMEOUT_S
local timeout_ms = timeout_s * 1000
if not coroutine.running() then
-- Should never happen — panto always runs handlers in a
-- coroutine — but be defensive rather than silently hang.
return "Error: shell tool requires a coroutine scheduler " ..
"(internal bug; report to panto)."
end
-- Pipes for the child's stdout/stderr. Stdin is `nil` in the
-- stdio table → child reads from /dev/null equivalent (luv
-- closes that fd in the child).
local stdout_pipe = uv.new_pipe(false)
local stderr_pipe = uv.new_pipe(false)
-- Output accumulator: a FIFO of arrival-order chunks whose
-- total length is always ≤ MAX_BYTES. On overflow we evict
-- from the front (oldest bytes first) so the in-memory buffer
-- always holds the *tail* of the output. The full unabridged
-- transcript is mirrored to a spill file, opened lazily on
-- first overflow and pre-loaded with everything seen so far.
local chunks = {} -- FIFO of strings; total ≤ MAX_BYTES
local first = 1 -- index of oldest live chunk in `chunks`
local last = 0 -- index of newest live chunk
local total_bytes = 0
local total_received = 0 -- everything we ever saw, pre-eviction
local overflowed = false -- true once a byte has been evicted
local spill_path = nil
local spill_handle = nil -- uv fd (integer) once opened
local spill_error = nil -- if opening/writing the spill file fails
local total_written_to_spill = 0
-- NOTE: the spill helpers below use SYNCHRONOUS `uv.fs_*`
-- calls on purpose. They run inside `append_chunk`, which runs
-- inside the stdout/stderr read callbacks — i.e. in event-loop
-- context, NOT in the handler coroutine's own frame. You cannot
-- `coroutine.yield` (and thus cannot `await`) from a libuv
-- callback, so these must be the blocking variants. The brief
-- per-write stall is acceptable; spilling only happens once
-- output exceeds the in-memory cap.
local function open_spill_and_seed()
-- Opens the spill file and writes every chunk currently
-- live in the FIFO. Called exactly once, the moment we
-- realize we're going to overflow. After this returns,
-- the spill file contains the entire history; every
-- subsequent chunk just appends.
if spill_handle or spill_error then return end
local path, err = std.fresh_spill_path()
if not path then
spill_error = err
return
end
-- O_WRONLY | O_CREAT | O_TRUNC; mode 0644.
local fd, oerr = uv.fs_open(path, "w", tonumber("644", 8))
if not fd then
spill_error = oerr or "unknown error opening spill file"
return
end
spill_path = path
spill_handle = fd
for i = first, last do
local c = chunks[i]
local n, werr = uv.fs_write(fd, c, -1)
if not n then
spill_error = werr or "spill write failed"
return
end
total_written_to_spill = total_written_to_spill + n
end
end
local function spill_write(data)
if not spill_handle then return end
local n, werr = uv.fs_write(spill_handle, data, -1)
if not n then
spill_error = spill_error or werr or "spill write failed"
else
total_written_to_spill = total_written_to_spill + n
end
end
local function append_chunk(data)
total_received = total_received + #data
-- First time we'd cross MAX_BYTES: open the spill file
-- and seed it with everything we've buffered so far. From
-- here on we always dual-write (spill gets the unabridged
-- stream; chunks holds only the tail).
if not overflowed and total_bytes + #data > MAX_BYTES then
overflowed = true
open_spill_and_seed()
end
if overflowed then
spill_write(data)
end
-- Append to the ring. If the new chunk alone is bigger
-- than MAX_BYTES, drop everything we have and keep just
-- its tail. Otherwise, evict whole front chunks until
-- there's room, then either trim the front-most or
-- append intact.
if #data >= MAX_BYTES then
-- Drop the whole FIFO; new chunk is the tail.
for i = first, last do chunks[i] = nil end
first = last + 1
total_bytes = 0
local trimmed = data:sub(#data - MAX_BYTES + 1)
last = last + 1
chunks[last] = trimmed
total_bytes = #trimmed
return
end
while total_bytes + #data > MAX_BYTES and first <= last do
local front = chunks[first]
if total_bytes - #front + #data <= MAX_BYTES then
-- Trim the front chunk: drop only as many bytes
-- as needed to make room.
local need_to_drop = (total_bytes + #data) - MAX_BYTES
chunks[first] = front:sub(need_to_drop + 1)
total_bytes = total_bytes - need_to_drop
else
-- Drop the entire front chunk.
chunks[first] = nil
total_bytes = total_bytes - #front
first = first + 1
end
end
last = last + 1
chunks[last] = data
total_bytes = total_bytes + #data
end
-- Per-call outcome state, filled by the libuv callbacks below.
local exit_code = nil
local exit_signal = nil
local timed_out = false
local process_handle = nil
local timeout_timer = nil
local kill_timer = nil
local function buffer_to_string()
local out = {}
local oi = 0
for i = first, last do
oi = oi + 1
out[oi] = chunks[i]
end
return table.concat(out)
end
-- Spawn first so a spawn failure can return *before* we commit
-- to awaiting (a failed spawn fires no events to resume us).
local stdio = { nil, stdout_pipe, stderr_pipe }
local opts = { args = { "-c", command }, stdio = stdio }
if cwd then opts.cwd = cwd end
local on_exit_signal -- set by await_n before any event fires
local handle, pid_or_err, name = uv.spawn("/bin/sh", opts, function(code, sig)
exit_code = code
exit_signal = sig
-- The on-exit callback owns closing the process handle.
if process_handle and not process_handle:is_closing() then
process_handle:close()
end
on_exit_signal()
end)
if not handle then
-- Spawn failed before any event could fire. Clean up the
-- pipes and return without awaiting.
local spawn_error = tostring(pid_or_err) ..
(name and (" (" .. name .. ")") or "")
stdout_pipe:close(); stderr_pipe:close()
return "Error: failed to spawn shell: " .. spawn_error
end
process_handle = handle
-- `await_n(3, ...)` resumes this coroutine exactly once, after
-- all three of {stdout EOF, stderr EOF, process exit} signal.
std.await_n("shell", 3, function(signal)
-- The exit callback was armed above (before this awaiter
-- existed); wire its signal now.
on_exit_signal = signal
-- stdout/stderr each signal once, on EOF or read error.
-- luv delivers stream EOF as a nil `data` with no error.
local function on_read(err, data)
if err then
append_chunk("[read error: " .. tostring(err) .. "]\n")
signal()
elseif data == nil then
signal()
else
append_chunk(data)
end
end
stdout_pipe:read_start(on_read)
stderr_pipe:read_start(on_read)
-- Timeout timer. If it fires, send SIGTERM and arm a short
-- follow-up timer for SIGKILL. These timers do NOT signal;
-- they cause the child to exit, which fires the exit/EOF
-- callbacks that do. We close them here so they stop
-- holding the event loop open.
timeout_timer = uv.new_timer()
timeout_timer:start(timeout_ms, 0, function()
timed_out = true
if process_handle and not process_handle:is_closing() then
process_handle:kill("sigterm")
end
timeout_timer:stop(); timeout_timer:close(); timeout_timer = nil
kill_timer = uv.new_timer()
kill_timer:start(SIGKILL_GRACE_MS, 0, function()
if process_handle and not process_handle:is_closing() then
process_handle:kill("sigkill")
end
kill_timer:stop(); kill_timer:close(); kill_timer = nil
end)
end)
end)
if spawn_error then
return "Error: failed to spawn shell: " .. spawn_error
end
-- Resumed: all three events fired. Cancel any still-pending
-- timers (the command may have finished before the timeout) so
-- they don't keep the loop alive, then close pipes/spill.
if timeout_timer and not timeout_timer:is_closing() then
timeout_timer:stop(); timeout_timer:close()
end
if kill_timer and not kill_timer:is_closing() then
kill_timer:stop(); kill_timer:close()
end
if not stdout_pipe:is_closing() then stdout_pipe:close() end
if not stderr_pipe:is_closing() then stderr_pipe:close() end
if spill_handle then uv.fs_close(spill_handle) end
-- Build the result.
local header
if timed_out then
header = string.format(
"[shell] timed out after %ds; killed (exit code=%s signal=%s)",
timeout_s, tostring(exit_code or "?"), tostring(exit_signal or "?")
)
elseif exit_signal and exit_signal ~= 0 then
header = string.format(
"[shell] terminated by signal %s (exit code=%s)",
tostring(exit_signal), tostring(exit_code or "?")
)
else
header = string.format("[shell] exit code %d", exit_code or -1)
end
local body = buffer_to_string()
-- Strip a single trailing newline so the notice (when appended) is
-- separated by exactly one blank line, not two.
body = body:gsub("\n$", "")
local parts = { header, "", body }
if overflowed then
if spill_path and not spill_error then
parts[#parts + 1] = string.format(
"[truncated: kept last %d bytes; full %d-byte transcript " ..
"saved to %s. Use `read` with `start_line`/`end_line` " ..
"to inspect specific regions.]",
MAX_BYTES, total_written_to_spill, spill_path
)
else
parts[#parts + 1] = string.format(
"[truncated: kept last %d bytes; could NOT spill to disk: %s]",
MAX_BYTES, spill_error or "unknown error"
)
end
end
return table.concat(parts, "\n")
end
std.install_renderer(tool.name, function(st)
local obj = std.decode(st.input)
if not obj or type(obj.command) ~= "string" then return "shell" end
local tail = ""
if type(obj.cwd) == "string" and obj.cwd ~= "" then tail = tail .. " cwd=" .. obj.cwd end
if type(obj.timeout) == "number" then tail = tail .. string.format(" timeout=%ds", obj.timeout) end
return "shell $ " .. obj.command .. tail
end)
return tool
|