feat(ai): add lua exec command

This commit is contained in:
Guillaume ARM 2026-06-09 08:25:12 +02:00
parent 58af683ca8
commit f6d6c9b5af
7 changed files with 567 additions and 12 deletions

View File

@ -1,6 +1,8 @@
local PING_PROMPT = 'reply with exactly: pong';
local DEFAULT_TIMEOUT_SECONDS = 1200;
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
@ -54,6 +56,83 @@ local function extractTextParts(parts)
return table.concat(texts, '');
end
local function tablePack(...)
return { n = select('#', ...), ... };
end
local function endsWithNewline(s)
return type(s) == 'string' and string.sub(s, -1) == '\n';
end
local function valuesToLine(values, first, last)
local parts = {};
for i = first, last do
parts[#parts + 1] = tostring(values[i]);
end
return table.concat(parts, '\t');
end
local function classifyLuaRuntimeError(err)
local text = tostring(err or '');
if string.find(text, 'attempt to', 1, true) and string.find(text, 'nil value', 1, true) then
return 'identifier';
end
if string.find(text, 'global', 1, true) and string.find(text, 'nil', 1, true) then
return 'identifier';
end
return 'other';
end
local function renderOutput(output)
if output == nil or output == '' then
return '(no output)';
end
return output;
end
local function buildLuaExecPrompt(userPrompt)
return table.concat({
'Write ComputerCraft Lua code to answer this user request.',
'Reply with raw Lua code only. Do not use markdown fences or explanations.',
'The code runs locally with normal ComputerCraft globals available.',
'Use print() or write() for values that should be sent back. Returned values are captured too.',
'',
'User request:',
userPrompt,
}, '\n');
end
local function buildLuaCorrectionPrompt(userPrompt, code, err, errorKind)
return table.concat({
'The previous ComputerCraft Lua failed.',
'Reply with corrected raw Lua code only. Do not use markdown fences or explanations.',
'',
'Original user request:',
userPrompt,
'',
'Error kind: ' .. tostring(errorKind),
'Error:',
tostring(err),
'',
'Previous code:',
code,
}, '\n');
end
local function buildLuaOutputPrompt(userPrompt, output)
return table.concat({
'The Lua executed successfully.',
'Answer the original user request in natural language using the output below.',
'Do not write more Lua unless the user explicitly asked for code.',
'',
'Original user request:',
userPrompt,
'',
'Lua output:',
renderOutput(output),
}, '\n');
end
local function sessionTime(session)
if type(session) ~= 'table' or type(session.time) ~= 'table' then
return 0;
@ -77,6 +156,19 @@ local function createAi(opts)
return DEFAULT_TIMEOUT_SECONDS;
end
local function resolveLuaExecMaxRetries(options)
local n = tonumber(options.maxRetries);
if n and n >= 0 then return math.floor(n); end
return DEFAULT_LUA_EXEC_MAX_RETRIES;
end
local function resolveLuaExecTimeout(options)
if options.luaTimeoutSeconds == false then return nil; end
local n = tonumber(options.luaTimeoutSeconds);
if n and n > 0 then return n; end
return DEFAULT_LUA_EXEC_TIMEOUT_SECONDS;
end
local function resolveConfig(options)
local url = options.serverUrl or settingsLib.get('opencc.server_url');
if not url or url == '' then
@ -169,13 +261,14 @@ local function createAi(opts)
local cfg, err = resolveConfig(options);
if not cfg then return false, err; end
local persist = options.persist ~= false;
local sessionId = options.sessionId;
if sessionId == nil then
if persist and sessionId == nil then
sessionId = settingsLib.get('opencc.session_id');
end
if not sessionId or sessionId == '' then
local body, code = doPost(cfg, '/session', { title = 'cc-ai' });
local body, code = doPost(cfg, '/session', { title = options.sessionTitle or 'cc-ai' });
if not body then return false, code; end
if code and code ~= 200 then
return false, 'impossible de creer une session: HTTP ' .. tostring(code);
@ -185,8 +278,10 @@ local function createAi(opts)
return false, 'reponse session invalide';
end
sessionId = decoded.id;
settingsLib.set('opencc.session_id', sessionId);
if settingsLib.save then settingsLib.save(); end
if persist then
settingsLib.set('opencc.session_id', sessionId);
if settingsLib.save then settingsLib.save(); end
end
end
local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', {
@ -194,8 +289,10 @@ local function createAi(opts)
});
if not body then return false, code; end
if code == 404 then
settingsLib.unset('opencc.session_id');
if settingsLib.save then settingsLib.save(); end
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
if code and code ~= 200 then
@ -215,6 +312,168 @@ local function createAi(opts)
return true, { reply = reply, sessionId = sessionId };
end
function api.createLuaExecutor(options)
options = options or {};
local baseEnv = options.env or _G;
local live = options.live ~= false;
local livePrint = options.print or print;
local liveWrite = options.write or write;
local timeoutSeconds = resolveLuaExecTimeout(options);
return function(code)
local buffer = {};
local function append(text)
buffer[#buffer + 1] = text;
end
local function capturedPrint(...)
local values = tablePack(...);
local line = valuesToLine(values, 1, values.n);
append(line .. '\n');
if live then livePrint(...); end
end
local function capturedWrite(text)
text = tostring(text or '');
append(text);
if live then liveWrite(text); end
end
local env = setmetatable({
print = capturedPrint,
write = capturedWrite,
}, { __index = baseEnv });
local chunk, loadErr = load(code, 'ai-lua-exec', 't', env);
if not chunk then
return false, tostring(loadErr), 'syntax';
end
local result;
local finished = false;
local function runner()
result = tablePack(pcall(chunk));
finished = true;
end
if timeoutSeconds then
parallel.waitForAny(runner, function() sleep(timeoutSeconds); end);
else
runner();
end
if not finished then
return false, 'lua execution timed out after ' .. tostring(timeoutSeconds) .. 's', 'other';
end
if not result[1] then
return false, tostring(result[2]), classifyLuaRuntimeError(result[2]);
end
if result.n > 1 then
if #buffer > 0 and not endsWithNewline(buffer[#buffer]) then
append('\n');
end
append(valuesToLine(result, 2, result.n) .. '\n');
end
return true, table.concat(buffer), nil;
end;
end
function api.luaExec(userPrompt, options)
options = options or {};
if isBlank(userPrompt) then
return false, { error = 'missing prompt; usage: ai lua-exec <prompt>', attempts = 0 };
end
local log = options.log or function() end;
local executor = options.executor or api.createLuaExecutor(options);
local maxRetries = resolveLuaExecMaxRetries(options);
local maxAttempts = maxRetries + 1;
local sessionId;
local function askOptions()
return {
persist = false,
sessionId = sessionId,
sessionTitle = 'cc-ai lua-exec',
serverUrl = options.serverUrl,
username = options.username,
password = options.password,
timeoutSeconds = options.timeoutSeconds,
};
end
log('requesting Lua from AI');
local ok, result = api.ask(buildLuaExecPrompt(userPrompt), askOptions());
if not ok then
return false, { error = result, attempts = 0, errorKind = 'ai' };
end
sessionId = result.sessionId;
log('session: ' .. sessionId);
local code = result.reply;
for attempt = 1, maxAttempts do
log('attempt ' .. tostring(attempt) .. '/' .. tostring(maxAttempts));
log('code:\n' .. code);
local execOk, outputOrErr, errorKind = executor(code);
if execOk then
local output = outputOrErr or '';
log('output:\n' .. renderOutput(output));
log('requesting final reply');
local finalOk, finalResult = api.ask(buildLuaOutputPrompt(userPrompt, output), askOptions());
if not finalOk then
return false, {
error = finalResult,
attempts = attempt,
errorKind = 'ai',
code = code,
output = output,
sessionId = sessionId,
};
end
log('final reply received');
return true, {
reply = finalResult.reply,
output = output,
code = code,
attempts = attempt,
sessionId = sessionId,
};
end
errorKind = errorKind or 'other';
log('error (' .. tostring(errorKind) .. '):\n' .. tostring(outputOrErr));
if (errorKind ~= 'syntax' and errorKind ~= 'identifier') or attempt >= maxAttempts then
return false, {
error = outputOrErr,
attempts = attempt,
errorKind = errorKind,
code = code,
sessionId = sessionId,
retryExhausted = attempt >= maxAttempts,
};
end
log('requesting corrected Lua');
local correctionOk, correctionResult = api.ask(
buildLuaCorrectionPrompt(userPrompt, code, outputOrErr, errorKind),
askOptions()
);
if not correctionOk then
return false, {
error = correctionResult,
attempts = attempt,
errorKind = 'ai',
code = code,
sessionId = sessionId,
};
end
code = correctionResult.reply;
end
return false, { error = 'lua-exec failed unexpectedly', attempts = maxAttempts };
end
function api.ping(options)
return api.ask(PING_PROMPT, options);
end

View File

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

View File

@ -5,7 +5,7 @@
"trapos-boot": "0.2.2",
"trapos-net": "0.2.1",
"trapos-ui": "0.2.2",
"trapos-ai": "0.4.2",
"trapos": "0.6.0"
"trapos-ai": "0.5.0",
"trapos": "0.6.1"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "trapos-ai",
"version": "0.4.2",
"version": "0.5.0",
"description": "TrapOS AI client for opencode serve",
"dependencies": ["trapos-core"],
"files": [

View File

@ -1,6 +1,6 @@
{
"name": "trapos",
"version": "0.6.0",
"version": "0.6.1",
"description": "TrapOS full install meta-package",
"dependencies": ["trapos-boot", "trapos-net", "trapos-ui", "trapos-test", "trapos-ai"],
"files": [],

View File

@ -10,6 +10,8 @@ local function printUsage()
print(' ai ping');
print(' ai new <prompt>');
print(' ai --new <prompt>');
print(' ai lua-exec <prompt>');
print(' ai --lua-exec <prompt>');
print(' ai sessions');
print(' ai --sessions');
print(' ai --version');
@ -56,6 +58,43 @@ local function askAndPrint(ai, prompt)
print(result.reply);
end
local function printLuaExecLog(message)
local text = tostring(message or '');
if text == '' then
print('[lua-exec]');
return;
end
local start = 1;
while start <= #text do
local newline = string.find(text, '\n', start, true);
if not newline then
print('[lua-exec] ' .. string.sub(text, start));
return;
end
print('[lua-exec] ' .. string.sub(text, start, newline - 1));
start = newline + 1;
end
end
local function luaExec(ai, prompt)
local ok, result = ai.luaExec(prompt, {
executor = ai.createLuaExecutor({ live = true }),
log = printLuaExecLog,
});
if not ok then
printLuaExecLog('failed after ' .. tostring(result.attempts or 0) .. ' attempt(s)');
if result.errorKind then
printLuaExecLog('error kind: ' .. tostring(result.errorKind));
end
printLuaExecLog(tostring(result.error));
return;
end
printLuaExecLog('final reply:');
print(result.reply);
end
local command = args[1];
if command == '--version' or command == '-version' or command == 'version' then
@ -101,6 +140,16 @@ if command == 'new' or command == '--new' then
return;
end
if command == 'lua-exec' or command == '--lua-exec' then
local prompt = joinArgs(2);
if prompt == '' then
printUsage();
return;
end
luaExec(ai, prompt);
return;
end
if string.sub(command, 1, 1) == '-' then
printUsage();
return;

View File

@ -5,13 +5,18 @@ local testlib = createLibTest({ ... });
local function fakeSettings(initial)
local values = initial or {};
local getCounts = {};
local saveCount = 0;
return {
get = function(key) return values[key]; end,
get = function(key)
getCounts[key] = (getCounts[key] or 0) + 1;
return values[key];
end,
set = function(key, value) values[key] = value; end,
unset = function(key) values[key] = nil; end,
save = function() saveCount = saveCount + 1; end,
values = values,
getCount = function(key) return getCounts[key] or 0; end,
saveCount = function() return saveCount; end,
};
end
@ -72,6 +77,11 @@ local function messageResp(reply)
}));
end
local function postedText(call)
local body = textutils.unserializeJSON(call.body);
return body.parts[1].text;
end
-- base64 --
testlib.test('base64encode encodes simple ascii', function()
@ -395,6 +405,48 @@ testlib.test('ask on HTTP error 404 clears session_id', function()
testlib.assertEquals(settingsStub.values['opencc.session_id'], nil);
end);
testlib.test('ask persist=false creates a session without saving session_id', function()
local httpStub = fakeHttp(
{ sessionResp('ses_ephemeral'), messageResp('reply') },
{}
);
local settingsStub = fakeSettings({
['opencc.server_url'] = 'http://host',
['opencc.session_id'] = 'ses_persisted',
});
local ai = createAi({ http = httpStub, settings = settingsStub });
local ok, result = ai.ask('hello', { persist = false });
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.sessionId, 'ses_ephemeral');
testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_persisted');
testlib.assertEquals(settingsStub.getCount('opencc.session_id'), 0);
testlib.assertEquals(settingsStub.saveCount(), 0);
testlib.assertEquals(#httpStub.postCalls, 2);
testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session', 1, true) ~= nil);
end);
testlib.test('ask persist=false does not clear session_id on 404', function()
local httpStub = fakeHttp(
{ response(404, '{}') },
{}
);
local settingsStub = fakeSettings({
['opencc.server_url'] = 'http://host',
['opencc.session_id'] = 'ses_persisted',
});
local ai = createAi({ http = httpStub, settings = settingsStub });
local ok, err = ai.ask('hello', { persist = false, sessionId = 'ses_missing' });
testlib.assertTrue(not ok);
testlib.assertTrue(string.find(err, 'session introuvable', 1, true) ~= nil);
testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_persisted');
testlib.assertEquals(settingsStub.getCount('opencc.session_id'), 0);
testlib.assertEquals(settingsStub.saveCount(), 0);
end);
testlib.test('ask omits Authorization header when no password', function()
local httpStub = fakeHttp(
{ sessionResp('ses_1'), messageResp('reply') },
@ -408,6 +460,201 @@ testlib.test('ask omits Authorization header when no password', function()
testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil);
end);
-- lua executor --
testlib.test('lua executor captures print write and returned values', function()
local ai = createAi({ http = fakeHttp({}, {}), settings = fakeSettings() });
local executor = ai.createLuaExecutor({ live = false, luaTimeoutSeconds = false });
local ok, output, kind = executor('print("hello", "world")\nwrite("x")\nreturn 7, "z"');
testlib.assertTrue(ok, tostring(output));
testlib.assertEquals(kind, nil);
testlib.assertEquals(output, 'hello\tworld\nx\n7\tz\n');
end);
testlib.test('lua executor classifies syntax and identifier errors', function()
local ai = createAi({ http = fakeHttp({}, {}), settings = fakeSettings() });
local executor = ai.createLuaExecutor({ live = false, luaTimeoutSeconds = false });
local syntaxOk, _, syntaxKind = executor('if true then');
local identifierOk, identifierErr, identifierKind = executor('definitely_missing()');
testlib.assertTrue(not syntaxOk);
testlib.assertEquals(syntaxKind, 'syntax');
testlib.assertTrue(not identifierOk);
testlib.assertEquals(identifierKind, 'identifier');
testlib.assertTrue(string.find(identifierErr, 'nil', 1, true) ~= nil);
end);
testlib.test('lua executor classifies non-identifier runtime errors as other', function()
local ai = createAi({ http = fakeHttp({}, {}), settings = fakeSettings() });
local executor = ai.createLuaExecutor({ live = false, luaTimeoutSeconds = false });
local ok, err, kind = executor('error("boom")');
testlib.assertTrue(not ok);
testlib.assertEquals(kind, 'other');
testlib.assertTrue(string.find(err, 'boom', 1, true) ~= nil);
end);
-- luaExec --
testlib.test('luaExec succeeds on first attempt and sends output for final reply', function()
local httpStub = fakeHttp(
{ sessionResp('ses_lua'), messageResp('print("hello")'), messageResp('final reply') },
{}
);
local settingsStub = fakeSettings({
['opencc.server_url'] = 'http://host',
['opencc.session_id'] = 'ses_persisted',
});
local ai = createAi({ http = httpStub, settings = settingsStub });
local execCalls = {};
local ok, result = ai.luaExec('say hello', {
executor = function(code)
execCalls[#execCalls + 1] = code;
return true, 'hello\n', nil;
end,
});
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'final reply');
testlib.assertEquals(result.output, 'hello\n');
testlib.assertEquals(result.attempts, 1);
testlib.assertEquals(#execCalls, 1);
testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_persisted');
testlib.assertEquals(settingsStub.getCount('opencc.session_id'), 0);
testlib.assertEquals(settingsStub.saveCount(), 0);
testlib.assertEquals(#httpStub.postCalls, 3);
local sessionBody = textutils.unserializeJSON(httpStub.postCalls[1].body);
testlib.assertEquals(sessionBody.title, 'cc-ai lua-exec');
testlib.assertTrue(string.find(postedText(httpStub.postCalls[2]), 'raw Lua code only', 1, true) ~= nil);
testlib.assertTrue(string.find(postedText(httpStub.postCalls[2]), 'say hello', 1, true) ~= nil);
testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'Lua output:', 1, true) ~= nil);
testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'natural language', 1, true) ~= nil);
testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'hello', 1, true) ~= nil);
end);
testlib.test('luaExec retries on identifier error and succeeds', function()
local httpStub = fakeHttp(
{
sessionResp('ses_lua'),
messageResp('fo0()'),
messageResp('print("fixed")'),
messageResp('fixed reply'),
},
{}
);
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
local ai = createAi({ http = httpStub, settings = settingsStub });
local execCalls = 0;
local ok, result = ai.luaExec('fix name', {
executor = function()
execCalls = execCalls + 1;
if execCalls == 1 then
return false, 'attempt to call a nil value (global "fo0")', 'identifier';
end
return true, 'fixed\n', nil;
end,
});
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'fixed reply');
testlib.assertEquals(result.attempts, 2);
testlib.assertEquals(execCalls, 2);
testlib.assertEquals(#httpStub.postCalls, 4);
testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'Error kind: identifier', 1, true) ~= nil);
testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'fo0()', 1, true) ~= nil);
end);
testlib.test('luaExec retries on syntax error and succeeds', function()
local httpStub = fakeHttp(
{
sessionResp('ses_lua'),
messageResp('if true then'),
messageResp('print("ok")'),
messageResp('ok reply'),
},
{}
);
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
local ai = createAi({ http = httpStub, settings = settingsStub });
local execCalls = 0;
local ok, result = ai.luaExec('write code', {
executor = function()
execCalls = execCalls + 1;
if execCalls == 1 then
return false, "'end' expected", 'syntax';
end
return true, 'ok\n', nil;
end,
maxRetries = 1,
});
testlib.assertTrue(ok, tostring(result));
testlib.assertEquals(result.reply, 'ok reply');
testlib.assertEquals(result.attempts, 2);
testlib.assertEquals(execCalls, 2);
testlib.assertEquals(#httpStub.postCalls, 4);
testlib.assertTrue(string.find(postedText(httpStub.postCalls[3]), 'Error kind: syntax', 1, true) ~= nil);
end);
testlib.test('luaExec aborts on non-identifier runtime error', function()
local httpStub = fakeHttp(
{ sessionResp('ses_lua'), messageResp('peripheral.wrap("left").call()') },
{}
);
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
local ai = createAi({ http = httpStub, settings = settingsStub });
local ok, result = ai.luaExec('use peripheral', {
executor = function()
return false, 'peripheral not present', 'other';
end,
});
testlib.assertTrue(not ok);
testlib.assertEquals(result.error, 'peripheral not present');
testlib.assertEquals(result.errorKind, 'other');
testlib.assertEquals(result.attempts, 1);
testlib.assertEquals(#httpStub.postCalls, 2);
end);
testlib.test('luaExec exhausts retry budget', function()
local httpStub = fakeHttp(
{
sessionResp('ses_lua'),
messageResp('bad1()'),
messageResp('bad2()'),
messageResp('bad3()'),
},
{}
);
local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' });
local ai = createAi({ http = httpStub, settings = settingsStub });
local execCalls = 0;
local ok, result = ai.luaExec('keep failing', {
executor = function()
execCalls = execCalls + 1;
return false, 'attempt to call a nil value (global "bad")', 'identifier';
end,
maxRetries = 2,
});
testlib.assertTrue(not ok);
testlib.assertEquals(result.errorKind, 'identifier');
testlib.assertEquals(result.attempts, 3);
testlib.assertTrue(result.retryExhausted);
testlib.assertEquals(execCalls, 3);
testlib.assertEquals(#httpStub.postCalls, 4);
end);
-- ping --
testlib.test('ping sends pong prompt', function()