From f6d6c9b5af0a90b787d685a42c4ae868b17b0160 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Tue, 9 Jun 2026 08:25:12 +0200 Subject: [PATCH] feat(ai): add lua exec command --- apis/libai.lua | 271 ++++++++++++++++++++++++++++++++++- manifest.json | 2 +- packages/index.json | 4 +- packages/trapos-ai/ccpm.json | 2 +- packages/trapos/ccpm.json | 2 +- programs/ai.lua | 49 +++++++ tests/ai.lua | 249 +++++++++++++++++++++++++++++++- 7 files changed, 567 insertions(+), 12 deletions(-) diff --git a/apis/libai.lua b/apis/libai.lua index a6f51eb..97681a8 100644 --- a/apis/libai.lua +++ b/apis/libai.lua @@ -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 '; 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 ', 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 diff --git a/manifest.json b/manifest.json index 10e8b32..c5951fc 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.6.0", + "version": "0.6.1", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index e307c88..e2d1056 100644 --- a/packages/index.json +++ b/packages/index.json @@ -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" } } diff --git a/packages/trapos-ai/ccpm.json b/packages/trapos-ai/ccpm.json index 3d78e8c..45ade31 100644 --- a/packages/trapos-ai/ccpm.json +++ b/packages/trapos-ai/ccpm.json @@ -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": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 4d8343e..9511aa3 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -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": [], diff --git a/programs/ai.lua b/programs/ai.lua index 9fbf538..db20027 100644 --- a/programs/ai.lua +++ b/programs/ai.lua @@ -10,6 +10,8 @@ local function printUsage() print(' ai ping'); print(' ai new '); print(' ai --new '); + print(' ai lua-exec '); + print(' ai --lua-exec '); 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; diff --git a/tests/ai.lua b/tests/ai.lua index f45ea5a..a983257 100644 --- a/tests/ai.lua +++ b/tests/ai.lua @@ -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()