Skip to content

Commit 601813d

Browse files
committed
feat(server): /tool endpoint for direct tool execution by plugins and scripts
Executes any registered OpenCode tool by name without LLM involvement. Results are streamed as plain text. When a messageID is provided, creates an external ToolPart so the execution is visible in the TUI. Introduces the emitter() and resolveModel() helpers shared by all plugin HTTP endpoints.
1 parent c3e3006 commit 601813d

3 files changed

Lines changed: 607 additions & 1 deletion

File tree

packages/opencode/src/server/routes/session.ts

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,39 @@ import { errors } from "../error"
2323
import { lazy } from "../../util/lazy"
2424
import { Bus } from "../../bus"
2525
import { NamedError } from "@opencode-ai/util/error"
26+
import { ToolRegistry } from "../../tool/registry"
2627

2728
const log = Log.create({ service: "server" })
2829

30+
/** Walk parent session messages backwards to find the model used in the last user message. */
31+
async function resolveModel(sessionID: SessionID) {
32+
const msgs = await Session.messages({ sessionID })
33+
for (let i = msgs.length - 1; i >= 0; i--) {
34+
const info = msgs[i].info
35+
if (info.role === "user" && info.model) return info.model
36+
}
37+
}
38+
39+
/** Create an emitter for external ToolPart updates (no-op when messageID is absent). */
40+
function emitter(opts: { sessionID: SessionID; messageID?: string; tool: string }) {
41+
const mid = opts.messageID ? MessageID.make(opts.messageID) : undefined
42+
const pid = mid ? PartID.ascending() : undefined
43+
const fn = (state: z.infer<typeof MessageV2.ToolState>) =>
44+
mid && pid
45+
? Session.updatePart({
46+
id: pid,
47+
messageID: mid,
48+
sessionID: opts.sessionID,
49+
type: "tool" as const,
50+
tool: opts.tool,
51+
callID: pid,
52+
external: true,
53+
state,
54+
})
55+
: undefined
56+
return { mid, pid, fn }
57+
}
58+
2959
export const SessionRoutes = lazy(() =>
3060
new Hono()
3161
.get(
@@ -1040,5 +1070,335 @@ export const SessionRoutes = lazy(() =>
10401070
})
10411071
return c.json(true)
10421072
},
1073+
)
1074+
// Direct tool execution — no LLM, deterministic. Enables plugins, tests,
1075+
// and scripts to execute tools outside the LLM event loop.
1076+
.post(
1077+
"/:sessionID/tool",
1078+
describeRoute({
1079+
summary: "Execute tool directly",
1080+
description:
1081+
"Execute an OpenCode tool without LLM involvement. Results are streamed as plain text. Optionally creates an external ToolPart for TUI visibility.",
1082+
operationId: "session.tool",
1083+
responses: {
1084+
200: {
1085+
description: "Tool output as plain text",
1086+
content: { "text/plain": { schema: resolver(z.string()) } },
1087+
},
1088+
...errors(400, 404),
1089+
},
1090+
}),
1091+
validator("param", z.object({ sessionID: SessionID.zod })),
1092+
validator(
1093+
"json",
1094+
z.object({
1095+
name: z.string().describe("Tool name (e.g. read, edit, grep, glob)"),
1096+
args: z.record(z.string(), z.unknown()).describe("Tool arguments"),
1097+
agent: z.string().optional().describe("Agent context for permissions"),
1098+
messageID: z.string().optional().describe("Parent message ID — creates external ToolPart when present"),
1099+
}),
1100+
),
1101+
async (c) => {
1102+
const param = c.req.valid("param")
1103+
const body = c.req.valid("json")
1104+
const session = await Session.get(param.sessionID)
1105+
const agent = body.agent ?? (await Agent.defaultAgent())
1106+
const ag = await Agent.get(agent)
1107+
1108+
const tools = await ToolRegistry.tools({
1109+
providerID: ProviderID.make(""),
1110+
modelID: ModelID.make(""),
1111+
agent: ag,
1112+
})
1113+
const tool = tools.find((t) => t.id === body.name)
1114+
if (!tool) return c.text(`Tool not found: ${body.name}`, 404)
1115+
1116+
const t0 = Date.now()
1117+
const emit = emitter({ sessionID: param.sessionID, messageID: body.messageID, tool: body.name })
1118+
1119+
await emit.fn({ status: "running", input: body.args, time: { start: t0 } })
1120+
1121+
const ctx = {
1122+
sessionID: param.sessionID,
1123+
messageID: emit.mid ?? MessageID.ascending(),
1124+
callID: emit.pid ?? PartID.ascending(),
1125+
agent,
1126+
abort: c.req.raw.signal,
1127+
messages: [] as MessageV2.WithParts[],
1128+
metadata(val: { title?: string; metadata?: Record<string, unknown> }) {
1129+
emit
1130+
.fn({
1131+
status: "running",
1132+
input: body.args,
1133+
title: val.title,
1134+
metadata: val.metadata,
1135+
time: { start: t0 },
1136+
})
1137+
?.catch(() => {})
1138+
},
1139+
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
1140+
await Permission.ask({
1141+
...req,
1142+
sessionID: param.sessionID,
1143+
tool: emit.mid ? { messageID: emit.mid, callID: emit.pid ?? ctx.callID } : undefined,
1144+
ruleset: Permission.merge(ag.permission ?? [], session.permission ?? []),
1145+
})
1146+
},
1147+
}
1148+
1149+
c.status(200)
1150+
c.header("Content-Type", "text/plain")
1151+
return stream(c, async (stream) => {
1152+
try {
1153+
const result = await tool.execute(body.args, ctx)
1154+
await emit.fn({
1155+
status: "completed",
1156+
input: body.args,
1157+
output: result.output,
1158+
title: result.title ?? "",
1159+
metadata: result.metadata ?? {},
1160+
time: { start: t0, end: Date.now() },
1161+
})
1162+
const file = typeof body.args.filePath === "string" ? body.args.filePath : undefined
1163+
await stream.write(result.output + (result.attachments?.length && file ? `\n\x00OC_FILE\x00:${file}` : ""))
1164+
} catch (error) {
1165+
const err = error instanceof Error ? error.message : String(error)
1166+
await emit.fn({
1167+
status: "error",
1168+
input: body.args,
1169+
error: err,
1170+
time: { start: t0, end: Date.now() },
1171+
})
1172+
await stream.write(`Error: ${err}`)
1173+
}
1174+
})
1175+
},
1176+
)
1177+
// Display-only status message — creates an external ToolPart for TUI visibility
1178+
.post(
1179+
"/:sessionID/status",
1180+
describeRoute({
1181+
summary: "Post status message",
1182+
description: "Create an external ToolPart for display in the TUI. Not sent to the LLM.",
1183+
operationId: "session.status.post",
1184+
responses: {
1185+
200: { description: "Status accepted", content: { "text/plain": { schema: resolver(z.string()) } } },
1186+
...errors(400, 404),
1187+
},
1188+
}),
1189+
validator("param", z.object({ sessionID: SessionID.zod })),
1190+
validator("json", z.object({ message: z.string(), messageID: z.string().optional() })),
1191+
async (c) => {
1192+
const sessionID = c.req.valid("param").sessionID
1193+
const body = c.req.valid("json")
1194+
await Session.get(sessionID)
1195+
const emit = emitter({ sessionID, messageID: body.messageID, tool: "status" })
1196+
if (emit.mid && body.message) {
1197+
await emit.fn({
1198+
status: "completed",
1199+
input: { message: body.message },
1200+
output: body.message,
1201+
title: "",
1202+
metadata: {},
1203+
time: { start: Date.now(), end: Date.now() },
1204+
})
1205+
}
1206+
return c.text("ok")
1207+
},
1208+
)
1209+
// AI judgment via child session — scripts can delegate decisions to the LLM.
1210+
// Each callback gets a fresh context (no token accumulation).
1211+
.post(
1212+
"/:sessionID/exec",
1213+
describeRoute({
1214+
summary: "Execute AI prompt",
1215+
description:
1216+
"Create a child session, send a prompt, and stream the AI response as plain text. Designed for script callbacks that need AI judgment at decision points.",
1217+
operationId: "session.exec",
1218+
responses: {
1219+
200: {
1220+
description: "AI response as plain text",
1221+
content: { "text/plain": { schema: resolver(z.string()) } },
1222+
},
1223+
...errors(400, 404),
1224+
},
1225+
}),
1226+
validator("param", z.object({ sessionID: SessionID.zod })),
1227+
validator(
1228+
"json",
1229+
z.object({
1230+
prompt: z.string().describe("The prompt text to send to the AI"),
1231+
system: z.string().optional().describe("Custom system prompt for specialist creation"),
1232+
agent: z.string().optional().describe("Agent type"),
1233+
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }).optional().describe("Model override"),
1234+
files: z
1235+
.array(z.object({ filename: z.string(), mime: z.string(), url: z.string() }))
1236+
.optional()
1237+
.describe("File attachments (PDFs, images) for multimodal prompts"),
1238+
format: z
1239+
.object({ type: z.literal("json_schema"), schema: z.record(z.string(), z.unknown()) })
1240+
.optional()
1241+
.describe("Force structured output via json_schema"),
1242+
messageID: z.string().optional().describe("Parent message ID — creates external ToolPart when present"),
1243+
}),
1244+
),
1245+
async (c) => {
1246+
const parent = c.req.valid("param").sessionID
1247+
const body = c.req.valid("json")
1248+
await Session.get(parent)
1249+
1250+
// Inherit model from parent session if not explicitly provided
1251+
const msgs = body.model ? [] : await Session.messages({ sessionID: parent })
1252+
const model =
1253+
body.model ??
1254+
msgs.findLast((m): m is typeof m & { info: MessageV2.User } => m.info.role === "user")?.info.model
1255+
1256+
const title = body.system ? `oc prompt -s "${body.system}"` : "oc prompt"
1257+
const child = await Session.create({ parentID: parent, title })
1258+
const cleanup = () => SessionPrompt.cancel(child.id)
1259+
// Register listener before checking — avoids race where abort fires
1260+
// between the check and addEventListener.
1261+
c.req.raw.signal.addEventListener("abort", cleanup)
1262+
if (c.req.raw.signal.aborted) {
1263+
c.req.raw.signal.removeEventListener("abort", cleanup)
1264+
cleanup()
1265+
return c.text("aborted", 503)
1266+
}
1267+
1268+
// Create external ToolPart for subagent visibility (opt-in via messageID)
1269+
const t0 = Date.now()
1270+
const preview = body.prompt.substring(0, 80) + (body.prompt.length > 80 ? "..." : "")
1271+
const emit = emitter({ sessionID: parent, messageID: body.messageID, tool: "task" })
1272+
1273+
await emit.fn({
1274+
status: "running",
1275+
input: { prompt: preview, description: preview, subagent_type: "oc" },
1276+
title,
1277+
metadata: { sessionId: child.id, model },
1278+
time: { start: t0 },
1279+
})
1280+
1281+
c.status(200)
1282+
c.header("Content-Type", "text/plain")
1283+
return stream(c, async (stream) => {
1284+
let text = ""
1285+
const unsub =
1286+
emit.mid && emit.pid
1287+
? Bus.subscribe(MessageV2.Event.PartDelta, (event) => {
1288+
if (event.properties.sessionID === child.id && event.properties.field === "text") {
1289+
text += event.properties.delta
1290+
if (text.length > 10_000) text = text.slice(-10_000)
1291+
// fire-and-forget — emitter already logs on rejection
1292+
void emit.fn({
1293+
status: "running",
1294+
input: { prompt: preview },
1295+
title,
1296+
metadata: { sessionId: child.id, model, output: text.substring(0, 2000) },
1297+
time: { start: t0 },
1298+
})
1299+
}
1300+
})
1301+
: undefined
1302+
1303+
// Keepalive markers prevent HTTP idle timeout for long-running callbacks
1304+
const timer = setInterval(() => stream.write("\x00OC_KEEPALIVE\x00").catch(() => {}), 15_000)
1305+
1306+
try {
1307+
const msg = await SessionPrompt.prompt({
1308+
sessionID: child.id,
1309+
parts: [
1310+
{ type: "text", text: body.prompt },
1311+
...(body.files ?? []).map((f) => ({
1312+
type: "file" as const,
1313+
mime: f.mime,
1314+
url: f.url,
1315+
filename: f.filename,
1316+
})),
1317+
],
1318+
system: body.system,
1319+
agent: body.agent,
1320+
model,
1321+
format: body.format ? { ...body.format, retryCount: 3 } : undefined,
1322+
})
1323+
const out =
1324+
body.format && msg.info.role === "assistant" && msg.info.structured !== undefined
1325+
? JSON.stringify(msg.info.structured)
1326+
: (msg.parts.findLast((p): p is typeof p & { type: "text"; text: string } => p.type === "text")?.text ??
1327+
"")
1328+
1329+
await emit.fn({
1330+
status: "completed",
1331+
input: { prompt: preview },
1332+
output: out.substring(0, 2000),
1333+
title,
1334+
metadata: { sessionId: child.id, model },
1335+
time: { start: t0, end: Date.now() },
1336+
})
1337+
await stream.write(out)
1338+
} catch (error) {
1339+
const err = error instanceof Error ? error.message : String(error)
1340+
await emit.fn({
1341+
status: "error",
1342+
input: { prompt: preview },
1343+
error: err,
1344+
time: { start: t0, end: Date.now() },
1345+
})
1346+
await stream.write(`Error: ${err}`)
1347+
} finally {
1348+
clearInterval(timer)
1349+
unsub?.()
1350+
c.req.raw.signal.removeEventListener("abort", cleanup)
1351+
}
1352+
})
1353+
},
1354+
)
1355+
// Todo CRUD — create and bulk update
1356+
.post(
1357+
"/:sessionID/todo",
1358+
describeRoute({
1359+
summary: "Create session todo",
1360+
operationId: "session.todo.create",
1361+
responses: {
1362+
200: {
1363+
description: "Updated todo list",
1364+
content: { "application/json": { schema: resolver(Todo.Info.array()) } },
1365+
},
1366+
...errors(400, 404),
1367+
},
1368+
}),
1369+
validator("param", z.object({ sessionID: SessionID.zod })),
1370+
validator("json", Todo.Info),
1371+
async (c) => {
1372+
const sessionID = c.req.valid("param").sessionID
1373+
await Session.get(sessionID)
1374+
const todo = c.req.valid("json")
1375+
const existing = await Todo.get(sessionID)
1376+
const todos = [...existing, todo]
1377+
await Todo.update({ sessionID, todos })
1378+
return c.json(todos)
1379+
},
1380+
)
1381+
.put(
1382+
"/:sessionID/todo",
1383+
describeRoute({
1384+
summary: "Update session todos",
1385+
operationId: "session.todo.update",
1386+
responses: {
1387+
200: {
1388+
description: "Updated todo list",
1389+
content: { "application/json": { schema: resolver(Todo.Info.array()) } },
1390+
},
1391+
...errors(400, 404),
1392+
},
1393+
}),
1394+
validator("param", z.object({ sessionID: SessionID.zod })),
1395+
validator("json", z.object({ todos: Todo.Info.array() })),
1396+
async (c) => {
1397+
const sessionID = c.req.valid("param").sessionID
1398+
await Session.get(sessionID)
1399+
const body = c.req.valid("json")
1400+
await Todo.update({ sessionID, todos: body.todos })
1401+
return c.json(body.todos)
1402+
},
10431403
),
10441404
)

0 commit comments

Comments
 (0)