summaryrefslogtreecommitdiff
path: root/agent/tools/write.lua
blob: 144a305f084269ae746ddc4ef1284fb8ca1f1b96 (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
-- 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 panto = require("panto")
local uv = require("luv")

local tool = {
    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" },
    },
}

panto.ext.on("tool", function(e)
    if e.tool_name ~= tool.name then return end
    e:set_component(e:get_component())
end)

-- ---------------------------------------------------------------------------
-- 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

tool.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

return tool