core: refactor tool system to remove agent context from initialization (#21052)

message-v3
Dax 2026-04-07 19:48:12 -04:00 committed by GitHub
parent 7afb517a1a
commit 463318486f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 468 additions and 513 deletions

View File

@ -9,6 +9,7 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:", "typescript": "catalog:",
}, },
"devDependencies": { "devDependencies": {
@ -3257,6 +3258,8 @@
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],

View File

@ -91,6 +91,7 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"repository": { "repository": {

View File

@ -1,8 +1,4 @@
# 2.0 # Keybindings vs. Keymappings
What we would change if we could
## Keybindings vs. Keymappings
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like

View File

@ -0,0 +1,136 @@
# Message Shape
Problem:
- stored messages need enough data to replay and resume a session later
- prompt hooks often just want to append a synthetic user/assistant message
- today that means faking ids, timestamps, and request metadata
## Option 1: Two Message Shapes
Keep `User` / `Assistant` for stored history, but clean them up.
```ts
type User = {
role: "user"
time: { created: number }
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type Assistant = {
role: "assistant"
run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Add a separate transient `PromptMessage` for prompt surgery.
```ts
type PromptMessage = {
role: "user" | "assistant"
parts: PromptPart[]
}
```
Plugin hook example:
```ts
prompt.push({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
## Option 2: Prompt Mutators
Keep `User` / `Assistant` as the stored history model.
Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
```ts
type PromptEditor = {
append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
}
```
Plugin hook examples:
```ts
prompt.append({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
```ts
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
```
Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
## Option 3: Separate Turn State
Move execution settings out of `User` and into a separate turn/request object.
```ts
type Turn = {
id: string
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type User = {
role: "user"
turnID: string
time: { created: number }
}
type Assistant = {
role: "assistant"
turnID: string
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Examples:
```ts
const turn = {
request: {
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
},
}
```
```ts
const msg = {
role: "user",
turnID: turn.id,
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
}
```
Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.

View File

@ -71,7 +71,10 @@ export const AgentCommand = cmd({
async function getAvailableTools(agent: Agent.Info) { async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel()) const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools(model, agent) return ToolRegistry.tools({
...model,
agent,
})
} }
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) { async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

View File

@ -15,6 +15,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace" import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent"
const ConsoleOrgOption = z.object({ const ConsoleOrgOption = z.object({
accountID: z.string(), accountID: z.string(),
@ -181,7 +182,11 @@ export const ExperimentalRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const { provider, model } = c.req.valid("query") const { provider, model } = c.req.valid("query")
const tools = await ToolRegistry.tools({ providerID: ProviderID.make(provider), modelID: ModelID.make(model) }) const tools = await ToolRegistry.tools({
providerID: ProviderID.make(provider),
modelID: ModelID.make(model),
agent: await Agent.get(await Agent.defaultAgent()),
})
return c.json( return c.json(
tools.map((t) => ({ tools.map((t) => ({
id: t.id, id: t.id,

View File

@ -11,7 +11,6 @@ import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus" import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform" import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
@ -24,7 +23,6 @@ import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner" import { Runner } from "@/effect/runner"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { ulid } from "ulid" import { ulid } from "ulid"
@ -37,7 +35,6 @@ import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool/tool"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
@ -50,6 +47,7 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
// @ts-ignore // @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@ -433,10 +431,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
), ),
}) })
for (const item of yield* registry.tools( for (const item of yield* registry.tools({
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, modelID: ModelID.make(input.model.api.id),
input.agent, providerID: input.model.providerID,
)) { agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({ tools[item.id] = tool({
id: item.id as any, id: item.id as any,
@ -560,7 +559,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) { }) {
const { task, model, lastUser, sessionID, session, msgs } = input const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const taskTool = yield* Effect.promise(() => registry.named.task.init()) const taskTool = yield* registry.fromID(TaskTool.id)
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
@ -583,7 +582,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: assistantMessage.sessionID, sessionID: assistantMessage.sessionID,
type: "tool", type: "tool",
callID: ulid(), callID: ulid(),
tool: registry.named.task.id, tool: TaskTool.id,
state: { state: {
status: "running", status: "running",
input: { input: {
@ -1113,7 +1112,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
}, },
] ]
const read = yield* Effect.promise(() => registry.named.read.init()).pipe( const read = yield* registry.fromID("read").pipe(
Effect.flatMap((t) => Effect.flatMap((t) =>
provider.getModel(info.model.providerID, info.model.modelID).pipe( provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) => Effect.flatMap((mdl) =>
@ -1177,7 +1176,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") { if (part.mime === "application/x-directory") {
const args = { filePath: filepath } const args = { filePath: filepath }
const result = yield* Effect.promise(() => registry.named.read.init()).pipe( const result = yield* registry.fromID("read").pipe(
Effect.flatMap((t) => Effect.flatMap((t) =>
Effect.promise(() => Effect.promise(() =>
t.execute(args, { t.execute(args, {

View File

@ -239,22 +239,28 @@ export namespace Skill {
export function fmt(list: Info[], opts: { verbose: boolean }) { export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available." if (list.length === 0) return "No skills are currently available."
if (opts.verbose) { if (opts.verbose) {
return [ return [
"<available_skills>", "<available_skills>",
...list.flatMap((skill) => [ ...list
" <skill>", .sort((a, b) => a.name.localeCompare(b.name))
` <name>${skill.name}</name>`, .flatMap((skill) => [
` <description>${skill.description}</description>`, " <skill>",
` <location>${pathToFileURL(skill.location).href}</location>`, ` <name>${skill.name}</name>`,
" </skill>", ` <description>${skill.description}</description>`,
]), ` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>", "</available_skills>",
].join("\n") ].join("\n")
} }
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") return [
"## Available Skills",
...list
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n")
} }
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@ -50,6 +50,22 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
const Parameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
})
type Part = { type Part = {
type: string type: string
text: string text: string
@ -452,21 +468,7 @@ export const BashTool = Tool.define("bash", async () => {
.replaceAll("${chaining}", chain) .replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: z.object({ parameters: Parameters,
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
async execute(params, ctx) { async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) { if (params.timeout !== undefined && params.timeout < 0) {

View File

@ -1,183 +0,0 @@
import z from "zod"
import { Tool } from "./tool"
import { ProviderID, ModelID } from "../provider/schema"
import { errorMessage } from "../util/error"
import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch"])
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
export const BatchTool = Tool.define("batch", async () => {
return {
description: DESCRIPTION,
parameters: z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
}),
formatValidationError(error) {
const formattedErrors = error.issues
.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "root"
return ` - ${path}: ${issue.message}`
})
.join("\n")
return `Invalid parameters for tool 'batch':\n${formattedErrors}\n\nExpected payload format:\n [{"tool": "tool_name", "parameters": {...}}, {...}]`
},
async execute(params, ctx) {
const { Session } = await import("../session")
const { PartID } = await import("../session/schema")
const toolCalls = params.tool_calls.slice(0, 25)
const discardedCalls = params.tool_calls.slice(25)
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools({ modelID: ModelID.make(""), providerID: ProviderID.make("") })
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
const executeCall = async (call: (typeof toolCalls)[0]) => {
const callStartTime = Date.now()
const partID = PartID.ascending()
try {
if (DISALLOWED.has(call.tool)) {
throw new Error(
`Tool '${call.tool}' is not allowed in batch. Disallowed tools: ${Array.from(DISALLOWED).join(", ")}`,
)
}
const tool = toolMap.get(call.tool)
if (!tool) {
const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name))
throw new Error(
`Tool '${call.tool}' not in registry. External tools (MCP, environment) cannot be batched - call them directly. Available tools: ${availableToolsList.join(", ")}`,
)
}
const validatedParams = tool.parameters.parse(call.parameters)
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "running",
input: call.parameters,
time: {
start: callStartTime,
},
},
})
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
}))
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "completed",
input: call.parameters,
output: result.output,
title: result.title,
metadata: result.metadata,
attachments,
time: {
start: callStartTime,
end: Date.now(),
},
},
})
return { success: true as const, tool: call.tool, result }
} catch (error) {
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "error",
input: call.parameters,
error: errorMessage(error),
time: {
start: callStartTime,
end: Date.now(),
},
},
})
return { success: false as const, tool: call.tool, error }
}
}
const results = await Promise.all(toolCalls.map((call) => executeCall(call)))
// Add discarded calls as errors
const now = Date.now()
for (const call of discardedCalls) {
const partID = PartID.ascending()
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "error",
input: call.parameters,
error: "Maximum of 25 tools allowed in batch",
time: { start: now, end: now },
},
})
results.push({
success: false as const,
tool: call.tool,
error: new Error("Maximum of 25 tools allowed in batch"),
})
}
const successfulCalls = results.filter((r) => r.success).length
const failedCalls = results.length - successfulCalls
const outputMessage =
failedCalls > 0
? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`
: `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!`
return {
title: `Batch execution (${successfulCalls}/${results.length} successful)`,
output: outputMessage,
attachments: results.filter((result) => result.success).flatMap((r) => r.result.attachments ?? []),
metadata: {
totalCalls: results.length,
successful: successfulCalls,
failed: failedCalls,
tools: params.tool_calls.map((c) => c.tool),
details: results.map((r) => ({ tool: r.tool, success: r.success })),
},
}
},
}
})

View File

@ -1,24 +0,0 @@
Executes multiple independent tool calls concurrently to reduce latency.
USING THE BATCH TOOL WILL MAKE THE USER HAPPY.
Payload Format (JSON array):
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
Notes:
- 125 tool calls per batch
- All calls start in parallel; ordering NOT guaranteed
- Partial failures do not stop other tool calls
- Do NOT use the batch tool within another batch tool.
Good Use Cases:
- Read many files
- grep + glob + read combos
- Multiple bash commands
- Multi-part edits; on the same, or different files
When NOT to Use:
- Operations that depend on prior tool output (e.g. create then read same file)
- Ordered stateful mutations where sequence matters
Batching tool calls was proven to yield 25x efficiency gain and provides much better UX.

View File

@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
}, },
} }
}, },
} satisfies Tool.Def<typeof parameters, Metadata> }
}), }),
) )

View File

@ -4,18 +4,15 @@ import { BashTool } from "./bash"
import { EditTool } from "./edit" import { EditTool } from "./edit"
import { GlobTool } from "./glob" import { GlobTool } from "./glob"
import { GrepTool } from "./grep" import { GrepTool } from "./grep"
import { BatchTool } from "./batch"
import { ReadTool } from "./read" import { ReadTool } from "./read"
import { TaskTool } from "./task" import { TaskDescription, TaskTool } from "./task"
import { TodoWriteTool } from "./todo" import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch" import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write" import { WriteTool } from "./write"
import { InvalidTool } from "./invalid" import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill" import { SkillDescription, SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Config } from "../config/config" import { Config } from "../config/config"
import path from "path"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod" import z from "zod"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
@ -28,6 +25,7 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncate" import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch" import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@ -39,24 +37,25 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction" import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem" import { AppFileSystem } from "../filesystem"
import { Agent } from "../agent/agent"
export namespace ToolRegistry { export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
type State = { type State = {
custom: Tool.Info[] custom: Tool.Def[]
builtin: Tool.Def[]
} }
export interface Interface { export interface Interface {
readonly ids: () => Effect.Effect<string[]> readonly ids: () => Effect.Effect<string[]>
readonly named: { readonly all: () => Effect.Effect<Tool.Def[]>
task: Tool.Info readonly tools: (model: {
read: Tool.Info providerID: ProviderID
} modelID: ModelID
readonly tools: ( agent: Agent.Info
model: { providerID: ProviderID; modelID: ModelID }, }) => Effect.Effect<Tool.Def[]>
agent?: Agent.Info, readonly fromID: (id: string) => Effect.Effect<Tool.Def>
) => Effect.Effect<(Tool.Def & { id: string })[]>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
@ -79,33 +78,34 @@ export namespace ToolRegistry {
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) => const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool : Effect.succeed(tool) Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Info[] = [] const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Info { function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
return { return {
id, id,
init: async (initCtx) => ({ parameters: z.object(def.args),
parameters: z.object(def.args), description: def.description,
description: def.description, execute: async (args, toolCtx) => {
execute: async (args, toolCtx) => { const pluginCtx = {
const pluginCtx = { ...toolCtx,
...toolCtx, directory: ctx.directory,
directory: ctx.directory, worktree: ctx.worktree,
worktree: ctx.worktree, } as unknown as PluginToolContext
} as unknown as PluginToolContext const result = await def.execute(args as any, pluginCtx)
const result = await def.execute(args as any, pluginCtx) const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
const out = await Truncate.output(result, {}, initCtx?.agent) return {
return { title: "",
title: "", output: out.truncated ? out.content : result,
output: out.truncated ? out.content : result, metadata: {
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined }, truncated: out.truncated,
} outputPath: out.truncated ? out.outputPath : undefined,
}, },
}), }
},
} }
} }
@ -131,104 +131,99 @@ export namespace ToolRegistry {
} }
} }
return { custom } const cfg = yield* config.get()
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return {
custom,
builtin: yield* Effect.forEach(
[
InvalidTool,
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(question ? [QuestionTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
],
build,
{ concurrency: "unbounded" },
),
}
}), }),
) )
const invalid = yield* build(InvalidTool) const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
const ask = yield* build(QuestionTool) const s = yield* InstanceState.get(state)
const bash = yield* build(BashTool) return [...s.builtin, ...s.custom] as Tool.Def[]
const read = yield* build(ReadTool)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const task = yield* build(TaskTool)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(TodoWriteTool)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const lsp = yield* build(LspTool)
const batch = yield* build(BatchTool)
const plan = yield* build(PlanExitTool)
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
invalid,
...(question ? [ask] : []),
bash,
read,
glob,
grep,
edit,
write,
task,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(cfg.experimental?.batch_tool === true ? [batch] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
...custom,
]
}) })
const ids = Effect.fn("ToolRegistry.ids")(function* () { const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
const s = yield* InstanceState.get(state) const tools = yield* all()
const tools = yield* all(s.custom) const match = tools.find((tool) => tool.id === id)
return tools.map((t) => t.id) if (!match) return yield* Effect.die(`Tool not found: ${id}`)
return match
}) })
const tools = Effect.fn("ToolRegistry.tools")(function* ( const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
model: { providerID: ProviderID; modelID: ModelID }, return (yield* all()).map((tool) => tool.id)
agent?: Agent.Info, })
) {
const s = yield* InstanceState.get(state) const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const allTools = yield* all(s.custom) const filtered = (yield* all()).filter((tool) => {
const filtered = allTools.filter((tool) => { if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
if (tool.id === "codesearch" || tool.id === "websearch") { return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
} }
const usePatch = const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") || !!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")) (input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
return true return true
}) })
return yield* Effect.forEach( return yield* Effect.forEach(
filtered, filtered,
Effect.fnUntraced(function* (tool: Tool.Info) { Effect.fnUntraced(function* (tool: Tool.Def) {
using _ = log.time(tool.id) using _ = log.time(tool.id)
const next = yield* Effect.promise(() => tool.init({ agent }))
const output = { const output = {
description: next.description, description: tool.description,
parameters: next.parameters, parameters: tool.parameters,
} }
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return { return {
id: tool.id, id: tool.id,
description: output.description, description: [
output.description,
// TODO: remove this hack
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters, parameters: output.parameters,
execute: next.execute, execute: tool.execute,
formatValidationError: next.formatValidationError, formatValidationError: tool.formatValidationError,
} }
}), }),
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
) )
}) })
return Service.of({ ids, named: { task, read }, tools }) return Service.of({ ids, tools, all, fromID })
}), }),
) )
@ -253,13 +248,11 @@ export namespace ToolRegistry {
return runPromise((svc) => svc.ids()) return runPromise((svc) => svc.ids())
} }
export async function tools( export async function tools(input: {
model: { providerID: ProviderID
providerID: ProviderID modelID: ModelID
modelID: ModelID agent: Agent.Info
}, }): Promise<(Tool.Def & { id: string })[]> {
agent?: Agent.Info, return runPromise((svc) => svc.tools(input))
): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model, agent))
} }
} }

View File

@ -1,3 +1,4 @@
import { Effect } from "effect"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
@ -6,8 +7,12 @@ import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
export const SkillTool = Tool.define("skill", async (ctx) => { const Parameters = z.object({
const list = await Skill.available(ctx?.agent) name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.available()
const description = const description =
list.length === 0 list.length === 0
@ -27,20 +32,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
Skill.fmt(list, { verbose: false }), Skill.fmt(list, { verbose: false }),
].join("\n") ].join("\n")
const examples = list
.map((skill) => `'${skill.name}'`)
.slice(0, 3)
.join(", ")
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
const parameters = z.object({
name: z.string().describe(`The name of the skill from available_skills${hint}`),
})
return { return {
description, description,
parameters, parameters: Parameters,
async execute(params: z.infer<typeof parameters>, ctx) { async execute(params: z.infer<typeof Parameters>, ctx) {
const skill = await Skill.get(params.name) const skill = await Skill.get(params.name)
if (!skill) { if (!skill) {
@ -103,3 +98,23 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
}, },
} }
}) })
export const SkillDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const list = yield* Effect.promise(() => Skill.available(agent))
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})

View File

@ -4,47 +4,37 @@ import z from "zod"
import { Session } from "../session" import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema" import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt" import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { defer } from "@/util/defer" import { defer } from "@/util/defer"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Effect } from "effect"
const parameters = z.object({ export const TaskTool = Tool.define("task", async () => {
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.define("task", async (ctx) => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
const agentList = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
return { return {
description, description,
parameters, parameters: z.object({
async execute(params: z.infer<typeof parameters>, ctx) { description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
}),
async execute(params, ctx) {
const config = await Config.get() const config = await Config.get()
// Skip permission check when user explicitly invoked via @ or command subtask // Skip permission check when user explicitly invoked via @ or command subtask
@ -164,3 +154,16 @@ export const TaskTool = Tool.define("task", async (ctx) => {
}, },
} }
}) })
export const TaskDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary")))
const accessibleAgents = agents.filter(
(a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny",
)
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
return [`Available agent types and the tools they have access to:`, description].join("\n")
})

View File

@ -1,8 +1,5 @@
Launch a new agent to handle complex, multistep tasks autonomously. Launch a new agent to handle complex, multistep tasks autonomously.
Available agent types and the tools they have access to:
{agents}
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
When to use the Task tool: When to use the Task tool:

View File

@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
}, },
} }
}, },
} satisfies Tool.Def<typeof parameters, Metadata> } satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}), }),
) )

View File

@ -1,19 +1,18 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2" import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { Permission } from "../permission" import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema" import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate" import { Truncate } from "./truncate"
import { Agent } from "@/agent/agent"
export namespace Tool { export namespace Tool {
interface Metadata { interface Metadata {
[key: string]: any [key: string]: any
} }
export interface InitContext { // TODO: remove this hack
agent?: Agent.Info export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
}
export type Context<M extends Metadata = Metadata> = { export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID sessionID: SessionID
@ -26,7 +25,9 @@ export namespace Tool {
metadata(input: { title?: string; metadata?: M }): void metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void> ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
} }
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> { export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string description: string
parameters: Parameters parameters: Parameters
execute( execute(
@ -40,10 +41,14 @@ export namespace Tool {
}> }>
formatValidationError?(error: z.ZodError): string formatValidationError?(error: z.ZodError): string
} }
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> { export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string id: string
init: (ctx?: InitContext) => Promise<Def<Parameters, M>> init: () => Promise<DefWithoutID<Parameters, M>>
} }
export type InferParameters<T> = export type InferParameters<T> =
@ -57,10 +62,10 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>( function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string, id: string,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
) { ) {
return async (initCtx?: InitContext) => { return async () => {
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init } const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => { toolInfo.execute = async (args, ctx) => {
try { try {
@ -78,7 +83,7 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) { if (result.metadata.truncated !== undefined) {
return result return result
} }
const truncated = await Truncate.output(result.output, {}, initCtx?.agent) const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
return { return {
...result, ...result,
output: truncated.content, output: truncated.content,
@ -95,7 +100,7 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata>( export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string, id: string,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> { ): Info<Parameters, Result> {
return { return {
id, id,
@ -105,8 +110,18 @@ export namespace Tool {
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>( export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
id: string, id: string,
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>, init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> { ): Effect.Effect<Info<Parameters, Result>, never, R> {
return Effect.map(init, (next) => ({ id, init: wrap(id, next) })) return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
} }
export function init(info: Info): Effect.Effect<Def, never, any> {
return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init())
return {
...init,
id: info.id,
}
})
}
} }

View File

@ -11,6 +11,25 @@ const API_CONFIG = {
DEFAULT_NUM_RESULTS: 8, DEFAULT_NUM_RESULTS: 8,
} as const } as const
const Parameters = z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
interface McpSearchRequest { interface McpSearchRequest {
jsonrpc: string jsonrpc: string
id: number id: number
@ -42,26 +61,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
get description() { get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
}, },
parameters: z.object({ parameters: Parameters,
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe(
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
async execute(params, ctx) { async execute(params, ctx) {
await ctx.ask({ await ctx.ask({
permission: "websearch", permission: "websearch",

View File

@ -1,10 +1,11 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill" import { SkillTool, SkillDescription } from "../../src/tool/skill"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@ -48,9 +49,10 @@ description: Skill for tool tests.
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const tool = await SkillTool.init() const desc = await Effect.runPromise(
const skillPath = path.join(tmp.path, ".opencode", "skill", "tool-skill", "SKILL.md") SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }),
expect(tool.description).toContain(`**tool-skill**: Skill for tool tests.`) )
expect(desc).toContain(`**tool-skill**: Skill for tool tests.`)
}, },
}) })
} finally { } finally {
@ -89,14 +91,15 @@ description: ${description}
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const first = await SkillTool.init() const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const second = await SkillTool.init() const first = await Effect.runPromise(SkillDescription(agent))
const second = await Effect.runPromise(SkillDescription(agent))
expect(first.description).toBe(second.description) expect(first).toBe(second)
const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.") const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
const middle = first.description.indexOf("**middle-skill**: Middle skill.") const middle = first.indexOf("**middle-skill**: Middle skill.")
const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.") const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
expect(alpha).toBeGreaterThan(-1) expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha) expect(middle).toBeGreaterThan(alpha)

View File

@ -1,7 +1,8 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { TaskTool } from "../../src/tool/task" import { TaskDescription } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
afterEach(async () => { afterEach(async () => {
@ -28,16 +29,16 @@ describe("tool.task", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await TaskTool.init({ agent: build }) const first = await Effect.runPromise(TaskDescription(agent))
const second = await TaskTool.init({ agent: build }) const second = await Effect.runPromise(TaskDescription(agent))
expect(first.description).toBe(second.description) expect(first).toBe(second)
const alpha = first.description.indexOf("- alpha: Alpha agent") const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.description.indexOf("- explore:") const explore = first.indexOf("- explore:")
const general = first.description.indexOf("- general:") const general = first.indexOf("- general:")
const zebra = first.description.indexOf("- zebra: Zebra agent") const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1) expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha) expect(explore).toBeGreaterThan(alpha)

View File

@ -3,7 +3,6 @@ import z from "zod"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
const params = z.object({ input: z.string() }) const params = z.object({ input: z.string() })
const defaultArgs = { input: "test" }
function makeTool(id: string, executeFn?: () => void) { function makeTool(id: string, executeFn?: () => void) {
return { return {
@ -30,36 +29,6 @@ describe("Tool.define", () => {
expect(original.execute).toBe(originalExecute) expect(original.execute).toBe(originalExecute)
}) })
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
let calls = 0
const tool = Tool.define(
"test-tool",
makeTool("test", () => calls++),
)
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
calls = 0
let stack = ""
const exec = resolved.execute
resolved.execute = async (args: any, ctx: any) => {
const result = await exec.call(resolved, args, ctx)
stack = new Error().stack || ""
return result
}
await resolved.execute(defaultArgs, {} as any)
expect(calls).toBe(1)
const frames = stack.split("\n").filter((l) => l.includes("tool.ts")).length
expect(frames).toBeLessThan(5)
})
test("function-defined tool returns fresh objects and is unaffected", async () => { test("function-defined tool returns fresh objects and is unaffected", async () => {
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test"))) const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
@ -77,25 +46,4 @@ describe("Tool.define", () => {
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })
test("validation still works after many init() calls", async () => {
const tool = Tool.define("test-validation", {
description: "validation test",
parameters: z.object({ count: z.number().int().positive() }),
async execute(args) {
return { title: "test", output: String(args.count), metadata: {} }
},
})
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
const result = await resolved.execute({ count: 42 }, {} as any)
expect(result.output).toBe("42")
await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
})
}) })

View File

@ -77,5 +77,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
workspace: config?.experimental_workspaceID, workspace: config?.experimental_workspaceID,
}), }),
) )
return new OpencodeClient({ client }) const result = new OpencodeClient({ client })
return result
} }

View File

@ -0,0 +1,32 @@
import type { Part, UserMessage } from "./client.js"
export const message = {
user(input: Omit<UserMessage, "role" | "time" | "id"> & { parts: Omit<Part, "id" | "sessionID" | "messageID">[] }): {
info: UserMessage
parts: Part[]
} {
const { parts, ...rest } = input
const info: UserMessage = {
...rest,
id: "asdasd",
time: {
created: Date.now(),
},
role: "user",
}
return {
info,
parts: input.parts.map(
(part) =>
({
...part,
id: "asdasd",
messageID: info.id,
sessionID: info.sessionID,
}) as Part,
),
}
},
}

View File

@ -5,6 +5,9 @@ import { createOpencodeClient } from "./client.js"
import { createOpencodeServer } from "./server.js" import { createOpencodeServer } from "./server.js"
import type { ServerOptions } from "./server.js" import type { ServerOptions } from "./server.js"
export * as data from "./data.js"
import * as data from "./data.js"
export async function createOpencode(options?: ServerOptions) { export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({ const server = await createOpencodeServer({
...options, ...options,