feat(core): attach global native tools (#30832)

This commit is contained in:
Kit Langton 2026-06-04 23:12:17 -04:00 committed by GitHub
commit 64dc6d39ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 647 additions and 71 deletions

View file

@ -27,7 +27,8 @@ import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
import { BuiltInTools } from "./tool/builtins"
import { ToolRegistry } from "./tool-registry"
import { ToolRegistry } from "./tool/registry"
import { ApplicationTools } from "./tool/application-tools"
import { ToolOutputStore } from "./tool-output-store"
import { AppProcess } from "./process"
import { Ripgrep } from "./ripgrep"
@ -108,5 +109,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
],
}) {}

View file

@ -3,6 +3,7 @@ export { Agent } from "./agent"
export { Model } from "./model"
export { OpenCode } from "./opencode"
export { Session } from "./session"
export { Tool } from "./tool"
export { Location } from "./location"
export { Prompt } from "../session/prompt"
export { AbsolutePath } from "../schema"

View file

@ -9,10 +9,13 @@ import { SessionV2 } from "../session"
import * as SessionExecutionLocal from "../session/execution/local"
import { SessionProjector } from "../session/projector"
import { SessionStore } from "../session/store"
import { ApplicationTools } from "../tool/application-tools"
import { Session } from "./session"
import { Tool } from "./tool"
export interface Interface {
readonly sessions: Session.Interface
readonly tools: Tool.Service
}
/** Intentional public native API for Effect applications embedding OpenCode. */
@ -28,13 +31,16 @@ const SessionsLayer = SessionV2.layer.pipe(
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
)
const ApplicationToolsLayer = ApplicationTools.layer
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const tools = yield* ApplicationTools.Service
return Service.of({
tools: { attach: tools.attach },
sessions: {
create: (input) =>
sessions.create({
@ -65,6 +71,6 @@ export const layer = Layer.effect(
},
})
}),
).pipe(Layer.provide(SessionsLayer))
).pipe(Layer.provide(Layer.merge(ApplicationToolsLayer, SessionsLayer)))
// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.

View file

@ -0,0 +1,19 @@
export * as Tool from "./tool"
import { Effect, Scope } from "effect"
import type { NativeTool } from "../tool/native"
export { Failure, make } from "../tool/native"
export type { Any, Content, Context, Executable } from "../tool/native"
export interface Service {
/**
* Attach same-process tools to this OpenCode instance for the current Scope.
* Location tools with the same name take precedence where they are installed.
* Closing the Scope removes the tools immediately, so calls that have not
* started settling may fail because the tool is no longer available.
*/
readonly attach: (
tools: Readonly<Record<string, NativeTool.Any>>,
) => Effect.Effect<void, never, Scope.Scope>
}

View file

@ -9,7 +9,7 @@ import { SessionStore } from "../store"
import { Service, StepLimitExceededError } from "./index"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { ToolRegistry } from "../../tool-registry"
import { ToolRegistry } from "../../tool/registry"
import { SessionRunnerModel } from "./model"
import { Database } from "../../database/database"
import { SessionInput } from "../input"

View file

@ -0,0 +1,139 @@
# Core Tool Architecture
This folder owns Core-native tool definition, contribution, effective lookup, and execution. Keep those concerns distinct even though `ToolRegistry` brings them together at runtime.
## Current Architecture
```txt
Public Tool.make NativeTool value ApplicationTools Location built-ins Location ToolRegistry Session runner
│ │ │ │ │ │
├─ construct ─────────▶ │ │ │ │
│ │ │ │ │ │
│ ├─ scoped attach ─────▶ │ │ │
│ │ │ │ │ │
│ │ │ ├─ scoped contributions ──▶ │
│ │ │ │ │ │
│ │ ├─ shared current entries ───────────────────────▶ │
│ │ │ │ │ │
│ │ │ │ ├─ effective definitions and settlement ──▶
│ │ │ │ │ │
```
There are three relevant representations:
- `native.ts` defines the plain Core-native executable value exposed publicly as `Tool.make(...)`. It combines an `@opencode-ai/llm` model-facing definition with a Session-aware handler.
- `application-tools.ts` stores process-scoped application contributions. It owns availability and scoped attachment, but it does not execute tools.
- `registry.ts` is the single execution registry. Each Location owns one registry, its built-in contributions, effective precedence, input/output validation, permissions, and settlement.
`ToolRegistry.Entry` is intentionally more powerful than the public native tool value. Internal Location tools may use Core-owned capabilities such as `assertPermission`; embedding applications receive only the narrow public execution context.
## Placement And Layers
- `ApplicationTools.Service` is process-scoped and must be shared by current and future Locations.
- `ToolRegistry.Service` is Location-scoped because built-in handlers close over Location services such as filesystem, permissions, and tool-output storage.
- `LocationServiceMap` constructs fresh Location services while receiving the shared `ApplicationTools.Service` as a dependency.
- `OpenCode.layer` exposes the same shared application-tool service through `opencode.tools.attach(...)`.
- `ToolRegistry.defaultLayer` creates isolated application-tool state. It is suitable for self-contained consumers and tests, but not when attachments must be shared with a separately constructed `LocationServiceMap`.
Do not make `ToolRegistry` process-global. Do not move Location resources into `ApplicationTools`. Do not construct independent `ApplicationTools.layer` instances when the caller expects one attachment to appear across Locations.
## Contribution And Precedence
Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`.
Both contribution mechanisms use `State` scoped transforms:
- Closing a contribution Scope rebuilds state without that contribution.
- A later same-name application attachment wins while active.
- Closing that later attachment reveals the earlier active application contribution.
- A Location tool always takes precedence over an application tool with the same name.
- Application attachment inputs are captured before registering the replayable transform; later caller mutation must not alter a contribution during an unrelated rebuild.
Do not introduce another application-specific tool type or registry. Plugins should contribute existing native tools or internal registry entries at the lifetime they actually own.
## Dynamic Removal Semantics
Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach.
```txt
Embedding App ApplicationTools Location ToolRegistry Session Runner
│ │ │ │
├─ attach({ opencord_run }) ──▶ │ │
│ │ │ │
│ │ ◀─ definitions() ──────────────────┤
│ │ │ │
│ ◀─ entries() ────────────┤ │
│ │ │ │
│ │ ├─ current effective definitions ──▶
│ │ │ │
├─ attachment Scope closes ───▶ │ │
│ │ │ │
│ │ ◀─ settle(opencord_run) ───────────┤
│ │ │ │
│ ◀─ current lookup ───────┤ │
│ │ │ │
│ │ ├─ Unknown tool ───────────────────▶
│ │ │ │
```
Consequences of this choice:
- Closing an attachment Scope revokes the tool immediately for calls that have not started settling.
- A call produced from an earlier advertised definition may fail as unknown.
- If a same-name replacement is currently active, a later call may execute that replacement.
- An execution that already resolved its entry continues with the handler it captured.
- Attachment Scope closure does not wait for already-started executions. Applications whose handlers depend on scoped resources must coordinate graceful shutdown themselves.
These are deliberate simplifications. Do not add snapshots, semaphores, leases, or deferred finalizers without a concrete requirement for stronger consistency or graceful draining.
## File Roles
```txt
tool/
native.ts plain public/Core-native executable tool value
application-tools.ts process-scoped State-backed application contributions
registry.ts Location-scoped effective lookup, validation, and execution
builtins.ts shipped Location tool layer composition
read.ts, bash.ts, ... individual Location-scoped built-in contributions
```
Keep model/provider-neutral tool schemas and output projection in `@opencode-ai/llm`. Keep Session identity, permissions, Location precedence, and settlement in Core.
## Future Directions
Tool availability may eventually gain a real third scope, such as Session-specific or plugin-owned contributions:
```txt
╭─────────────────╮
│ Tool definition │
╰────────┬────────╯
╭────────────────────────────────────────╰╮─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╮
│ │
▼ ▼ ▼
╭───────────────────────╮ ╭────────────────────────╮ ╭───────────────────────╮
│ Process contributions │ │ Location contributions │ │ Session contributions │
╰───────────┬───────────╯ ╰────────────┬───────────╯ ╰───────────┬───────────╯
│ │ │
│ │
╰─────────────────────────────────────────◀─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╯
╭──────────────────────╮
│ Effective resolution │
╭─────────╰───────────┬──────────╯────────────╮
│ │ │
▼ ▼
╭───────────────────────────────╮ ╭─────────────────────────╮
│ Advertise current definitions │ │ Execute current handler │
╰───────────────────────────────╯ ╰─────────────────────────╯
```
Prefer these directions only when a concrete use requires them:
- **Contextual availability:** Add Session/agent/plugin filtering at effective resolution. Keep tool definitions independent from where they are enabled.
- **Hierarchical overlays:** If a third contribution scope becomes real, consider one registry abstraction with process, Location, and Session overlays rather than adding another special registry service.
- **Plugin tools:** Reuse the existing native tool value for restricted handlers and `ToolRegistry.Entry` for trusted Core-owned capabilities. Choose process or Location contribution lifetime explicitly.
- **Stale-call rejection:** If executing a same-name replacement is unsafe, attach an identity/version to advertised definitions and reject stale calls without retaining removed handlers.
- **Pinned provider turns:** If exact advertisement-to-execution consistency becomes necessary, snapshot effective entries for one provider turn. This weakens immediate revocation.
- **Graceful plugin unload:** If attachment-owned resources must outlive started executions, add explicit execution draining. Keep this separate from whether new calls can discover the tool.
- **Cluster placement:** `ApplicationTools` is process-global, not cluster-global. Cluster-wide contribution and execution ownership require a separate durable design.
When choosing stronger semantics, state which property matters: immediate revocation, stale-call rejection, exact handler pinning, or graceful resource draining. They are different guarantees and should not arrive as one bundled lifecycle mechanism.

View file

@ -0,0 +1,51 @@
export * as ApplicationTools from "./application-tools"
import { Context, Effect, Layer, Scope } from "effect"
import { castDraft, enableMapSet } from "immer"
import { State } from "../state"
import { NativeTool } from "./native"
type Data = {
readonly entries: Map<string, NativeTool.Any>
}
type Editor = {
readonly set: (name: string, tool: NativeTool.Any) => void
}
export interface Interface {
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
readonly entries: () => ReadonlyMap<string, NativeTool.Any>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
set: (name, tool) => {
draft.entries.set(
name,
castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
)
},
}),
})
return Service.of({
attach: Effect.fn("ApplicationTools.attach")(function* (tools) {
const entries = Object.entries(tools)
const transform = yield* state.transform()
yield* transform((editor) => {
for (const [name, tool] of entries) editor.set(name, tool)
})
}),
entries: () => state.get().entries,
})
}),
)

View file

@ -6,7 +6,7 @@ import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "apply_patch"

View file

@ -10,7 +10,7 @@ import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process"
import { PositiveInt } from "../schema"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "bash"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000

View file

@ -12,7 +12,7 @@ import { Cause, Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "edit"

View file

@ -4,7 +4,7 @@ import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "glob"

View file

@ -5,7 +5,7 @@ import { Cause, Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { Ripgrep } from "../ripgrep"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "grep"

View file

@ -0,0 +1,73 @@
export * as NativeTool from "./native"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import type { SessionSchema } from "../session/schema"
export interface Context {
readonly sessionID: SessionSchema.ID
readonly id: string
readonly name: string
}
export type SchemaType<A> = Schema.Codec<A, any, never, never>
export interface Executable<Parameters extends SchemaType<any>, Success extends SchemaType<any>> {
readonly definition: Tool.Tool<Parameters, Success>
readonly execute: (
parameters: Schema.Schema.Type<Parameters>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
}
export type Any = Executable<any, any>
export const Failure = ToolFailure
export type Failure = ToolFailure
export type Content =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly data: string
readonly mime: string
readonly name?: string
}
export function make<Parameters extends SchemaType<any>, Success extends SchemaType<any>>(config: {
readonly description: string
readonly parameters: Parameters
readonly success: Success
readonly execute: (
parameters: Schema.Schema.Type<Parameters>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly toModelOutput?: (input: {
readonly callID: string
readonly parameters: Schema.Schema.Type<Parameters>
readonly output: Success["Encoded"]
}) => ReadonlyArray<Content>
}): Executable<Parameters, Success> {
const toModelOutput = config.toModelOutput
return {
definition: Tool.make({
description: config.description,
parameters: config.parameters,
success: config.success,
toModelOutput: toModelOutput
? (input) =>
toModelOutput(input).map((content) =>
content.type === "text"
? content
: {
type: "file",
source: { type: "data", data: content.data },
mime: content.mime,
name: content.name,
},
)
: undefined,
}),
execute: config.execute,
}
}

View file

@ -3,7 +3,7 @@ export * as QuestionTool from "./question"
import { Tool, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { QuestionV2 } from "../question"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "question"

View file

@ -6,7 +6,7 @@ import { FileSystem } from "../filesystem"
import { NonNegativeInt, PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "read"
const LocationInput = Schema.Struct({
@ -93,7 +93,7 @@ export const layer = Layer.effectDiscard(
}),
)
export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.layer),
Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(PermissionV2.locationLayer),
Layer.provideMerge(ToolOutputStore.defaultLayer),

View file

@ -1,4 +1,4 @@
export * as ToolRegistry from "./tool-registry"
export * as ToolRegistry from "./registry"
import {
Tool,
@ -13,10 +13,11 @@ import {
} from "@opencode-ai/llm"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet } from "immer"
import { PermissionV2 } from "./permission"
import { State } from "./state"
import { SessionSchema } from "./session/schema"
import type { SessionV2 } from "./session"
import { PermissionV2 } from "../permission"
import { State } from "../state"
import { SessionSchema } from "../session/schema"
import type { SessionV2 } from "../session"
import { ApplicationTools } from "./application-tools"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
@ -86,6 +87,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const applications = yield* ApplicationTools.Service
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
@ -104,19 +106,36 @@ export const layer = Layer.effect(
})
const definitions = Effect.fn("ToolRegistry.definitions")(function* () {
return Tool.toDefinitions(
Object.fromEntries(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool])),
)
const tools = new Map(Array.from(state.get().entries, ([name, entry]) => [name, entry.tool] as const))
// Location tools own their names. Application tools fill otherwise-unclaimed names.
for (const [name, tool] of applications.entries()) {
if (!tools.has(name)) tools.set(name, tool.definition)
}
return Tool.toDefinitions(Object.fromEntries(tools))
})
const entry = (name: string): Entry | undefined => {
const local = state.get().entries.get(name)
if (local !== undefined) return local
const tool = applications.entries().get(name)
if (tool === undefined) return
return {
tool: tool.definition,
execute: ({ parameters, sessionID, call }) =>
tool.execute(parameters, { sessionID, id: call.id, name: call.name }),
}
}
const invocation = (input: ExecuteInput): Invocation => ({
...input,
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
assertPermission: (request) => permission.assert({ ...request, sessionID: input.sessionID }),
})
const settle = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput) {
const entry = state.get().entries.get(input.call.name)
const settleEntry = Effect.fn("ToolRegistry.settleEntry")(function* (
entry: Entry | undefined,
input: ExecuteInput,
) {
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
if (!entry.execute && !entry.tool.execute)
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
@ -155,6 +174,7 @@ export const layer = Layer.effect(
)
})
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) => settleEntry(entry(input.call.name), input))
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
})
@ -171,3 +191,5 @@ export const layer = Layer.effect(
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(ApplicationTools.layer))

View file

@ -8,7 +8,7 @@ import { FSUtil } from "../fs-util"
import { PluginBoot } from "../plugin/boot"
import { SkillV2 } from "../skill"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "skill"
const FILE_LIMIT = 10

View file

@ -3,7 +3,7 @@ export * as TodoWriteTool from "./todowrite"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "todowrite"

View file

@ -6,7 +6,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "webfetch"
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024

View file

@ -7,7 +7,7 @@ import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
import { checksum } from "../util/encode"
export const name = "websearch"

View file

@ -11,7 +11,7 @@ import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
import { ToolRegistry } from "../tool-registry"
import { ToolRegistry } from "./registry"
export const name = "write"