fix(ai): use async prompts by default

This commit is contained in:
Guillaume ARM 2026-06-11 22:59:35 +02:00
parent 3b647090fa
commit 3cea167261
9 changed files with 240 additions and 114 deletions

View File

@ -10,65 +10,12 @@ local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
local function base64encode(s)
local pad = (3 - #s % 3) % 3;
s = s .. string.rep('\0', pad);
local r = {};
for i = 1, #s, 3 do
local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2);
local n = a * 65536 + b * 256 + c;
r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
.. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
.. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
.. B64:sub(n % 64 + 1, n % 64 + 1);
end
local result = table.concat(r);
if pad > 0 then
result = result:sub(1, #result - pad) .. string.rep('=', pad);
end
return result;
end
local function trimTrailingSlash(s)
return (s:gsub('/+$', ''));
end
local function urlEncode(s)
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
return string.format('%%%02X', string.byte(c));
end));
end
local createHttp = require('/apis/libhttp');
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
end
local function queryString(params)
local parts = {};
for _, item in ipairs(params) do
if not isBlank(item[2]) then
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
end
end
if #parts == 0 then return ''; end
return '?' .. table.concat(parts, '&');
end
local function readAllAndClose(response)
local body = response.readAll();
response.close();
return body;
end
local function statusCode(response)
if response.getResponseCode then
return response.getResponseCode();
end
return nil;
end
local function extractTextParts(parts)
if type(parts) ~= 'table' then
return '';
@ -210,6 +157,7 @@ local function createAi(opts)
local eventloopFactory = opts.eventloop or require('/apis/eventloop');
local nowFunc = opts.now or nowSeconds;
local osLib = opts.os or os;
local httpClient = opts.httpClient or createHttp({ http = httpLib });
local api = {};
@ -278,7 +226,7 @@ local function createAi(opts)
local directory = options.directory or settingsLib.get('opencc.directory');
local providerId, modelId = resolveModel(options);
return {
url = trimTrailingSlash(url),
url = httpClient.trimTrailingSlash(url),
username = username,
password = password,
directory = directory,
@ -333,6 +281,29 @@ local function createAi(opts)
return type(message.info.time) == 'table' and message.info.time.completed ~= nil;
end
local function errorMessage(errorInfo)
if type(errorInfo) ~= 'table' then return nil; end
if type(errorInfo.data) == 'table' and type(errorInfo.data.message) == 'string' then
return errorInfo.data.message;
end
if type(errorInfo.message) == 'string' then
return errorInfo.message;
end
if type(errorInfo.name) == 'string' then
return errorInfo.name;
end
return 'unknown assistant error';
end
local function sessionStatusText(status)
if type(status) ~= 'table' then return nil; end
if type(status.type) ~= 'string' then return nil; end
if status.type == 'retry' then
return 'retry #' .. tostring(status.attempt or '?') .. ': ' .. tostring(status.message or 'unknown error');
end
return status.type;
end
local function decodeMessage(value)
local decoded = value;
if type(value) == 'string' then
@ -409,16 +380,30 @@ local function createAi(opts)
local reply = decoded and extractTextParts(decoded.parts) or '';
local complete = decoded and isMessageComplete(decoded) or false;
local matchedId = decoded and type(decoded.info) == 'table' and decoded.info.id or 'nil';
local assistantError = decoded and type(decoded.info) == 'table' and errorMessage(decoded.info.error) or nil;
log('poll #' .. tostring(attemptCount)
.. ': messages=' .. tostring(#messages)
.. ', found=' .. tostring(matchedId)
.. ', complete=' .. tostring(complete)
.. ', text=' .. tostring(reply ~= ''));
.. ', text=' .. tostring(reply ~= '')
.. ', error=' .. tostring(assistantError ~= nil));
if assistantError then
return finish(false, 'erreur assistant: ' .. assistantError);
end
if decoded and reply ~= '' and complete then
log('async reply completed');
return finish(true, { reply = reply, sessionId = sessionId, messageId = messageId });
end
if nowFunc() >= deadline then
local statusBody, statusCodeValue = doGet(cfg, '/session/status');
if statusBody and (not statusCodeValue or statusCodeValue == 200) then
local statuses = textutils.unserializeJSON(statusBody);
local statusText = type(statuses) == 'table' and sessionStatusText(statuses[sessionId]) or nil;
if statusText then
log('session status at timeout: ' .. statusText);
return finish(false, 'delai depasse en attendant la reponse AI (status: ' .. statusText .. ')');
end
end
return finish(false, 'delai depasse en attendant la reponse AI');
end
loop.setTimeout(attempt, cfg.pollIntervalSeconds);
@ -429,46 +414,12 @@ local function createAi(opts)
return resultOk, resultValue;
end
local function buildHeaders(cfg)
local headers = {
['Content-Type'] = 'application/json',
['Accept'] = 'application/json',
};
if cfg.password and cfg.password ~= '' then
headers['Authorization'] = 'Basic ' .. base64encode(cfg.username .. ':' .. cfg.password);
end
return headers;
end
local function callHttp(method, request)
local ok, response, httpErr, errorResponse = pcall(httpLib[method], request);
if not ok then
return nil, 'http ' .. method .. ' threw: ' .. tostring(response);
end
response = response or errorResponse;
if not response then
return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error');
end
local code = statusCode(response);
local body = readAllAndClose(response);
return body, code;
end
function doGet(cfg, path)
return callHttp('get', {
url = cfg.url .. path,
headers = buildHeaders(cfg),
timeout = cfg.timeoutSeconds,
});
return httpClient.getJson(cfg, path);
end
function doPost(cfg, path, payload)
return callHttp('post', {
url = cfg.url .. path,
body = textutils.serializeJSON(payload),
headers = buildHeaders(cfg),
timeout = cfg.timeoutSeconds,
});
return httpClient.postJson(cfg, path, payload);
end
local function askBlocking(cfg, sessionId, prompt, persist, sessionSettingKey, log)
@ -494,7 +445,7 @@ local function createAi(opts)
end
local function listSessionsWithDirectory(cfg, directory)
return doGet(cfg, '/session' .. queryString({ { 'directory', directory } }));
return doGet(cfg, '/session' .. httpClient.queryString({ { 'directory', directory } }));
end
local function decodeSessionList(body, log)
@ -609,8 +560,8 @@ local function createAi(opts)
promptWithContext = buildPromptWithCallerContext(prompt, osLib);
end
if not (cfg.providerID and cfg.modelID) then
log('provider/model unset; using blocking message endpoint');
if options.blocking == true then
log('using blocking message endpoint');
return askBlocking(cfg, sessionId, promptWithContext, persist, sessionSettingKey, log);
end

122
apis/libhttp.lua Normal file
View File

@ -0,0 +1,122 @@
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
local function base64encode(s)
local pad = (3 - #s % 3) % 3;
s = s .. string.rep('\0', pad);
local r = {};
for i = 1, #s, 3 do
local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2);
local n = a * 65536 + b * 256 + c;
r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
.. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
.. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
.. B64:sub(n % 64 + 1, n % 64 + 1);
end
local result = table.concat(r);
if pad > 0 then
result = result:sub(1, #result - pad) .. string.rep('=', pad);
end
return result;
end
local function trimTrailingSlash(s)
return (s:gsub('/+$', ''));
end
local function urlEncode(s)
return (tostring(s):gsub('[^%w%-_%.~]', function(c)
return string.format('%%%02X', string.byte(c));
end));
end
local function isBlank(s)
return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil;
end
local function queryString(params)
local parts = {};
for _, item in ipairs(params) do
if not isBlank(item[2]) then
parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]);
end
end
if #parts == 0 then return ''; end
return '?' .. table.concat(parts, '&');
end
local function readAllAndClose(response)
local body = response.readAll();
response.close();
return body;
end
local function statusCode(response)
if response.getResponseCode then
return response.getResponseCode();
end
return nil;
end
local function createHttp(opts)
opts = opts or {};
local httpLib = opts.http or http;
local textutilsLib = opts.textutils or textutils;
local api = {
base64encode = base64encode,
trimTrailingSlash = trimTrailingSlash,
urlEncode = urlEncode,
queryString = queryString,
};
function api.basicAuth(username, password)
return 'Basic ' .. base64encode(tostring(username or '') .. ':' .. tostring(password or ''));
end
function api.jsonHeaders(options)
options = options or {};
local headers = {
['Content-Type'] = 'application/json',
['Accept'] = 'application/json',
};
if options.password and options.password ~= '' then
headers['Authorization'] = api.basicAuth(options.username, options.password);
end
return headers;
end
function api.call(method, request)
local ok, response, httpErr, errorResponse = pcall(httpLib[method], request);
if not ok then
return nil, 'http ' .. method .. ' threw: ' .. tostring(response);
end
response = response or errorResponse;
if not response then
return nil, 'serveur injoignable: ' .. tostring(httpErr or 'unknown error');
end
local code = statusCode(response);
local body = readAllAndClose(response);
return body, code;
end
function api.getJson(cfg, path)
return api.call('get', {
url = cfg.url .. path,
headers = api.jsonHeaders(cfg),
timeout = cfg.timeoutSeconds,
});
end
function api.postJson(cfg, path, payload)
return api.call('post', {
url = cfg.url .. path,
body = textutilsLib.serializeJSON(payload),
headers = api.jsonHeaders(cfg),
timeout = cfg.timeoutSeconds,
});
end
return api;
end
return createHttp;

View File

@ -144,7 +144,7 @@ Abort a running generation.
### `POST /session/:id/prompt_async`
Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the request body so the submitted message can be matched to the later assistant response. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
Fire-and-forget variant. Returns `204` immediately and starts generation in the background. Include `messageID` in the request body so the submitted user message can be matched to the later assistant response. Opencode validates caller-provided message IDs; use IDs starting with `msg`.
**Request body:**
```json
@ -158,9 +158,9 @@ Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the r
}
```
Unlike `/message`, `model` is **not** optional in practice — omitting it causes the request to be accepted (`204`) without triggering generation, so the assistant message never appears. `ai` only uses this async endpoint when `opencc.provider_id` and `opencc.model_id` are configured; otherwise it falls back to blocking `POST /session/:id/message`.
`agent` and `model` are optional. Omit `model` to use the server/session default model, or include it to force a specific provider/model for this prompt.
When async mode is available, `ai` uses this endpoint to avoid `504` failures from the blocking `/message` endpoint when the LLM takes longer than one HTTP request timeout. The submitted `messageID` identifies the user or assistant message depending on the opencode response shape; `ai` polls `GET /session/:id/message` and reads the completed assistant message. If `opencc.agent` or `--agent <name>` is set, `ai` includes it as `agent` in the request body.
`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. The submitted `messageID` identifies the user message; `ai` polls `GET /session/:id/message` and reads the completed assistant message after it. If `opencc.agent` or `--agent <name>` is set, `ai` includes it as `agent` in the request body. If generation fails in the background, opencode records the failure on the assistant message or session event stream; `ai` surfaces assistant message errors while polling.
---

View File

@ -116,7 +116,7 @@ Optional — scope `ai sessions` to a specific opencode project directory. If om
set opencc.directory /Users/garm/trap/cc-libs
```
Optional but recommended: pick the provider and model. When both are set, `ai` posts to `/session/:id/prompt_async` and polls for completion. Without them, `ai` falls back to blocking `/session/:id/message`, which can use the server default model but is more exposed to HTTP timeouts:
Optional: pick the provider and model for requests from this computer. If omitted, `ai` still posts to `/session/:id/prompt_async`; opencode uses the server/session default model:
```sh
set opencc.provider_id anthropic
@ -169,6 +169,7 @@ Set settings inside the harness before running, or inject them via the test API.
| `erreur message: HTTP 401` | Wrong password | Check `opencc.password` matches `OPENCODE_SERVER_PASSWORD` |
| `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 |
| `erreur message: HTTP 504` | Blocking mode AI call took too long | Retry; prefer the default async mode |
| `erreur assistant: ...` | Opencode accepted the async prompt but generation failed | Check the provider/model/agent and opencode logs |
| `delai depasse en attendant la reponse AI` | Async polling timed out | Increase `opencc.poll_timeout_seconds`; use `ai --verbose` and check opencode logs |
| `reponse vide` | Reply had no text parts | Check opencode logs |

View File

@ -1,6 +1,6 @@
{
"name": "TrapOS",
"version": "0.8.14",
"version": "0.8.15",
"branch": "next",
"packages": [
"trapos"

View File

@ -5,8 +5,8 @@
"trapos-boot": "0.3.2",
"trapos-net": "0.3.0",
"trapos-ui": "0.2.2",
"trapos-ai": "0.6.12",
"trapos-ai": "0.6.13",
"trapos-sandbox": "0.2.2",
"trapos": "0.8.14"
"trapos": "0.8.15"
}
}

View File

@ -1,10 +1,11 @@
{
"name": "trapos-ai",
"version": "0.6.12",
"version": "0.6.13",
"description": "TrapOS AI client for opencode serve",
"dependencies": ["trapos-core"],
"files": [
"apis/libai.lua",
"apis/libhttp.lua",
"apis/libtrapgpt.lua",
"programs/ai.lua",
"programs/trapgpt.lua"

View File

@ -1,6 +1,6 @@
{
"name": "trapos",
"version": "0.8.14",
"version": "0.8.15",
"description": "TrapOS full install meta-package",
"dependencies": [
"trapos-boot",

View File

@ -161,6 +161,18 @@ local function assistantMessage(id, reply, completed)
};
end
local function assistantErrorMessage(id, message)
return {
info = {
id = id,
role = 'assistant',
error = { name = 'UnknownError', data = { message = message } },
time = { completed = 1 },
},
parts = {},
};
end
local function postedText(call)
local body = textutils.unserializeJSON(call.body);
return body.parts[1].text;
@ -358,7 +370,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()
@ -419,7 +431,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 reuses custom sessionSettingKey without creating a new session', function()
@ -438,10 +450,10 @@ testlib.test('ask reuses custom sessionSettingKey without creating a new session
testlib.assertTrue(ok);
testlib.assertEquals(#httpStub.postCalls, 1);
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/message', 1, true) ~= nil);
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_trapgpt/prompt_async', 1, true) ~= nil);
end);
testlib.test('ask falls back to blocking message when model is unset', function()
testlib.test('ask can use blocking message when explicitly requested', function()
local httpStub = fakeHttp(
{ sessionResp('ses_blocking'), messageResp('reply') },
{}
@ -449,13 +461,29 @@ testlib.test('ask falls back to blocking message when model is unset', function(
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
local ai = createAi({ http = httpStub, settings = settingsStub });
local ok, result = ai.ask('hello', { blocking = true });
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'reply');
testlib.assertEquals(#httpStub.postCalls, 2);
testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_blocking/message', 1, true) ~= nil);
testlib.assertEquals(#httpStub.getCalls, 0);
end);
testlib.test('ask uses async prompt when model is unset', function()
local httpStub = fakeHttp(
{ sessionResp('ses_async'), messageResp('reply') },
{}
);
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
local ai = createAi({ http = httpStub, settings = settingsStub });
local ok, result = ai.ask('hello');
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'reply');
testlib.assertEquals(#httpStub.postCalls, 2);
testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_blocking/message', 1, true) ~= nil);
testlib.assertEquals(#httpStub.getCalls, 0);
testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_async/prompt_async', 1, true) ~= nil);
end);
testlib.test('ask wraps prompt with caller context', function()
@ -834,6 +862,29 @@ testlib.test('ask polling tolerates assistant message without parts', function()
testlib.assertEquals(#httpStub.getCalls, 2);
end);
testlib.test('ask polling reports assistant errors', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), asyncResp() },
{
messageListResp({ userMessage('msg_1', 'hello'), assistantErrorMessage('msg_2', 'bad model') }),
}
);
local settingsStub = fakeAsyncSettings();
local elFactory = fakeEventloopFactory();
local ai = createAi({
http = httpStub,
settings = settingsStub,
now = function() return 10; end,
eventloop = elFactory,
});
local ok, err = ai.ask('hello', { messageId = 'msg_1' });
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'erreur assistant: bad model', 1, true) ~= nil);
testlib.assertEquals(#httpStub.getCalls, 1);
end);
testlib.test('ask polling default timeout allows ten minute replies', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), asyncResp() },
@ -938,7 +989,7 @@ testlib.test('ask polling times out', function()
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
testlib.assertEquals(#httpStub.getCalls, 2);
testlib.assertEquals(#httpStub.getCalls, 3);
testlib.assertTrue(elState.lastLoop.inspect().stopped);
testlib.assertEquals(#elState.lastLoop.inspect().pending, 0);
end);
@ -977,7 +1028,7 @@ testlib.test('ask caps polling timeout at ten minutes', function()
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'delai depasse', 1, true) ~= nil);
testlib.assertEquals(#httpStub.getCalls, 3);
testlib.assertEquals(#httpStub.getCalls, 4);
testlib.assertEquals(now, 600);
end);