Skip to content

Commit 4633184

Browse files
authored
core: refactor tool system to remove agent context from initialization (#21052)
1 parent 7afb517 commit 4633184

File tree

25 files changed

+467
-512
lines changed

25 files changed

+467
-512
lines changed

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"@opencode-ai/plugin": "workspace:*",
9292
"@opencode-ai/script": "workspace:*",
9393
"@opencode-ai/sdk": "workspace:*",
94+
"heap-snapshot-toolkit": "1.1.3",
9495
"typescript": "catalog:"
9596
},
9697
"repository": {
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
# 2.0
2-
3-
What we would change if we could
4-
5-
## Keybindings vs. Keymappings
1+
# Keybindings vs. Keymappings
62

73
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
84

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Message Shape
2+
3+
Problem:
4+
5+
- stored messages need enough data to replay and resume a session later
6+
- prompt hooks often just want to append a synthetic user/assistant message
7+
- today that means faking ids, timestamps, and request metadata
8+
9+
## Option 1: Two Message Shapes
10+
11+
Keep `User` / `Assistant` for stored history, but clean them up.
12+
13+
```ts
14+
type User = {
15+
role: "user"
16+
time: { created: number }
17+
request: {
18+
agent: string
19+
model: ModelRef
20+
variant?: string
21+
format?: OutputFormat
22+
system?: string
23+
tools?: Record<string, boolean>
24+
}
25+
}
26+
27+
type Assistant = {
28+
role: "assistant"
29+
run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
30+
usage: { cost: number; tokens: Tokens }
31+
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
32+
}
33+
```
34+
35+
Add a separate transient `PromptMessage` for prompt surgery.
36+
37+
```ts
38+
type PromptMessage = {
39+
role: "user" | "assistant"
40+
parts: PromptPart[]
41+
}
42+
```
43+
44+
Plugin hook example:
45+
46+
```ts
47+
prompt.push({
48+
role: "user",
49+
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
50+
})
51+
```
52+
53+
Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
54+
55+
## Option 2: Prompt Mutators
56+
57+
Keep `User` / `Assistant` as the stored history model.
58+
59+
Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
60+
61+
```ts
62+
type PromptEditor = {
63+
append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
64+
prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
65+
appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
66+
insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
67+
insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
68+
}
69+
```
70+
71+
Plugin hook examples:
72+
73+
```ts
74+
prompt.append({
75+
role: "user",
76+
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
77+
})
78+
```
79+
80+
```ts
81+
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
82+
```
83+
84+
Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
85+
86+
## Option 3: Separate Turn State
87+
88+
Move execution settings out of `User` and into a separate turn/request object.
89+
90+
```ts
91+
type Turn = {
92+
id: string
93+
request: {
94+
agent: string
95+
model: ModelRef
96+
variant?: string
97+
format?: OutputFormat
98+
system?: string
99+
tools?: Record<string, boolean>
100+
}
101+
}
102+
103+
type User = {
104+
role: "user"
105+
turnID: string
106+
time: { created: number }
107+
}
108+
109+
type Assistant = {
110+
role: "assistant"
111+
turnID: string
112+
usage: { cost: number; tokens: Tokens }
113+
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
114+
}
115+
```
116+
117+
Examples:
118+
119+
```ts
120+
const turn = {
121+
request: {
122+
agent: "build",
123+
model: { providerID: "openai", modelID: "gpt-5" },
124+
},
125+
}
126+
```
127+
128+
```ts
129+
const msg = {
130+
role: "user",
131+
turnID: turn.id,
132+
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
133+
}
134+
```
135+
136+
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.

packages/opencode/src/cli/cmd/debug/agent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ export const AgentCommand = cmd({
7171

7272
async function getAvailableTools(agent: Agent.Info) {
7373
const model = agent.model ?? (await Provider.defaultModel())
74-
return ToolRegistry.tools(model, agent)
74+
return ToolRegistry.tools({
75+
...model,
76+
agent,
77+
})
7578
}
7679

7780
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
1515
import { errors } from "../error"
1616
import { lazy } from "../../util/lazy"
1717
import { WorkspaceRoutes } from "./workspace"
18+
import { Agent } from "@/agent/agent"
1819

1920
const ConsoleOrgOption = z.object({
2021
accountID: z.string(),
@@ -181,7 +182,11 @@ export const ExperimentalRoutes = lazy(() =>
181182
),
182183
async (c) => {
183184
const { provider, model } = c.req.valid("query")
184-
const tools = await ToolRegistry.tools({ providerID: ProviderID.make(provider), modelID: ModelID.make(model) })
185+
const tools = await ToolRegistry.tools({
186+
providerID: ProviderID.make(provider),
187+
modelID: ModelID.make(model),
188+
agent: await Agent.get(await Agent.defaultAgent()),
189+
})
185190
return c.json(
186191
tools.map((t) => ({
187192
id: t.id,

packages/opencode/src/session/prompt.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { Provider } from "../provider/provider"
1111
import { ModelID, ProviderID } from "../provider/schema"
1212
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
1313
import { SessionCompaction } from "./compaction"
14-
import { Instance } from "../project/instance"
1514
import { Bus } from "../bus"
1615
import { ProviderTransform } from "../provider/transform"
1716
import { SystemPrompt } from "./system"
@@ -24,7 +23,6 @@ import { ToolRegistry } from "../tool/registry"
2423
import { Runner } from "@/effect/runner"
2524
import { MCP } from "../mcp"
2625
import { LSP } from "../lsp"
27-
import { ReadTool } from "../tool/read"
2826
import { FileTime } from "../file/time"
2927
import { Flag } from "../flag/flag"
3028
import { ulid } from "ulid"
@@ -37,7 +35,6 @@ import { ConfigMarkdown } from "../config/markdown"
3735
import { SessionSummary } from "./summary"
3836
import { NamedError } from "@opencode-ai/util/error"
3937
import { SessionProcessor } from "./processor"
40-
import { TaskTool } from "@/tool/task"
4138
import { Tool } from "@/tool/tool"
4239
import { Permission } from "@/permission"
4340
import { SessionStatus } from "./status"
@@ -50,6 +47,7 @@ import { Process } from "@/util/process"
5047
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
5148
import { InstanceState } from "@/effect/instance-state"
5249
import { makeRuntime } from "@/effect/run-service"
50+
import { TaskTool } from "@/tool/task"
5351

5452
// @ts-ignore
5553
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
433431
),
434432
})
435433

436-
for (const item of yield* registry.tools(
437-
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
438-
input.agent,
439-
)) {
434+
for (const item of yield* registry.tools({
435+
modelID: ModelID.make(input.model.api.id),
436+
providerID: input.model.providerID,
437+
agent: input.agent,
438+
})) {
440439
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
441440
tools[item.id] = tool({
442441
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
560559
}) {
561560
const { task, model, lastUser, sessionID, session, msgs } = input
562561
const ctx = yield* InstanceState.context
563-
const taskTool = yield* Effect.promise(() => registry.named.task.init())
562+
const taskTool = yield* registry.fromID(TaskTool.id)
564563
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
565564
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
566565
id: MessageID.ascending(),
@@ -583,7 +582,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
583582
sessionID: assistantMessage.sessionID,
584583
type: "tool",
585584
callID: ulid(),
586-
tool: registry.named.task.id,
585+
tool: TaskTool.id,
587586
state: {
588587
status: "running",
589588
input: {
@@ -1113,7 +1112,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
11131112
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
11141113
},
11151114
]
1116-
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
1115+
const read = yield* registry.fromID("read").pipe(
11171116
Effect.flatMap((t) =>
11181117
provider.getModel(info.model.providerID, info.model.modelID).pipe(
11191118
Effect.flatMap((mdl) =>
@@ -1177,7 +1176,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
11771176

11781177
if (part.mime === "application/x-directory") {
11791178
const args = { filePath: filepath }
1180-
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
1179+
const result = yield* registry.fromID("read").pipe(
11811180
Effect.flatMap((t) =>
11821181
Effect.promise(() =>
11831182
t.execute(args, {

packages/opencode/src/skill/index.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,22 +239,28 @@ export namespace Skill {
239239

240240
export function fmt(list: Info[], opts: { verbose: boolean }) {
241241
if (list.length === 0) return "No skills are currently available."
242-
243242
if (opts.verbose) {
244243
return [
245244
"<available_skills>",
246-
...list.flatMap((skill) => [
247-
" <skill>",
248-
` <name>${skill.name}</name>`,
249-
` <description>${skill.description}</description>`,
250-
` <location>${pathToFileURL(skill.location).href}</location>`,
251-
" </skill>",
252-
]),
245+
...list
246+
.sort((a, b) => a.name.localeCompare(b.name))
247+
.flatMap((skill) => [
248+
" <skill>",
249+
` <name>${skill.name}</name>`,
250+
` <description>${skill.description}</description>`,
251+
` <location>${pathToFileURL(skill.location).href}</location>`,
252+
" </skill>",
253+
]),
253254
"</available_skills>",
254255
].join("\n")
255256
}
256257

257-
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
258+
return [
259+
"## Available Skills",
260+
...list
261+
.toSorted((a, b) => a.name.localeCompare(b.name))
262+
.map((skill) => `- **${skill.name}**: ${skill.description}`),
263+
].join("\n")
258264
}
259265

260266
const { runPromise } = makeRuntime(Service, defaultLayer)

packages/opencode/src/tool/bash.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ const FILES = new Set([
5050
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
5151
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
5252

53+
const Parameters = z.object({
54+
command: z.string().describe("The command to execute"),
55+
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
56+
workdir: z
57+
.string()
58+
.describe(
59+
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
60+
)
61+
.optional(),
62+
description: z
63+
.string()
64+
.describe(
65+
"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'",
66+
),
67+
})
68+
5369
type Part = {
5470
type: string
5571
text: string
@@ -452,21 +468,7 @@ export const BashTool = Tool.define("bash", async () => {
452468
.replaceAll("${chaining}", chain)
453469
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
454470
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
455-
parameters: z.object({
456-
command: z.string().describe("The command to execute"),
457-
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
458-
workdir: z
459-
.string()
460-
.describe(
461-
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
462-
)
463-
.optional(),
464-
description: z
465-
.string()
466-
.describe(
467-
"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'",
468-
),
469-
}),
471+
parameters: Parameters,
470472
async execute(params, ctx) {
471473
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
472474
if (params.timeout !== undefined && params.timeout < 0) {

0 commit comments

Comments
 (0)