summaryrefslogtreecommitdiff
path: root/agent/tools/shell.lua
blob: c325946221cb2eb31f9d8f24ce6a55d41ccdb1b9 (plain)
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