summaryrefslogtreecommitdiff
path: root/warming/warm.lua
blob: e5e41fce19e77870e8ea68d9de4b5cc46a17ed2a (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
-- One transient warming request: the session's current prompt, a minimal
-- extra user message, one model response -- and none of it kept.
--
-- The request runs on a throwaway agent so the live session is never
-- touched. The pieces:
--
--   transcript  `panto.file_system_jsonl_store` over the session's own
--               store dir, then `load` -- the same reconstruction `/resume`
--               uses, so system blocks, tool inputs, and thinking
--               signatures come back verbatim and the request shares the
--               session's prompt prefix byte for byte.
--   config      `panto.ext.resolve_model` for the session's current
--               model/reasoning label, which inherits the live credentialed
--               transport when it resolves the session's own model.
--   tools       the session agent's declarations, copied with `set_tools`
--               so the tools array matches the session's own requests (see
--               the README's fidelity notes on schema re-encoding).
--   store       `panto.null_store()` -- the warm turn's user message and
--               response are appended to the throwaway conversation and
--               persisted nowhere.
--   turn        `agent:run_async { prompt, dispatch_tools = false }` on its
--               own pump thread, so the UI thread never blocks. Tools are
--               never dispatched: a misbehaving model cannot run real
--               tools from a keep-alive prompt. The response is drained
--               and dropped.
--
-- Nothing here raises: the binding reports failures by raising, and every
-- call goes through `try` so callers get one `nil, message` shape.

local M = {}

local function try(fn, ...)
    local ok, value = pcall(fn, ...)
    if not ok then
        return false, tostring(value)
    end
    return true, value
end

-- start(prompt) -> handle | nil, err
--
-- Everything that can fail before the turn starts fails here: a caller
-- either gets a handle to poll or a message.
function M.start(prompt)
    local panto = require("panto")
    local ext = panto.ext

    local ok, info = try(ext.session_info)
    if not ok then
        return nil, "session_info: " .. tostring(info)
    end
    if type(info) ~= "table" or type(info.session_dir) ~= "string" or type(info.session_id) ~= "string" then
        return nil, "no session information available"
    end

    local opened, store = try(panto.file_system_jsonl_store, { dir = info.session_dir })
    if not opened then
        return nil, "session store: " .. tostring(store)
    end
    if store == nil then
        return nil, "the session store could not be opened"
    end

    local loaded, conv = try(store.load, store, info.session_id)
    if not loaded then
        return nil, "session store: " .. tostring(conv)
    end
    if conv == nil then
        return nil, "the session transcript could not be loaded"
    end

    local asked, cfg, resolve_err = pcall(ext.resolve_model, {
        model = info.model,
        reasoning = info.reasoning,
    })
    if not asked then
        return nil, tostring(cfg)
    end
    if cfg == nil then
        return nil, resolve_err and tostring(resolve_err) or "the session model could not be resolved"
    end

    local made, no_store = try(panto.null_store)
    if not made then
        return nil, tostring(no_store)
    end

    local built, agent = try(panto.agent, {
        config = cfg,
        store = no_store,
        conversation = conv,
    })
    if not built then
        return nil, "warm agent: " .. tostring(agent)
    end
    if agent == nil then
        return nil, "the warm agent could not be created"
    end

    local primary = ext.agent
    if primary ~= nil then
        local got, decls = try(primary.tools, primary)
        if not got then
            return nil, "tools: " .. tostring(decls)
        end
        if type(decls) == "table" and #decls > 0 then
            local armed, tools_err = try(agent.set_tools, agent, decls)
            if not armed then
                return nil, "warm tools: " .. tostring(tools_err)
            end
        end
    end

    local started, job = try(agent.run_async, agent, {
        prompt = prompt,
        dispatch_tools = false,
    })
    if not started then
        return nil, "warm turn: " .. tostring(job)
    end
    if job == nil then
        return nil, "the warm turn could not be started"
    end

    return { agent = agent, job = job, store = store, conv = conv }
end

-- poll(handle) -> nil while the pump runs, then a settled
-- { status = "completed"|"failed"|"cancelled", err = ... }. Drains buffered
-- events as it goes so a chatty response cannot grow the queue. The result
-- content is deliberately discarded; `close` joins the (settled) pump.
function M.poll(handle)
    local job = handle.job
    while true do
        local ok, event = pcall(job.next_event, job)
        if not ok or event == nil then
            break
        end
    end
    local ok, result = pcall(job.result, job)
    if not ok then
        pcall(job.close, job)
        return { status = "failed", err = tostring(result) }
    end
    if type(result) ~= "table" then
        return nil
    end
    pcall(job.close, job)
    return { status = result.status or "failed", err = result.err }
end

-- abandon(handle): cancel and join the pump, on session teardown. The
-- cancel half shuts the response socket down, so the join returns promptly
-- even mid-request.
function M.abandon(handle)
    pcall(handle.job.request_cancel, handle.job)
    pcall(handle.job.close, handle.job)
end

return M