feat(ai): support opencode agent selection

This commit is contained in:
Guillaume ARM 2026-06-11 05:10:50 +02:00
parent 835f6f012d
commit f44aebf5d5
11 changed files with 163 additions and 15 deletions

View File

@ -2,8 +2,10 @@
description: Answers in-game ComputerCraft and TrapOS user questions with concise, actionable replies; use for programs/ai.lua requests. description: Answers in-game ComputerCraft and TrapOS user questions with concise, actionable replies; use for programs/ai.lua requests.
mode: primary mode: primary
permission: permission:
edit: allow "*": deny
bash: allow 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`. 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. 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. If the user asks for repo internals, answer only what is needed to unblock them.

View File

@ -7,6 +7,7 @@ local DEFAULT_POLL_INTERVAL_SECONDS = 2;
local DEFAULT_LUA_EXEC_MAX_RETRIES = 2; local DEFAULT_LUA_EXEC_MAX_RETRIES = 2;
local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5; local DEFAULT_LUA_EXEC_TIMEOUT_SECONDS = 5;
local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id'; local DEFAULT_SESSION_SETTING_KEY = 'opencc.session_id';
local DEFAULT_AGENT_SETTING_KEY = 'opencc.agent';
local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
@ -211,6 +212,13 @@ local function createAi(opts)
return providerId, modelId; return providerId, modelId;
end 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 function resolveConfig(options)
local url = options.serverUrl or settingsLib.get('opencc.server_url'); local url = options.serverUrl or settingsLib.get('opencc.server_url');
if not url or url == '' then if not url or url == '' then
@ -225,6 +233,7 @@ local function createAi(opts)
password = password, password = password,
providerID = providerId, providerID = providerId,
modelID = modelId, modelID = modelId,
agent = resolveAgent(options),
timeoutSeconds = resolveTimeout(options), timeoutSeconds = resolveTimeout(options),
pollTimeoutSeconds = resolvePollTimeout(options), pollTimeoutSeconds = resolvePollTimeout(options),
pollIntervalSeconds = resolvePollInterval(options), pollIntervalSeconds = resolvePollInterval(options),
@ -239,6 +248,9 @@ local function createAi(opts)
if cfg.providerID and cfg.modelID then if cfg.providerID and cfg.modelID then
body.model = { providerID = cfg.providerID, modelID = cfg.modelID }; body.model = { providerID = cfg.providerID, modelID = cfg.modelID };
end end
if cfg.agent then
body.agent = cfg.agent;
end
return body; return body;
end end
@ -249,6 +261,9 @@ local function createAi(opts)
if cfg.providerID and cfg.modelID then if cfg.providerID and cfg.modelID then
body.model = { providerID = cfg.providerID, modelID = cfg.modelID }; body.model = { providerID = cfg.providerID, modelID = cfg.modelID };
end end
if cfg.agent then
body.agent = cfg.agent;
end
return body; return body;
end end
@ -609,6 +624,7 @@ local function createAi(opts)
password = options.password, password = options.password,
providerID = options.providerID, providerID = options.providerID,
modelID = options.modelID, modelID = options.modelID,
agent = options.agent,
timeoutSeconds = options.timeoutSeconds, timeoutSeconds = options.timeoutSeconds,
}; };
end end

View File

@ -49,6 +49,12 @@ set opencc.provider_id anthropic
set opencc.model_id claude-opus-4-7 set opencc.model_id claude-opus-4-7
``` ```
Optional agent setting for the in-game ComputerCraft assistant:
```sh
set opencc.agent computercraft
```
Test it: Test it:
```sh ```sh

View File

@ -85,11 +85,12 @@ Send a message and wait for the AI reply (blocking). Returns when the assistant
"parts": [ "parts": [
{ "type": "text", "text": "your prompt here" } { "type": "text", "text": "your prompt here" }
], ],
"agent": "computercraft",
"model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" } "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`: **Response** `200`:
```json ```json
@ -149,13 +150,14 @@ Fire-and-forget variant. Returns `204` immediately. Include `messageID` in the r
"parts": [ "parts": [
{ "type": "text", "text": "your prompt here" } { "type": "text", "text": "your prompt here" }
], ],
"agent": "computercraft",
"model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" } "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`. 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 <name>` is set, `ai` includes it as `agent` in the request body.
--- ---

View File

@ -104,6 +104,12 @@ Optional — override the Basic Auth username (default `opencode`):
set opencc.username myuser 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: 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 ```sh
@ -132,6 +138,7 @@ ai ping -- ping, reuses existing session
ai "explain what a turtle is" ai "explain what a turtle is"
ai new "start a fresh topic" -- forget current session, start fresh ai new "start a fresh topic" -- forget current session, start fresh
ai sessions -- list all server sessions with their IDs ai sessions -- list all server sessions with their IDs
ai --agent computercraft "what peripherals are attached?"
ai --verbose ping -- show HTTP/session diagnostics ai --verbose ping -- show HTTP/session diagnostics
ai --help ai --help
ai --version ai --version

View File

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

View File

@ -5,8 +5,8 @@
"trapos-boot": "0.3.2", "trapos-boot": "0.3.2",
"trapos-net": "0.3.0", "trapos-net": "0.3.0",
"trapos-ui": "0.2.2", "trapos-ui": "0.2.2",
"trapos-ai": "0.6.4", "trapos-ai": "0.6.5",
"trapos-sandbox": "0.1.4", "trapos-sandbox": "0.1.4",
"trapos": "0.8.6" "trapos": "0.8.7"
} }
} }

View File

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

View File

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

View File

@ -4,14 +4,25 @@ local createVersion = require('/apis/libversion');
local rawArgs = table.pack(...); local rawArgs = table.pack(...);
local args = { n = 0 }; local args = { n = 0 };
local verbose = false; local verbose = false;
local agentName = nil;
local parseError = nil;
for i = 1, rawArgs.n do local argIndex = 1;
if rawArgs[i] == '--verbose' or rawArgs[i] == '-v' then while argIndex <= rawArgs.n do
if rawArgs[argIndex] == '--verbose' or rawArgs[argIndex] == '-v' then
verbose = true; 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 else
args.n = args.n + 1; args.n = args.n + 1;
args[args.n] = rawArgs[i]; args[args.n] = rawArgs[argIndex];
end end
argIndex = argIndex + 1;
end end
local function printUsage() local function printUsage()
@ -25,6 +36,7 @@ local function printUsage()
print(' ai --lua-exec <prompt> (deprecated)'); print(' ai --lua-exec <prompt> (deprecated)');
print(' ai sessions'); print(' ai sessions');
print(' ai --sessions'); print(' ai --sessions');
print(' ai --agent <name> <command>');
print(' ai --verbose <command>'); print(' ai --verbose <command>');
print(' ai --version'); print(' ai --version');
print(' ai --help'); print(' ai --help');
@ -36,6 +48,7 @@ local function printUsage()
print(' opencc.username (default: opencode)'); print(' opencc.username (default: opencode)');
print(' opencc.password (Basic Auth password)'); print(' opencc.password (Basic Auth password)');
print(' opencc.session_id (auto-managed)'); print(' opencc.session_id (auto-managed)');
print(' opencc.agent (e.g. computercraft)');
print(' opencc.provider_id (e.g. anthropic)'); print(' opencc.provider_id (e.g. anthropic)');
print(' opencc.model_id (e.g. claude-opus-4-7)'); print(' opencc.model_id (e.g. claude-opus-4-7)');
print(' opencc.timeout_seconds (per HTTP call, max 60)'); print(' opencc.timeout_seconds (per HTTP call, max 60)');
@ -48,10 +61,16 @@ local function printAiLog(message)
end end
local function askOptions() local function askOptions()
local options = nil;
if verbose then if verbose then
return { log = printAiLog }; options = options or {};
options.log = printAiLog;
end end
return nil; if agentName then
options = options or {};
options.agent = agentName;
end
return options;
end end
local function joinArgs(start) local function joinArgs(start)
@ -135,6 +154,12 @@ if command == '--help' or command == '-help' or command == 'help' then
return; return;
end end
if parseError then
print(parseError);
printUsage();
return;
end
if args.n == 0 then if args.n == 0 then
printUsage(); printUsage();
return; return;

View File

@ -394,6 +394,60 @@ testlib.test('ask sends exact prompt text', function()
testlib.assertEquals(body.parts[1].text, 'my prompt'); testlib.assertEquals(body.parts[1].text, 'my prompt');
end); 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() testlib.test('ask includes model when provider_id and model_id are set', function()
local httpStub = fakeHttp( local httpStub = fakeHttp(
{ messageResp('reply') }, { messageResp('reply') },
@ -507,6 +561,32 @@ testlib.test('ask generates opencode-compatible message ids', function()
testlib.assertTrue(string.find(body.messageID, '^msg_') ~= nil); testlib.assertTrue(string.find(body.messageID, '^msg_') ~= nil);
end); 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() testlib.test('ask polls async message until completion', function()
local httpStub = fakeHttp( local httpStub = fakeHttp(
{ sessionResp('ses_1'), asyncResp() }, { sessionResp('ses_1'), asyncResp() },