Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/client/src/promise/generated/types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/core/src/plugin/system-prompt/meta.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ Use the instructions below and the tools available to assist the user.
- `read` may be used to inspect or locate files, but final numeric or rendered results should come from executed code, not copied text plus mental math.

# Tool Use - OpenCode Specifics
- When `webfetch` returns a message about a redirect to a different host, you should immediately make a new `webfetch` request with the redirect URL provided in the response.
- When `plan` mode is active, you will see a <system-reminder> about this. Follow that reminder for the files you may edit and all other Plan mode restrictions. If the user asks you to implement changes, inform them that `plan` mode is active and that they need to switch to build mode.

# Code Style - Comments
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/session/model-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { Cause, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/http"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { App } from "../app.js"
import { Flag } from "../flag.js"
import { Instructions } from "../instructions/index.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
Expand Down Expand Up @@ -270,6 +271,7 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service
const transport = yield* SessionModelTransport.Service
const app = yield* App.Service
const flags = yield* Flag.Service
const prepare = Effect.fn("SessionModelRequest.prepare")(function* <
S extends SessionRequest & { tools?: Definitions },
>(kind: SessionRequestKind, input: Input, shape: (draft: SessionRequest, tools: Definitions) => Effect.Effect<S>) {
Expand Down Expand Up @@ -315,6 +317,16 @@ export const layer = Layer.effect(
return t ? [[name, { ...t, description: d.description, inputSchema: d.input }] as const] : []
}),
)
// Hooks can remove or rename execute. Check the resolved tool behind each advertised name.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hiding webfetch loses more than a duplicate tool. The standalone tool converts HTML to markdown, attaches images so the model can see them, retries Cloudflare challenges and caps the output size. Code Mode's fetch returns the raw Response, so with this flag the model gets raw HTML (more tokens) and cannot look at fetched images. The flag is on by default in local/dev builds, so those users get this now. The Meta system prompt (plugin/system-prompt/meta.txt) also still tells the model to use webfetch. Could Code Mode offer the markdown conversion (for example tools.webfetch inside execute), or could this gap be accepted explicitly as part of the experiment?

if (
input.tools?.codeModeCatalog &&
Array.from(hooked.values()).some((tool) => tool.name === "execute") &&
(yield* flags.enabled("codemode_hide_webfetch"))
) {
for (const [name, tool] of hooked) {
if (tool.name === "webfetch") hooked.delete(name)
}
}
const entries = Object.entries(shaped.options)
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
Expand Down Expand Up @@ -476,5 +488,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHooks.node, SessionModelTransport.node, App.node],
deps: [PluginHooks.node, SessionModelTransport.node, App.node, Flag.node],
})
61 changes: 61 additions & 0 deletions packages/core/test/session-model-request-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@ import { describe, expect } from "bun:test"
import { ToolDefinition } from "@opencode/ai/effect"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Agent } from "@opencode/schema/agent"
import { Document, Info } from "@opencode/schema/config"
import { ConfigExperimental } from "@opencode/schema/config/experimental"
import { Money } from "@opencode/schema/money"
import { Session } from "@opencode/schema/session"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
import { Config } from "@opencode/core/config"
import { Flag } from "@opencode/core/flag"
import { Location } from "@opencode/core/location"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { Project } from "@opencode/core/project"
import { AbsolutePath } from "@opencode/core/schema"
import { SessionModelRequest } from "@opencode/core/session/model-request"
import { SessionModelTransport } from "@opencode/core/session/model-transport"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { Tool } from "@opencode/core/tool"
import { WebFetchTool } from "@opencode/core/tool/plugin/webfetch"
import { WebSearchTool } from "@opencode/core/tool/plugin/websearch"
import { DateTime, Effect, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/http"
import { testEffect } from "./lib/effect"
import { registerToolPlugin } from "./lib/tool"
import { PluginTestLayer } from "./plugin/fixture"

const it = testEffect(PluginTestLayer)
Expand All @@ -40,6 +48,59 @@ const transport = SessionModelTransport.Service.of({
closeAll: Effect.void,
})

describe("SessionModelRequest tools", () => {
it.effect("hides standalone webfetch only when enabled and execute survives request shaping", () =>
Effect.gen(function* () {
yield* registerToolPlugin(WebFetchTool.Plugin)
yield* registerToolPlugin(WebSearchTool.Plugin)
const registry = yield* Tool.Service
const hooks = yield* PluginHooks.Service
const config = yield* Config.Test
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
const names = Effect.fnUntraced(function* (permissions?: Parameters<typeof registry.snapshot>[0]) {
const tools = yield* registry.snapshot(permissions)
const prepared = yield* requests.primary({
session,
agent: Agent.ID.make("build"),
model,
system: [],
messages: [],
tools,
})
return prepared.request.tools.map((tool) => tool.name)
})

expect(yield* names()).toEqual(["websearch", "execute"])
expect(yield* names([{ action: "execute", resource: "*", effect: "deny" }])).toEqual(["webfetch", "websearch"])
yield* config.setEntries([
new Document({
type: "document",
info: new Info({ experimental: new ConfigExperimental.Info({ codemode_hide_webfetch: false }) }),
}),
])
expect(yield* names()).toEqual(["webfetch", "websearch", "execute"])
yield* config.setEntries([])

yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
if (event.tools.execute) event.tools.run = event.tools.execute
delete event.tools.execute
}),
)
expect(yield* names()).toEqual(["websearch", "run"])
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
delete event.tools.run
}),
)
expect(yield* names()).toEqual(["webfetch", "websearch"])
}).pipe(
Effect.provide(Flag.testLayer([], { app: { channel: "dev" } })),
Effect.provideService(SessionModelTransport.Service, transport),
),
)
})

describe("SessionModelRequest HTTP hooks", () => {
it.effect(
"advertises only supported freeform formats without removing tools or changing the shared definitions",
Expand Down
3 changes: 3 additions & 0 deletions packages/protocol/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/schema/src/config/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const wildcard = "all"
* `experimental.all_flags: true` turn every flag on. Read one with Core's `Flag.enabled("<key>")`.
*/
export const registry = {
codemode_hide_webfetch:
"Hide the standalone webfetch tool when Code Mode's execute tool is available for the request.",
todo: "Enable the experimental todowrite tool.",
background_compaction:
"Enable experimental background compaction: summarize the conversation in the background as it nears the compaction threshold, so automatic compaction does not wait for the summary. Background summaries that are never used still cost tokens.",
Expand Down
Loading