refactor(core): unify v2 tool architecture (#31168)

This commit is contained in:
Kit Langton 2026-06-06 20:49:12 -04:00 committed by GitHub
parent effd27b239
commit 660a00d317
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2314 additions and 2058 deletions

View file

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

View file

@ -13,11 +13,10 @@ 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
readonly tools: import("./tool").Service
}
/** Intentional public native API for Effect applications embedding OpenCode. */
@ -88,7 +87,7 @@ export const layer = Layer.effect(
const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation
return Service.of({
tools: { attach: tools.attach },
tools: { register: tools.register },
sessions: {
create: (input) =>
sessions.create({

View file

@ -1,17 +1,16 @@
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 { Failure, RegistrationError, make } from "../tool/tool"
export type { AnyTool, Content, Context, Tool } from "../tool/tool"
export interface Service {
/**
* Attach same-process tools to this OpenCode instance for the current Scope.
* Register same-process tools on 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>
readonly register: (
tools: Readonly<Record<string, import("../tool/tool").AnyTool>>,
) => Effect.Effect<void, import("../tool/tool").RegistrationError, Scope.Scope>
}

View file

@ -7,6 +7,7 @@ import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError",
@ -24,6 +25,7 @@ export type RunError =
| StepLimitExceededError
| SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {

View file

@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextEpoch } from "../context-epoch"
import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
@ -63,7 +64,7 @@ import { toLLMMessages } from "./to-llm-message"
* - [x] Authorize and execute recorded local calls through a core-owned registry hook.
* - [x] Persist typed success, failure, and provider-executed tool outcomes.
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
* - [ ] Add scoped runtime context, progress updates, output truncation, attachment normalization,
* - [ ] Add scoped runtime context, progress updates, attachment normalization,
* plugins, and cancellation settlement.
* - [x] Reload projected history and start the next explicit provider turn after local tool results.
* - [x] Continue for durable user steering accepted during an active provider turn.
@ -131,7 +132,7 @@ export const layer = Layer.effect(
}
})
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, never>) =>
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
@ -185,7 +186,7 @@ export const layer = Layer.effect(
session.location,
agent.id,
).pipe(retryAgentMismatch(promotion))
const toolFibers = yield* FiberSet.make<void, never>()
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
if (promotion) {
const cutoff = yield* SessionInput.latestSeq(db, session.id)
@ -211,6 +212,7 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@ -219,7 +221,7 @@ export const layer = Layer.effect(
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(agent.info?.permissions),
tools: toolMaterialization.definitions,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
@ -251,16 +253,16 @@ export const layer = Layer.effect(
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) =>
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe(
Effect.catchCause((cause) => {
if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause)
return Effect.succeed({
result: { type: "error" as const, value: String(Cause.squash(cause)) },
output: undefined,
outputPaths: [],
})
restore(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
call: event,
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
@ -322,8 +324,8 @@ export const layer = Layer.effect(
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
const attempt = stream._tag === "Failure" ? stream : settled
if (attempt._tag === "Failure") return yield* Effect.failCause(attempt.cause)
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
return !publisher.hasProviderError() && needsContinuation
}),
)

View file

@ -218,6 +218,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
})
const assistantMessageIDForTool = (callID: string) => {
const tool = tools.get(callID)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
@ -408,5 +413,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
startAssistant,
assistantMessageID: assistantMessageIDForTool,
}
}

View file

@ -1,7 +1,7 @@
export * as ToolOutputStore from "./tool-output-store"
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Config } from "./config"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
@ -11,27 +11,11 @@ import type { ToolOutput } from "@opencode-ai/llm"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
export const MAX_INLINE_MEDIA_BYTES = 5 * 1024 * 1024
export const RETENTION = Duration.days(7)
export const MANAGED_DIRECTORY = "tool-output"
export interface WriteInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
readonly content: string
readonly mime?: string
readonly name?: string
}
export interface TruncateInput extends WriteInput {
readonly maxLines?: number
readonly maxBytes?: number
}
export type TruncateResult =
| { readonly content: string; readonly truncated: false }
| { readonly content: string; readonly truncated: true; readonly outputPath: string }
export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
@ -43,11 +27,22 @@ export interface BoundResult {
readonly outputPaths: ReadonlyArray<string>
}
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
operation: Schema.Literals(["encode", "write"]),
cause: Schema.Defect,
}) {}
export class MediaLimitError extends Schema.TaggedErrorClass<MediaLimitError>()("ToolOutputStore.MediaLimitError", {
mime: Schema.String,
bytes: Schema.Int,
limit: Schema.Int,
}) {}
export type Error = StorageError | MediaLimitError
export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly write: (input: WriteInput) => Effect.Effect<string>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
readonly cleanup: () => Effect.Effect<void>
}
@ -109,6 +104,12 @@ const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes
return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
}
const lineCount = (text: string) => {
let count = 1
for (const char of text) if (char === "\n") count++
return count
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@ -116,7 +117,6 @@ export const layer = Layer.effect(
const global = yield* Global.Service
const config = yield* Effect.serviceOption(Config.Service)
const directory = path.join(global.data, MANAGED_DIRECTORY)
const limits = Effect.fn("ToolOutputStore.limits")(function* () {
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
@ -127,68 +127,54 @@ export const layer = Layer.effect(
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
})
const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
const file = path.join(directory, `tool_${Identifier.ascending()}`)
yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie)
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
yield* fs
.writeFileString(file, content, { flag: "wx" })
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
return file
})
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
const configured = yield* limits()
const maxLines = input.maxLines ?? configured.maxLines
const maxBytes = input.maxBytes ?? configured.maxBytes
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
return { content: input.content, truncated: false } as const
}
const outputPath = yield* write(input)
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
content: boundedPreview(input.content, marker, maxLines, maxBytes),
truncated: true,
outputPath,
} as const
})
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n")
const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe(
Effect.catch(() => Effect.succeed(String(input.output.structured))),
)
const content = text || input.output.content.length > 0 ? text : structured
if (content === undefined) return { output: input.output, outputPaths: [] }
const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file")
let mediaBytes = 0
for (const item of media) {
if (item.source.type !== "data") continue
mediaBytes += Buffer.byteLength(item.source.data, "utf-8")
if (mediaBytes > MAX_INLINE_MEDIA_BYTES)
return yield* new MediaLimitError({ mime: item.mime, bytes: mediaBytes, limit: MAX_INLINE_MEDIA_BYTES })
}
const contextual = {
structured: media.length > 0 ? {} : input.output.structured,
content: input.output.content.filter((item) => item.type === "text"),
}
const encoded = yield* Effect.try({
try: () => JSON.stringify(contextual, null, 2),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes)
return {
output: { structured: contextual.structured, content: input.output.content },
outputPaths: [],
}
const truncated = yield* truncate({
sessionID: input.sessionID,
toolCallID: input.toolCallID,
content,
mime: "text/plain",
name: `${input.toolCallID}.txt`,
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Unable to retain complete tool output", cause).pipe(
Effect.andThen(limits()),
Effect.map(({ maxLines, maxBytes }) => {
const marker = "... output truncated; omitted content could not be retained ..."
return {
content: boundedPreview(content, marker, maxLines, maxBytes),
truncated: true as const,
}
}),
),
),
)
if (!truncated.truncated) return { output: input.output, outputPaths: [] }
const outputPath = yield* write(encoded)
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
output: {
structured: input.output.structured,
structured: {},
content: [
{ type: "text" as const, text: truncated.content },
...input.output.content.filter((item) => item.type === "file"),
{
type: "text" as const,
text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
},
outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [],
outputPaths: [outputPath],
}
})
@ -207,7 +193,7 @@ export const layer = Layer.effect(
}
})
return Service.of({ limits, write, truncate, bound, cleanup })
return Service.of({ limits, bound, cleanup })
}),
)

View file

@ -1,139 +1,58 @@
# 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.
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
## Current Architecture
## Representations
```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 ──▶
│ │ │ │ │ │
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Application tools and shipped built-ins use the same type.
- `application-tools.ts` stores process-scoped application registrations.
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
- `registry.ts` stores only canonical tools, overlays Location registrations over application registrations, derives definitions, invokes tools, and applies generic output bounding.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
## Construction
Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
```ts
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
```
There are three relevant representations:
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive.
- `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.
## Registration
`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.
Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`.
## Placement And Layers
Both are scoped:
- `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`.
- The latest active same-placement registration wins.
- Closing any registration removes only that registration and reveals the next active one.
- Location registrations take precedence over application registrations.
- An invocation captures the effective tool once settlement starts.
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.
`ApplicationTools.Service` is process-scoped and shared by all Locations. `ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
## Contribution And Precedence
## Permissions
Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`.
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action.
Both contribution mechanisms use `State` scoped transforms:
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
- 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.
## Output
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.
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
## Dynamic Removal Semantics
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach.
## Current Gaps
```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.
- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
- MCP and future Session-scoped registrations still need an explicit canonical registration design.

View file

@ -1,21 +1,28 @@
export * as ApplicationTools from "./application-tools"
import { Context, Effect, Layer, Scope } from "effect"
import { castDraft, enableMapSet } from "immer"
import { enableMapSet } from "immer"
import { State } from "../state"
import { NativeTool } from "./native"
import { Tool } from "./tool"
type Data = {
readonly entries: Map<string, NativeTool.Any>
readonly entries: Map<string, Entry>
}
type Editor = {
readonly set: (name: string, tool: NativeTool.Any) => void
readonly set: (name: string, entry: Entry) => void
}
export interface Entry {
readonly identity: object
readonly tool: Tool.AnyTool
}
export interface Interface {
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
readonly entries: () => ReadonlyMap<string, NativeTool.Any>
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
readonly entries: () => ReadonlyMap<string, Entry>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
@ -29,20 +36,20 @@ export const layer = Layer.effect(
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,
)
draft.entries.set(name, tool)
},
}),
})
return Service.of({
attach: Effect.fn("ApplicationTools.attach")(function* (tools) {
register: Effect.fn("ApplicationTools.register")(function* (tools) {
const entries = Object.entries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
const transform = yield* state.transform()
yield* transform((editor) => {
for (const [name, tool] of entries) editor.set(name, tool)
for (const [name, entry] of registrations) editor.set(name, entry)
})
}),
entries: () => state.get().entries,

View file

@ -1,12 +1,14 @@
export * as ApplyPatchTool from "./apply-patch"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "apply_patch"
@ -33,14 +35,6 @@ export const toModelOutput = (output: Success) =>
),
].join("\n")
const definition = Tool.make({
description:
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
@ -51,116 +45,133 @@ type Prepared =
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string, cause: unknown) => {
const prefix =
applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix, error: cause })
}
return Effect.gen(function* () {
if (!parameters.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({
try: () => Patch.parse(parameters.patchText),
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
})
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks)
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { target } of targets) {
const external = target.externalDirectory
if (external) externalDirectories.set(external.resource, external)
}
for (const external of externalDirectories.values()) {
yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
}
yield* assertPermission({
action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
})
const prepared: Prepared[] = []
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({ ...hunk, target })
return
yield* tools
.register({
[name]: Tool.withPermission(
Tool.make({
description:
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Parameters,
output: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string) => {
const prefix =
applied.length === 0
? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix })
}
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
if ((yield* fs.stat(target.canonical)).type !== "File")
yield* fail(hunk.path, new Error("Target file does not exist"))
if (hunk.type === "delete") {
prepared.push({ ...hunk, target })
return
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({
try: () => Patch.parse(input.patchText),
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
})
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(hunk.path, Cause.squash(cause)))))
}
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
target: change.target,
content:
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ target: change.target })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({
target: change.target,
expected: change.source,
content: change.content,
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks)
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { target } of targets) {
const external = target.externalDirectory
if (external) externalDirectories.set(external.resource, external)
}
for (const external of externalDirectories.values()) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.path, Cause.squash(cause))))),
{ discard: true },
)
return { applied }
}).pipe(
Effect.catchCause((cause) => {
const error = Cause.squash(cause)
return Effect.fail(error instanceof ToolFailure ? error : fail("patch", error))
}),
)
},
}),
)
}
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const prepared: Prepared[] = []
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({ ...hunk, target })
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
if (hunk.type === "delete") {
prepared.push({ ...hunk, target })
return
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
target: change.target,
content:
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ target: change.target })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({
target: change.target,
expected: change.source,
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
"edit",
),
})
.pipe(Effect.orDie)
}),
)

View file

@ -1,16 +1,17 @@
export * as BashTool from "./bash"
import path from "path"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Duration, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process"
import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "bash"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@ -41,7 +42,6 @@ const Success = Schema.Struct({
truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
outputPath: Schema.String.pipe(Schema.optional),
timedOut: Schema.Boolean.pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
@ -59,6 +59,7 @@ const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
return undefined
}
const modelOutput = (output: Success) => {
@ -72,13 +73,6 @@ const modelOutput = (output: Success) => {
const isTimeout = (error: AppProcess.AppProcessError) =>
error.cause instanceof Error && error.cause.message === "Timed out"
const definition = Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
})
/**
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core.
@ -112,96 +106,101 @@ const externalCommandDirectories = (command: string, cwd: string) => {
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service
const fs = yield* FSUtil.Service
const appProcess = yield* AppProcess.Service
const resources = yield* ToolOutputStore.Service
const config = yield* Config.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
const warnings = externalCommandDirectories(parameters.command, target.canonical).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* assertPermission({ action: name, resources: [parameters.command], save: [parameters.command] })
if ((yield* fs.stat(target.canonical)).type !== "Directory")
throw new Error(`Working directory is not a directory: ${target.canonical}`)
const entries = yield* config.entries()
const shell =
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))).shell ??
defaultShell()
const command = ChildProcess.make(parameters.command, [], {
cwd: target.canonical,
shell,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
})
const timeout = parameters.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess
.run(command, {
timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES,
maxErrorBytes: MAX_CAPTURE_BYTES,
})
.pipe(
Effect.catchTag("AppProcessError", (error) =>
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
),
)
if (!result) {
return {
command: parameters.command,
cwd: target.canonical,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timedOut: true,
...(warnings.length ? { warnings } : {}),
yield* tools
.register({
[name]: Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Parameters,
output: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
}
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const warnings = externalCommandDirectories(input.command, target.canonical).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* permission.assert({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
const truncated = yield* resources.truncate({
sessionID,
toolCallID: call.id,
content: notice ? `${compact}\n\n${notice}` : compact,
})
return {
command: parameters.command,
cwd: target.canonical,
exitCode: result.exitCode,
output: truncated.content,
truncated: truncated.truncated || result.stdoutTruncated || result.stderrTruncated,
...(warnings.length ? { warnings } : {}),
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
? { outputPath: truncated.outputPath }
: {}),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({
message: `Unable to execute command: ${parameters.command}`,
error: Cause.squash(cause),
}),
),
),
),
}),
)
if ((yield* fs.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const entries = yield* config.entries()
const shell =
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : [])))
.shell ?? defaultShell()
const command = ChildProcess.make(input.command, [], {
cwd: target.canonical,
shell,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
})
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess
.run(command, {
timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES,
maxErrorBytes: MAX_CAPTURE_BYTES,
})
.pipe(
Effect.catchTag("AppProcessError", (error) =>
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
),
)
if (!result) {
return {
command: input.command,
cwd: target.canonical,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timedOut: true,
...(warnings.length ? { warnings } : {}),
}
}
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
return {
command: input.command,
cwd: target.canonical,
exitCode: result.exitCode,
output: notice ? `${compact}\n\n${notice}` : compact,
truncated: result.stdoutTruncated || result.stderrTruncated,
...(warnings.length ? { warnings } : {}),
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}),
})
.pipe(Effect.orDie)
}),
)

View file

@ -17,7 +17,7 @@ import { WriteTool } from "./write"
/**
* Composes only the shipped Location-scoped built-in tool contributions.
* Each tool retains its implementation and focused tests independently. Dynamic
* MCP and plugin tools later use separate scoped ToolRegistry transforms, while
* MCP and plugin tools later use separate scoped canonical registrations, while
* provider/model filtering belongs to a future materialization phase rather
* than this static list. The caller intentionally supplies shared Location
* services once to this merged set.

View file

@ -7,12 +7,14 @@
*/
export * as EditTool from "./edit"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "edit"
@ -78,16 +80,6 @@ export const toModelOutput = (output: Success, oldString: string, newString: str
"```",
].join("\n")
const definition = Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
parameters: Parameters,
success: Success,
toModelOutput: ({ parameters, output }) => [
toolText({ type: "text", text: toModelOutput(output, parameters.oldString, parameters.newString) }),
],
})
/** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
// TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.
// TODO: Add formatter integration after V2 formatter runtime exists.
@ -97,80 +89,112 @@ const definition = Tool.make({
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchCause((cause) => {
const error = Cause.squash(cause)
return Effect.fail(
error instanceof FileMutation.StaleContentError
? new ToolFailure({
message: "File changed after permission approval. Read it again before editing.",
})
: new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }),
yield* tools
.register({
[name]: Tool.withPermission(
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
input: Parameters,
output: Success,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
],
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.mapError((error) =>
error instanceof FileMutation.StaleContentError
? new ToolFailure({
message: "File changed after permission approval. Read it again before editing.",
})
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
),
)
}),
)
return Effect.gen(function* () {
if (parameters.oldString === parameters.newString) {
return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." })
}
if (parameters.oldString === "") {
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
return Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
message: "No changes to apply: oldString and newString are identical.",
})
}
if (input.oldString === "") {
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
})
}
const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" }))
const external = target.externalDirectory
if (external) {
yield* unableToEdit(
permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
}),
)
}
yield* unableToEdit(
permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
}),
)
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
const ending = detectLineEnding(source.text)
const oldString = convertToLineEnding(input.oldString, ending)
const newString = convertToLineEnding(input.newString, ending)
const replacements = countOccurrences(source.text, oldString)
if (replacements === 0) {
return yield* new ToolFailure({
message:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
})
}
if (replacements > 1 && input.replaceAll !== true) {
return yield* new ToolFailure({
message:
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
})
}
const replaced =
input.replaceAll === true
? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
target,
expected: source.content,
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Success
})
}
const target = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
const external = target.externalDirectory
if (external) {
yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external)))
}
yield* unableToEdit(assertPermission({ action: "edit", resources: [target.resource], save: ["*"] }))
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
const ending = detectLineEnding(source.text)
const oldString = convertToLineEnding(parameters.oldString, ending)
const newString = convertToLineEnding(parameters.newString, ending)
const replacements = countOccurrences(source.text, oldString)
if (replacements === 0) {
return yield* new ToolFailure({
message:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
})
}
if (replacements > 1 && parameters.replaceAll !== true) {
return yield* new ToolFailure({
message:
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
})
}
const replaced =
parameters.replaceAll === true
? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
target,
expected: source.content,
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Success
})
},
}),
)
},
}),
"edit",
),
})
.pipe(Effect.orDie)
}),
)

View file

@ -1,10 +1,12 @@
export * as GlobTool from "./glob"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "glob"
@ -36,14 +38,6 @@ export const toModelOutput = (output: ModelOutput) => {
return lines.join("\n")
}
const definition = Tool.make({
description:
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
parameters: Parameters,
success: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
/**
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and traversal.
@ -52,39 +46,42 @@ const definition = Tool.make({
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot({ path: parameters.path, reference: parameters.reference })
yield* assertPermission({
action: name,
resources: [parameters.pattern],
save: ["*"],
metadata: {
root: root.resource,
reference: parameters.reference,
path: parameters.path,
limit: parameters.limit,
},
})
return yield* search.files(parameters)
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({
message: `Unable to find files matching ${parameters.pattern}`,
error: Cause.squash(cause),
}),
),
yield* tools
.register({
[name]: Tool.make({
description:
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Parameters,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference })
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: root.resource,
reference: input.reference,
path: input.path,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
return yield* search.files(input)
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
),
),
}),
)
}),
})
.pipe(Effect.orDie)
}),
)

View file

@ -1,11 +1,13 @@
export * as GrepTool from "./grep"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { Ripgrep } from "../ripgrep"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "grep"
@ -51,14 +53,6 @@ export const toModelOutput = (output: Success) => {
return lines.join("\n")
}
const definition = Tool.make({
description:
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
parameters: Parameters,
success: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
/**
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and ripgrep execution.
@ -67,40 +61,49 @@ const definition = Tool.make({
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot(parameters)
yield* assertPermission({
action: name,
resources: [parameters.pattern],
save: ["*"],
metadata: {
root: root.resource,
reference: parameters.reference,
path: parameters.path,
include: parameters.include,
limit: parameters.limit,
},
})
return yield* search.grep(parameters)
}).pipe(
Effect.catchCause((cause) => {
const error = Cause.squash(cause)
const message =
error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
: `Unable to grep for ${parameters.pattern}`
return Effect.fail(new ToolFailure({ message, error }))
}),
),
}),
)
yield* tools
.register({
[name]: Tool.make({
description:
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Parameters,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot(input)
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: root.resource,
reference: input.reference,
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
return yield* search.grep(input)
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}`
: `Unable to grep for ${input.pattern}`
return new ToolFailure({ message })
}),
),
}),
})
.pipe(Effect.orDie)
}),
)

View file

@ -1,73 +0,0 @@
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

@ -1,9 +1,11 @@
export * as QuestionTool from "./question"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "question"
@ -40,42 +42,45 @@ export const toModelOutput = (
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
}
const definition = Tool.make({
description,
parameters: Parameters,
success: Success,
toModelOutput: ({ parameters, output }) => [
toolText({ type: "text", text: toModelOutput(parameters.questions, output.answers) }),
],
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const question = yield* QuestionV2.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
permission: { action: "question", resource: "*" },
authorize: ({ assertPermission }) =>
assertPermission({ action: "question", resources: ["*"] }).pipe(
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
),
execute: ({ parameters, sessionID, source }) =>
question
.ask({
sessionID,
questions: parameters.questions,
// The registry intentionally leaves source absent until it owns the durable assistant message ID.
tool: source?.type === "tool" ? { messageID: source.messageID, callID: source.callID } : undefined,
})
.pipe(
Effect.map((answers) => ({ answers })),
// V1 treats a dismissed question as an interrupted tool invocation rather than model-facing text.
Effect.orDie,
),
}),
)
yield* tools
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }),
],
execute: (input, context) =>
permission
.assert({
action: "question",
resources: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
.pipe(
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
Effect.andThen(
question
.ask({
sessionID: context.sessionID,
questions: input.questions,
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
})
.pipe(Effect.orDie),
),
Effect.map((answers) => ({ answers })),
),
}),
})
.pipe(Effect.orDie)
}),
)

View file

@ -1,15 +1,16 @@
export * as ReadTool from "./read"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Cause, Effect, Layer, Schema } from "effect"
import { Effect, Layer, Schema } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { Config } from "../config"
import { FileSystem } from "../filesystem"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "read"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
@ -53,34 +54,12 @@ const LocationInput = Schema.Struct({
const Input = LocationInput
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const definition = Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
parameters: Input,
success: Success,
toStructuredOutput: (output) =>
"type" in output && output.type === "binary" && SUPPORTED_IMAGE_MIMES.has(output.mime)
? { type: "media", mime: output.mime }
: output,
toModelOutput: ({ parameters, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return [
{ type: "text", text: "Image read successfully" },
{
type: "file",
source: { type: "data", data: output.content },
mime: output.mime,
name: parameters.path,
},
]
},
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const config = yield* Config.Service
const permission = yield* PermissionV2.Service
const loadPhoton = yield* Effect.cached(
Effect.sync(() => {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
@ -88,54 +67,113 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
)
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) => {
const input = parameters
return Effect.gen(function* () {
const resolved = yield* filesystem.resolveReadPath(input)
if (resolved.type === "directory") {
yield* assertPermission({ action: name, resources: [resolved.resource], save: ["*"] })
return yield* filesystem.listPage(input)
}
yield* assertPermission({
action: name,
resources: [resolved.resource],
save: ["*"],
})
const content = yield* filesystem.readTool(input, {
offset: input.offset,
limit: input.limit,
})
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
const mime = content.mime
const base64 = content.content
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: () => new ImageDecodeError(resolved.resource),
yield* tools
.register({
[name]: Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
input: Input,
output: Success,
toModelOutput: ({ input, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return [
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
]
},
execute: (input, context) => {
return Effect.gen(function* () {
const resolved = yield* filesystem.resolveReadPath(input)
yield* permission.assert({
action: name,
resources: [resolved.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(base64, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
if (!limits.autoResize)
return yield* Effect.die(
if (resolved.type === "directory") return yield* filesystem.listPage(input)
const content = yield* filesystem.readTool(input, {
offset: input.offset,
limit: input.limit,
})
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
const mime = content.mime
const base64 = content.content
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: () => new ImageDecodeError(resolved.resource),
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(base64, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
if (!limits.autoResize)
return yield* Effect.fail(
new ImageSizeError(
resolved.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height)
? acc
: [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
} finally {
resized.free()
}
}
return yield* Effect.fail(
new ImageSizeError(
resolved.resource,
width,
@ -146,65 +184,15 @@ export const layer = Layer.effectDiscard(
limits.maxBase64Bytes,
),
)
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height)
? acc
: [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
} finally {
resized.free()
}
} finally {
decoded.free()
}
return yield* Effect.die(
new ImageSizeError(
resolved.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
} finally {
decoded.free()
}
}
if (content.type === "binary") return yield* Effect.die(new FileSystem.BinaryFileError(resolved.resource))
return content
}).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
const error = Cause.squash(cause)
if (content.type === "binary")
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
return content
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError ||
@ -212,18 +200,12 @@ export const layer = Layer.effectDiscard(
error instanceof ImageSizeError
? error.message
: `Unable to read ${input.path}`
return yield* new ToolFailure({ message, error })
return new ToolFailure({ message })
}),
),
)
},
}),
)
)
},
}),
})
.pipe(Effect.orDie)
}),
)
export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(PermissionV2.locationLayer),
)

View file

@ -1,91 +1,35 @@
export * as ToolRegistry from "./registry"
import {
Tool,
ToolFailure,
ToolOutput,
ToolResultValue as ToolResult,
type Tool as TypedTool,
type ToolCall,
type ToolResultValue,
type ToolSchema,
type ToolSettlement,
} 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 { ApplicationTools } from "./application-tools"
import { ToolOutputStore } from "../tool-output-store"
import { Tool as LlmTool, ToolOutput, type ToolCall, type ToolSettlement } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ApplicationTools } from "./application-tools"
import { Tool } from "./tool"
import { Tools } from "./tools"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
readonly agent?: AgentV2.ID
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly call: ToolCall
}
/**
* Narrow cross-cutting context for one registry invocation. Leaf tools retain
* ownership of sequence-sensitive policy decisions; the registry only binds
* identity and shared helper behavior consistently.
*
* TODO: Add `source` when the runner can pass the durable owning assistant
* message ID alongside the call ID. Do not infer it from the tool call alone.
* TODO: Add cancellation and progress only when the runner exposes a real
* signal and durable/live progress sink.
*/
export type Invocation = ExecuteInput & {
readonly source?: PermissionV2.Source
readonly assertPermission: (
input: Omit<PermissionV2.AssertInput, "sessionID" | "agent" | "source">,
) => Effect.Effect<void, PermissionV2.Error | SessionV2.NotFoundError>
}
/** Kept as the leaf entry input name for backwards-compatible execute usage. */
export type AuthorizeInput<Parameters = unknown> = Invocation & {
readonly parameters: Parameters
}
export type Entry<
Parameters extends ToolSchema<any> = ToolSchema<any>,
Success extends ToolSchema<any> = ToolSchema<any>,
> = {
readonly tool: TypedTool<Parameters, Success>
/** Catalog visibility only. Execution authorization remains leaf-owned. */
readonly permission?: { readonly action: string; readonly resource: "*" }
readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
readonly execute?: (
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly outputPaths?: (output: Schema.Schema.Type<Success>) => ReadonlyArray<string>
}
type Data = {
readonly entries: Map<string, Entry>
}
export type Editor = {
readonly list: () => ReadonlyArray<readonly [string, Entry]>
readonly get: (name: string) => Entry | undefined
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(
name: string,
entry: Entry<Parameters, Success>,
) => void
readonly remove: (name: string) => void
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
readonly definitions: (
permissions?: PermissionV2.Ruleset,
) => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement>
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
export interface Materialization {
readonly definitions: ReadonlyArray<ReturnType<typeof LlmTool.toDefinitions>[number]>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
}
export interface Settlement extends ToolSettlement {
@ -94,153 +38,94 @@ export interface Settlement extends ToolSettlement {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
enableMapSet()
export const layer = Layer.effect(
const registryLayer = Layer.effect(
Service,
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const applications = yield* ApplicationTools.Service
const resources = yield* ToolOutputStore.Service
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
get: (name) => draft.entries.get(name) as Entry | undefined,
set: (name, entry) => {
draft.entries.set(
name,
castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
)
},
remove: (name) => {
draft.entries.delete(name)
},
}),
})
type Registration = { readonly identity: object; readonly tool: Tool.AnyTool }
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const definitions = Effect.fn("ToolRegistry.definitions")(function* (permissions: PermissionV2.Ruleset = []) {
const tools = new Map(state.get().entries)
// 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: tool.definition })
}
return Tool.toDefinitions(
Object.fromEntries(
Array.from(tools)
.filter(([name, entry]) => !whollyDisabled(entry.permission ?? defaultPermission(name), permissions))
.map(([name, entry]) => [name, entry.tool]),
),
)
})
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, ...(input.agent ? { agent: input.agent } : {}) }),
})
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}` } }
return yield* entry.tool._decode(input.call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((parameters) => {
const context = { ...invocation(input), parameters }
const execute =
entry.execute?.(context) ?? entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
return (
entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))
).pipe(
Effect.flatMap((value) =>
entry.tool._encode(value).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its success schema: ${error.message}`,
}),
),
),
),
Effect.map((value): Settlement => {
const settled = (() => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
})()
const retained = entry.outputPaths?.(value) ?? []
return retained.length > 0 ? { ...settled, outputPaths: retained } : settled
}),
)
}),
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
const registration =
local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name)
if (!registration)
return {
result: {
type: "error" as const,
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
},
}
if (advertised && registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
const pending = yield* Tool.settle(registration.tool, input.call, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
}).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
),
)
})
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const settled = yield* restore(settleEntry(entry(input.call.name), input))
if (!settled.output) return settled
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
output: settled.output,
})
if (bounded.output === settled.output && bounded.outputPaths.length === 0) return settled
const retained = [...(settled.outputPaths ?? []), ...bounded.outputPaths]
const result = ToolOutput.toResultValue(bounded.output)
return result.type === "error"
? { result, outputPaths: retained }
: { result, output: bounded.output, outputPaths: retained }
}),
),
)
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
if ("result" in pending) return pending
const output = pending.output
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
const result = ToolOutput.toResultValue(bounded.output)
if (result.type === "error")
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
return bounded.outputPaths.length > 0
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
: { result, output: bounded.output }
})
return Service.of({
transform: state.transform,
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
register: Effect.fn("ToolRegistry.register")(function* (tools) {
const entries = Object.entries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
const token = {}
for (const [name, tool] of entries)
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(name, registrations)
else local.delete(name)
}
}),
)
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) {
const registrations = new Map(applications.entries())
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, registration)
}
for (const [name, registration] of registrations)
if (whollyDisabled(Tool.permission(registration.tool, name), permissions)) registrations.delete(name)
return {
definitions: Array.from(registrations, ([name, registration]) => Tool.definition(name, registration.tool)),
settle: (input) => {
const registration = registrations.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
},
}
}),
definitions,
execute,
settle,
})
}),
)
function defaultPermission(name: string) {
return { action: ["edit", "write", "apply_patch"].includes(name) ? "edit" : name, resource: "*" as const }
}
export const layer = Layer.effect(
Tools.Service,
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
).pipe(Layer.provideMerge(registryLayer))
function whollyDisabled(permission: { readonly action: string; readonly resource: "*" }, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(permission.action, rule.action))
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
}

View file

@ -2,13 +2,14 @@ export * as SkillTool from "./skill"
import path from "path"
import { pathToFileURL } from "url"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FSUtil } from "../fs-util"
import { PluginBoot } from "../plugin/boot"
import { SkillV2 } from "../skill"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "skill"
const FILE_LIMIT = 10
@ -21,8 +22,6 @@ export const Success = Schema.Struct({
name: Schema.String,
directory: Schema.String,
output: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
})
export const description = [
@ -57,53 +56,50 @@ const unableToLoad = (name: string, error?: unknown) =>
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const fs = yield* FSUtil.Service
const boot = yield* PluginBoot.Service
const skills = yield* SkillV2.Service
const resources = yield* ToolOutputStore.Service
const permission = yield* PermissionV2.Service
yield* boot.wait()
const definition = Tool.make({
description,
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
})
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const current = yield* skills.list()
const skill = current.find((skill) => skill.name === parameters.name)
if (!skill) return yield* unableToLoad(parameters.name)
return yield* Effect.gen(function* () {
yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] })
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT)
: []
const output = yield* resources.truncate({
sessionID,
toolCallID: call.id,
content: toModelOutput(skill, files),
})
return {
name: skill.name,
directory,
output: output.content,
truncated: output.truncated,
...(output.truncated ? { outputPath: output.outputPath } : {}),
}
}).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause)))))
}),
}),
)
yield* tools
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
const skill = current.find((skill) => skill.name === input.name)
if (!skill) return yield* unableToLoad(input.name)
return yield* Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [skill.name],
save: [skill.name],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT)
: []
return {
name: skill.name,
directory,
output: toModelOutput(skill, files),
}
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
}),
}),
})
.pipe(Effect.orDie)
}),
)

View file

@ -1,9 +1,11 @@
export * as TodoWriteTool from "./todowrite"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "todowrite"
@ -18,33 +20,35 @@ export type Success = typeof Success.Type
export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2)
const definition = Tool.make({
description:
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const todos = yield* SessionTodo.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, sessionID, assertPermission }) =>
Effect.gen(function* () {
yield* assertPermission({ action: name, resources: ["*"], save: ["*"] })
yield* todos.update({ sessionID, todos: parameters.todos })
return { todos: parameters.todos }
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(new ToolFailure({ message: "Unable to update todos", error: Cause.squash(cause) })),
),
),
}),
)
yield* tools
.register({
[name]: Tool.make({
description:
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
input: Parameters,
output: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: ["*"],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
return { todos: input.todos }
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
}),
})
.pipe(Effect.orDie)
}),
)

View file

@ -0,0 +1,136 @@
export * as Tool from "./tool"
import { Tool as LlmTool, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import type { AgentV2 } from "../agent"
import type { SessionMessage } from "../session/message"
import type { SessionSchema } from "../session/schema"
export interface Context {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
}
export type SchemaType<A> = Schema.Codec<A, any, never, never>
declare const TypeId: unique symbol
export interface Tool<Input extends SchemaType<any>, Output extends SchemaType<any>> {
readonly [TypeId]: {
readonly _Input: Input
readonly _Output: Output
}
}
export type AnyTool = Tool<any, any>
export const Failure = ToolFailure
export type Failure = ToolFailure
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
name: Schema.String,
message: Schema.String,
}) {}
export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly execute: (
input: Schema.Schema.Type<Input>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
readonly toModelOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => ReadonlyArray<Content>
}
type Runtime = {
readonly permission?: string
readonly definition: (name: string) => ReturnType<typeof LlmTool.toDefinitions>[number]
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
}
const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Tool<Input, Output> {
const tool = Object.freeze({}) as Tool<Input, Output>
const definitions = new Map<string, ReturnType<typeof LlmTool.toDefinitions>[number]>()
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
if (cached) return cached
const definition = LlmTool.toDefinitions({
[name]: LlmTool.make({ description: config.description, parameters: config.input, success: config.output }),
})[0]
definitions.set(name, definition)
return definition
},
settle: (call, context) =>
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((input) =>
config.execute(input, context).pipe(
Effect.flatMap((output) =>
Schema.encodeEffect(config.output)(output).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its output schema: ${error.message}`,
}),
),
),
),
Effect.map((output) =>
ToolOutput.make(
output,
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
source: { type: "data" as const, data: part.data },
mime: part.mime,
name: part.name,
},
) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
),
),
),
),
),
})
return tool
}
export const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Tool<Input, Output>,
permission: string,
) => {
const decorated = Object.freeze({}) as Tool<Input, Output>
runtimes.set(decorated, { ...runtimeOf(tool), permission })
return decorated
}
export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name)
export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
function runtimeOf(tool: AnyTool) {
const runtime = runtimes.get(tool)
if (!runtime) throw new TypeError("Invalid Core Tool value")
return runtime
}

View file

@ -0,0 +1,13 @@
export * as Tools from "./tools"
import { Context, Effect, Scope } from "effect"
import { Tool } from "./tool"
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
/** Narrow registration-only Location capability. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Tools") {}

View file

@ -1,12 +1,13 @@
export * as WebFetchTool from "./webfetch"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "webfetch"
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
@ -34,8 +35,6 @@ const Success = Schema.Struct({
contentType: Schema.String,
format: Parameters.fields.format,
output: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
})
type Format = (typeof Parameters.Type)["format"]
@ -49,6 +48,7 @@ const acceptHeader = (format: Format) => {
case "html":
return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
}
return "*/*"
}
const headers = (format: Format, userAgent: string) => ({
@ -89,15 +89,17 @@ const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
const contentLength = response.headers["content-length"]
if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
return yield* Effect.die(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
}
const chunks: Uint8Array[] = []
let size = 0
yield* Stream.runForEach(response.stream, (chunk) =>
Effect.sync(() => {
Effect.gen(function* () {
size += chunk.byteLength
if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)
if (size > MAX_RESPONSE_BYTES)
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
chunks.push(chunk)
return undefined
}),
)
return Buffer.concat(chunks, size)
@ -115,9 +117,6 @@ const isTextualMime = (mime: string) =>
mime.endsWith("+xml") ||
mime === "application/javascript" ||
mime === "application/x-javascript"
const outputMime = (format: Format) =>
format === "markdown" ? "text/markdown" : format === "html" ? "text/html" : "text/plain"
const convert = (content: string, contentType: string, format: Format) => {
if (!contentType.includes("text/html")) return content
if (format === "markdown") return convertHTMLToMarkdown(content)
@ -125,71 +124,64 @@ const convert = (content: string, contentType: string, format: Format) => {
return content
}
const definition = Tool.make({
description,
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const http = yield* HttpClient.HttpClient
const resources = yield* ToolOutputStore.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const parsed = new URL(parameters.url)
assertHttpUrl(parsed)
yield* tools
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => assertHttpUrl(new URL(input.url)),
catch: (error) => error,
})
yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters })
yield* permission.assert({
action: name,
resources: [input.url],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, parameters.url, parameters.format).pipe(
Effect.catchIf(isCloudflareChallenge, () =>
execute(http, parameters.url, parameters.format, "opencode"),
),
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, input.url, input.format).pipe(
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
)
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)
if (isImageAttachment(mime))
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
if (!isTextualMime(mime))
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
return { body: yield* collectBody(response), contentType }
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.fail(new Error("Request timed out")),
}),
)
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)
if (isImageAttachment(mime)) throw new Error(`Unsupported fetched image content type: ${mime}`)
if (!isTextualMime(mime)) throw new Error(`Unsupported fetched file content type: ${mime}`)
return { body: yield* collectBody(response), contentType }
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(parameters.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.die(new Error("Request timed out")),
}),
)
const content = convert(new TextDecoder().decode(body), contentType, parameters.format)
const truncated = yield* resources.truncate({
sessionID,
toolCallID: call.id,
content,
mime: outputMime(parameters.format),
})
return {
url: parameters.url,
contentType,
format: parameters.format,
output: truncated.content,
truncated: truncated.truncated,
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({ message: `Unable to fetch ${parameters.url}`, error: Cause.squash(cause) }),
),
),
),
}),
)
const content = convert(new TextDecoder().decode(body), contentType, input.format)
return {
url: input.url,
contentType,
format: input.format,
output: content,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}),
})
.pipe(Effect.orDie)
}),
)

View file

@ -1,13 +1,14 @@
export * as WebSearchTool from "./websearch"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Context, Duration, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { checksum } from "../util/encode"
export const name = "websearch"
@ -165,12 +166,12 @@ const callMcp = <F extends Schema.Struct.Fields>(
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* response.text
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
return yield* parseResponse(body)
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.die(new Error(`${tool} request timed out`)),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}),
)
})
@ -178,82 +179,68 @@ const callMcp = <F extends Schema.Struct.Fields>(
const Success = Schema.Struct({
provider: Provider,
text: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
})
const definition = Tool.make({
description,
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const http = yield* HttpClient.HttpClient
const config = yield* ConfigService
const resources = yield* ToolOutputStore.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) => {
const provider = selectProvider(sessionID, config, config.provider)
return Effect.gen(function* () {
yield* assertPermission({
action: name,
resources: [parameters.query],
save: ["*"],
metadata: { ...parameters, provider },
})
yield* tools
.register({
[name]: Tool.make({
description,
input: Parameters,
output: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
yield* permission.assert({
action: name,
resources: [input.query],
save: ["*"],
metadata: { ...input, provider },
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const text =
provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: parameters.query,
type: parameters.type || "auto",
numResults: parameters.numResults || 8,
livecrawl: parameters.livecrawl || "fallback",
contextMaxCharacters: parameters.contextMaxCharacters,
})
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: parameters.query,
search_queries: [parameters.query],
session_id: sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
const truncated = yield* resources.truncate({ sessionID, toolCallID: call.id, content: text ?? NO_RESULTS })
return {
provider,
text: truncated.content,
truncated: truncated.truncated,
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({
message: `Unable to search the web for ${parameters.query}`,
error: Cause.squash(cause),
}),
),
),
)
},
}),
)
const text =
provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: input.query,
type: input.type || "auto",
numResults: input.numResults || 8,
livecrawl: input.livecrawl || "fallback",
contextMaxCharacters: input.contextMaxCharacters,
})
: yield* callMcp(
http,
PARALLEL_URL,
"web_search",
ParallelArgs,
{
objective: input.query,
search_queries: [input.query],
session_id: context.sessionID,
// V2 invocation context does not safely expose the model yet.
},
{
"User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
return {
provider,
text: text ?? NO_RESULTS,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
},
}),
})
.pipe(Effect.orDie)
}),
)

View file

@ -7,11 +7,13 @@
*/
export * as WriteTool from "./write"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
import { ToolRegistry } from "./registry"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "write"
@ -35,14 +37,6 @@ export type Success = typeof Success.Type
export const toModelOutput = (output: Success) =>
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
const definition = Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
/** Deferred V2 write UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after V2 formatter runtime exists.
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
@ -51,28 +45,50 @@ const definition = Tool.make({
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
execute: ({ parameters, assertPermission }) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: parameters.path, kind: "file" })
const external = target.externalDirectory
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
yield* assertPermission({ action: "edit", resources: [target.resource], save: ["*"] })
return yield* files.writeTextPreservingBom({ target, content: parameters.content })
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({ message: `Unable to write ${parameters.path}`, error: Cause.squash(cause) }),
),
),
),
}),
)
yield* tools
.register({
[name]: Tool.withPermission(
Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
input: Parameters,
output: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
}),
"edit",
),
})
.pipe(Effect.orDie)
}),
)

View file

@ -3,8 +3,12 @@ import { Tool } from "@opencode-ai/core/public"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tools } from "@opencode-ai/core/tool/tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
@ -20,11 +24,13 @@ const registry = ToolRegistry.layer.pipe(
const it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool")
const agent = AgentV2.ID.make("build")
const assistantMessageID = SessionMessage.ID.make("msg_application_tool")
const contextual = (contexts: Tool.Context[]) =>
Tool.make({
description: "Read application context",
parameters: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ answer: Schema.String }),
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
@ -37,23 +43,69 @@ const contextual = (contexts: Tool.Context[]) =>
})
describe("ApplicationTools", () => {
it.effect("keeps the Core carrier opaque and executes its single handler", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
const tool = contextual(contexts)
expect(Object.keys(tool)).toEqual([])
yield* applications.register({ opaque: tool })
expect(
yield* executeTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "ONCE" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
],
})
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
}),
)
it.effect("exposes narrow scoped Location registration and validates names", () =>
Effect.gen(function* () {
const tools: Tools.Interface = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* tools.register({ location_tool: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool"])
expect(yield* Effect.flip(tools.register({ "invalid name": contextual([]) }))).toBeInstanceOf(
Tool.RegistrationError,
)
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
it.effect("filters an application tool by its name without adding execution authorization", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* applications.attach({ application_context: contextual(contexts) })
yield* applications.register({ application_context: contextual(contexts) })
expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual(
[],
)
expect(
yield* registry.settle({
yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
).toEqual([])
expect(
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }])
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
}),
)
@ -63,14 +115,16 @@ describe("ApplicationTools", () => {
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* applications.attach({ application_context: contextual(contexts) })
yield* applications.register({ application_context: contextual(contexts) })
expect(yield* registry.definitions()).toMatchObject([
expect(yield* toolDefinitions(registry)).toMatchObject([
{ name: "application_context", description: "Read application context" },
])
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
}),
).toEqual({
@ -82,14 +136,14 @@ describe("ApplicationTools", () => {
],
},
output: {
structured: { answer: "HELLO" },
structured: {},
content: [
{ type: "text", text: "HELLO" },
{ type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" },
],
},
})
expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }])
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
}),
)
@ -99,11 +153,11 @@ describe("ApplicationTools", () => {
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"])
yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
yield* Scope.close(scope, Exit.void)
expect(yield* registry.definitions()).toEqual([])
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
@ -112,13 +166,15 @@ describe("ApplicationTools", () => {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const attachmentScope = yield* Scope.make()
yield* applications.attach({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* Scope.close(attachmentScope, Exit.void)
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
}),
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
@ -132,9 +188,9 @@ describe("ApplicationTools", () => {
const scope = yield* Scope.make()
yield* Scope.close(scope, Exit.void)
yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope))
yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
expect(yield* registry.definitions()).toEqual([])
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
@ -143,12 +199,12 @@ describe("ApplicationTools", () => {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const attached = { stable: contextual([]) }
yield* applications.attach(attached)
yield* applications.register(attached)
Object.assign(attached, { late: contextual([]) })
yield* Effect.scoped(applications.attach({ temporary: contextual([]) }))
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
}),
)
@ -159,22 +215,26 @@ describe("ApplicationTools", () => {
const firstContexts: Tool.Context[] = []
const secondContexts: Tool.Context[] = []
const scope = yield* Scope.make()
yield* applications.attach({ contextual: contextual(firstContexts) })
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
yield* applications.attach({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
yield* applications.register({ contextual: contextual(firstContexts) })
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
})
yield* Scope.close(scope, Exit.void)
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
})
expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }])
expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }])
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
}),
)
@ -182,32 +242,26 @@ describe("ApplicationTools", () => {
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const locationContexts: Tool.Context[] = []
const applicationContexts: Tool.Context[] = []
const location = contextual(locationContexts)
yield* transform((editor) =>
editor.set("shared", {
tool: location.definition,
permission: { action: "question", resource: "*" },
execute: ({ parameters, sessionID, call }) =>
location.execute(parameters, { sessionID, id: call.id, name: call.name }),
}),
)
yield* applications.attach({ shared: contextual(applicationContexts) })
yield* registry.register({ shared: location })
yield* applications.register({ shared: contextual(applicationContexts) })
expect(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map(
(yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual([])
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }])
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
expect(applicationContexts).toEqual([])
}),
)

View file

@ -0,0 +1,20 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Effect } from "effect"
export const toolIdentity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
}
export const toolDefinitions = (
registry: ToolRegistry.Interface,
permissions?: Parameters<typeof registry.materialize>[0],
) => registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))

View file

@ -10,6 +10,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool"
import { FSUtil } from "../src/fs-util"
import { Auth } from "../src/auth"
import { EventV2 } from "../src/event"
@ -50,11 +51,11 @@ describe("LocationServiceMap", () => {
).pipe(
Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () {
yield* (yield* ApplicationTools.Service).attach({
yield* (yield* ApplicationTools.Service).register({
application_context: Tool.make({
description: "Read application context",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
@ -77,7 +78,7 @@ describe("LocationServiceMap", () => {
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
return {
providers: yield* catalog.provider.all(),
tools: yield* (yield* ToolRegistry.Service).definitions(),
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
}
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))

View file

@ -30,11 +30,11 @@ describe("public native OpenCode API", () => {
expect(Session.ID.create()).toStartWith("ses_")
expect(Session.MessageID.create()).toStartWith("msg_")
expect(yield* opencode.sessions.list()).toBeArray()
yield* opencode.tools.attach({
yield* opencode.tools.register({
public_tool: Tool.make({
description: "Public tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})

View file

@ -1,65 +1,69 @@
import { describe, expect } from "bun:test"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
bound: (input) => {
if (input.toolCallID === "call-retention-failure") return Effect.fail(retentionFailure)
return Effect.sync(() => bounds.push(input)).pipe(
Effect.as(
input.toolCallID === "call-bounded"
? {
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
}
: { output: input.output, outputPaths: [] },
),
)
},
})
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
const it = testEffect(registry)
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"),
}
const sessionID = SessionV2.ID.make("ses_registry")
const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
sessionID,
...identity,
call: { type: "tool-call", id, name, input: { text: name } },
})
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(ApplicationTools.layer),
Layer.provide(outputStore),
)
const it = testEffect(Layer.mergeAll(permission, registry))
const echo = Tool.make({
description: "Echo text",
parameters: Schema.Struct({ text: Schema.String }),
success: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
})
const make = (permission?: string) => {
const tool = Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
})
return permission ? Tool.withPermission(tool, permission) : tool
}
describe("ToolRegistry", () => {
it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () =>
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_filter")
yield* transform((editor) => {
editor.set("question", { tool: echo })
editor.set("bash", { tool: echo })
editor.set("edit", { tool: echo })
editor.set("write", { tool: echo })
editor.set("apply_patch", { tool: echo })
const service = yield* ToolRegistry.Service
yield* service.register({
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
apply_patch: make("edit"),
})
const names = (rules: PermissionV2.Ruleset) =>
registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
const names = (rules: Parameters<ToolRegistry.Interface["materialize"]>[0]) =>
toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
@ -67,253 +71,254 @@ describe("ToolRegistry", () => {
"write",
"apply_patch",
])
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "question", resource: "private", effect: "allow" },
]),
).toEqual(["question"])
expect(
yield* names([
{ action: "question", resource: "private", effect: "allow" },
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
expect(yield* names([{ action: "question", resource: "*", effect: "ask" }])).toContain("question")
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
expect(
yield* names([
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: "*.md", effect: "ask" },
]),
).toEqual(["question", "bash", "edit", "write", "apply_patch"])
expect(
yield* names([
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
]),
).toEqual(["question", "bash"])
}),
)
it.effect("settles only through concrete leaf authorization, not catalog visibility", () =>
it.effect("keeps permission decoration isolated between registrations", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_stale")
let executed = false
yield* transform((editor) =>
editor.set("question", {
tool: echo,
permission: { action: "question", resource: "*" },
authorize: ({ assertPermission }) =>
assertPermission({ action: "question", resources: ["actual"] }).pipe(
Effect.mapError(() => new ToolFailure({ message: "Denied" })),
),
execute: () =>
Effect.sync(() => {
executed = true
return { text: "unexpected" }
}),
}),
)
const service = yield* ToolRegistry.Service
const shared = make()
yield* service.register({ first: shared })
yield* service.register({ second: Tool.withPermission(shared, "edit") })
Tool.withPermission(shared, "question")
expect(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name),
).toEqual([])
expect(
yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } },
}),
).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } })
expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] })
expect(executed).toBe(true)
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual(["first"])
}),
)
it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
it.effect("reuses model definitions across provider turns", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const first = yield* toolDefinitions(service)
const second = yield* toolDefinitions(service)
expect(second[0]).toBe(first[0])
}),
)
it.effect("removes a scoped registration", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const transform = yield* registry.transform().pipe(Scope.provide(scope))
yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void }))
expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }])
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
yield* Scope.close(scope, Exit.void)
expect(yield* registry.definitions()).toEqual([])
expect(yield* toolDefinitions(service)).toEqual([])
}),
)
it.effect("returns an error result for an unknown tool", () =>
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const service = yield* ToolRegistry.Service
yield* service.register({
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
})
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-missing", name: "missing", input: {} },
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "failed", name: "failed", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "missing", name: "missing", input: {} },
}),
).toEqual({ type: "error", value: "Unknown tool: missing" })
}),
)
it.effect("does not execute a tool when authorization fails", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
let executed = false
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("denied", {
authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })),
tool: Tool.make({
description: "Denied tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
execute: () =>
Effect.sync(() => {
executed = true
return { ok: true }
}),
}),
yield* service.register({
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
)
})
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-denied", name: "denied", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(executed).toBe(false)
yield* service.materialize().pipe(
Effect.flatMap((materialized) =>
materialized.settle({
sessionID,
...identity,
call: { type: "tool-call", id: "defect", name: "defect", input: {} },
}),
),
Effect.catchDefect(Effect.succeed),
),
).toBe("unexpected executor defect")
}),
)
it.effect("binds invocation identity while preserving leaf-owned permission inputs", () =>
it.effect("propagates retention failures through settlement", () =>
Effect.gen(function* () {
assertions.length = 0
denyAction = undefined
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_context")
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
yield* transform((editor) =>
editor.set("context", {
tool: Tool.make({
description: "Context tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
execute: ({ assertPermission, call, source }) =>
assertPermission({
action: "inspect",
resources: [call.id],
save: ["*"],
metadata: { tool: call.name },
}).pipe(
Effect.as({ ok: source === undefined }),
Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))),
),
}),
)
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
}),
).toEqual({ type: "json", value: { ok: true } })
expect(assertions).toEqual([
{
sessionID,
action: "inspect",
resources: ["call-context"],
save: ["*"],
metadata: { tool: "context" },
},
])
expect(assertions[0]).not.toHaveProperty("source")
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
}),
)
it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () =>
it.effect("exposes settlement only through materialization", () =>
Effect.gen(function* () {
assertions.length = 0
denyAction = "execute"
let executed = false
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("ordered", {
tool: Tool.make({
description: "Ordered policy tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
execute: ({ assertPermission }) =>
Effect.gen(function* () {
yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] })
yield* assertPermission({ action: "execute", resources: ["pwd"] })
executed = true
return { ok: true }
}).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))),
}),
)
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_context"),
call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"])
expect(executed).toBe(false)
denyAction = undefined
const service = yield* ToolRegistry.Service
expect("definitions" in service).toBe(false)
expect("execute" in service).toBe(false)
expect("settle" in service).toBe(false)
expect(typeof service.materialize).toBe("function")
}),
)
it.effect("settles encoded structured output with canonical projected content", () =>
it.effect("passes complete invocation identity to the canonical handler", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* service.register({
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
})
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }])
}),
)
it.effect("encodes output and applies generic settlement bounding", () =>
Effect.gen(function* () {
bounds.length = 0
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("projected", {
tool: Tool.make({
description: "Projected tool",
parameters: Schema.Struct({ prefix: Schema.String }),
success: Schema.Struct({ count: Schema.NumberFromString }),
execute: () => Effect.succeed({ count: 2 }),
toModelOutput: ({ callID, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.prefix}:${output.count}` },
],
}),
}),
)
const service = yield* ToolRegistry.Service
yield* service.register({ bounded: make() })
expect(
yield* registry.settle({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
}),
).toMatchObject({
result: { type: "text", value: "call-projected:count:2" },
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
).toEqual({
result: { type: "text", value: "bounded reference" },
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
})
expect(bounds).toEqual([
{
sessionID: SessionV2.ID.make("ses_registry_test"),
toolCallID: "call-projected",
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
},
])
expect(bounds).toHaveLength(1)
}),
)
it.effect("executes the unchanged registration advertised for a provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
}),
)
it.effect("rejects a call when its advertised registration was removed", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("rejects only the replaced name from a multi-tool provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ first: make(), second: make() })
const materialized = yield* service.materialize()
yield* service.register({ first: make() })
expect((yield* materialized.settle(call("first"))).result).toEqual({
type: "error",
value: "Stale tool call: first",
})
expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" })
}),
)
it.effect("treats revealing a previous overlay as stale", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const overlay = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(overlay))
const materialized = yield* service.materialize()
yield* Scope.close(overlay, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("keeps captured execution running after registration mutation", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* service
.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
}),
})
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: make() })
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } })
}),
)
})

View file

@ -4,7 +4,6 @@ import {
LLMError,
LLMEvent,
Model,
Tool,
TransportReason,
InvalidRequestReason,
type LLMClientShape,
@ -38,7 +37,7 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { NativeTool } from "@opencode-ai/core/tool/native"
import { Tool } from "@opencode-ai/core/tool/tool"
import {
SessionContextEpochTable,
SessionInputTable,
@ -110,7 +109,7 @@ const recoveryModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
})
const authorizations: ToolRegistry.AuthorizeInput[] = []
const authorizations: Tool.Context[] = []
const executions: string[] = []
const permission = Layer.succeed(
PermissionV2.Service,
@ -132,38 +131,31 @@ const registry = ToolRegistry.layer.pipe(
const agents = AgentV2.layer
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
registry.contribute((editor) => {
;(editor.set("echo", {
authorize: (input) =>
Effect.sync(() => {
authorizations.push(input)
}),
tool: Tool.make({
description: "Echo text",
parameters: Schema.Struct({ text: Schema.String }),
success: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }) =>
Effect.gen(function* () {
executions.push(text)
activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { text }
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
registry.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }, context) =>
Effect.gen(function* () {
authorizations.push(context)
executions.push(text)
activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { text }
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
defect: Tool.make({
description: "Fail unexpectedly",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected tool defect"),
}),
editor.set("defect", {
tool: Tool.make({
description: "Fail unexpectedly",
parameters: Schema.Struct({}),
success: Schema.Struct({}),
execute: () => Effect.die("unexpected tool defect"),
}),
}))
}),
),
).pipe(Layer.provide(registry))
@ -567,12 +559,12 @@ describe("SessionRunnerLLM", () => {
yield* setup
const applicationTools = yield* ApplicationTools.Service
const session = yield* SessionV2.Service
const contexts: NativeTool.Context[] = []
yield* applicationTools.attach({
application_context: NativeTool.make({
const contexts: Tool.Context[] = []
yield* applicationTools.register({
application_context: Tool.make({
description: "Read application context",
parameters: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ answer: Schema.String }),
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
@ -594,7 +586,14 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
expect(contexts).toEqual([{ sessionID, id: "call-application", name: "application_context" }])
expect(contexts).toEqual([
{
sessionID,
agent: AgentV2.ID.make("build"),
assistantMessageID: expect.stringMatching(/^msg_/),
toolCallID: "call-application",
},
])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" },
{
@ -1783,7 +1782,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, call: { id: "call-echo", name: "echo" } }])
expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
expect(executions).toEqual(["hello"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Echo this" },
@ -2937,7 +2936,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("durably settles unexpected local tool defects before continuing", () =>
it.effect("propagates unexpected local tool defects operationally", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -2951,25 +2950,11 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
yield* session.resume(sessionID)
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-defect",
state: { status: "error", error: { message: "unexpected tool defect" } },
},
],
},
])
expect(requests).toHaveLength(1)
}),
)
@ -2979,17 +2964,15 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const questions = yield* QuestionV2.Service
const transform = yield* registry.transform()
yield* transform((editor) =>
editor.set("question", {
tool: Tool.make({
description: "Ask the user",
parameters: Schema.Struct({}),
success: Schema.Struct({}),
}),
execute: ({ sessionID }) => questions.ask({ sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
yield* registry.register({
question: Tool.make({
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
}),
)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false })
requests.length = 0

View file

@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
@ -14,6 +14,7 @@ import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -92,6 +93,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const patch = ApplyPatchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
Layer.provide(filesystem),
@ -103,6 +105,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (patchText: string, id = "call-apply-patch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
})
@ -129,8 +132,9 @@ describe("ApplyPatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* registry.settle(
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* settleTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
@ -146,7 +150,7 @@ describe("ApplyPatchTool", () => {
{ type: "delete", resource: "remove.txt" },
],
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
])
expect(readsBeforeEditApproval).toBe(0)
@ -175,7 +179,8 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
@ -203,7 +208,8 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "text" })
@ -236,7 +242,8 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call(
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
@ -266,7 +273,8 @@ describe("ApplyPatchTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
@ -291,7 +299,8 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
@ -315,7 +324,10 @@ describe("ApplyPatchTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch")),
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
}),
@ -325,7 +337,7 @@ describe("ApplyPatchTool", () => {
),
)
it.live("reports earlier sequential applications when a later commit fails", () =>
it.live("preserves a later commit defect after earlier sequential applications", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -338,13 +350,13 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
Exit.isFailure(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.exit),
),
).toEqual({
type: "error",
value: "Patch partially applied before failing at second.txt. Applied: first.txt",
})
).toBe(true)
expect(yield* exists(first)).toBe(false)
expect(yield* exists(second)).toBe(true)
}),
@ -370,11 +382,10 @@ describe("ApplyPatchTool", () => {
yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
yield* withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const run = yield* registry
.execute(
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
)
.pipe(Effect.forkChild)
const run = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
yield* Deferred.succeed(releaseRemove!, undefined)

View file

@ -13,11 +13,11 @@ import { AppProcess } from "@opencode-ai/core/process"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_bash_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -27,7 +27,6 @@ const runs: Array<{
readonly shell?: string | boolean
readonly options?: AppProcess.RunOptions
}> = []
const truncations: ToolOutputStore.TruncateInput[] = []
let denyAction: string | undefined
let result: AppProcess.RunResult = {
command: "mock",
@ -39,8 +38,6 @@ let result: AppProcess.RunResult = {
}
let runFailure: AppProcess.AppProcessError | undefined
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const permission = Layer.succeed(
PermissionV2.Service,
@ -70,16 +67,6 @@ const appProcess = Layer.succeed(
}),
} as unknown as AppProcess.Interface),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
@ -90,7 +77,6 @@ const config = Layer.succeed(
const reset = () => {
assertions.length = 0
runs.length = 0
truncations.length = 0
denyAction = undefined
runFailure = undefined
afterPermission = () => Effect.void
@ -102,7 +88,6 @@ const reset = () => {
stdoutTruncated: false,
stderrTruncated: false,
}
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
}
const withTool = <A, E, R>(
@ -123,7 +108,6 @@ const withTool = <A, E, R>(
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(processLayer),
Layer.provide(resources),
Layer.provide(config),
)
return Effect.gen(function* () {
@ -133,6 +117,7 @@ const withTool = <A, E, R>(
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input },
})
@ -146,11 +131,13 @@ describe("BashTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* registry.definitions()
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: {
structured: {
@ -168,7 +155,7 @@ describe("BashTool", () => {
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
)
},
@ -182,7 +169,9 @@ describe("BashTool", () => {
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
),
@ -206,7 +195,9 @@ describe("BashTool", () => {
}).pipe(Effect.orDie)
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => {
expect(runs).toEqual([])
@ -227,7 +218,7 @@ describe("BashTool", () => {
reset()
return withTool(
tmp.path,
(registry) => registry.settle(call({ command: "printf core-bash" })),
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
AppProcess.defaultLayer,
).pipe(
Effect.andThen((settled) =>
@ -254,7 +245,7 @@ describe("BashTool", () => {
([active, outside]) => {
reset()
return withTool(active.path, (registry) =>
registry.execute(call({ command: "pwd", workdir: outside.path })),
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
@ -281,13 +272,15 @@ describe("BashTool", () => {
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd", workdir: outside.path })))
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
expect(runs).toEqual([])
reset()
denyAction = "bash"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" })))
yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toEqual([])
}),
@ -305,7 +298,7 @@ describe("BashTool", () => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe(
return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
@ -327,19 +320,13 @@ describe("BashTool", () => {
),
)
it.live("keeps non-zero exits useful and exposes managed overflow by path", () =>
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
@ -350,13 +337,9 @@ describe("BashTool", () => {
command: "false",
cwd: realpathSync(tmp.path),
exitCode: 7,
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
output: "HEAD full output TAIL",
truncated: false,
})
expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"])
expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
])
}),
),
)
@ -371,7 +354,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
result = { ...result, stdoutTruncated: true }
return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
@ -394,7 +377,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({

View file

@ -15,6 +15,7 @@ import { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_edit_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -82,6 +83,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const edit = EditTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
Layer.provide(filesystem),
@ -93,6 +95,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "edit", input },
})
@ -109,9 +112,12 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* registry.definitions([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([])
const settled = yield* registry.settle(
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
[],
)
const settled = yield* settleTool(
registry,
call({ path: "hello.txt", oldString: "before", newString: "after" }),
)
expect(settled.result).toEqual({
@ -126,7 +132,7 @@ describe("EditTool", () => {
replacements: 1,
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
}),
),
@ -146,7 +152,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@ -171,7 +177,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@ -202,7 +208,7 @@ describe("EditTool", () => {
denyAction = "external_directory"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
@ -216,7 +222,7 @@ describe("EditTool", () => {
denyAction = "edit"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
@ -245,10 +251,12 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const matching = yield* registry.execute(
const matching = yield* executeTool(
registry,
call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
)
const missing = yield* registry.execute(
const missing = yield* executeTool(
registry,
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
)
@ -277,26 +285,26 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "same" })),
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
).toEqual({
type: "error",
value: "No changes to apply: oldString and newString are identical.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "", newString: "after" })),
yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })),
).toEqual({
type: "error",
value: "oldString must not be empty. Use write to create or overwrite a file.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "missing", newString: "after" })),
yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })),
).toEqual({
type: "error",
value:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
})
expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "after" })),
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
).toEqual({
type: "error",
value:
@ -321,7 +329,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.settle(call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
),
),
Effect.andThen((settled) =>
@ -346,7 +354,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
),
),
Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
@ -367,7 +375,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
registry.execute(call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@ -390,7 +398,7 @@ describe("EditTool", () => {
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }

View file

@ -8,6 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/glob"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_glob_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -92,6 +93,7 @@ const reset = () => {
const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "glob", input },
})
@ -99,7 +101,7 @@ describe("GlobTool", () => {
it.effect("registers the glob definition", () =>
Effect.gen(function* () {
reset()
expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"])
expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"])
}),
)
@ -108,11 +110,13 @@ describe("GlobTool", () => {
reset()
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({
expect(
yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })),
).toEqual({
type: "text",
value: "No files found",
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{
sessionID,
action: "glob",
@ -131,7 +135,7 @@ describe("GlobTool", () => {
reset()
allow = false
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({
type: "error",
value: "Unable to find files matching *.secret",
})
@ -155,7 +159,7 @@ describe("GlobTool", () => {
partial: false,
})
expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({
expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({
result: { type: "text", value: "src/index.ts" },
output: {
structured: result,
@ -181,11 +185,11 @@ describe("GlobTool", () => {
partial: false,
})
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
type: "text",
value: "docs:guide.md",
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{
sessionID,
action: "glob",

View file

@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Exit, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
@ -19,6 +19,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it as runtimeIt } from "./lib/effect"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const assertions: PermissionV2.AssertInput[] = []
const searches: LocationSearch.GrepInput[] = []
@ -90,12 +91,20 @@ const sessionID = SessionV2.ID.make("ses_grep_tool_test")
const execute = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) =>
registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-grep", name: "grep", input },
}),
)
const settle = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) =>
registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-grep", name: "grep", input },
}),
)
const reset = () => {
@ -145,7 +154,7 @@ describe("GrepTool", () => {
it.effect("registers the grep contribution", () =>
Effect.gen(function* () {
reset()
expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }])
expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
}),
)
@ -155,7 +164,7 @@ describe("GrepTool", () => {
const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{
sessionID,
action: "grep",
@ -226,7 +235,7 @@ describe("GrepTool", () => {
}),
)
it.effect("returns a useful tool error for an invalid regex", () =>
it.effect("preserves an unexpected search defect", () =>
Effect.gen(function* () {
reset()
searchFailure = new Ripgrep.InvalidPatternError({
@ -234,10 +243,7 @@ describe("GrepTool", () => {
message: "regex parse error: unclosed character class",
})
expect(yield* execute({ pattern: "[" })).toEqual({
type: "error",
value: 'Invalid grep pattern "[": regex parse error: unclosed character class',
})
expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
expect(searches).toEqual([{ pattern: "[" }])
}),
)

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@opencode-ai/core/config"
@ -43,35 +43,7 @@ const withStore = <A, E, R>(
const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => {
it.live("returns under-limit text unchanged without writing a file", () =>
withStore(({ store }) =>
Effect.gen(function* () {
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({
content: "one\ntwo",
truncated: false,
})
}),
),
)
it.live("stores full output at an absolute managed path", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const content = "HEAD-" + "x".repeat(500) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(path.isAbsolute(result.outputPath)).toBe(true)
expect(result.outputPath).toStartWith(path.join(root, "tool-output", "tool_"))
expect(result.content).toContain(result.outputPath)
expect(result.content).toContain("HEAD-")
expect(result.content).toContain("-TAIL")
expect(yield* fs.readFileString(result.outputPath)).toBe(content)
}),
),
)
it.live("bounds aggregate text blocks with one managed file", () =>
it.live("bounds aggregate text and structured output with one managed file", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const first = "HEAD-" + "x".repeat(30_000)
@ -87,9 +59,15 @@ describe("ToolOutputStore", () => {
],
},
})
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.output.structured).toEqual({})
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({
structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
})
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
}),
@ -101,37 +79,172 @@ describe("ToolOutputStore", () => {
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toBe(structured)
expect(result.output.structured).toEqual({})
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured))
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual({ structured, content: [] })
expect(result.output.content).toHaveLength(1)
}),
),
)
it.live("degrades to lossy bounded output when writing fails", () =>
it.live("preserves oversized inline media without duplicating its data", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const data = "a".repeat(ToolOutputStore.MAX_BYTES)
const result = yield* store.bound({
sessionID,
toolCallID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }],
},
})
expect(result.outputPaths).toEqual([])
expect(result.output.structured).toEqual({})
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
type: "file",
source: { type: "data", data },
mime: "image/png",
name: "pixel.png",
})
}),
),
)
it.live("rejects inline media beyond the settlement media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-file-too-large",
output: {
structured: {},
content: [
{
type: "file",
source: { type: "data", data: "a".repeat(ToolOutputStore.MAX_INLINE_MEDIA_BYTES + 1) },
mime: "image/png",
},
],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError")
}),
),
)
it.live("rejects inline media whose aggregate size exceeds the settlement limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-files-too-large",
output: {
structured: {},
content: [
{ type: "file", source: { type: "data", data: "a".repeat(3 * 1024 * 1024) }, mime: "image/png" },
{ type: "file", source: { type: "data", data: "b".repeat(3 * 1024 * 1024) }, mime: "image/png" },
],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.MediaLimitError")
}),
),
)
it.live("fails oversized settlement when complete retention cannot be written", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
const result = yield* store.bound({
sessionID,
toolCallID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
expect(result.outputPaths).toEqual([])
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(result.output.content[0].text).toContain("could not be retained")
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
}),
),
)
it.live("fails operationally when output cannot be encoded for bounding", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-unencodable",
output: {
structured: { value: 1n },
content: [{ type: "text", text: "readable text" }],
},
})
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
}),
),
)
it.live("preserves interruption while retaining complete output", () =>
Effect.gen(function* () {
const root = yield* Effect.promise(() => tmpdir())
const blockedFilesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
ensureDir: () => Effect.void,
writeFileString: () => Effect.never,
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
const store = ToolOutputStore.layer.pipe(
Layer.provide(blockedFilesystem),
Layer.provide(Global.layerWith({ data: root.path })),
)
const exit = yield* Effect.gen(function* () {
const service = yield* ToolOutputStore.Service
const fiber = yield* service
.bound({
sessionID,
toolCallID: "call-interrupted",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
.pipe(Effect.forkChild)
yield* Fiber.interrupt(fiber)
return yield* Fiber.await(fiber)
}).pipe(Effect.provide(store))
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true)
yield* Effect.promise(() => root[Symbol.asyncDispose]())
}),
)
it.live("honors configured limits", () =>
withStore(
({ store }) =>
Effect.gen(function* () {
expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
expect(
(yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated,
).toBe(true)
const result = yield* store.bound({
sessionID,
toolCallID: "call-config",
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
})
expect(result.outputPaths).toHaveLength(1)
}),
new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
),
@ -140,9 +253,12 @@ describe("ToolOutputStore", () => {
it.live("cleans expired managed files and preserves unrelated files", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" })
const old = path.join(root, "tool-output", "tool_old")
const recent = path.join(root, "tool-output", "tool_recent")
const unrelated = path.join(root, "tool-output", "keep.txt")
yield* fs.ensureDir(path.join(root, "tool-output"))
yield* fs.writeFileString(old, "old")
yield* fs.writeFileString(recent, "recent")
yield* fs.writeFileString(unrelated, "keep")
const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
yield* fs.utimes(old, expired, expired)

View file

@ -6,6 +6,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -40,7 +41,7 @@ const question = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question))
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
describe("QuestionTool", () => {
@ -50,10 +51,11 @@ describe("QuestionTool", () => {
deny = true
const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}),
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
@ -82,10 +84,11 @@ describe("QuestionTool", () => {
},
]
expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"])
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
}),
).toEqual({
@ -104,8 +107,12 @@ describe("QuestionTool", () => {
],
},
})
expect(assertions).toEqual([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({
sessionID,
questions,
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
})
}),
)
@ -116,11 +123,16 @@ describe("QuestionTool", () => {
deny = false
const registryService = yield* ToolRegistry.Service
yield* registryService.execute({
yield* executeTool(registryService, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
})
expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined })
expect(capturedInput()).toEqual({
sessionID,
questions: [],
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
})
}),
)
@ -130,12 +142,11 @@ describe("QuestionTool", () => {
reject = true
deny = false
const registryService = yield* ToolRegistry.Service
const fiber = yield* registryService
.execute({
sessionID,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
})
.pipe(Effect.forkScoped)
const fiber = yield* executeTool(registryService, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
}).pipe(Effect.forkScoped)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Exit, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { FileSystem } from "@opencode-ai/core/filesystem"
@ -8,6 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const assertions: PermissionV2.AssertInput[] = []
const readCalls: {
@ -100,11 +101,12 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
@ -125,8 +127,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
@ -138,12 +141,12 @@ describe("ReadTool", () => {
})
expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
const settled = yield* registry.settle({
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" })
expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
expect(settled.output?.structured).toEqual({})
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
@ -151,6 +154,40 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves a PNG above the generic text limit as native media", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const pixels = Uint8Array.from({ length: 256 * 256 * 4 }, (_, index) => (index * 73 + (index >> 3)) % 256)
const source = new photon.PhotonImage(pixels, 256, 256)
const png = Buffer.from(source.get_bytes()).toString("base64")
source.free()
expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024)
readResult = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } },
})
expect(settled.outputPaths).toBeUndefined()
expect(settled.output?.structured).toEqual({})
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "large.png" },
],
})
}),
)
it.effect("rejects invalid image data returned by the filesystem", () =>
Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({
@ -162,8 +199,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
@ -193,8 +231,9 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
})
@ -224,8 +263,9 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
})
@ -261,8 +301,9 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
})
@ -283,8 +324,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
}),
).toMatchObject({
@ -294,22 +336,25 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves unsupported binary errors from the filesystem", () =>
it.effect("preserves unexpected filesystem defects", () =>
Effect.gen(function* () {
readFailure = new FileSystem.BinaryFileError("archive.dat")
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-binary",
name: "read",
input: { path: "archive.dat", offset: 2, limit: 1 },
},
}),
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-binary",
name: "read",
input: { path: "archive.dat", offset: 2, limit: 1 },
},
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([
{ input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
])
@ -322,8 +367,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "error", value: "Unable to read README.md" })
@ -337,8 +383,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory",
@ -359,8 +406,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ type: "error", value: "Unable to read src" })
@ -372,8 +420,9 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
})
@ -381,17 +430,20 @@ describe("ReadTool", () => {
}),
)
it.effect("settles missing files as typed tool errors", () =>
it.effect("preserves unexpected resolution defects", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
resolveFailure = new Error("missing")
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}),
).toEqual({ type: "error", value: "Unable to read missing.txt" })
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([])
}),
@ -410,8 +462,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-large",
@ -438,8 +491,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
}),
).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })

View file

@ -9,10 +9,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillTool } from "@opencode-ai/core/tool/skill"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
@ -41,9 +41,6 @@ describe("SkillTool", () => {
let current = [info]
const assertions: PermissionV2.AssertInput[] = []
let deny = false
const truncations: ToolOutputStore.TruncateInput[] = []
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
let bootWaited = false
const boot = Layer.succeed(
PluginBoot.Service,
@ -77,75 +74,58 @@ describe("SkillTool", () => {
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const tool = SkillTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(boot),
Layer.provide(skills),
Layer.provide(resources),
)
const layer = Layer.mergeAll(permission, skills, registry, boot, resources, tool)
const layer = Layer.mergeAll(permission, skills, registry, boot, tool)
return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(bootWaited).toBe(true)
expect((yield* registry.definitions())[0]).toMatchObject({
expect((yield* toolDefinitions(registry))[0]).toMatchObject({
name: "skill",
description: SkillTool.description,
})
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } },
}),
).toEqual({
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
})
expect(truncations).toEqual([
{ sessionID, toolCallID: "call-skill", content: SkillTool.toModelOutput(info, [reference]) },
])
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(
yield* registry.settle({
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
}),
).toMatchObject({
result: { type: "text", value: expect.stringContaining("/tmp/tool-output/tool_opaque") },
output: {
structured: { truncated: true, outputPath: "/tmp/tool-output/tool_opaque" },
},
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "effect" } },
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
])
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
}),
).toEqual({ type: "error", value: "Unable to load skill missing" })
deny = true
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
@ -163,10 +143,10 @@ describe("SkillTool", () => {
]),
)
current = [flat]
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })

View file

@ -12,6 +12,7 @@ import { SessionTodo } from "@opencode-ai/core/session/todo"
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -35,7 +36,7 @@ const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos))
const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool))
const setup = Effect.gen(function* () {
@ -63,6 +64,7 @@ const setup = Effect.gen(function* () {
const call = (todos: ReadonlyArray<SessionTodo.Info>, id = "call-todowrite") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } },
})
@ -74,15 +76,15 @@ describe("TodoWriteTool", () => {
const service = yield* SessionTodo.Service
const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }]
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* registry.settle(call(todoList))).toEqual({
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* settleTool(registry, call(todoList))).toEqual({
result: { type: "text", value: JSON.stringify(todoList, null, 2) },
output: {
structured: { todos: todoList },
content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }],
},
})
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(yield* service.get(sessionID)).toEqual(todoList)
}),
)
@ -95,12 +97,14 @@ describe("TodoWriteTool", () => {
yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] })
deny = true
expect(yield* registry.execute(call([{ content: "blocked", status: "completed", priority: "high" }]))).toEqual({
expect(
yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])),
).toEqual({
type: "error",
value: "Unable to update todos",
})
expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }])
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
}),
)
})

View file

@ -4,19 +4,16 @@ import * as TestClock from "effect/testing/TestClock"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_webfetch_test")
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let respond = (_request: HttpClientRequest.HttpClientRequest) =>
Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const http = Layer.succeed(
HttpClient.HttpClient,
@ -38,36 +35,25 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources))
const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch))
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http))
const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
const fetchWebfetch = WebFetchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(FetchHttpClient.layer),
Layer.provide(resources),
)
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, resources, fetchWebfetch))
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch))
const reset = () => {
requests.length = 0
assertions.length = 0
truncations.length = 0
respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
}
const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "webfetch", input },
})
@ -93,15 +79,15 @@ describe("WebFetchTool contribution", () => {
const registry = yield* ToolRegistry.Service
const url = "http://example.com/public"
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* registry.settle(call({ url, format: "text", timeout: 4 }))).toEqual({
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello", truncated: false },
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
content: [{ type: "text", text: "hello" }],
},
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
@ -114,11 +100,11 @@ describe("WebFetchTool contribution", () => {
const registry = yield* ToolRegistry.Service
const url = "http://localhost/private"
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "hello",
})
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
expect(requests.map((request) => request.url)).toEqual([url])
@ -142,8 +128,11 @@ describe("WebFetchTool contribution", () => {
const registry = yield* ToolRegistry.Service
const url = new URL("/redirect", server.url).toString()
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ type: "text", value: "redirected" })
expect(assertions).toEqual([
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "redirected",
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
}),
@ -156,7 +145,7 @@ describe("WebFetchTool contribution", () => {
reset()
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch file:///etc/passwd",
})
@ -176,41 +165,17 @@ describe("WebFetchTool contribution", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
type: "text",
value: "# Hello\n\nworld",
})
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "Helloworld",
})
}),
)
it.effect("exposes managed overflow through a path", () =>
Effect.gen(function* () {
reset()
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow"))
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
})
expect(settled.output?.structured).toMatchObject({
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "hello", mime: "text/html" }])
}),
)
it.effect("rejects declared and streamed oversized bodies", () =>
Effect.gen(function* () {
reset()
@ -221,7 +186,7 @@ describe("WebFetchTool contribution", () => {
headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
}),
)
expect(yield* registry.execute(call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/declared",
})
@ -230,7 +195,7 @@ describe("WebFetchTool contribution", () => {
Effect.succeed(
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
)
expect(yield* registry.execute(call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/streamed",
})
@ -242,17 +207,16 @@ describe("WebFetchTool contribution", () => {
reset()
const registry = yield* ToolRegistry.Service
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
expect(yield* registry.execute(call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/image",
})
respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
expect(yield* registry.execute(call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/file",
})
expect(truncations).toEqual([])
}),
)
@ -268,7 +232,7 @@ describe("WebFetchTool contribution", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "ok",
})
@ -283,9 +247,10 @@ describe("WebFetchTool contribution", () => {
reset()
respond = () => Effect.never
const registry = yield* ToolRegistry.Service
const fiber = yield* registry
.execute(call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }))
.pipe(Effect.forkChild)
const fiber = yield* executeTool(
registry,
call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }),
).pipe(Effect.forkChild)
yield* TestClock.adjust(Duration.seconds(1))
expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })

View file

@ -5,8 +5,8 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_websearch_test")
const payload = (text: string) =>
@ -65,11 +65,8 @@ interface Request {
const requests: Request[] = []
const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let responseBody = payload("search results")
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const http = Layer.succeed(
HttpClient.HttpClient,
@ -117,40 +114,28 @@ const websearchConfig = Layer.succeed(
},
}),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const websearch = WebSearchTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(http),
Layer.provide(websearchConfig),
Layer.provide(resources),
)
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch))
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
describe("WebSearchTool contribution", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
truncations.length = 0
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
responseBody = payload("exa results")
config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-exa",
@ -165,7 +150,7 @@ describe("WebSearchTool contribution", () => {
},
}),
).toEqual({ type: "text", value: "exa results" })
expect(assertions).toEqual([
expect(assertions).toMatchObject([
{
sessionID,
action: "websearch",
@ -213,8 +198,9 @@ describe("WebSearchTool contribution", () => {
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
})
@ -235,7 +221,7 @@ describe("WebSearchTool contribution", () => {
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results", truncated: false },
structured: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }],
},
})
@ -251,8 +237,9 @@ describe("WebSearchTool contribution", () => {
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
})
@ -270,47 +257,15 @@ describe("WebSearchTool contribution", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
}),
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
}),
)
it.effect("exposes managed overflow through typed structured output", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
truncations.length = 0
responseBody = payload("full search results")
config = { provider: "exa", enableExa: false, enableParallel: false }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } },
})
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
})
expect(settled.output?.structured).toMatchObject({
provider: "exa",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
}),
)
it.effect("rejects oversized MCP response bodies", () =>
Effect.gen(function* () {
requests.length = 0
@ -320,8 +275,9 @@ describe("WebSearchTool contribution", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}),
).toEqual({ type: "error", value: "Unable to search the web for too much" })

View file

@ -15,6 +15,7 @@ import { WriteTool } from "@opencode-ai/core/tool/write"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_write_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -64,7 +65,12 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(resolution), Layer.provide(mutation))
const write = WriteTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
@ -72,6 +78,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "write", input },
})
@ -85,8 +92,8 @@ describe("WriteTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* registry.settle(call({ path: "src/new.txt", content: "created" }))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" },
output: {
@ -102,7 +109,7 @@ describe("WriteTool", () => {
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
)
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
}),
)
@ -118,7 +125,7 @@ describe("WriteTool", () => {
reset()
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => registry.settle(call({ path: "existing.txt", content: "after" }))),
withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
@ -149,8 +156,11 @@ describe("WriteTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
yield* registry.settle(call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* registry.settle(call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"))
yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* settleTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
)
expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
@ -169,7 +179,7 @@ describe("WriteTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "absolute.txt")
return withTool(tmp.path, (registry) => registry.execute(call({ path: target, content: "inside" }))).pipe(
return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
@ -189,7 +199,9 @@ describe("WriteTool", () => {
([active, outside]) => {
reset()
const target = path.join(outside.path, "external.txt")
return withTool(active.path, (registry) => registry.settle(call({ path: target, content: "external" }))).pipe(
return withTool(active.path, (registry) =>
settleTool(registry, call({ path: target, content: "external" })),
).pipe(
Effect.andThen((settled) =>
Effect.gen(function* () {
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
@ -227,7 +239,9 @@ describe("WriteTool", () => {
reset()
denyAction = "external_directory"
expect(
yield* withTool(active.path, (registry) => registry.execute(call({ path: external, content: "blocked" }))),
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ path: external, content: "blocked" })),
),
).toEqual({
type: "error",
value: `Unable to write ${external}`,
@ -239,7 +253,7 @@ describe("WriteTool", () => {
denyAction = "edit"
expect(
yield* withTool(active.path, (registry) =>
registry.execute(call({ path: "denied.txt", content: "blocked" })),
executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
),
).toEqual({
type: "error",
@ -259,7 +273,7 @@ describe("WriteTool", () => {
test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }

184
specs/v2/tools.md Normal file
View file

@ -0,0 +1,184 @@
# V2 Tools
## Design
V2 has one opaque type for locally executable tools:
```ts
type Tool<Input, Output>
type AnyTool = Tool<any, any>
const make: <
Input extends Schema.Codec<any, any, never, never>,
Output extends Schema.Codec<any, any, never, never>,
>(config: {
readonly description: string
readonly input: Input
readonly output: Output
readonly execute: (
input: Schema.Type<Input>,
context: Tool.Context,
) => Effect.Effect<Schema.Type<Output>, ToolFailure>
readonly toModelOutput?: (input: {
readonly input: Schema.Type<Input>
readonly output: Output["Encoded"]
}) => ReadonlyArray<Tool.Content>
}) => Tool<Input, Output>
```
Application tools, built-ins, and statically authored plugin tools use this same constructor and execution contract.
`Tool` is opaque and has exactly one executor. Its schemas and executor are not public fields. The Tool module privately derives model definitions and interprets invocations for the registry; it never embeds another executable tool representation.
Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`.
## Invocation Context
Every local tool receives the same concrete invocation context:
```ts
interface Tool.Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: Session.MessageID
readonly toolCallID: ToolCall.ID
}
```
`assistantMessageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it.
Decoded tool input is passed separately to `execute`. Raw provider input and domain services do not belong in the invocation context.
Effect interruption is the cancellation mechanism. Tools may translate expected typed failures into `ToolFailure`, but must not translate interruption or defects into model-visible failures.
## Registration
Tools are named when registered:
```ts
yield *
tools.register({
read,
write,
grep,
})
```
The record key is the effective model-facing name. A reusable tool value has no intrinsic name.
```ts
interface Tools {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
```
Tool names use a conservative provider-neutral grammar and are validated at registration. Provider-specific restrictions that cannot be validated generically fail during request preparation with an explicit model-compatibility error.
Process application tools and Location tools expose the same `register` operation but retain separate services and stores. Registration placement determines scope, precedence, and authority; it does not change the tool type.
A Location plugin receives only the narrow `Tools` registration capability, not the internal registry. Its installation effect runs once per applicable Location, acquires that Location's services, constructs its tools, and registers them in the plugin-owned Scope.
Within one placement:
- The latest active registration for a name wins.
- Closing a registration removes only that contribution.
- Closing the winner reveals the next-latest active contribution.
- Mutating the caller's registration record later does not change the captured contribution.
Location registrations take precedence over process application registrations.
## Built-In Tools
Built-ins use the same tool API while capturing trusted Location services:
```ts
const filesystem = yield * FileSystem.Service
const permission = yield * PermissionV2.Service
const tools = yield * Tools.Service
yield *
tools.register({
grep: Tool.make({
description: "Search file contents",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot(input)
yield* permission.assert({
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.assistantMessageID,
callID: context.toolCallID,
},
action: "grep",
resources: [input.pattern],
save: ["*"],
metadata: { root: root.resource },
})
return yield* filesystem.grep(input, root)
}).pipe(/* translate expected typed errors to ToolFailure */),
}),
})
```
Trusted tools formulate and sequence permission requests. `PermissionV2` evaluates policy and manages approval. The registry does not inject an `assertPermission` helper.
Sharing a tool type does not imply equal authority. Built-ins and trusted Location plugins may capture services that are not available to application tools.
## Execution
The Location-scoped registry owns effective lookup and settlement. For each local call it:
1. Resolves one effective named registration.
2. Decodes provider input with the input codec.
3. Invokes the tool with the runner-supplied context.
4. Encodes the returned output with the output codec.
5. Projects encoded output into model-facing content.
6. Bounds the complete model-facing output.
7. Persists the settlement and any internal managed-output references.
Invalid input never invokes the tool. Invalid output never produces a successful settlement.
`toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output.
Provider-turn materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; detaching or replacing it afterward does not affect the running invocation.
## Output Bounding
Tools return complete validated domain output. They do not truncate model-facing output or manage retention files.
After projection, one generic settlement boundary bounds textual and structured provider context. Supported inline media remains native up to the producer's media limit and is never encoded into a text preview. Structured data duplicated by native media content is omitted from provider settlement accounting and storage. Oversized textual or structured values are materialized in managed storage and replaced with bounded previews or references; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths are internal settlement metadata and never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping.
Model-output bounding is not producer memory management. Processes and streaming sources may need separate capture or spooling limits before a tool result exists. Those limits must be modeled at the producer boundary and must not masquerade as model-output truncation. A producer cannot claim a complete retained output after it has already discarded bytes.
## Failure Semantics
Outcomes remain distinct:
- `ToolFailure` is an expected model-visible failure.
- Interruption cancels the invocation and is not a tool result.
- Unexpected typed errors and defects follow the runner's operational failure policy.
- Unknown, invalid, and stale calls become explicit model-visible settlement errors without invoking a handler.
Leaf tools translate only errors they deliberately classify as recoverable. Broad cause-catching around an executor is invalid because it consumes interruption and defects.
## Laws
- **Single executor:** `Tool.make(config)` can invoke only `config.execute`.
- **Codec boundary:** execution observes decoded input; projection observes encoded output.
- **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner.
- **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay.
- **Captured execution:** registration changes cannot alter an invocation after effective lookup.
- **Stale rejection:** a call never executes a registration other than the one advertised for its provider turn.
- **Storage encapsulation:** domain output does not change according to model-output bounding or retention policy.
## Follow-Up
Location plugin installation should receive the same narrow `Tools` capability. That requires a separate Location-layer ordering change so built-ins register before plugins without introducing a `PluginBoot -> Tools -> PluginBoot` dependency cycle. The carrier, registrar, and plugin-owned Scope semantics are already suitable; no tool-specific plugin hook is needed.