diff --git a/apis/libai.lua b/apis/libai.lua index 4285bb0..6cf3d66 100644 --- a/apis/libai.lua +++ b/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 '; + 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 '; + 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) diff --git a/docs/opencode_api.md b/docs/opencode_api.md index 07eb16e..30248dc 100644 --- a/docs/opencode_api.md +++ b/docs/opencode_api.md @@ -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. --- diff --git a/docs/opencode_server_guide.md b/docs/opencode_server_guide.md index eec05ec..9c746cb 100644 --- a/docs/opencode_server_guide.md +++ b/docs/opencode_server_guide.md @@ -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 ` or `ai ping` | | `session introuvable; lance: ai new ` | Session was deleted or server restarted | Run `ai new ` | | `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 | diff --git a/manifest.json b/manifest.json index 5ccda29..fca3756 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.6.2", + "version": "0.6.3", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index d6acbbf..cfc547a 100644 --- a/packages/index.json +++ b/packages/index.json @@ -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" } } diff --git a/packages/trapos-ai/ccpm.json b/packages/trapos-ai/ccpm.json index a629bf9..d45617f 100644 --- a/packages/trapos-ai/ccpm.json +++ b/packages/trapos-ai/ccpm.json @@ -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": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index c4fd092..d1cbb44 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -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": [], diff --git a/programs/ai.lua b/programs/ai.lua index db20027..250f78d 100644 --- a/programs/ai.lua +++ b/programs/ai.lua @@ -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) diff --git a/tests/ai.lua b/tests/ai.lua index a983257..4f7878b 100644 --- a/tests/ai.lua +++ b/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' });