Skip to content

feat(tools-box): introduce tools modal with builtin tools, mcp, and subagents #1854

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LSP } from "../lsp"
import { Plugin } from "../plugin"
import { Share } from "../share/share"
import { Snapshot } from "../snapshot"
import { MCP } from "../mcp"

export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Promise<T>) {
return App.provide(input, async (app) => {
Expand All @@ -15,6 +16,11 @@ export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Prom
LSP.init()
Snapshot.init()

// Initialize MCP servers early so tools are available immediately and first message isn't delayed by MCP booting up
MCP.clients().catch(() => {
// Ignore errors during startup - MCP servers will be retried when needed
})

return cb(app)
})
}
16 changes: 16 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,20 @@ export namespace MCP {
}
return result
}

export async function getToolInfo(): Promise<Record<string, any>> {
const mcpTools = await tools()
const result: Record<string, any> = {}

for (const [toolName, tool] of Object.entries(mcpTools)) {
result[toolName] = {
name: toolName,
description: tool.description || "",
source: "mcp",
defaultEnabled: true,
}
}

return result
}
}
107 changes: 107 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { callTui, TuiRoute } from "./tui"
import { Permission } from "../permission"
import { lazy } from "../util/lazy"
import { Agent } from "../agent/agent"
import { ToolRegistry } from "../tool/registry"
import { MCP } from "../mcp"
import { Auth } from "../auth"

const ERRORS = {
Expand Down Expand Up @@ -712,6 +714,79 @@ export namespace Server {
return c.json(true)
},
)
.get(
"/session/:id/overrides",
describeRoute({
description: "Get session overrides for all agents (tools and subagents)",
operationId: "session.getAllOverrides",
responses: {
200: {
description: "Overrides for all agents in the session",
content: {
"application/json": {
schema: resolver(
z.record(
z.string(),
z.object({
tools: z.record(z.string(), z.boolean()).default({}),
agents: z.record(z.string(), z.boolean()).default({}),
}),
),
),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
async (c) => {
const { id: sessionID } = c.req.valid("param")
const allOverrides = Session.getAllOverrides(sessionID)
return c.json(allOverrides)
},
)
.put(
"/session/:id/:agent/overrides",
describeRoute({
description: "Set session overrides for a specific agent (tools and subagents)",
operationId: "session.setOverrides",
responses: {
200: {
description: "Overrides updated successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
agent: z.string(),
}),
),
zValidator(
"json",
z.object({
tools: z.record(z.string(), z.boolean()).optional(),
agents: z.record(z.string(), z.boolean()).optional(),
}),
),
async (c) => {
const { id: sessionID, agent } = c.req.valid("param")
const { tools, agents } = c.req.valid("json")
await Session.setOverrides(sessionID, agent, { tools, agents })
return c.json(true)
},
)
.get(
"/config/providers",
describeRoute({
Expand Down Expand Up @@ -964,6 +1039,38 @@ export namespace Server {
return c.json(modes)
},
)
.get(
"/app/tools",
describeRoute({
description: "List all available tools",
operationId: "app.tools",
responses: {
200: {
description: "List of tools",
content: {
"application/json": {
schema: resolver(
z.record(
z.string(),
z.object({
name: z.string(),
description: z.string().optional(),
source: z.enum(["builtin", "mcp"]),
defaultEnabled: z.boolean().optional().default(true),
}),
),
),
},
},
},
},
}),
async (c) => {
const builtin = await ToolRegistry.getToolInfo()
const mcp = await MCP.getToolInfo()
return c.json({ ...builtin, ...mcp })
},
)
.post(
"/tui/append-prompt",
describeRoute({
Expand Down
119 changes: 118 additions & 1 deletion packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,17 @@ export namespace Session {
callback: (input: { info: MessageV2.Assistant; parts: MessageV2.Part[] }) => void
}[]
>()
const subagentOverrides = new Map<string, Record<string, Record<string, boolean>>>()
const toolOverrides = new Map<string, Record<string, Record<string, boolean>>>()

return {
sessions,
messages,
pending,
autoCompacting,
queued,
subagentOverrides,
toolOverrides,
}
},
async (state) => {
Expand All @@ -163,6 +167,69 @@ export namespace Session {
},
)

type OverridesMap = Record<string, Record<string, boolean>>
type Overrides = { tools: Record<string, boolean>; agents: Record<string, boolean> }

async function persistOverrides(sessionID: string) {
const tools = state().toolOverrides.get(sessionID) ?? {}
const agents = state().subagentOverrides.get(sessionID) ?? {}
await Storage.writeJSON("session/overrides/" + sessionID, { tools, agents })
}

export async function setOverrides(sessionID: string, agentName: string, input: Partial<Overrides>) {
if (input.tools) {
const tools: OverridesMap = state().toolOverrides.get(sessionID) ?? {}
tools[agentName] = input.tools
state().toolOverrides.set(sessionID, tools)
}
if (input.agents) {
const agents: OverridesMap = state().subagentOverrides.get(sessionID) ?? {}
agents[agentName] = input.agents
state().subagentOverrides.set(sessionID, agents)
}
await persistOverrides(sessionID)
}

export function getOverrides(sessionID: string, agentName: string): Overrides {
const toolsAll = state().toolOverrides.get(sessionID) ?? {}
const agentsAll = state().subagentOverrides.get(sessionID) ?? {}
return {
tools: toolsAll[agentName] ?? {},
agents: agentsAll[agentName] ?? {},
}
}

export function getAllOverrides(sessionID: string): Record<string, Overrides> {
const toolsAll = state().toolOverrides.get(sessionID) ?? {}
const agentsAll = state().subagentOverrides.get(sessionID) ?? {}

// Get all unique agent names from both tools and agents overrides
const allAgentNames = new Set([...Object.keys(toolsAll), ...Object.keys(agentsAll)])

const result: Record<string, Overrides> = {}
for (const agentName of allAgentNames) {
result[agentName] = {
tools: toolsAll[agentName] ?? {},
agents: agentsAll[agentName] ?? {},
}
}

return result
}

async function loadOverrides(sessionID: string) {
try {
const overrides = await Storage.readJSON<{
tools?: OverridesMap
agents?: OverridesMap
}>("session/overrides/" + sessionID)
state().subagentOverrides.set(sessionID, overrides.agents ?? {})
state().toolOverrides.set(sessionID, overrides.tools ?? {})
} catch {
// ignore
}
}

export async function create(parentID?: string) {
const result: Info = {
id: Identifier.descending("session"),
Expand Down Expand Up @@ -201,6 +268,8 @@ export namespace Session {
}
const read = await Storage.readJSON<Info>("session/info/" + id)
state().sessions.set(id, read)
// Load overrides (tools and agents) from unified file
await loadOverrides(id)
return read as Info
}

Expand Down Expand Up @@ -328,9 +397,12 @@ export namespace Session {
}
await unshare(sessionID).catch(() => {})
await Storage.remove(`session/info/${sessionID}`).catch(() => {})
await Storage.remove(`session/overrides/${sessionID}`).catch(() => {})
await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {})
state().sessions.delete(sessionID)
state().messages.delete(sessionID)
state().subagentOverrides.delete(sessionID)
state().toolOverrides.delete(sessionID)
if (emitEvent) {
Bus.publish(Event.Deleted, {
info: session,
Expand Down Expand Up @@ -364,6 +436,7 @@ export namespace Session {
agent: z.string().optional(),
system: z.string().optional(),
tools: z.record(z.boolean()).optional(),
agents: z.record(z.boolean()).optional(),
parts: z.array(
z.discriminatedUnion("type", [
MessageV2.TextPart.omit({
Expand Down Expand Up @@ -773,16 +846,60 @@ export namespace Session {

const processor = createProcessor(assistantMsg, model.info)

// Load saved overrides for this session/agent
const saved = getOverrides(input.sessionID, input.agent ?? inputAgent)
const savedToolOverrides = saved.tools

const enabledTools = pipe(
agent.tools,
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, agent)),
mergeDeep(savedToolOverrides),
mergeDeep(input.tools ?? {}),
)

// Store overrides for tools and agents to access, merging with existing ones
if (input.agent) {
const existing = getOverrides(input.sessionID, input.agent)
const next = {
tools: input.tools ? { ...existing.tools, ...input.tools } : undefined,
agents: input.agents ? { ...existing.agents, ...input.agents } : undefined,
}
if (next.tools || next.agents) await setOverrides(input.sessionID, input.agent, next)
}

for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) {
if (Wildcard.all(item.id, enabledTools) === false) continue
if (enabledTools[item.id] === false) continue
// Dynamically filter agent descriptions for the task tool based on session overrides
let description = item.description
if (item.id === "task") {
// Load saved subagent overrides for this session/agent
const savedSubagentOverrides = getOverrides(input.sessionID, input.agent ?? inputAgent).agents
const effectiveSubagentOverrides = { ...savedSubagentOverrides, ...(input.agents ?? {}) }

if (effectiveSubagentOverrides && Object.keys(effectiveSubagentOverrides).length > 0) {
// Get available agents and filter out disabled ones
const availableAgents = await Agent.list().then((x) =>
x.filter((a) => a.mode !== "primary" && effectiveSubagentOverrides![a.name] !== false),
)
// Rebuild the description with only enabled agents
const agentList = availableAgents
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
// Replace placeholder with filtered agents
description = description.replace("{agents}", agentList)
} else {
// No filtering needed, show all non-primary agents
const allAgents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const agentList = allAgents
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
description = description.replace("{agents}", agentList)
}
}
tools[item.id] = tool({
id: item.id as any,
description: item.description,
description: description,
inputSchema: item.parameters as ZodSchema,
async execute(args, options) {
await Plugin.trigger(
Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,35 @@ export namespace ToolRegistry {
return result
}

export async function getToolInfo(): Promise<Record<string, any>> {
const result: Record<string, any> = {}
// Create a default agent info for tool listing - use permissive defaults
const defaultAgent: Agent.Info = {
name: "default",
mode: "primary",
permission: {
edit: "allow",
bash: { "*": "allow" },
webfetch: "allow",
},
tools: {},
options: {},
}
const enabled = await ToolRegistry.enabled("", "", defaultAgent)

for (const tool of ALL) {
const toolId = tool.id
result[toolId] = {
name: toolId,
description: (await tool.init()).description || "",
source: "builtin",
defaultEnabled: enabled[toolId] !== false,
}
}

return result
}

function sanitizeGeminiParameters(schema: z.ZodTypeAny, visited = new Set()): z.ZodTypeAny {
if (!schema || visited.has(schema)) {
return schema
Expand Down
Loading
Loading