mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-06 05:54:54 +00:00
Compare commits
6 Commits
layered-de
...
tool-optim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8921a7a26b | ||
|
|
03b7c2d3fb | ||
|
|
163a9e85b5 | ||
|
|
8bf3107879 | ||
|
|
cd3da22afd | ||
|
|
389d9deee9 |
3
bun.lock
3
bun.lock
@@ -9,6 +9,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3232,6 +3233,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
# 2.0
|
||||
|
||||
What we would change if we could
|
||||
|
||||
## Keybindings vs. Keymappings
|
||||
# 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
|
||||
|
||||
136
packages/opencode/specs/v2/message-shape.md
Normal file
136
packages/opencode/specs/v2/message-shape.md
Normal 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.
|
||||
@@ -71,7 +71,7 @@ export const AgentCommand = cmd({
|
||||
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
const model = agent.model ?? (await Provider.defaultModel())
|
||||
return ToolRegistry.tools(model, agent)
|
||||
return ToolRegistry.tools(model)
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "."
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import z from "zod"
|
||||
@@ -219,7 +218,6 @@ When constructing the summary, try to stick to this template:
|
||||
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
|
||||
const msgs = structuredClone(messages)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
|
||||
const ctx = yield* InstanceState.context
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
@@ -261,10 +259,29 @@ When constructing the summary, try to stick to this template:
|
||||
tools: {},
|
||||
system: [],
|
||||
messages: [
|
||||
...modelMessages,
|
||||
...msgs,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: prompt }],
|
||||
info: {
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
id: MessageID.ascending(),
|
||||
time: { created: Date.now() },
|
||||
agent: agent.name,
|
||||
model: {
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
sessionID: input.sessionID,
|
||||
messageID: MessageID.ascending(),
|
||||
id: PartID.ascending(),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
model,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Log } from "@/util/log"
|
||||
import { Cause, Effect, Layer, Record, ServiceMap } from "effect"
|
||||
import * as Queue from "effect/Queue"
|
||||
import { Effect, Layer, Record, ServiceMap } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
||||
import { mergeDeep, pipe } from "remeda"
|
||||
@@ -10,13 +9,14 @@ import { ProviderTransform } from "@/provider/transform"
|
||||
import { Config } from "@/config/config"
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Permission } from "@/permission"
|
||||
import { Auth } from "@/auth"
|
||||
import { Installation } from "@/installation"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export namespace LLM {
|
||||
const log = Log.create({ service: "llm" })
|
||||
@@ -30,7 +30,7 @@ export namespace LLM {
|
||||
agent: Agent.Info
|
||||
permission?: Permission.Ruleset
|
||||
system: string[]
|
||||
messages: ModelMessage[]
|
||||
messages: MessageV2.WithParts[]
|
||||
small?: boolean
|
||||
tools: Record<string, Tool>
|
||||
retries?: number
|
||||
@@ -146,19 +146,18 @@ export namespace LLM {
|
||||
}
|
||||
|
||||
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
||||
const messages = isOpenaiOauth
|
||||
? input.messages
|
||||
: isWorkflow
|
||||
? input.messages
|
||||
: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...input.messages,
|
||||
]
|
||||
const messages = await iife(async () => {
|
||||
if (isOpenaiOauth || isWorkflow) return MessageV2.toModelMessages(input.messages, input.model)
|
||||
return [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...(await MessageV2.toModelMessages(input.messages, input.model)),
|
||||
]
|
||||
})
|
||||
|
||||
const params = await Plugin.trigger(
|
||||
"chat.params",
|
||||
@@ -198,7 +197,7 @@ export namespace LLM {
|
||||
? undefined
|
||||
: ProviderTransform.maxOutputTokens(input.model)
|
||||
|
||||
const tools = await resolveTools(input)
|
||||
const tools = resolveTools(input)
|
||||
|
||||
// LiteLLM and some Anthropic proxies require the tools parameter to be present
|
||||
// when message history contains tool calls, even if no tools are being used.
|
||||
@@ -242,7 +241,7 @@ export namespace LLM {
|
||||
try {
|
||||
const result = await t.execute!(JSON.parse(argsJson), {
|
||||
toolCallId: _requestID,
|
||||
messages: input.messages,
|
||||
messages: [],
|
||||
abortSignal: input.abort,
|
||||
})
|
||||
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
|
||||
@@ -346,13 +345,7 @@ export namespace LLM {
|
||||
|
||||
// Check if messages contain any tool-call content
|
||||
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
|
||||
export function hasToolCalls(messages: ModelMessage[]): boolean {
|
||||
for (const msg of messages) {
|
||||
if (!Array.isArray(msg.content)) continue
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "tool-call" || part.type === "tool-result") return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
export function hasToolCalls(messages: MessageV2.WithParts[]): boolean {
|
||||
return messages.some((msg) => msg.parts.some((part) => part.type === "tool"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Provider } from "../provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Bus } from "../bus"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
import { SystemPrompt } from "./system"
|
||||
@@ -24,7 +23,6 @@ import { ToolRegistry } from "../tool/registry"
|
||||
import { Runner } from "@/effect/runner"
|
||||
import { MCP } from "../mcp"
|
||||
import { LSP } from "../lsp"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
@@ -37,7 +35,6 @@ import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { Permission } from "@/permission"
|
||||
import { SessionStatus } from "./status"
|
||||
@@ -50,6 +47,8 @@ import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { ReadTool } from "@/tool/read"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -433,10 +432,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
),
|
||||
})
|
||||
|
||||
for (const item of yield* registry.tools(
|
||||
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
|
||||
input.agent,
|
||||
)) {
|
||||
for (const item of yield* registry.tools({
|
||||
modelID: ModelID.make(input.model.api.id),
|
||||
providerID: input.model.providerID,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||
tools[item.id] = tool({
|
||||
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 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 assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||
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,
|
||||
type: "tool",
|
||||
callID: ulid(),
|
||||
tool: registry.named.task.id,
|
||||
tool: TaskTool.id,
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
@@ -1110,7 +1109,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)}`,
|
||||
},
|
||||
]
|
||||
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
|
||||
const read = yield* registry.fromID(ReadTool.id).pipe(
|
||||
Effect.flatMap((t) =>
|
||||
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
||||
Effect.flatMap((mdl) =>
|
||||
@@ -1174,7 +1173,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
if (part.mime === "application/x-directory") {
|
||||
const args = { filePath: filepath }
|
||||
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
|
||||
const result = yield* registry.fromID(ReadTool.id).pipe(
|
||||
Effect.flatMap((t) =>
|
||||
Effect.promise(() =>
|
||||
t.execute(args, {
|
||||
|
||||
@@ -239,22 +239,28 @@ export namespace Skill {
|
||||
|
||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
if (list.length === 0) return "No skills are currently available."
|
||||
|
||||
if (opts.verbose) {
|
||||
return [
|
||||
"<available_skills>",
|
||||
...list.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
...list
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
].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)
|
||||
|
||||
@@ -50,6 +50,22 @@ const FILES = new Set([
|
||||
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
||||
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: string
|
||||
text: string
|
||||
@@ -452,21 +468,7 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
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 ${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'",
|
||||
),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
async execute(params, ctx) {
|
||||
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
|
||||
@@ -7,20 +7,22 @@ import DESCRIPTION from "./batch.txt"
|
||||
const DISALLOWED = new Set(["batch"])
|
||||
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
|
||||
|
||||
const 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"),
|
||||
})
|
||||
|
||||
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"),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
formatValidationError(error) {
|
||||
const formattedErrors = error.issues
|
||||
.map((issue) => {
|
||||
|
||||
@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
|
||||
},
|
||||
}
|
||||
},
|
||||
} satisfies Tool.Def<typeof parameters, Metadata>
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -12,10 +12,8 @@ import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import { SkillTool } from "./skill"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { Tool } from "./tool"
|
||||
import { Config } from "../config/config"
|
||||
import path from "path"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import z from "zod"
|
||||
import { Plugin } from "../plugin"
|
||||
@@ -35,24 +33,21 @@ import { makeRuntime } from "@/effect/run-service"
|
||||
import { Env } from "../env"
|
||||
import { Question } from "../question"
|
||||
import { Todo } from "../session/todo"
|
||||
import path from "path"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
type State = {
|
||||
custom: Tool.Info[]
|
||||
custom: Tool.Def[]
|
||||
builtin: Tool.Def[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ids: () => Effect.Effect<string[]>
|
||||
readonly named: {
|
||||
task: Tool.Info
|
||||
read: Tool.Info
|
||||
}
|
||||
readonly tools: (
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
) => Effect.Effect<(Tool.Def & { id: string })[]>
|
||||
readonly all: () => Effect.Effect<Tool.Def[]>
|
||||
readonly tools: (model: { providerID: ProviderID; modelID: ModelID }) => Effect.Effect<Tool.Def[]>
|
||||
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||
@@ -65,33 +60,31 @@ export namespace ToolRegistry {
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
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>(
|
||||
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 {
|
||||
id,
|
||||
init: async (initCtx) => ({
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
execute: async (args, toolCtx) => {
|
||||
const pluginCtx = {
|
||||
...toolCtx,
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
} as unknown as PluginToolContext
|
||||
const result = await def.execute(args as any, pluginCtx)
|
||||
const out = await Truncate.output(result, {}, initCtx?.agent)
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
||||
}
|
||||
},
|
||||
}),
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
execute: async (args, toolCtx) => {
|
||||
const pluginCtx = {
|
||||
...toolCtx,
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
} as unknown as PluginToolContext
|
||||
const result = await def.execute(args as any, pluginCtx)
|
||||
const out = await Truncate.output(result, {})
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,71 +110,60 @@ 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] : []),
|
||||
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
||||
],
|
||||
build,
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const invalid = yield* build(InvalidTool)
|
||||
const ask = yield* build(QuestionTool)
|
||||
const bash = yield* build(BashTool)
|
||||
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* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return [...s.builtin, ...s.custom] as Tool.Def[]
|
||||
})
|
||||
|
||||
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 fromID = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
|
||||
const allTools = yield* all()
|
||||
const match = allTools.find((tool) => tool.id === id)
|
||||
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
|
||||
return match
|
||||
})
|
||||
|
||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const tools = yield* all(s.custom)
|
||||
return tools.map((t) => t.id)
|
||||
return yield* all().pipe(Effect.map((t) => t.map((x) => x.id)))
|
||||
})
|
||||
|
||||
const tools = Effect.fn("ToolRegistry.tools")(function* (
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const allTools = yield* all(s.custom)
|
||||
const tools = Effect.fn("ToolRegistry.tools")(function* (model: { providerID: ProviderID; modelID: ModelID }) {
|
||||
const allTools = yield* all()
|
||||
const filtered = allTools.filter((tool) => {
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
@@ -195,27 +177,26 @@ export namespace ToolRegistry {
|
||||
})
|
||||
return yield* Effect.forEach(
|
||||
filtered,
|
||||
Effect.fnUntraced(function* (tool: Tool.Info) {
|
||||
Effect.fnUntraced(function* (tool: Tool.Def) {
|
||||
using _ = log.time(tool.id)
|
||||
const next = yield* Effect.promise(() => tool.init({ agent }))
|
||||
const output = {
|
||||
description: next.description,
|
||||
parameters: next.parameters,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
return {
|
||||
id: tool.id,
|
||||
description: output.description,
|
||||
parameters: output.parameters,
|
||||
execute: next.execute,
|
||||
formatValidationError: next.formatValidationError,
|
||||
execute: tool.execute,
|
||||
formatValidationError: tool.formatValidationError,
|
||||
}
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ ids, named: { task, read }, tools })
|
||||
return Service.of({ ids, tools, all, fromID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -236,13 +217,10 @@ export namespace ToolRegistry {
|
||||
return runPromise((svc) => svc.ids())
|
||||
}
|
||||
|
||||
export async function tools(
|
||||
model: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
},
|
||||
agent?: Agent.Info,
|
||||
): Promise<(Tool.Def & { id: string })[]> {
|
||||
return runPromise((svc) => svc.tools(model, agent))
|
||||
export async function tools(model: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
}): Promise<(Tool.Def & { id: string })[]> {
|
||||
return runPromise((svc) => svc.tools(model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import { Skill } from "../skill"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
const list = await Skill.available(ctx?.agent)
|
||||
const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
})
|
||||
|
||||
export const SkillTool = Tool.define("skill", async () => {
|
||||
const list = await Skill.all()
|
||||
const description =
|
||||
list.length === 0
|
||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||
@@ -33,14 +36,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
.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 {
|
||||
description,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
parameters: Parameters,
|
||||
async execute(params: z.infer<typeof Parameters>, ctx) {
|
||||
const skill = await Skill.get(params.name)
|
||||
|
||||
if (!skill) {
|
||||
|
||||
@@ -4,13 +4,11 @@ import z from "zod"
|
||||
import { Session } from "../session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { Permission } from "@/permission"
|
||||
|
||||
const parameters = z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
@@ -25,19 +23,12 @@ const parameters = z.object({
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
})
|
||||
|
||||
export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
export const TaskTool = Tool.define("task", async () => {
|
||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
|
||||
// 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
|
||||
agents
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n"),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
import { Truncate } from "./truncate"
|
||||
import { Agent } from "@/agent/agent"
|
||||
|
||||
export namespace Tool {
|
||||
interface Metadata {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface InitContext {
|
||||
agent?: Agent.Info
|
||||
}
|
||||
|
||||
export type Context<M extends Metadata = Metadata> = {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
@@ -26,7 +22,9 @@ export namespace Tool {
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
}
|
||||
|
||||
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
execute(
|
||||
@@ -40,10 +38,14 @@ export namespace Tool {
|
||||
}>
|
||||
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> {
|
||||
id: string
|
||||
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
|
||||
init: () => Promise<DefWithoutID<Parameters, M>>
|
||||
}
|
||||
|
||||
export type InferParameters<T> =
|
||||
@@ -57,10 +59,10 @@ export namespace Tool {
|
||||
|
||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
|
||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
||||
) {
|
||||
return async (initCtx?: InitContext) => {
|
||||
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
|
||||
return async () => {
|
||||
const toolInfo = init instanceof Function ? await init() : { ...init }
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = async (args, ctx) => {
|
||||
try {
|
||||
@@ -78,7 +80,7 @@ export namespace Tool {
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
|
||||
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
@@ -95,7 +97,7 @@ export namespace Tool {
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
|
||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
||||
): Info<Parameters, Result> {
|
||||
return {
|
||||
id,
|
||||
@@ -105,8 +107,18 @@ export namespace Tool {
|
||||
|
||||
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
|
||||
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> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,25 @@ const API_CONFIG = {
|
||||
DEFAULT_NUM_RESULTS: 8,
|
||||
} 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 {
|
||||
jsonrpc: string
|
||||
id: number
|
||||
@@ -42,26 +61,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
get description() {
|
||||
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
|
||||
},
|
||||
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)"),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
async execute(params, ctx) {
|
||||
await ctx.ask({
|
||||
permission: "websearch",
|
||||
|
||||
@@ -28,9 +28,8 @@ describe("tool.task", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
const first = await TaskTool.init({ agent: build })
|
||||
const second = await TaskTool.init({ agent: build })
|
||||
const first = await TaskTool.init()
|
||||
const second = await TaskTool.init()
|
||||
|
||||
expect(first.description).toBe(second.description)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import z from "zod"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
|
||||
const params = z.object({ input: z.string() })
|
||||
const defaultArgs = { input: "test" }
|
||||
|
||||
function makeTool(id: string, executeFn?: () => void) {
|
||||
return {
|
||||
@@ -30,36 +29,6 @@ describe("Tool.define", () => {
|
||||
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 () => {
|
||||
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
|
||||
|
||||
@@ -77,25 +46,4 @@ describe("Tool.define", () => {
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,5 +77,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
|
||||
workspace: config?.experimental_workspaceID,
|
||||
}),
|
||||
)
|
||||
return new OpencodeClient({ client })
|
||||
const result = new OpencodeClient({ client })
|
||||
return result
|
||||
}
|
||||
|
||||
32
packages/sdk/js/src/v2/data.ts
Normal file
32
packages/sdk/js/src/v2/data.ts
Normal 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,
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { createOpencodeClient } from "./client.js"
|
||||
import { createOpencodeServer } 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) {
|
||||
const server = await createOpencodeServer({
|
||||
...options,
|
||||
|
||||
Reference in New Issue
Block a user