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.
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.

View File

@ -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

View File

@ -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

View File

@ -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 <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
```
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

View File

@ -1,6 +1,6 @@
{
"name": "TrapOS",
"version": "0.8.6",
"version": "0.8.7",
"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.4",
"trapos-ai": "0.6.5",
"trapos-sandbox": "0.1.4",
"trapos": "0.8.6"
"trapos": "0.8.7"
}
}

View File

@ -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": [

View File

@ -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": [],

View File

@ -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 <prompt> (deprecated)');
print(' ai sessions');
print(' ai --sessions');
print(' ai --agent <name> <command>');
print(' ai --verbose <command>');
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;

View File

@ -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() },