feat(ai): poll async opencode responses
This commit is contained in:
parent
dea9bd52ee
commit
cf54b492b2
118
apis/libai.lua
118
apis/libai.lua
@ -2,6 +2,8 @@ local PING_PROMPT = 'reply with exactly: pong';
|
||||
|
||||
local DEFAULT_TIMEOUT_SECONDS = 60;
|
||||
local MAX_TIMEOUT_SECONDS = 60;
|
||||
local DEFAULT_POLL_TIMEOUT_SECONDS = 300;
|
||||
local DEFAULT_POLL_INTERVAL_SECONDS = 2;
|
||||
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
|
||||
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
|
||||
|
||||
@ -57,6 +59,13 @@ local function extractTextParts(parts)
|
||||
return table.concat(texts, '');
|
||||
end
|
||||
|
||||
local function nowSeconds()
|
||||
if os.epoch then
|
||||
return os.epoch('utc') / 1000;
|
||||
end
|
||||
return os.clock();
|
||||
end
|
||||
|
||||
local function tablePack(...)
|
||||
return { n = select('#', ...), ... };
|
||||
end
|
||||
@ -146,6 +155,8 @@ local function createAi(opts)
|
||||
|
||||
local httpLib = opts.http or http;
|
||||
local settingsLib = opts.settings or settings;
|
||||
local sleepFunc = opts.sleep or sleep;
|
||||
local nowFunc = opts.now or nowSeconds;
|
||||
|
||||
local api = {};
|
||||
|
||||
@ -158,6 +169,22 @@ local function createAi(opts)
|
||||
return n;
|
||||
end
|
||||
|
||||
local function resolvePollTimeout(options)
|
||||
local raw = options.pollTimeoutSeconds;
|
||||
if raw == nil then raw = settingsLib.get('opencc.poll_timeout_seconds'); end
|
||||
local n = tonumber(raw);
|
||||
if not n or n <= 0 then n = DEFAULT_POLL_TIMEOUT_SECONDS; end
|
||||
return n;
|
||||
end
|
||||
|
||||
local function resolvePollInterval(options)
|
||||
local raw = options.pollIntervalSeconds;
|
||||
if raw == nil then raw = settingsLib.get('opencc.poll_interval_seconds'); end
|
||||
local n = tonumber(raw);
|
||||
if not n or n <= 0 then n = DEFAULT_POLL_INTERVAL_SECONDS; end
|
||||
return n;
|
||||
end
|
||||
|
||||
local function resolveLuaExecMaxRetries(options)
|
||||
local n = tonumber(options.maxRetries);
|
||||
if n and n >= 0 then return math.floor(n); end
|
||||
@ -183,9 +210,68 @@ local function createAi(opts)
|
||||
username = username,
|
||||
password = password,
|
||||
timeoutSeconds = resolveTimeout(options),
|
||||
pollTimeoutSeconds = resolvePollTimeout(options),
|
||||
pollIntervalSeconds = resolvePollInterval(options),
|
||||
};
|
||||
end
|
||||
|
||||
local function createMessageId()
|
||||
local t = math.floor(nowFunc() * 1000);
|
||||
return 'cc_' .. tostring(t) .. '_' .. tostring(math.random(100000, 999999));
|
||||
end
|
||||
|
||||
local function isMessageComplete(message)
|
||||
if type(message) ~= 'table' or type(message.info) ~= 'table' then
|
||||
return false;
|
||||
end
|
||||
if type(message.info.finish) == 'string' then
|
||||
return true;
|
||||
end
|
||||
return type(message.info.time) == 'table' and message.info.time.completed ~= nil;
|
||||
end
|
||||
|
||||
local function decodeMessage(body)
|
||||
local decoded = textutils.unserializeJSON(body);
|
||||
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
|
||||
return nil, 'reponse message invalide';
|
||||
end
|
||||
return decoded, nil;
|
||||
end
|
||||
|
||||
local function handleMissingSession(persist)
|
||||
if persist then
|
||||
settingsLib.unset('opencc.session_id');
|
||||
if settingsLib.save then settingsLib.save(); end
|
||||
end
|
||||
return false, 'session introuvable; lance: ai new <prompt>';
|
||||
end
|
||||
|
||||
local doGet;
|
||||
local doPost;
|
||||
|
||||
local function pollMessage(cfg, sessionId, messageId, persist)
|
||||
local deadline = nowFunc() + cfg.pollTimeoutSeconds;
|
||||
while true do
|
||||
local body, code = doGet(cfg, '/session/' .. sessionId .. '/message/' .. messageId);
|
||||
if not body then return false, code; end
|
||||
if code == 404 then return handleMissingSession(persist); end
|
||||
if code and code ~= 200 then
|
||||
return false, 'erreur message: HTTP ' .. tostring(code);
|
||||
end
|
||||
|
||||
local decoded, decodeErr = decodeMessage(body);
|
||||
if not decoded then return false, decodeErr; end
|
||||
local reply = extractTextParts(decoded.parts);
|
||||
if reply ~= '' and isMessageComplete(decoded) then
|
||||
return true, { reply = reply, sessionId = sessionId, messageId = messageId };
|
||||
end
|
||||
if nowFunc() >= deadline then
|
||||
return false, 'delai depasse en attendant la reponse AI';
|
||||
end
|
||||
sleepFunc(cfg.pollIntervalSeconds);
|
||||
end
|
||||
end
|
||||
|
||||
local function buildHeaders(cfg)
|
||||
local headers = {
|
||||
['Content-Type'] = 'application/json',
|
||||
@ -211,7 +297,7 @@ local function createAi(opts)
|
||||
return body, code;
|
||||
end
|
||||
|
||||
local function doGet(cfg, path)
|
||||
function doGet(cfg, path)
|
||||
return callHttp('get', {
|
||||
url = cfg.url .. path,
|
||||
headers = buildHeaders(cfg),
|
||||
@ -219,7 +305,7 @@ local function createAi(opts)
|
||||
});
|
||||
end
|
||||
|
||||
local function doPost(cfg, path, payload)
|
||||
function doPost(cfg, path, payload)
|
||||
return callHttp('post', {
|
||||
url = cfg.url .. path,
|
||||
body = textutils.serializeJSON(payload),
|
||||
@ -286,32 +372,28 @@ local function createAi(opts)
|
||||
end
|
||||
end
|
||||
|
||||
local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', {
|
||||
local messageId = options.messageId or createMessageId();
|
||||
local body, code = doPost(cfg, '/session/' .. sessionId .. '/prompt_async', {
|
||||
messageID = messageId,
|
||||
parts = { { type = 'text', text = prompt } },
|
||||
});
|
||||
if not body then return false, code; end
|
||||
if code == 404 then
|
||||
if persist then
|
||||
settingsLib.unset('opencc.session_id');
|
||||
if settingsLib.save then settingsLib.save(); end
|
||||
end
|
||||
return false, 'session introuvable; lance: ai new <prompt>';
|
||||
return handleMissingSession(persist);
|
||||
end
|
||||
if code and code ~= 200 then
|
||||
if code and code ~= 204 and code ~= 200 then
|
||||
return false, 'erreur message: HTTP ' .. tostring(code);
|
||||
end
|
||||
|
||||
local decoded = textutils.unserializeJSON(body);
|
||||
if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then
|
||||
return false, 'reponse message invalide';
|
||||
if code == 200 and body and body ~= '' then
|
||||
local decoded, decodeErr = decodeMessage(body);
|
||||
if not decoded then return false, decodeErr; end
|
||||
local reply = extractTextParts(decoded.parts);
|
||||
if reply == '' then return false, 'reponse vide'; end
|
||||
return true, { reply = reply, sessionId = sessionId, messageId = messageId };
|
||||
end
|
||||
|
||||
local reply = extractTextParts(decoded.parts);
|
||||
if reply == '' then
|
||||
return false, 'reponse vide';
|
||||
end
|
||||
|
||||
return true, { reply = reply, sessionId = sessionId };
|
||||
return pollMessage(cfg, sessionId, messageId, persist);
|
||||
end
|
||||
|
||||
function api.createLuaExecutor(options)
|
||||
|
||||
@ -100,6 +100,22 @@ Parts can include non-text types (`tool-call`, `step-start`, etc.) — collect a
|
||||
|
||||
---
|
||||
|
||||
### `GET /session/:id/message/:messageID`
|
||||
|
||||
Get a message by ID. `ai` uses this to poll async prompts until the assistant message has text parts and completion metadata.
|
||||
|
||||
**Response** `200`:
|
||||
```json
|
||||
{
|
||||
"info": { "id": "msg_xyz", "sessionID": "ses_abc123", "role": "assistant", "time": { "completed": 1234567890 } },
|
||||
"parts": [
|
||||
{ "type": "text", "text": "the reply" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /session/:id`
|
||||
|
||||
Delete a session.
|
||||
@ -114,7 +130,9 @@ Abort a running generation.
|
||||
|
||||
### `POST /session/:id/prompt_async`
|
||||
|
||||
Fire-and-forget variant. Returns `204` immediately; result arrives over the SSE stream.
|
||||
Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the request body to make the assistant response addressable by `GET /session/:id/message/:messageID`.
|
||||
|
||||
`ai` uses this endpoint by default to avoid `504` failures from the blocking `/message` endpoint when the LLM takes longer than one HTTP request timeout.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -124,4 +124,5 @@ Set settings inside the harness before running, or inject them via the test API.
|
||||
| `missing prompt` | No prompt was passed | Run `ai <prompt>` or `ai ping` |
|
||||
| `session introuvable; lance: ai new <prompt>` | Session was deleted or server restarted | Run `ai new <prompt>` |
|
||||
| `erreur message: HTTP 504` | AI took too long | Retry; consider a faster model |
|
||||
| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds` or check opencode logs |
|
||||
| `reponse vide` | Reply had no text parts | Check opencode logs |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"branch": "next",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
"trapos-boot": "0.2.2",
|
||||
"trapos-net": "0.2.1",
|
||||
"trapos-ui": "0.2.2",
|
||||
"trapos-ai": "0.5.1",
|
||||
"trapos-ai": "0.5.2",
|
||||
"trapos-sandbox": "0.1.0",
|
||||
"trapos": "0.6.2"
|
||||
"trapos": "0.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos-ai",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"description": "TrapOS AI client for opencode serve",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
|
||||
"files": [],
|
||||
|
||||
@ -24,6 +24,9 @@ local function printUsage()
|
||||
print(' opencc.username (default: opencode)');
|
||||
print(' opencc.password (Basic Auth password)');
|
||||
print(' opencc.session_id (auto-managed)');
|
||||
print(' opencc.timeout_seconds (per HTTP call, max 60)');
|
||||
print(' opencc.poll_timeout_seconds (default: 300)');
|
||||
print(' opencc.poll_interval_seconds (default: 2)');
|
||||
end
|
||||
|
||||
local function joinArgs(start)
|
||||
|
||||
66
tests/ai.lua
66
tests/ai.lua
@ -72,7 +72,18 @@ end
|
||||
|
||||
local function messageResp(reply)
|
||||
return response(200, textutils.serializeJSON({
|
||||
info = {},
|
||||
info = { time = { completed = 1 } },
|
||||
parts = { { type = 'text', text = reply } },
|
||||
}));
|
||||
end
|
||||
|
||||
local function asyncResp()
|
||||
return response(204, '');
|
||||
end
|
||||
|
||||
local function pendingMessageResp(reply)
|
||||
return response(200, textutils.serializeJSON({
|
||||
info = { time = {} },
|
||||
parts = { { type = 'text', text = reply } },
|
||||
}));
|
||||
end
|
||||
@ -187,7 +198,7 @@ testlib.test('ask creates session then sends message when no session_id', functi
|
||||
testlib.assertEquals(result.sessionId, 'ses_new');
|
||||
testlib.assertEquals(#httpStub.postCalls, 2);
|
||||
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session', 1, true) ~= nil);
|
||||
testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_new/message', 1, true) ~= nil);
|
||||
testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_new/prompt_async', 1, true) ~= nil);
|
||||
end);
|
||||
|
||||
testlib.test('ask creates cc-ai titled sessions', function()
|
||||
@ -233,7 +244,7 @@ testlib.test('ask reuses existing session_id without creating a new session', fu
|
||||
|
||||
testlib.assertTrue(ok);
|
||||
testlib.assertEquals(#httpStub.postCalls, 1);
|
||||
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/message', 1, true) ~= nil);
|
||||
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/prompt_async', 1, true) ~= nil);
|
||||
end);
|
||||
|
||||
testlib.test('ask sends exact prompt text', function()
|
||||
@ -255,6 +266,55 @@ testlib.test('ask sends exact prompt text', function()
|
||||
testlib.assertEquals(body.parts[1].text, 'my prompt');
|
||||
end);
|
||||
|
||||
testlib.test('ask polls async message until completion', function()
|
||||
local httpStub = fakeHttp(
|
||||
{ sessionResp('ses_1'), asyncResp() },
|
||||
{ pendingMessageResp('partial'), messageResp('reply') }
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local sleeps = {};
|
||||
local ai = createAi({
|
||||
http = httpStub,
|
||||
settings = settingsStub,
|
||||
now = function() return 10; end,
|
||||
sleep = function(n) sleeps[#sleeps + 1] = n; end,
|
||||
});
|
||||
|
||||
local ok, result = ai.ask('hello', { messageId = 'msg_1', pollIntervalSeconds = 3 });
|
||||
|
||||
testlib.assertTrue(ok, tostring(result));
|
||||
testlib.assertEquals(result.reply, 'reply');
|
||||
testlib.assertEquals(result.messageId, 'msg_1');
|
||||
testlib.assertEquals(#httpStub.getCalls, 2);
|
||||
testlib.assertTrue(string.find(httpStub.getCalls[1].url, '/session/ses_1/message/msg_1', 1, true) ~= nil);
|
||||
testlib.assertEquals(sleeps[1], 3);
|
||||
end);
|
||||
|
||||
testlib.test('ask polling times out', function()
|
||||
local httpStub = fakeHttp(
|
||||
{ sessionResp('ses_1'), asyncResp() },
|
||||
{ pendingMessageResp('partial'), pendingMessageResp('partial') }
|
||||
);
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
local now = 0;
|
||||
local ai = createAi({
|
||||
http = httpStub,
|
||||
settings = settingsStub,
|
||||
now = function() return now; end,
|
||||
sleep = function(n) now = now + n; end,
|
||||
});
|
||||
|
||||
local ok, err = ai.ask('hello', {
|
||||
messageId = 'msg_1',
|
||||
pollTimeoutSeconds = 1,
|
||||
pollIntervalSeconds = 1,
|
||||
});
|
||||
|
||||
testlib.assertTrue(not ok);
|
||||
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
|
||||
testlib.assertEquals(#httpStub.getCalls, 2);
|
||||
end);
|
||||
|
||||
testlib.test('ask rejects missing prompt without HTTP calls', function()
|
||||
local httpStub = fakeHttp({}, {});
|
||||
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user