From f44aebf5d5c9868f369edf0501b2a37029cb53fa Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Thu, 11 Jun 2026 05:10:50 +0200 Subject: [PATCH] feat(ai): support opencode agent selection --- .opencode/agent/computercraft.md | 18 +++++-- apis/libai.lua | 16 ++++++ docs/ingame-trapos-ai-mcp-guide.md | 6 +++ docs/opencode_api.md | 6 ++- docs/opencode_server_guide.md | 7 +++ manifest.json | 2 +- packages/index.json | 4 +- packages/trapos-ai/ccpm.json | 2 +- packages/trapos/ccpm.json | 2 +- programs/ai.lua | 35 +++++++++++-- tests/ai.lua | 80 ++++++++++++++++++++++++++++++ 11 files changed, 163 insertions(+), 15 deletions(-) diff --git a/.opencode/agent/computercraft.md b/.opencode/agent/computercraft.md index 9760750..5e93eb9 100644 --- a/.opencode/agent/computercraft.md +++ b/.opencode/agent/computercraft.md @@ -2,8 +2,10 @@ description: Answers in-game ComputerCraft and TrapOS user questions with concise, actionable replies; use for programs/ai.lua requests. mode: primary permission: - edit: allow - bash: allow + "*": deny + websearch: allow + computercraft-mcp-bridge_probe-computers: allow + computercraft-mcp-bridge_exec-lua: allow --- You answer ComputerCraft / CC:Tweaked users from inside Minecraft through TrapOS `ai`. @@ -18,7 +20,17 @@ When giving commands, make them directly runnable in CraftOS when possible. When giving code, keep it minimal and runnable. Avoid markdown fences unless the user clearly asks for a code block. -You may use repository tools, bash, and edits when asked to test or change TrapOS code. Keep tool-driven investigation focused and summarize only the result. +You may use web search for current external facts and documentation. Keep searches focused and summarize only what unblocks the user. + +You may use the ComputerCraft MCP bridge only through `probe-computers` and `exec-lua`. + +Use `probe-computers` before `exec-lua` unless the target computer id is already clear from the conversation. + +Treat `exec-lua` as privileged in-game execution. Prefer read-only inspection. Do not delete files, reboot or shut down computers, move turtles, change inventories, transmit network traffic, mutate peripherals, or run long loops unless the user explicitly asks for that specific effect. + +Keep `exec-lua` snippets small and bounded. Use short timeouts. Avoid blocking pulls, sleeps, infinite loops, and assumptions that a timeout stops code already running in ComputerCraft. + +`print()` and `write()` output is captured in MCP results. To intentionally write to the visible ComputerCraft screen, use terminal APIs such as `term.clear()`, `term.setCursorPos()`, and `term.write()`. If the user asks for repo internals, answer only what is needed to unblock them. diff --git a/apis/libai.lua b/apis/libai.lua index d7464c5..1fb4715 100644 --- a/apis/libai.lua +++ b/apis/libai.lua @@ -7,6 +7,7 @@ local DEFAULT_POLL_INTERVAL_SECONDS = 2; local DEFAULT_LUA_EXEC_MAX_RETRIES = 2; 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+/'; @@ -211,6 +212,13 @@ local function createAi(opts) return providerId, modelId; end + local function resolveAgent(options) + local agent = options.agent; + if agent == nil then agent = settingsLib.get(DEFAULT_AGENT_SETTING_KEY); end + if isBlank(agent) then return nil; end + return agent; + end + local function resolveConfig(options) local url = options.serverUrl or settingsLib.get('opencc.server_url'); if not url or url == '' then @@ -225,6 +233,7 @@ local function createAi(opts) password = password, providerID = providerId, modelID = modelId, + agent = resolveAgent(options), timeoutSeconds = resolveTimeout(options), pollTimeoutSeconds = resolvePollTimeout(options), pollIntervalSeconds = resolvePollInterval(options), @@ -239,6 +248,9 @@ local function createAi(opts) if cfg.providerID and cfg.modelID then body.model = { providerID = cfg.providerID, modelID = cfg.modelID }; end + if cfg.agent then + body.agent = cfg.agent; + end return body; end @@ -249,6 +261,9 @@ local function createAi(opts) if cfg.providerID and cfg.modelID then body.model = { providerID = cfg.providerID, modelID = cfg.modelID }; end + if cfg.agent then + body.agent = cfg.agent; + end return body; end @@ -609,6 +624,7 @@ local function createAi(opts) password = options.password, providerID = options.providerID, modelID = options.modelID, + agent = options.agent, timeoutSeconds = options.timeoutSeconds, }; end diff --git a/docs/ingame-trapos-ai-mcp-guide.md b/docs/ingame-trapos-ai-mcp-guide.md index 4bd16f8..5378a28 100644 --- a/docs/ingame-trapos-ai-mcp-guide.md +++ b/docs/ingame-trapos-ai-mcp-guide.md @@ -49,6 +49,12 @@ set opencc.provider_id anthropic set opencc.model_id claude-opus-4-7 ``` +Optional agent setting for the in-game ComputerCraft assistant: + +```sh +set opencc.agent computercraft +``` + Test it: ```sh diff --git a/docs/opencode_api.md b/docs/opencode_api.md index 9524b5f..43af22d 100644 --- a/docs/opencode_api.md +++ b/docs/opencode_api.md @@ -85,11 +85,12 @@ Send a message and wait for the AI reply (blocking). Returns when the assistant "parts": [ { "type": "text", "text": "your prompt here" } ], + "agent": "computercraft", "model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" } } ``` -`model` is optional — omit to use the server's configured default. +`agent` and `model` are optional — omit them to use the server's configured defaults. **Response** `200`: ```json @@ -149,13 +150,14 @@ Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the r "parts": [ { "type": "text", "text": "your prompt here" } ], + "agent": "computercraft", "model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" } } ``` 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`. -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. +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 ` is set, `ai` includes it as `agent` in the request body. --- diff --git a/docs/opencode_server_guide.md b/docs/opencode_server_guide.md index 30448cc..a15dd41 100644 --- a/docs/opencode_server_guide.md +++ b/docs/opencode_server_guide.md @@ -104,6 +104,12 @@ Optional — override the Basic Auth username (default `opencode`): set opencc.username myuser ``` +Optional — select an opencode agent for requests from this computer: + +```sh +set opencc.agent computercraft +``` + 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: ```sh @@ -132,6 +138,7 @@ ai ping -- ping, reuses existing session ai "explain what a turtle is" ai new "start a fresh topic" -- forget current session, start fresh ai sessions -- list all server sessions with their IDs +ai --agent computercraft "what peripherals are attached?" ai --verbose ping -- show HTTP/session diagnostics ai --help ai --version diff --git a/manifest.json b/manifest.json index e18ebb0..dda857e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.8.6", + "version": "0.8.7", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index e92ac64..80ab054 100644 --- a/packages/index.json +++ b/packages/index.json @@ -5,8 +5,8 @@ "trapos-boot": "0.3.2", "trapos-net": "0.3.0", "trapos-ui": "0.2.2", - "trapos-ai": "0.6.4", + "trapos-ai": "0.6.5", "trapos-sandbox": "0.1.4", - "trapos": "0.8.6" + "trapos": "0.8.7" } } diff --git a/packages/trapos-ai/ccpm.json b/packages/trapos-ai/ccpm.json index 482d9ac..117432b 100644 --- a/packages/trapos-ai/ccpm.json +++ b/packages/trapos-ai/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-ai", - "version": "0.6.4", + "version": "0.6.5", "description": "TrapOS AI client for opencode serve", "dependencies": ["trapos-core"], "files": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index bd4fd49..5d10f25 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.8.6", + "version": "0.8.7", "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 e65223b..17e8e74 100644 --- a/programs/ai.lua +++ b/programs/ai.lua @@ -4,14 +4,25 @@ local createVersion = require('/apis/libversion'); local rawArgs = table.pack(...); local args = { n = 0 }; local verbose = false; +local agentName = nil; +local parseError = nil; -for i = 1, rawArgs.n do - if rawArgs[i] == '--verbose' or rawArgs[i] == '-v' then +local argIndex = 1; +while argIndex <= rawArgs.n do + if rawArgs[argIndex] == '--verbose' or rawArgs[argIndex] == '-v' then verbose = true; + elseif rawArgs[argIndex] == '--agent' or rawArgs[argIndex] == '-a' then + if argIndex == rawArgs.n then + parseError = 'missing agent name after ' .. rawArgs[argIndex]; + else + argIndex = argIndex + 1; + agentName = rawArgs[argIndex]; + end else args.n = args.n + 1; - args[args.n] = rawArgs[i]; + args[args.n] = rawArgs[argIndex]; end + argIndex = argIndex + 1; end local function printUsage() @@ -25,6 +36,7 @@ local function printUsage() print(' ai --lua-exec (deprecated)'); print(' ai sessions'); print(' ai --sessions'); + print(' ai --agent '); print(' ai --verbose '); print(' ai --version'); print(' ai --help'); @@ -36,6 +48,7 @@ local function printUsage() print(' opencc.username (default: opencode)'); print(' opencc.password (Basic Auth password)'); print(' opencc.session_id (auto-managed)'); + print(' opencc.agent (e.g. computercraft)'); print(' opencc.provider_id (e.g. anthropic)'); print(' opencc.model_id (e.g. claude-opus-4-7)'); print(' opencc.timeout_seconds (per HTTP call, max 60)'); @@ -48,10 +61,16 @@ local function printAiLog(message) end local function askOptions() + local options = nil; if verbose then - return { log = printAiLog }; + options = options or {}; + options.log = printAiLog; end - return nil; + if agentName then + options = options or {}; + options.agent = agentName; + end + return options; end local function joinArgs(start) @@ -135,6 +154,12 @@ if command == '--help' or command == '-help' or command == 'help' then return; end +if parseError then + print(parseError); + printUsage(); + return; +end + if args.n == 0 then printUsage(); return; diff --git a/tests/ai.lua b/tests/ai.lua index 47fd494..907a04d 100644 --- a/tests/ai.lua +++ b/tests/ai.lua @@ -394,6 +394,60 @@ testlib.test('ask sends exact prompt text', function() testlib.assertEquals(body.parts[1].text, 'my prompt'); end); +testlib.test('ask includes agent from settings', function() + local httpStub = fakeHttp( + { messageResp('reply') }, + {} + ); + local settingsStub = fakeSettings({ + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_1', + ['opencc.agent'] = 'computercraft', + }); + local ai = createAi({ http = httpStub, settings = settingsStub }); + + ai.ask('hello'); + + local body = textutils.unserializeJSON(httpStub.postCalls[1].body); + testlib.assertEquals(body.agent, 'computercraft'); +end); + +testlib.test('ask option agent overrides settings', function() + local httpStub = fakeHttp( + { messageResp('reply') }, + {} + ); + local settingsStub = fakeSettings({ + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_1', + ['opencc.agent'] = 'build', + }); + local ai = createAi({ http = httpStub, settings = settingsStub }); + + ai.ask('hello', { agent = 'computercraft' }); + + local body = textutils.unserializeJSON(httpStub.postCalls[1].body); + testlib.assertEquals(body.agent, 'computercraft'); +end); + +testlib.test('ask omits blank agent setting', function() + local httpStub = fakeHttp( + { messageResp('reply') }, + {} + ); + local settingsStub = fakeSettings({ + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_1', + ['opencc.agent'] = ' ', + }); + local ai = createAi({ http = httpStub, settings = settingsStub }); + + ai.ask('hello'); + + local body = textutils.unserializeJSON(httpStub.postCalls[1].body); + testlib.assertEquals(body.agent, nil); +end); + testlib.test('ask includes model when provider_id and model_id are set', function() local httpStub = fakeHttp( { messageResp('reply') }, @@ -507,6 +561,32 @@ testlib.test('ask generates opencode-compatible message ids', function() testlib.assertTrue(string.find(body.messageID, '^msg_') ~= nil); end); +testlib.test('ask includes agent in async prompts', function() + local httpStub = fakeHttp( + { asyncResp() }, + { + messageListResp({ assistantMessage('msg_1', 'reply', true) }), + } + ); + local settingsStub = fakeAsyncSettings({ + ['opencc.session_id'] = 'ses_1', + ['opencc.agent'] = 'computercraft', + }); + local elFactory = fakeEventloopFactory(); + local ai = createAi({ + http = httpStub, + settings = settingsStub, + now = function() return 10; end, + eventloop = elFactory, + }); + + local ok = ai.ask('hello', { messageId = 'msg_1' }); + + testlib.assertTrue(ok); + local body = textutils.unserializeJSON(httpStub.postCalls[1].body); + testlib.assertEquals(body.agent, 'computercraft'); +end); + testlib.test('ask polls async message until completion', function() local httpStub = fakeHttp( { sessionResp('ses_1'), asyncResp() },