summaryrefslogtreecommitdiff
path: root/agent/tools/write.lua
blob: 42cb85ac7f104457db3f8b33afc8d0c04166a5db (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
-- Write text to a file, creating parent directories as needed. Keep the
-- description terse because every byte of every tool description rides
-- in the system prompt on every turn.

local uv = require("luv")

-- ---------------------------------------------------------------------------
-- Coroutine-synchronous libuv wrappers. panto runs each tool handler in
-- its own coroutine and drives a single `uv.run()` to completion; a
-- handler may only block by yielding on a pending libuv op whose
-- callback resumes it exactly once. `await` encapsulates that.
-- ---------------------------------------------------------------------------
local function await(arm)
    local co = assert(coroutine.running(),
        "write: await must run inside a tool handler coroutine")
    local res, fired = nil, false
    arm(function(...)
        assert(not fired, "write: libuv callback fired twice")
        fired = true
        res = table.pack(...)
        local ok, err = coroutine.resume(co)
        if not ok then error(err, 0) end
    end)
    coroutine.yield()
    return table.unpack(res, 1, res.n)
end

-- Async `fs_mkdir`. luv's async callback is `(err, success)`, and on
-- EEXIST it reports the error via a string code in `err`; we detect
-- that textually since the async form doesn't surface the errno name
-- separately the way the sync form's third return does.
local function fs_mkdir(path, mode)
    return await(function(resolve) uv.fs_mkdir(path, mode, resolve) end)
end
local function fs_open(path, flags, mode)
    return await(function(resolve) uv.fs_open(path, flags, mode, resolve) end)
end
local function fs_write(fd, data, offset)
    return await(function(resolve) uv.fs_write(fd, data, offset, resolve) end)
end
local function fs_close(fd)
    return await(function(resolve) uv.fs_close(fd, resolve) end)
end

-- mkdir -p equivalent: walk the path components and create each.
-- Ignores "already exists" so the call is idempotent.
local function mkdir_p(path)
    if path == "" or path == "." or path == "/" then return true end
    local parts = {}
    for piece in string.gmatch(path, "[^/]+") do
        parts[#parts + 1] = piece
    end
    local cur = (path:sub(1, 1) == "/") and "" or "."
    for _, piece in ipairs(parts) do
        cur = cur .. "/" .. piece
        local err = fs_mkdir(cur, tonumber("755", 8))
        if err and not tostring(err):find("EEXIST") then
            return nil, err
        end
    end
    return true
end

-- Return the dirname of `path`, or nil if there isn't one (basename only).
local function dirname(path)
    local slash = path:find("/[^/]*$")
    if not slash or slash == 1 then return nil end
    return path:sub(1, slash - 1)
end

return {
    name = "std.write",
    description = "Save content to a path. Overwrites existing files; missing parent directories are created automatically.",
    schema = {
        type = "object",
        properties = {
            path = { type = "string", description = "Path to the file (relative or absolute)." },
            content = { type = "string", description = "Exact bytes to write. No newline is added." },
        },
        required = { "path", "content" },
    },
    handler = function(input)
        local path = input.path
        local content = input.content

        if type(path) ~= "string" or path == "" then
            return "Error: `path` must be a non-empty string."
        end
        if type(content) ~= "string" then
            return "Error: `content` must be a string."
        end

        local parent = dirname(path)
        if parent then
            local ok, err = mkdir_p(parent)
            if not ok then
                return "Error: could not create parent directory " .. parent .. ": " .. tostring(err)
            end
        end

        local open_err, fd = fs_open(path, "w", tonumber("644", 8))
        if not fd then
            return "Error: " .. (open_err or ("could not open " .. path .. " for writing"))
        end

        -- A single `fs_write` may short-write; loop until all bytes land.
        local offset = 0
        while offset < #content do
            local werr, n = fs_write(fd, content:sub(offset + 1), offset)
            if not n then
                fs_close(fd)
                return "Error: write failed: " .. tostring(werr)
            end
            offset = offset + n
        end

        local close_err = fs_close(fd)
        if close_err then
            return "Error: close failed: " .. tostring(close_err)
        end

        return string.format("Wrote %d bytes to %s.", #content, path)
    end,
}