Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton 3d03b49a11 fix(test): release Windows search handles 2026-06-06 21:03:39 -04:00
Kit Langton ac07fe16a0 Merge branch 'dev' into fix/http-recorder-release 2026-06-06 20:10:04 -04:00
Kit Langton 8a1f4e7727 fix(storybook): mock keybind formatter 2026-06-06 18:50:27 -04:00
Kit Langton 4511d05c61 chore(http-recorder): disable release automation 2026-06-06 18:38:15 -04:00
Kit Langton f63469f04e fix(http-recorder): isolate changeset versioning 2026-06-06 18:32:26 -04:00
54 changed files with 2453 additions and 3067 deletions
+3 -7
View File
@@ -237,10 +237,6 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
// and does not await the scan; the native background scan starts as soon as
// the picker exists. The `wait` gate dedupes concurrent creation.
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
// The opencode test runtime owns an isolated XDG tree that Windows must
// remove before process exit, so use ripgrep instead of native FFF there.
if (process.env.OPENCODE_TEST_HOME) return undefined
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => {
log.warn("fff availability check failed", { error })
@@ -266,9 +262,9 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
basePath: dir,
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
historyDbPath: path.join(root, `${id}.history.mdb`),
// fff uses a bit different log version, also with spans so keep
// them in the same folder for debuggability
logFilePath: path.join(Global.Path.log, "fff.log"),
// fff 0.9.3 keeps its process-global log file open until exit, so
// disable it in tests to let Windows remove the isolated XDG tree.
logFilePath: process.env.NODE_ENV === "test" ? undefined : path.join(Global.Path.log, "fff.log"),
logLevel: Log.getLevel().toLowerCase() as Lowercase<Log.Level>,
aiMode: true,
// only the first toolcall picker can accumulate resources to index
-72
View File
@@ -1,72 +0,0 @@
export * as Image from "./image"
import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config"
import { FileSystem } from "./filesystem"
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
"Image.ResizerUnavailableError",
{},
) {}
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("Image.DecodeError", {
resource: Schema.String,
}) {
override get message() {
return `Image could not be decoded: ${this.resource}`
}
}
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeError", {
resource: Schema.String,
width: Schema.Number,
height: Schema.Number,
bytes: Schema.Number,
maxWidth: Schema.Number,
maxHeight: Schema.Number,
maxBytes: Schema.Number,
}) {
override get message() {
return `Image ${this.resource} is ${this.width}x${this.height} with base64 size ${this.bytes}, exceeding configured limits ${this.maxWidth}x${this.maxHeight}/${this.maxBytes} bytes`
}
}
export interface Interface {
readonly normalize: (
resource: string,
content: FileSystem.BinaryContent,
) => Effect.Effect<FileSystem.BinaryContent, ResizerUnavailableError | DecodeError | SizeError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const loadAdapter = yield* Effect.cached(
Effect.tryPromise({
try: () => import("./image/photon"),
catch: () => new ResizerUnavailableError(),
}).pipe(Effect.flatMap((adapter) => adapter.make)),
)
const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) {
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const normalize = yield* loadAdapter
return yield* normalize(resource, content, {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? 2_000,
maxHeight: image.max_height ?? 2_000,
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
})
})
return Service.of({ normalize })
}),
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
-94
View File
@@ -1,94 +0,0 @@
// @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 { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
export const make = Effect.gen(function* () {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
const loadPhoton = yield* Effect.cached(
Effect.tryPromise({
try: () => import("@silvia-odwyer/photon-node"),
catch: () => new ResizerUnavailableError(),
}),
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.BinaryContent,
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64")),
catch: () => new DecodeError({ resource }),
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(content.content, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes) return content
if (!limits.autoResize)
return yield* new SizeError({
resource,
width,
height,
bytes,
maxWidth: limits.maxWidth,
maxHeight: limits.maxHeight,
maxBytes: 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 encoders: Array<readonly [mime: string, encode: () => Uint8Array]> = [
["image/png", () => resized.get_bytes()],
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
]
for (const [mime, encode] of encoders) {
const candidate = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime })
}
} finally {
resized.free()
}
}
return yield* new SizeError({
resource,
width,
height,
bytes,
maxWidth: limits.maxWidth,
maxHeight: limits.maxHeight,
maxBytes: limits.maxBase64Bytes,
})
} finally {
decoded.free()
}
})
})
+3 -15
View File
@@ -28,7 +28,6 @@ import { Pty } from "./pty"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { BuiltInTools } from "./tool/builtins"
import { Image } from "./image"
import { ToolRegistry } from "./tool/registry"
import { ApplicationTools } from "./tool/application-tools"
import { ToolOutputStore } from "./tool-output-store"
@@ -72,7 +71,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(base),
)
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
@@ -85,7 +83,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(resources),
Layer.provide(todos),
Layer.provide(questions),
Layer.provide(image),
)
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe(
@@ -93,18 +90,9 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(model),
Layer.provide(skillGuidance),
)
return Layer.mergeAll(
services,
image,
mutation,
searches,
resources,
todos,
questions,
model,
runner,
builtInTools,
).pipe(Layer.fresh)
return Layer.mergeAll(services, mutation, searches, resources, todos, questions, model, runner, builtInTools).pipe(
Layer.fresh,
)
},
idleTimeToLive: "60 minutes",
dependencies: [
+2 -2
View File
@@ -17,7 +17,7 @@ import { Tool } from "./tool"
export interface Interface {
readonly sessions: Session.Interface
readonly tools: Tool.Interface
readonly tools: Tool.Service
}
/** Intentional public native API for Effect applications embedding OpenCode. */
@@ -88,7 +88,7 @@ export const layer = Layer.effect(
const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation
return Service.of({
tools: { register: tools.register },
tools: { attach: tools.attach },
sessions: {
create: (input) =>
sessions.create({
+6 -6
View File
@@ -1,17 +1,17 @@
export * as Tool from "./tool"
import { Effect, Scope } from "effect"
import type { AnyTool, RegistrationError } from "../tool/tool"
import type { NativeTool } from "../tool/native"
export { Failure, RegistrationError, make } from "../tool/tool"
export type { AnyTool, Content, Context, Definition } from "../tool/tool"
export { Failure, make } from "../tool/native"
export type { Any, Content, Context, Executable } from "../tool/native"
export interface Interface {
export interface Service {
/**
* Register same-process tools on this OpenCode instance for the current Scope.
* Attach same-process tools to this OpenCode instance for the current Scope.
* Location tools with the same name take precedence where they are installed.
* Closing the Scope removes the tools immediately, so calls that have not
* started settling may fail because the tool is no longer available.
*/
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
}
@@ -7,7 +7,6 @@ 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",
@@ -25,7 +24,6 @@ export type RunError =
| StepLimitExceededError
| SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
+275 -9
View File
@@ -1,12 +1,36 @@
import { DateTime, Effect, Layer } from "effect"
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { QuestionV2 } from "../../question"
import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance"
import { ToolRegistry } from "../../tool/registry"
import { SessionContextEpoch } from "../context-epoch"
import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { Service, StepLimitExceededError } from "./index"
import { RunTurn } from "./run-turn"
import { type RunError, Service, StepLimitExceededError } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
/**
* Runs one durable coding-agent Session until it settles.
@@ -39,7 +63,7 @@ import { RunTurn } from "./run-turn"
* - [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, attachment normalization,
* - [ ] Add scoped runtime context, progress updates, output truncation, 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.
@@ -50,9 +74,8 @@ import { RunTurn } from "./run-turn"
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
*
* `RunTurn` owns provider-turn preparation, streaming, tool settlement, and continuation signals.
* This module owns durable activity scheduling and bounded continuation. Durable activity recovery
* remains a separate future slice with an explicit retry policy.
* Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here.
* Durable activity recovery remains a separate future slice with an explicit retry policy.
*
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and a
@@ -66,9 +89,22 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
const systemContext = yield* SystemContextRegistry.Service
const skillGuidance = yield* SkillGuidance.Service
const config = yield* Config.Service
const db = (yield* Database.Service).db
const runTurn = yield* RunTurn.make
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return session
})
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
return yield* store.context(sessionID)
@@ -95,6 +131,236 @@ export const layer = Layer.effect(
}
})
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, never>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
type TurnTransition =
// Request preparation observed a concurrent Session change and must restart from durable state.
| { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
// Overflow compaction completed; rebuild once through the path without overflow recovery.
| { readonly _tag: "ContinueAfterOverflowCompaction" }
class TurnTransitionError extends Error {
constructor(readonly transition: TurnTransition) {
super()
}
}
const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
const continueAfterOverflowCompaction = new TurnTransitionError({
_tag: "ContinueAfterOverflowCompaction",
})
const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) =>
Effect.catchDefect((defect) =>
defect instanceof SessionContextEpoch.AgentMismatch
? Effect.die(rebuildPreparedTurn(promotion))
: Effect.die(defect),
)
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent)], { concurrency: "unbounded" }).pipe(
Effect.map(SystemContext.combine),
)
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
recoverOverflow?: typeof compaction.compactAfterOverflow,
) {
const session = yield* getSession(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
const agent = yield* agents.select(session.agent)
const initialized = yield* SessionContextEpoch.initialize(
db,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(promotion))
const toolFibers = yield* FiberSet.make<void, never>()
let needsContinuation = false
if (promotion) {
const cutoff = yield* SessionInput.latestSeq(db, session.id)
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
if (promotion === "queue") {
yield* SessionInput.promoteNextQueued(db, events, session.id)
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
}
}
const system =
initialized ??
(yield* SessionContextEpoch.prepare(
db,
events,
loadSystemContext(agent),
session.id,
session.location,
agent.id,
).pipe(retryAgentMismatch(undefined)))
const current = yield* getSession(sessionID)
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
return yield* Effect.die(rebuildPreparedTurn())
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
providerOptions: { openai: { promptCacheKey } },
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(agent.info?.permissions),
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
model: {
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
},
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths))
let overflowFailure: ProviderErrorEvent | undefined
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
return yield* Effect.die(rebuildPreparedTurn())
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
overflowFailure = event
return
}
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
needsContinuation = true
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: [],
})
}),
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
),
),
),
).pipe(FiberSet.run(toolFibers))
}),
),
Effect.ensuring(withPublication(publisher.flush())),
)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const failure =
stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
if (
recoverOverflow &&
!publisher.hasAssistantStarted() &&
isContextOverflowFailure(overflowFailure ?? failure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request })))
)
return yield* Effect.die(continueAfterOverflowCompaction)
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(
events.publish(SessionEvent.Step.Failed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
error: { type: "unknown", message: llmFailure.reason.message },
}),
)
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
return yield* Effect.interrupt
}
if (
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
}
if (publisher.hasProviderError())
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)
return !publisher.hasProviderError() && needsContinuation
}),
)
}, Effect.scoped)
type RunTurn = (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
) => Effect.Effect<boolean, RunError>
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
return yield* runTurnAttempt(sessionID, promotion).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion)
}),
),
)
})
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined)
return yield* runTurn(sessionID, defect.transition.promotion)
}),
),
)
})
const run = Effect.fn("SessionRunner.run")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force?: boolean
@@ -108,7 +374,7 @@ export const layer = Layer.effect(
while (openActivity) {
let needsContinuation = true
for (let step = 0; step < MAX_STEPS; step++) {
needsContinuation = yield* runTurn({ sessionID: input.sessionID, delivery: promotion })
needsContinuation = yield* runTurn(input.sessionID, promotion)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
if (!needsContinuation) break
@@ -218,11 +218,6 @@ 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> = [],
@@ -413,6 +408,5 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
startAssistant,
assistantMessageID: assistantMessageIDForTool,
}
}
@@ -1,332 +0,0 @@
export * as RunTurn from "./run-turn"
/**
* Sends the next request to the model and finishes every tool call it starts.
*
* Before sending, it makes admitted input visible, loads the latest Session
* history and instructions, and compacts oversized history. If the model rejects
* the request for being too large before producing output, it may compact and
* try once more. Returns `true` when tool results require another model request.
*/
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, Exit, FiberSet, Option, Schema, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { QuestionV2 } from "../../question"
import { SkillGuidance } from "../../skill/guidance"
import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { ToolRegistry } from "../../tool/registry"
import { SessionCompaction } from "../compaction"
import { SessionContextEpoch } from "../context-epoch"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import type { RunError } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
export interface Input {
readonly sessionID: SessionSchema.ID
readonly delivery?: SessionInput.Delivery
}
const AttemptResult = Schema.TaggedUnion({
Complete: { needsContinuation: Schema.Boolean },
CompactedOverflow: {},
})
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
const awaitTools = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
export const make = Effect.gen(function* () {
const events = yield* EventV2.Service
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
const systemContext = yield* SystemContextRegistry.Service
const skillGuidance = yield* SkillGuidance.Service
const config = yield* Config.Service
const db = (yield* Database.Service).db
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return session
})
const stale = Symbol("stale turn preparation")
const retryAgentMismatch = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionContextEpoch.AgentMismatch ? Effect.succeed(stale) : Effect.die(defect),
),
)
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent)], { concurrency: "unbounded" }).pipe(
Effect.map(SystemContext.combine),
)
const promoteDelivery = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, delivery: SessionInput.Delivery) {
const cutoff = yield* SessionInput.latestSeq(db, sessionID)
if (delivery === "queue") yield* SessionInput.promoteNextQueued(db, events, sessionID)
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
})
/**
* Builds the next model request from durable Session state.
*
* Initial instructions must be available before admitted input becomes visible.
* Once input is promoted, retries load it from history instead of promoting
* again. This matters for queued input because promotion opens the next item.
*/
const buildRequest = Effect.fn("SessionRunner.buildRequest")(function* (
sessionID: SessionSchema.ID,
delivery: SessionInput.Delivery | undefined,
) {
let pendingDelivery = delivery
while (true) {
const session = yield* getSession(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
const agent = yield* agents.select(session.agent)
const initialized = yield* retryAgentMismatch(
SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id, session.location, agent.id),
)
if (initialized === stale) continue
if (pendingDelivery) {
yield* promoteDelivery(session.id, pendingDelivery)
pendingDelivery = undefined
}
const prepared =
initialized ??
(yield* retryAgentMismatch(
SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id, session.location, agent.id),
))
if (prepared === stale) continue
const system = prepared
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
const request = LLM.request({
model,
providerOptions: {
openai: { promptCacheKey: /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id },
},
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(
entries.map((entry) => entry.message),
model,
),
tools: toolMaterialization.definitions,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) {
continue
}
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision))) {
continue
}
return { session, agent, model, entries, request, toolMaterialization }
}
})
type RequestSnapshot = Effect.Success<ReturnType<typeof buildRequest>>
/**
* Reads one model response and finishes every tool it starts.
*
* Provider events and tool results share one permit so durable events stay in
* order. Tool calls are recorded before their side effects begin. A pre-output
* overflow is held back so successful compaction does not leave a terminal
* error in Session history.
*/
const streamAndSettle = Effect.fn("SessionRunner.streamAndSettle")(function* (
prepared: RequestSnapshot,
canRecoverOverflow: boolean,
) {
const publisher = createLLMEventPublisher(events, {
sessionID: prepared.session.id,
agent: prepared.agent.id,
model: {
id: ModelV2.ID.make(prepared.model.id),
providerID: ProviderV2.ID.make(prepared.model.provider),
...(prepared.session.model?.variant === undefined ? {} : { variant: prepared.session.model.variant }),
},
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths))
const failUnsettled = (message: string, providerExecuted = false) =>
withPublication(publisher.failUnsettledTools(message, providerExecuted))
let needsContinuation = false
let overflowFailure: ProviderErrorEvent | undefined
const toolEffect = Effect.fnUntraced(function* (event: Extract<LLMEvent, { readonly type: "tool-call" }>) {
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
return yield* prepared.toolMaterialization
.settle({
sessionID: prepared.session.id,
agent: prepared.agent.id,
assistantMessageID,
call: event,
})
.pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
),
),
)
})
const handleProviderEvent = Effect.fnUntraced(function* (
event: LLMEvent,
tools: FiberSet.FiberSet<void, ToolOutputStore.Error>,
) {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event) && isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
overflowFailure = event
return
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
yield* toolEffect(event).pipe(FiberSet.run(tools))
})
const providerStream = (tools: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
llm.stream(prepared.request).pipe(
Stream.runForEach((event) => handleProviderEvent(event, tools)),
Effect.ensuring(withPublication(publisher.flush())),
)
const projectProviderFailure = Effect.fnUntraced(function* (failure: unknown) {
if (overflowFailure) yield* publish(overflowFailure)
if (!(failure instanceof LLMError) || publisher.hasProviderError()) return
yield* failUnsettled("Provider did not return a tool result", true)
yield* withPublication(
events.publish(SessionEvent.Step.Failed, {
sessionID: prepared.session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
error: { type: "unknown", message: failure.reason.message },
}),
)
})
const finishToolsSuccessfully = Effect.fnUntraced(function* <A, E>(stream: Exit.Exit<A, E>) {
if (Exit.hasInterrupts(stream) || publisher.hasProviderError()) yield* failUnsettled("Tool execution interrupted")
if (Exit.isSuccess(stream) && !publisher.hasProviderError())
yield* failUnsettled("Provider did not return a tool result", true)
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
})
const finishToolsAfterFailure = Effect.fnUntraced(function* <A, E>(
stream: Exit.Exit<A, E>,
tools: FiberSet.FiberSet<void, ToolOutputStore.Error>,
cause: Cause.Cause<ToolOutputStore.Error>,
) {
if (isQuestionRejected(cause)) {
yield* FiberSet.clear(tools)
yield* failUnsettled("Tool execution interrupted")
return yield* Effect.interrupt
}
const interrupted = Cause.hasInterrupts(cause)
if (interrupted) yield* FiberSet.clear(tools)
if (Exit.hasInterrupts(stream) || interrupted || publisher.hasProviderError())
yield* failUnsettled("Tool execution interrupted")
if (!interrupted) {
const failure = Cause.squash(cause)
yield* failUnsettled(`Tool execution failed: ${failure instanceof Error ? failure.message : String(failure)}`)
}
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
return yield* Effect.failCause(cause)
})
const finishTools = Effect.fnUntraced(function* <A, E>(
stream: Exit.Exit<A, E>,
tools: FiberSet.FiberSet<void, ToolOutputStore.Error>,
wait: Effect.Effect<void, ToolOutputStore.Error>,
) {
if (Exit.hasInterrupts(stream)) yield* FiberSet.clear(tools)
return yield* wait.pipe(
Effect.matchCauseEffect({
onFailure: (cause) => finishToolsAfterFailure(stream, tools, cause),
onSuccess: () => finishToolsSuccessfully(stream),
}),
)
})
const settleProviderTurn = Effect.fnUntraced(function* () {
const tools = yield* FiberSet.make<void, ToolOutputStore.Error>()
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream(tools)).pipe(Effect.exit)
const failure =
stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
const recovered =
canRecoverOverflow &&
!publisher.hasAssistantStarted() &&
isContextOverflowFailure(overflowFailure ?? failure) &&
(yield* restore(
compaction.compactAfterOverflow({
sessionID: prepared.session.id,
entries: prepared.entries,
model: prepared.model,
request: prepared.request,
}),
))
if (recovered) return AttemptResult.cases.CompactedOverflow.make({})
yield* projectProviderFailure(failure)
yield* finishTools(stream, tools, restore(awaitTools(tools)))
return AttemptResult.cases.Complete.make({
needsContinuation: !publisher.hasProviderError() && needsContinuation,
})
}),
)
})
return yield* settleProviderTurn()
}, Effect.scoped)
const run = Effect.fn("SessionRunner.runTurn")(function* (input: Input): Effect.fn.Return<boolean, RunError> {
let pendingDelivery = input.delivery
let canRecoverOverflow = true
while (true) {
const request = yield* buildRequest(input.sessionID, pendingDelivery)
pendingDelivery = undefined
const result = yield* streamAndSettle(request, canRecoverOverflow)
const next = AttemptResult.match(result, {
Complete: (completed) => completed.needsContinuation,
CompactedOverflow: () => undefined,
})
if (next !== undefined) return next
canRecoverOverflow = false
}
})
return run
})
+13 -23
View File
@@ -61,40 +61,30 @@ export function create<State extends Objectish, Editor>(options: Options<State,
state = next
})
const rebuild = Effect.fnUntraced(function* () {
const rebuild = Effect.fn("State.rebuild")(function* () {
const next = options.initial()
const api = options.editor(next as Draft<State>)
for (const transform of transforms)
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
yield* commit(next)
})
}, semaphore.withPermit)
return {
get: () => state,
transform: Effect.fn("State.transform")(function* () {
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
const scope = yield* Scope.Scope
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
yield* Scope.addFinalizer(
scope,
semaphore.withPermit(
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
}).pipe(Effect.andThen(rebuild())),
),
)
return (update: Transform<Editor>) =>
Effect.uninterruptible(
semaphore.withPermit(
Effect.sync(() => {
transform.update = update
}).pipe(Effect.andThen(rebuild())),
),
)
}),
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
}).pipe(Effect.andThen(rebuild())),
)
return Effect.fnUntraced(function* (update: Transform<Editor>) {
transform.update = update
yield* rebuild()
})
}),
update: Effect.fn("State.update")(function* (update, reason) {
const api = options.editor(state as Draft<State>)
+73 -48
View File
@@ -1,7 +1,7 @@
export * as ToolOutputStore from "./tool-output-store"
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { Config } from "./config"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
@@ -15,6 +15,23 @@ 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
@@ -26,16 +43,11 @@ 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 type Error = StorageError
export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
readonly write: (input: WriteInput) => Effect.Effect<string>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
readonly cleanup: () => Effect.Effect<void>
}
@@ -97,12 +109,6 @@ 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* () {
@@ -110,6 +116,7 @@ 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[])))
@@ -120,50 +127,68 @@ 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* (content: string) {
const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
const file = path.join(directory, `tool_${Identifier.ascending()}`)
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 })))
yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie)
return file
})
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file")
const text = input.output.content.filter((item) => item.type === "text")
const contextual =
input.output.content.length === 0
? yield* Effect.try({
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
: text.map((item) => item.text).join("")
if (
lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
)
return {
output: input.output,
outputPaths: [],
}
const outputPath = yield* write(contextual)
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 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: [] }
return {
output: {
structured: input.output.structured,
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
{ type: "text" as const, text: truncated.content },
...input.output.content.filter((item) => item.type === "file"),
],
},
outputPaths: [outputPath],
outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [],
}
})
@@ -182,7 +207,7 @@ export const layer = Layer.effect(
}
})
return Service.of({ limits, bound, cleanup })
return Service.of({ limits, write, truncate, bound, cleanup })
}),
)
+120 -40
View File
@@ -1,59 +1,139 @@
# Core Tool Architecture
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
This folder owns Core-native tool definition, contribution, effective lookup, and execution. Keep those concerns distinct even though `ToolRegistry` brings them together at runtime.
## Representations
## Current Architecture
- `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,
}
```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 ──▶
│ │ │ │ │ │
```
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.
There are three relevant representations:
## Registration
- `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.
Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`.
`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.
Both are scoped:
## Placement And Layers
- 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.
- `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`.
`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.
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.
## Permissions
## Contribution And Precedence
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.
Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`.
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
Both contribution mechanisms use `State` scoped transforms:
## Output
- 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.
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.
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.
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`.
## Dynamic Removal Semantics
## Current Gaps
Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach.
- 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.
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
```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.
+12 -19
View File
@@ -1,28 +1,21 @@
export * as ApplicationTools from "./application-tools"
import { Context, Effect, Layer, Scope } from "effect"
import { enableMapSet } from "immer"
import { castDraft, enableMapSet } from "immer"
import { State } from "../state"
import { Tool } from "./tool"
import { NativeTool } from "./native"
type Data = {
readonly entries: Map<string, Entry>
readonly entries: Map<string, NativeTool.Any>
}
type Editor = {
readonly set: (name: string, entry: Entry) => void
}
export interface Entry {
readonly identity: object
readonly tool: Tool.AnyTool
readonly set: (name: string, tool: NativeTool.Any) => void
}
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
readonly entries: () => ReadonlyMap<string, Entry>
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
readonly entries: () => ReadonlyMap<string, NativeTool.Any>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
@@ -36,20 +29,20 @@ export const layer = Layer.effect(
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
set: (name, tool) => {
draft.entries.set(name, tool)
draft.entries.set(
name,
castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
)
},
}),
})
return Service.of({
register: Effect.fn("ApplicationTools.register")(function* (tools) {
attach: Effect.fn("ApplicationTools.attach")(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, entry] of registrations) editor.set(name, entry)
for (const [name, tool] of entries) editor.set(name, tool)
})
}),
entries: () => state.get().entries,
+118 -129
View File
@@ -1,18 +1,16 @@
export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, 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 { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
export const name = "apply_patch"
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
patchText: Schema.String.annotate({
description: "The full patch text describing add, update, and delete operations",
}),
@@ -24,10 +22,10 @@ export const Applied = Schema.Struct({
target: Schema.String,
})
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
export type Output = typeof Output.Type
export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
export type Success = typeof Success.Type
export const toModelOutput = (output: Output) =>
export const toModelOutput = (output: Success) =>
[
"Applied patch sequentially:",
...output.applied.map(
@@ -35,6 +33,14 @@ export const toModelOutput = (output: Output) =>
),
].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" }> & {
@@ -45,133 +51,116 @@ type Prepared =
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
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: Input,
output: Output,
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 (!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)}` }),
})
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* 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* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
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
}
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)))
const prepared: Prepared[] = []
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({ ...hunk, target })
return
}
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 },
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),
)
return { applied }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
"edit",
),
})
.pipe(Effect.orDie)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
})
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(hunk.path, Cause.squash(cause)))))
}
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.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))
}),
)
},
}),
)
}),
)
+97 -96
View File
@@ -1,24 +1,23 @@
export * as BashTool from "./bash"
import path from "path"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, 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 { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
export const name = "bash"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
@@ -33,7 +32,7 @@ export const Input = Schema.Struct({
}),
})
const Output = Schema.Struct({
const Success = Schema.Struct({
command: Schema.String,
cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional),
@@ -42,11 +41,12 @@ const Output = 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),
})
type Output = typeof Output.Type
type Success = typeof Success.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
@@ -59,10 +59,9 @@ 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: Output) => {
const modelOutput = (output: Success) => {
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
@@ -73,6 +72,13 @@ const modelOutput = (output: Output) => {
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.
@@ -106,101 +112,96 @@ const externalCommandDirectories = (command: string, cwd: string) => {
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const registry = yield* ToolRegistry.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* 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: Input,
output: Output,
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* 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),
),
)
yield* permission.assert({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
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)
if (!result) {
return {
command: input.command,
command: parameters.command,
cwd: target.canonical,
exitCode: result.exitCode,
output: notice ? `${compact}\n\n${notice}` : compact,
truncated: result.stdoutTruncated || result.stderrTruncated,
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 } : {}),
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}),
})
.pipe(Effect.orDie)
}
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),
}),
),
),
),
}),
)
}),
)
+1 -1
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 canonical registrations, while
* MCP and plugin tools later use separate scoped ToolRegistry transforms, 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.
+85 -109
View File
@@ -7,18 +7,16 @@
*/
export * as EditTool from "./edit"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
export const name = "edit"
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@@ -30,14 +28,14 @@ export const Input = Schema.Struct({
}),
})
export const Output = Schema.Struct({
export const Success = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
replacements: Schema.Number,
})
export type Output = typeof Output.Type
export type Success = typeof Success.Type
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
@@ -70,7 +68,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
return shown
}
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
export const toModelOutput = (output: Success, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.resource}`,
`Replacements: ${output.replacements}`,
@@ -80,6 +78,16 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
"```",
].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.
@@ -89,112 +97,80 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
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: Input,
output: Output,
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}` }),
),
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 }),
)
}),
)
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 Output
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.",
})
},
}),
"edit",
),
})
.pipe(Effect.orDie)
}
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
})
},
}),
)
}),
)
+42 -39
View File
@@ -1,16 +1,14 @@
export * as GlobTool from "./glob"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
export const name = "glob"
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: LocationSearch.FilesInput.fields.path.annotate({
description: "Relative directory to search. Defaults to the active Location.",
@@ -38,6 +36,14 @@ 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.
@@ -46,42 +52,39 @@ export const toModelOutput = (output: ModelOutput) => {
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
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: Input,
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}` })),
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),
}),
),
),
}),
})
.pipe(Effect.orDie)
),
}),
)
}),
)
+46 -49
View File
@@ -1,17 +1,15 @@
export * as GrepTool from "./grep"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { Ripgrep } from "../ripgrep"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
export const name = "grep"
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents",
}),
@@ -29,10 +27,10 @@ export const Input = Schema.Struct({
}),
})
type Output = typeof LocationSearch.GrepResult.Encoded
type Success = typeof LocationSearch.GrepResult.Encoded
/** Format raw Location search matches into the familiar concise model output. */
export const toModelOutput = (output: Output) => {
export const toModelOutput = (output: Success) => {
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
let current = ""
for (const match of output.items) {
@@ -53,6 +51,14 @@ export const toModelOutput = (output: Output) => {
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.
@@ -61,49 +67,40 @@ export const toModelOutput = (output: Output) => {
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
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: Input,
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)
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 }))
}),
),
}),
)
}),
)
+73
View File
@@ -0,0 +1,73 @@
export * as NativeTool from "./native"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import type { SessionSchema } from "../session/schema"
export interface Context {
readonly sessionID: SessionSchema.ID
readonly id: string
readonly name: string
}
export type SchemaType<A> = Schema.Codec<A, any, never, never>
export interface Executable<Parameters extends SchemaType<any>, Success extends SchemaType<any>> {
readonly definition: Tool.Tool<Parameters, Success>
readonly execute: (
parameters: Schema.Schema.Type<Parameters>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
}
export type Any = Executable<any, any>
export const Failure = ToolFailure
export type Failure = ToolFailure
export type Content =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly data: string
readonly mime: string
readonly name?: string
}
export function make<Parameters extends SchemaType<any>, Success extends SchemaType<any>>(config: {
readonly description: string
readonly parameters: Parameters
readonly success: Success
readonly execute: (
parameters: Schema.Schema.Type<Parameters>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly toModelOutput?: (input: {
readonly callID: string
readonly parameters: Schema.Schema.Type<Parameters>
readonly output: Success["Encoded"]
}) => ReadonlyArray<Content>
}): Executable<Parameters, Success> {
const toModelOutput = config.toModelOutput
return {
definition: Tool.make({
description: config.description,
parameters: config.parameters,
success: config.success,
toModelOutput: toModelOutput
? (input) =>
toModelOutput(input).map((content) =>
content.type === "text"
? content
: {
type: "file",
source: { type: "data", data: content.data },
mime: content.mime,
name: content.name,
},
)
: undefined,
}),
execute: config.execute,
}
}
+38 -43
View File
@@ -1,11 +1,9 @@
export * as QuestionTool from "./question"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
export const name = "question"
@@ -20,14 +18,14 @@ Usage notes:
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
})
export const Output = Schema.Struct({
export const Success = Schema.Struct({
answers: Schema.Array(QuestionV2.Answer),
})
export type Output = typeof Output.Type
export type Success = typeof Success.Type
export const toModelOutput = (
questions: ReadonlyArray<QuestionV2.Prompt>,
@@ -42,45 +40,42 @@ 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 tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const question = yield* QuestionV2.Service
const permission = yield* PermissionV2.Service
yield* tools
.register({
[name]: Tool.make({
description,
input: Input,
output: Output,
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)
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,
),
}),
)
}),
)
+198 -56
View File
@@ -1,15 +1,46 @@
export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Tool, 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 path from "node:path"
import { fileURLToPath } from "node:url"
import { Config } from "../config"
import { FileSystem } from "../filesystem"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
export const name = "read"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
const MAX_IMAGE_WIDTH = 2_000
const MAX_IMAGE_HEIGHT = 2_000
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
class ImageDecodeError extends Error {
constructor(readonly resource: string) {
super(`Image could not be decoded: ${resource}`)
this.name = "ImageDecodeError"
}
}
class ImageSizeError extends Error {
constructor(
readonly resource: string,
readonly width: number,
readonly height: number,
readonly bytes: number,
readonly maxWidth: number,
readonly maxHeight: number,
readonly maxBytes: number,
) {
super(
`Image ${resource} is ${width}x${height} with base64 size ${bytes}, exceeding configured limits ${maxWidth}x${maxHeight}/${maxBytes} bytes`,
)
this.name = "ImageSizeError"
}
}
const LocationInput = Schema.Struct({
...FileSystem.ReadInput.fields,
offset: FileSystem.ListPageInput.fields.offset.annotate({
@@ -20,68 +51,179 @@ const LocationInput = Schema.Struct({
}),
})
const Input = LocationInput
const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
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 tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const filesystem = yield* FileSystem.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
const config = yield* Config.Service
const loadPhoton = yield* Effect.cached(
Effect.sync(() => {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
)
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: Output,
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 },
})
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)) {
return yield* image
.normalize(resolved.resource, content)
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
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,
}
if (content.type === "binary")
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
return content
}).pipe(
Effect.mapError((error) => {
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.die(
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.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)
const message =
error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
error instanceof ImageDecodeError ||
error instanceof ImageSizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message })
return yield* new ToolFailure({ message, error })
}),
)
},
}),
})
.pipe(Effect.orDie)
),
)
},
}),
)
}),
)
export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(PermissionV2.locationLayer),
)
+204 -91
View File
@@ -1,33 +1,91 @@
export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
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 { SessionMessage } from "../session/message"
import { State } from "../state"
import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import type { SessionV2 } from "../session"
import { ApplicationTools } from "./application-tools"
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
import { Tools } from "./tools"
import { ToolOutputStore } from "../tool-output-store"
import { AgentV2 } from "../agent"
import { Wildcard } from "../util/wildcard"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent?: AgentV2.ID
readonly call: ToolCall
}
export interface Interface {
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
/**
* 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>
}
export interface Materialization {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
/** 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>
}
export interface Settlement extends ToolSettlement {
@@ -36,98 +94,153 @@ export interface Settlement extends ToolSettlement {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
const registryLayer = Layer.effect(
enableMapSet()
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const applications = yield* ApplicationTools.Service
const resources = yield* ToolOutputStore.Service
type Registration = { readonly identity: object; readonly tool: AnyTool }
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
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)
},
}),
})
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* settle(registration.tool, input.call, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
}).pipe(
Effect.map((output) => ({ output })),
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
}),
)
}),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
),
)
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 }
})
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
})
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools) {
const entries = Object.entries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true })
yield* Effect.uninterruptible(
Effect.gen(function* () {
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(permission(registration.tool, name), permissions)) registrations.delete(name)
return {
definitions: Array.from(registrations, ([name, registration]) => 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}` } })
},
}
transform: state.transform,
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
const transform = yield* state.transform()
yield* transform(update)
}),
definitions,
execute,
settle,
})
}),
)
export const layer = Layer.effect(
Tools.Service,
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
).pipe(Layer.provideMerge(registryLayer))
function defaultPermission(name: string) {
return { action: ["edit", "write", "apply_patch"].includes(name) ? "edit" : name, resource: "*" as const }
}
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
function whollyDisabled(permission: { readonly action: string; readonly resource: "*" }, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(permission.action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
}
+52 -48
View File
@@ -2,26 +2,27 @@ export * as SkillTool from "./skill"
import path from "path"
import { pathToFileURL } from "url"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { FSUtil } from "../fs-util"
import { PluginBoot } from "../plugin/boot"
import { SkillV2 } from "../skill"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
})
export const Output = Schema.Struct({
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 = [
@@ -56,50 +57,53 @@ const unableToLoad = (name: string, error?: unknown) =>
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const fs = yield* FSUtil.Service
const boot = yield* PluginBoot.Service
const skills = yield* SkillV2.Service
const permission = yield* PermissionV2.Service
const resources = yield* ToolOutputStore.Service
yield* boot.wait()
yield* tools
.register({
[name]: Tool.make({
description,
input: Input,
output: Output,
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)
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)))))
}),
}),
)
}),
)
+31 -35
View File
@@ -1,54 +1,50 @@
export * as TodoWriteTool from "./todowrite"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { SessionTodo } from "../session/todo"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
export const name = "todowrite"
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }),
})
export const Output = Schema.Struct({
export const Success = Schema.Struct({
todos: Schema.Array(SessionTodo.Info),
})
export type Output = typeof Output.Type
export type Success = typeof Success.Type
export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
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 tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const todos = yield* SessionTodo.Service
const permission = yield* PermissionV2.Service
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: Input,
output: Output,
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)
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) })),
),
),
}),
)
}),
)
-145
View File
@@ -1,145 +0,0 @@
export * as Tool from "./tool"
import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
import { Effect, JsonSchema, 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 Definition<Input extends SchemaType<any>, Output extends SchemaType<any>> {
readonly [TypeId]: {
readonly _Input: Input
readonly _Output: Output
}
}
export type AnyTool = Definition<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) => ToolDefinition
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>,
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
if (cached) return cached
const definition = new ToolDefinition({
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.output),
})
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: Definition<Input, Output>,
permission: string,
) => {
const decorated = Object.freeze({}) as Definition<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
}
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
}
-13
View File
@@ -1,13 +0,0 @@
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") {}
+75 -67
View File
@@ -1,13 +1,12 @@
export * as WebFetchTool from "./webfetch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema, Stream } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
export const name = "webfetch"
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
@@ -16,11 +15,11 @@ export const MAX_TIMEOUT_SECONDS = 120
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.`
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.`
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"])
.annotate({ description: "The format to return the content in. Defaults to markdown." })
@@ -30,14 +29,16 @@ export const Input = Schema.Struct({
}),
})
const Output = Schema.Struct({
const Success = Schema.Struct({
url: Schema.String,
contentType: Schema.String,
format: Input.fields.format,
format: Parameters.fields.format,
output: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
})
type Format = (typeof Input.Type)["format"]
type Format = (typeof Parameters.Type)["format"]
const acceptHeader = (format: Format) => {
switch (format) {
@@ -48,7 +49,6 @@ 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,17 +89,15 @@ 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.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
return yield* Effect.die(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
}
const chunks: Uint8Array[] = []
let size = 0
yield* Stream.runForEach(response.stream, (chunk) =>
Effect.gen(function* () {
Effect.sync(() => {
size += chunk.byteLength
if (size > MAX_RESPONSE_BYTES)
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)
chunks.push(chunk)
return undefined
}),
)
return Buffer.concat(chunks, size)
@@ -117,6 +115,9 @@ 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)
@@ -124,64 +125,71 @@ 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 tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const http = yield* HttpClient.HttpClient
const permission = yield* PermissionV2.Service
const resources = yield* ToolOutputStore.Service
yield* tools
.register({
[name]: Tool.make({
description,
input: Input,
output: Output,
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* 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* 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 },
})
yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters })
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 { 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 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)
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) }),
),
),
),
}),
)
}),
)
+77 -64
View File
@@ -1,14 +1,13 @@
export * as WebSearchTool from "./websearch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, 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 { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "./registry"
import { checksum } from "../util/encode"
export const name = "websearch"
@@ -33,7 +32,7 @@ Optional controls support result count, live crawling ('fallback' or 'preferred'
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
@@ -166,81 +165,95 @@ 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.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
return yield* parseResponse(body)
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
orElse: () => Effect.die(new Error(`${tool} request timed out`)),
}),
)
})
const Output = Schema.Struct({
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 tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const http = yield* HttpClient.HttpClient
const config = yield* ConfigService
const permission = yield* PermissionV2.Service
const resources = yield* ToolOutputStore.Service
yield* tools
.register({
[name]: Tool.make({
description,
input: Input,
output: Output,
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 },
})
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 },
})
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)
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),
}),
),
),
)
},
}),
)
}),
)
+35 -51
View File
@@ -7,18 +7,16 @@
*/
export * as WriteTool from "./write"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
export const name = "write"
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
export const Input = Schema.Struct({
export const Parameters = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@@ -26,17 +24,25 @@ export const Input = Schema.Struct({
content: Schema.String.annotate({ description: "Content to write to the file" }),
})
export const Output = Schema.Struct({
export const Success = Schema.Struct({
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
})
export type Output = typeof Output.Type
export type Success = typeof Success.Type
export const toModelOutput = (output: Output) =>
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.
@@ -45,50 +51,28 @@ export const toModelOutput = (output: Output) =>
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const permission = yield* PermissionV2.Service
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: Input,
output: Output,
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)
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) }),
),
),
),
}),
)
}),
)
+53 -130
View File
@@ -3,13 +3,9 @@ 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 { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
const permission = Layer.mock(PermissionV2.Service, {
@@ -24,13 +20,11 @@ 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",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
parameters: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
@@ -43,69 +37,23 @@ 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.register({ application_context: contextual(contexts) })
yield* applications.attach({ application_context: contextual(contexts) })
expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual(
[],
)
expect(
yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
).toEqual([])
expect(
yield* settleTool(registry, {
yield* registry.settle({
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }])
}),
)
@@ -115,16 +63,14 @@ describe("ApplicationTools", () => {
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* applications.register({ application_context: contextual(contexts) })
yield* applications.attach({ application_context: contextual(contexts) })
expect(yield* toolDefinitions(registry)).toMatchObject([
expect(yield* registry.definitions()).toMatchObject([
{ name: "application_context", description: "Read application context" },
])
expect(
yield* settleTool(registry, {
yield* registry.settle({
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
}),
).toEqual({
@@ -143,21 +89,21 @@ describe("ApplicationTools", () => {
],
},
})
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }])
}),
)
it.effect("removes an application tool when its registration scope closes", () =>
it.effect("removes an application tool when its attachment scope closes", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
expect(yield* registry.definitions()).toEqual([])
}),
)
@@ -165,99 +111,70 @@ describe("ApplicationTools", () => {
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const registrationScope = yield* Scope.make()
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
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* Scope.close(registrationScope, Exit.void)
yield* Scope.close(attachmentScope, Exit.void)
expect(
yield* settleTool(registry, {
yield* registry.settle({
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
}),
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
}),
)
it.effect("does not leak a registration into an already closed scope", () =>
it.effect("does not leak an attachment into an already closed scope", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* Scope.close(scope, Exit.void)
yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope))
expect(yield* toolDefinitions(registry)).toEqual([])
expect(yield* registry.definitions()).toEqual([])
}),
)
it.effect("preserves an interrupted application registration until its scope closes", () =>
it.effect("captures the attached record before later State rebuilds", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const registered = yield* Deferred.make<void>()
const fiber = yield* applications
.register({ interrupted: contextual([]) })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
Scope.provide(scope),
Effect.forkChild,
)
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
const attached = { stable: contextual([]) }
yield* applications.attach(attached)
Object.assign(attached, { late: contextual([]) })
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
yield* Effect.scoped(applications.attach({ temporary: contextual([]) }))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"])
}),
)
it.effect("captures the registered record before later State rebuilds", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const registered = { stable: contextual([]) }
yield* applications.register(registered)
Object.assign(registered, { late: contextual([]) })
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
}),
)
it.effect("settles with the current same-name application tool and restores earlier registrations", () =>
it.effect("settles with the current same-name application tool and restores earlier attachments", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const firstContexts: Tool.Context[] = []
const secondContexts: Tool.Context[] = []
const scope = yield* Scope.make()
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* 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* settleTool(registry, {
yield* registry.settle({
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
})
yield* Scope.close(scope, Exit.void)
yield* settleTool(registry, {
yield* registry.settle({
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
})
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }])
expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }])
}),
)
@@ -265,26 +182,32 @@ 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* registry.register({ shared: location })
yield* applications.register({ shared: contextual(applicationContexts) })
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) })
expect(
(yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual([])
expect(
yield* settleTool(registry, {
yield* registry.settle({
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
}),
).toMatchObject({ result: { type: "content" } })
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }])
expect(applicationContexts).toEqual([])
}),
)
-20
View File
@@ -1,20 +0,0 @@
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))
+4 -5
View File
@@ -10,7 +10,6 @@ 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"
@@ -51,11 +50,11 @@ describe("LocationServiceMap", () => {
).pipe(
Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () {
yield* (yield* ApplicationTools.Service).register({
yield* (yield* ApplicationTools.Service).attach({
application_context: Tool.make({
description: "Read application context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
@@ -78,7 +77,7 @@ describe("LocationServiceMap", () => {
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
return {
providers: yield* catalog.provider.all(),
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
tools: yield* (yield* ToolRegistry.Service).definitions(),
}
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))
+3 -3
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.register({
yield* opencode.tools.attach({
public_tool: Tool.make({
description: "Public tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
-13
View File
@@ -1,13 +0,0 @@
import { describe, expect, it } from "bun:test"
import { Tool } from "@opencode-ai/core/public"
import { Effect } from "effect"
describe("public Tool API", () => {
it("keeps the public registration capability narrow", () => {
const tools = {
register: () => Effect.void,
} satisfies Tool.Interface
expect(Object.keys(tools)).toEqual(["register"])
})
})
@@ -1,70 +1,65 @@
import { describe, expect } from "bun:test"
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 { Tool, ToolFailure } from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
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, SchemaGetter, SchemaIssue, Scope } from "effect"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, {
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 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,
),
),
)
},
})
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
const it = testEffect(registry)
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, 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 } },
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 outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
})
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(ApplicationTools.layer),
Layer.provide(outputStore),
)
const it = testEffect(Layer.mergeAll(permission, registry))
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
}
const echo = Tool.make({
description: "Echo text",
parameters: Schema.Struct({ text: Schema.String }),
success: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
})
describe("ToolRegistry", () => {
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
apply_patch: make("edit"),
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 names = (rules: Parameters<ToolRegistry.Interface["materialize"]>[0]) =>
toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
const names = (rules: PermissionV2.Ruleset) =>
registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
@@ -72,374 +67,253 @@ 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"])
}),
)
it.effect("keeps permission decoration isolated between registrations", () =>
Effect.gen(function* () {
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* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
(definition) => definition.name,
),
).toEqual(["first"])
}),
)
it.effect("reuses model definitions across provider turns", () =>
Effect.gen(function* () {
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()
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* toolDefinitions(service)).toEqual([])
}),
)
it.effect("preserves an interrupted registration until its scope closes", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const registered = yield* Deferred.make<void>()
const fiber = yield* service
.register({ echo: make() })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
Scope.provide(scope),
Effect.forkChild,
)
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
}),
)
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
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" })),
}),
})
yield* names([
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: "*.md", effect: "ask" },
]),
).toEqual(["question", "bash", "edit", "write", "apply_patch"])
expect(
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" })
yield* names([
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
]),
).toEqual(["question", "bash"])
}),
)
yield* service.register({
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
})
expect(
yield* service.materialize().pipe(
Effect.flatMap((materialized) =>
materialized.settle({
sessionID,
...identity,
call: { type: "tool-call", id: "defect", name: "defect", input: {} },
it.effect("settles only through concrete leaf authorization, not catalog visibility", () =>
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" }
}),
),
Effect.catchDefect(Effect.succeed),
),
).toBe("unexpected executor defect")
}),
)
it.effect("propagates retention failures through settlement", () =>
Effect.gen(function* () {
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)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
}),
)
it.effect("exposes settlement only through materialization", () =>
Effect.gen(function* () {
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("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 service = yield* ToolRegistry.Service
yield* service.register({ bounded: make() })
expect(
yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
}),
).toEqual({
result: { type: "text", value: "bounded reference" },
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
})
expect(bounds).toHaveLength(1)
}),
)
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const executed: string[] = []
const Transformed = Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
encode: SchemaGetter.transform((value) => value === "yes"),
}),
)
yield* service.register({
transformed: Tool.make({
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}),
})
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
}),
).toEqual({ type: "text", value: "true" })
expect(executed).toEqual(["yes"])
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name),
).toEqual([])
expect(
yield* executeTool(service, {
yield* registry.settle({
sessionID,
...identity,
call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
expect(executed).toEqual(["yes"])
).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } })
expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] })
expect(executed).toBe(true)
}),
)
yield* service.register({
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
Effect.gen(function* () {
const registry = 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* Scope.close(scope, Exit.void)
expect(yield* registry.definitions()).toEqual([])
}),
)
it.effect("returns an error result for an unknown tool", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-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 }
}),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
})
)
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-denied", name: "denied", input: {} },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") })
).toEqual({ type: "error", value: "Denied" })
expect(executed).toBe(false)
}),
)
it.effect("executes the unchanged registration advertised for a provider turn", () =>
it.effect("binds invocation identity while preserving leaf-owned permission inputs", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
assertions.length = 0
denyAction = undefined
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const sessionID = SessionV2.ID.make("ses_registry_context")
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",
})
}),
)
integrated.effect("rejects an application call after a Location override is registered", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
const materialized = yield* service.materialize()
yield* service.register({ echo: make() })
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
integrated.effect("rejects a Location call after removal reveals an application registration", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
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("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 }],
yield* transform((editor) =>
editor.set("context", {
tool: Tool.make({
description: "Context tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
})
.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)
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* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } })
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")
}),
)
it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () =>
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
}),
)
it.effect("settles encoded structured output with canonical projected content", () =>
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}` },
],
}),
}),
)
expect(
yield* registry.settle({
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
}),
).toMatchObject({
result: { type: "text", value: "call-projected:count:2" },
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
})
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" }] },
},
])
}),
)
})
+56 -55
View File
@@ -4,6 +4,7 @@ import {
LLMError,
LLMEvent,
Model,
Tool,
TransportReason,
InvalidRequestReason,
type LLMClientShape,
@@ -37,7 +38,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 { Tool } from "@opencode-ai/core/tool/tool"
import { NativeTool } from "@opencode-ai/core/tool/native"
import {
SessionContextEpochTable,
SessionInputTable,
@@ -109,7 +110,7 @@ const recoveryModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
})
const authorizations: Tool.Context[] = []
const authorizations: ToolRegistry.AuthorizeInput[] = []
const executions: string[] = []
const permission = Layer.succeed(
PermissionV2.Service,
@@ -131,31 +132,38 @@ const registry = ToolRegistry.layer.pipe(
const agents = AgentV2.layer
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
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"),
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--))),
}),
}),
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))
@@ -559,12 +567,12 @@ describe("SessionRunnerLLM", () => {
yield* setup
const applicationTools = yield* ApplicationTools.Service
const session = yield* SessionV2.Service
const contexts: Tool.Context[] = []
yield* applicationTools.register({
application_context: Tool.make({
const contexts: NativeTool.Context[] = []
yield* applicationTools.attach({
application_context: NativeTool.make({
description: "Read application context",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
parameters: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
contexts.push(context)
@@ -586,14 +594,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
expect(contexts).toEqual([
{
sessionID,
agent: AgentV2.ID.make("build"),
assistantMessageID: expect.stringMatching(/^msg_/),
toolCallID: "call-application",
},
])
expect(contexts).toEqual([{ sessionID, id: "call-application", name: "application_context" }])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" },
{
@@ -1782,7 +1783,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
expect(authorizations).toMatchObject([{ sessionID, call: { id: "call-echo", name: "echo" } }])
expect(executions).toEqual(["hello"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Echo this" },
@@ -2936,7 +2937,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("propagates unexpected local tool defects operationally", () =>
it.effect("durably settles unexpected local tool defects before continuing", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -2950,11 +2951,12 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect" },
{
@@ -2963,10 +2965,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-defect",
state: {
status: "error",
error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" },
},
state: { status: "error", error: { message: "unexpected tool defect" } },
},
],
},
@@ -2980,15 +2979,17 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const questions = yield* QuestionV2.Service
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),
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* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false })
requests.length = 0
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
describe("State", () => {
it.effect("commits a transform atomically when its updater is interrupted", () =>
Effect.gen(function* () {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let block = true
const state = State.create({
initial: () => ({ values: [] as string[] }),
editor: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () =>
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
})
const scope = yield* Scope.make()
const update = yield* state.transform().pipe(Scope.provide(scope))
const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild)
yield* Deferred.await(rebuilding)
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
block = false
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interruption)
expect(state.get().values).toEqual(["registered"])
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual([])
}),
)
})
+22 -33
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Deferred, Effect, 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,7 +14,6 @@ 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[] = []
@@ -93,7 +92,6 @@ 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),
@@ -105,7 +103,6 @@ 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 } },
})
@@ -132,9 +129,8 @@ describe("ApplyPatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* settleTool(
registry,
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* registry.settle(
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",
),
@@ -150,7 +146,7 @@ describe("ApplyPatchTool", () => {
{ type: "delete", resource: "remove.txt" },
],
})
expect(assertions).toMatchObject([
expect(assertions).toEqual([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
])
expect(readsBeforeEditApproval).toBe(0)
@@ -179,8 +175,7 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
yield* registry.execute(
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",
),
@@ -208,8 +203,7 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
yield* registry.execute(
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "text" })
@@ -242,8 +236,7 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
yield* registry.execute(
call(
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
@@ -273,8 +266,7 @@ describe("ApplyPatchTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
yield* registry.execute(
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
@@ -299,8 +291,7 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
yield* registry.execute(
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
@@ -324,10 +315,7 @@ describe("ApplyPatchTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
),
yield* registry.execute(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")
}),
@@ -337,7 +325,7 @@ describe("ApplyPatchTool", () => {
),
)
it.live("preserves a later commit defect after earlier sequential applications", () =>
it.live("reports earlier sequential applications when a later commit fails", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -350,13 +338,13 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
Exit.isFailure(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.exit),
yield* registry.execute(
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
),
).toBe(true)
).toEqual({
type: "error",
value: "Patch partially applied before failing at second.txt. Applied: first.txt",
})
expect(yield* exists(first)).toBe(false)
expect(yield* exists(second)).toBe(true)
}),
@@ -382,10 +370,11 @@ 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* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.forkChild)
const run = yield* registry
.execute(
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)
+45 -28
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,6 +27,7 @@ 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",
@@ -38,6 +39,8 @@ 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,
@@ -67,6 +70,16 @@ 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({
@@ -77,6 +90,7 @@ const config = Layer.succeed(
const reset = () => {
assertions.length = 0
runs.length = 0
truncations.length = 0
denyAction = undefined
runFailure = undefined
afterPermission = () => Effect.void
@@ -88,6 +102,7 @@ const reset = () => {
stdoutTruncated: false,
stderrTruncated: false,
}
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
}
const withTool = <A, E, R>(
@@ -108,6 +123,7 @@ const withTool = <A, E, R>(
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(processLayer),
Layer.provide(resources),
Layer.provide(config),
)
return Effect.gen(function* () {
@@ -115,9 +131,8 @@ const withTool = <A, E, R>(
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
}
const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input },
})
@@ -131,13 +146,11 @@ describe("BashTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const definitions = yield* registry.definitions()
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
).toEqual({
expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: {
structured: {
@@ -155,7 +168,7 @@ describe("BashTool", () => {
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
)
},
@@ -169,9 +182,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
),
@@ -195,9 +206,7 @@ describe("BashTool", () => {
}).pipe(Effect.orDie)
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
Effect.sync(() => {
expect(runs).toEqual([])
@@ -218,7 +227,7 @@ describe("BashTool", () => {
reset()
return withTool(
tmp.path,
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
(registry) => registry.settle(call({ command: "printf core-bash" })),
AppProcess.defaultLayer,
).pipe(
Effect.andThen((settled) =>
@@ -245,7 +254,7 @@ describe("BashTool", () => {
([active, outside]) => {
reset()
return withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
registry.execute(call({ command: "pwd", workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
@@ -272,15 +281,13 @@ describe("BashTool", () => {
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
)
yield* withTool(active.path, (registry) => registry.execute(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) => executeTool(registry, call({ command: "pwd" })))
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toEqual([])
}),
@@ -298,7 +305,7 @@ describe("BashTool", () => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
@@ -320,13 +327,19 @@ describe("BashTool", () => {
),
)
it.live("keeps non-zero exits useful", () =>
it.live("keeps non-zero exits useful and exposes managed overflow by path", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
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(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
@@ -337,9 +350,13 @@ describe("BashTool", () => {
command: "false",
cwd: realpathSync(tmp.path),
exitCode: 7,
output: "HEAD full output TAIL",
truncated: false,
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"])
expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
])
}),
),
)
@@ -354,7 +371,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
result = { ...result, stdoutTruncated: true }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
@@ -377,7 +394,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
+19 -27
View File
@@ -15,7 +15,6 @@ 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[] = []
@@ -83,7 +82,6 @@ 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,9 +91,8 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
}
const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "edit", input },
})
@@ -112,12 +109,9 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
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,
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(
call({ path: "hello.txt", oldString: "before", newString: "after" }),
)
expect(settled.result).toEqual({
@@ -132,7 +126,7 @@ describe("EditTool", () => {
replacements: 1,
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
}),
),
@@ -152,7 +146,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@@ -177,7 +171,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@@ -208,7 +202,7 @@ describe("EditTool", () => {
denyAction = "external_directory"
expect(
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
@@ -222,7 +216,7 @@ describe("EditTool", () => {
denyAction = "edit"
expect(
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
),
).toEqual({
type: "error",
@@ -251,12 +245,10 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const matching = yield* executeTool(
registry,
const matching = yield* registry.execute(
call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
)
const missing = yield* executeTool(
registry,
const missing = yield* registry.execute(
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
)
@@ -285,26 +277,26 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "same" })),
).toEqual({
type: "error",
value: "No changes to apply: oldString and newString are identical.",
})
expect(
yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })),
yield* registry.execute(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* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })),
yield* registry.execute(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* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "after" })),
).toEqual({
type: "error",
value:
@@ -329,7 +321,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
registry.settle(call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
),
),
Effect.andThen((settled) =>
@@ -354,7 +346,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
registry.execute(call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
),
),
Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
@@ -375,7 +367,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
registry.execute(call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
@@ -398,7 +390,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) => toolDefinitions(registry)),
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
+8 -12
View File
@@ -8,7 +8,6 @@ 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[] = []
@@ -91,9 +90,8 @@ const reset = () => {
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
}
const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({
const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "glob", input },
})
@@ -101,7 +99,7 @@ describe("GlobTool", () => {
it.effect("registers the glob definition", () =>
Effect.gen(function* () {
reset()
expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"])
expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"])
}),
)
@@ -110,13 +108,11 @@ describe("GlobTool", () => {
reset()
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })),
).toEqual({
expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({
type: "text",
value: "No files found",
})
expect(assertions).toMatchObject([
expect(assertions).toEqual([
{
sessionID,
action: "glob",
@@ -135,7 +131,7 @@ describe("GlobTool", () => {
reset()
allow = false
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({
type: "error",
value: "Unable to find files matching *.secret",
})
@@ -159,7 +155,7 @@ describe("GlobTool", () => {
partial: false,
})
expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({
expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({
result: { type: "text", value: "src/index.ts" },
output: {
structured: result,
@@ -185,11 +181,11 @@ describe("GlobTool", () => {
partial: false,
})
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({
type: "text",
value: "docs:guide.md",
})
expect(assertions).toMatchObject([
expect(assertions).toEqual([
{
sessionID,
action: "glob",
+11 -17
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { Effect, 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,7 +19,6 @@ 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[] = []
@@ -91,20 +90,12 @@ const sessionID = SessionV2.ID.make("ses_grep_tool_test")
const execute = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) =>
executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-grep", name: "grep", input },
}),
registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
)
const settle = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) =>
settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-grep", name: "grep", input },
}),
registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
)
const reset = () => {
@@ -151,10 +142,10 @@ function provideLive(directory: string, projectReferences = references({})) {
}
describe("GrepTool", () => {
it.effect("registers grep", () =>
it.effect("registers the grep contribution", () =>
Effect.gen(function* () {
reset()
expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }])
}),
)
@@ -164,7 +155,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).toMatchObject([
expect(assertions).toEqual([
{
sessionID,
action: "grep",
@@ -235,7 +226,7 @@ describe("GrepTool", () => {
}),
)
it.effect("preserves an unexpected search defect", () =>
it.effect("returns a useful tool error for an invalid regex", () =>
Effect.gen(function* () {
reset()
searchFailure = new Ripgrep.InvalidPatternError({
@@ -243,7 +234,10 @@ describe("GrepTool", () => {
message: "regex parse error: unclosed character class",
})
expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
expect(yield* execute({ pattern: "[" })).toEqual({
type: "error",
value: 'Invalid grep pattern "[": regex parse error: unclosed character class',
})
expect(searches).toEqual([{ pattern: "[" }])
}),
)
+46 -135
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Effect, Layer } 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,7 +43,35 @@ const withStore = <A, E, R>(
const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => {
it.live("bounds the provider-facing text channel with one managed file", () =>
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", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const first = "HEAD-" + "x".repeat(30_000)
@@ -61,7 +89,7 @@ describe("ToolOutputStore", () => {
})
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${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)
}),
@@ -73,151 +101,37 @@ 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).toEqual(structured)
expect(result.output.structured).toBe(structured)
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured))
}),
),
)
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const data = "a".repeat(6 * 1024 * 1024)
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({ caption: "pixel" })
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("preserves structured metadata and native media when bounding text", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
const media = {
type: "file" as const,
source: { type: "data" as const, data: "aGVsbG8=" },
mime: "image/png",
name: "pixel.png",
}
const result = yield* store.bound({
sessionID,
toolCallID: "call-text-and-media",
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
})
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content[1]).toEqual(media)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
}),
),
)
it.live("does not double-count structured data duplicated in projected text", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const text = "x".repeat(30_000)
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({
output,
outputPaths: [],
})
}),
),
)
it.live("fails oversized settlement when complete retention cannot be written", () =>
it.live("degrades to lossy bounded output when writing fails", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
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("does not encode ignored structured metadata when projected content exists", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({
output,
outputPaths: [],
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")
}),
),
)
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 })
const result = yield* store.bound({
sessionID,
toolCallID: "call-config",
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
})
expect(result.outputPaths).toHaveLength(1)
expect(
(yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated,
).toBe(true)
}),
new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
),
@@ -226,12 +140,9 @@ describe("ToolOutputStore", () => {
it.live("cleans expired managed files and preserves unrelated files", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const old = path.join(root, "tool-output", "tool_old")
const recent = path.join(root, "tool-output", "tool_recent")
const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "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)
+15 -26
View File
@@ -6,7 +6,6 @@ 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[] = []
@@ -41,7 +40,7 @@ const question = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question))
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
describe("QuestionTool", () => {
@@ -51,11 +50,10 @@ describe("QuestionTool", () => {
deny = true
const registry = yield* ToolRegistry.Service
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, {
yield* registry.settle({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}),
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
@@ -84,11 +82,10 @@ describe("QuestionTool", () => {
},
]
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"])
expect(
yield* settleTool(registry, {
yield* registry.settle({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
}),
).toEqual({
@@ -107,12 +104,8 @@ describe("QuestionTool", () => {
],
},
})
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({
sessionID,
questions,
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
})
expect(assertions).toEqual([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
}),
)
@@ -123,16 +116,11 @@ describe("QuestionTool", () => {
deny = false
const registryService = yield* ToolRegistry.Service
yield* executeTool(registryService, {
yield* registryService.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
})
expect(capturedInput()).toEqual({
sessionID,
questions: [],
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
})
expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined })
}),
)
@@ -142,11 +130,12 @@ describe("QuestionTool", () => {
reject = true
deny = false
const registryService = yield* ToolRegistry.Service
const fiber = yield* executeTool(registryService, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
}).pipe(Effect.forkScoped)
const fiber = yield* registryService
.execute({
sessionID,
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)
+37 -132
View File
@@ -1,15 +1,13 @@
import { beforeEach, describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { Effect, 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"
import { Image } from "@opencode-ai/core/image"
import { PermissionV2 } from "@opencode-ai/core/permission"
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: {
@@ -76,29 +74,13 @@ const permission = Layer.succeed(
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const image = Image.layer.pipe(Layer.provide(config))
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(image),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, image, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, filesystem, permission, config, unavailableImage, unavailableRead),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read))
const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => {
@@ -118,12 +100,11 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* executeTool(registry, {
yield* registry.execute({
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" } })
@@ -144,9 +125,8 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
@@ -158,12 +138,12 @@ describe("ReadTool", () => {
})
expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
const settled = yield* settleTool(registry, {
const settled = yield* registry.settle({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" })
expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
@@ -171,64 +151,6 @@ 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).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "large.png" },
],
})
}),
)
itWithoutResizer.effect("returns the original image when the resizer is unavailable", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", data: png }],
})
}),
)
it.effect("rejects invalid image data returned by the filesystem", () =>
Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({
@@ -240,9 +162,8 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
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" })
@@ -272,9 +193,8 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* executeTool(registry, {
const result = yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
})
@@ -304,9 +224,8 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* executeTool(registry, {
const result = yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
})
@@ -342,9 +261,8 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* executeTool(registry, {
const result = yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
})
@@ -365,9 +283,8 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
}),
).toMatchObject({
@@ -377,25 +294,22 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves unexpected filesystem defects", () =>
it.effect("preserves unsupported binary errors from the filesystem", () =>
Effect.gen(function* () {
readFailure = new FileSystem.BinaryFileError("archive.dat")
const registry = yield* ToolRegistry.Service
expect(
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)
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" })
expect(readCalls).toEqual([
{ input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
])
@@ -408,9 +322,8 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}),
).toEqual({ type: "error", value: "Unable to read README.md" })
@@ -424,9 +337,8 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory",
@@ -447,9 +359,8 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ type: "error", value: "Unable to read src" })
@@ -461,9 +372,8 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
})
@@ -471,20 +381,17 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves unexpected resolution defects", () =>
it.effect("settles missing files as typed tool errors", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
resolveFailure = new Error("missing")
expect(
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)
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" })
expect(readCalls).toEqual([])
}),
@@ -503,9 +410,8 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-large",
@@ -532,9 +438,8 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
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" })
+37 -17
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,6 +41,9 @@ 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,
@@ -74,58 +77,75 @@ 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, tool)
const layer = Layer.mergeAll(permission, skills, registry, boot, resources, tool)
return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
expect(bootWaited).toBe(true)
expect((yield* toolDefinitions(registry))[0]).toMatchObject({
expect((yield* registry.definitions())[0]).toMatchObject({
name: "skill",
description: SkillTool.description,
})
expect(
yield* executeTool(registry, {
yield* registry.execute({
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* settleTool(registry, {
yield* registry.settle({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
}),
).toMatchObject({
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { structured: { name: "effect" } },
result: { type: "text", value: expect.stringContaining("/tmp/tool-output/tool_opaque") },
output: {
structured: { truncated: true, outputPath: "/tmp/tool-output/tool_opaque" },
},
})
expect(assertions).toMatchObject([
expect(assertions).toEqual([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
])
expect(
yield* executeTool(registry, {
yield* registry.execute({
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* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
@@ -143,10 +163,10 @@ describe("SkillTool", () => {
]),
)
current = [flat]
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
expect(
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
+6 -10
View File
@@ -12,7 +12,6 @@ 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[] = []
@@ -36,7 +35,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(permission), Layer.provide(todos))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos))
const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool))
const setup = Effect.gen(function* () {
@@ -64,7 +63,6 @@ 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 } },
})
@@ -76,15 +74,15 @@ describe("TodoWriteTool", () => {
const service = yield* SessionTodo.Service
const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }]
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* settleTool(registry, call(todoList))).toEqual({
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* registry.settle(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).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(yield* service.get(sessionID)).toEqual(todoList)
}),
)
@@ -97,14 +95,12 @@ describe("TodoWriteTool", () => {
yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] })
deny = true
expect(
yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])),
).toEqual({
expect(yield* registry.execute(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).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
}),
)
})
+67 -32
View File
@@ -4,16 +4,19 @@ 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,
@@ -35,31 +38,42 @@ 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(permission), Layer.provide(http))
const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
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 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, fetchWebfetch))
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, resources, 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.Input.Type, id = "call-webfetch") => ({
const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "webfetch", input },
})
describe("WebFetchTool helpers", () => {
test("defaults format and rejects invalid timeout controls", () => {
const decode = Schema.decodeUnknownSync(WebFetchTool.Input)
const decode = Schema.decodeUnknownSync(WebFetchTool.Parameters)
expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
@@ -72,22 +86,22 @@ describe("WebFetchTool helpers", () => {
})
})
describe("WebFetchTool registration", () => {
describe("WebFetchTool contribution", () => {
it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
Effect.gen(function* () {
reset()
const registry = yield* ToolRegistry.Service
const url = "http://example.com/public"
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* registry.settle(call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
structured: { url, contentType: "text/plain", format: "text", output: "hello", truncated: false },
content: [{ type: "text", text: "hello" }],
},
})
expect(assertions).toMatchObject([
expect(assertions).toEqual([
{ 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") } }])
@@ -100,11 +114,11 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = "http://localhost/private"
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({
type: "text",
value: "hello",
})
expect(assertions).toMatchObject([
expect(assertions).toEqual([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
expect(requests.map((request) => request.url)).toEqual([url])
@@ -128,11 +142,8 @@ describe("WebFetchTool registration", () => {
const registry = yield* ToolRegistry.Service
const url = new URL("/redirect", server.url).toString()
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text",
value: "redirected",
})
expect(assertions).toMatchObject([
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ type: "text", value: "redirected" })
expect(assertions).toEqual([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
])
}),
@@ -145,7 +156,7 @@ describe("WebFetchTool registration", () => {
reset()
const registry = yield* ToolRegistry.Service
expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
expect(yield* registry.execute(call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch file:///etc/passwd",
})
@@ -165,17 +176,41 @@ describe("WebFetchTool registration", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
type: "text",
value: "# Hello\n\nworld",
})
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
expect(yield* registry.execute(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()
@@ -186,7 +221,7 @@ describe("WebFetchTool registration", () => {
headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
}),
)
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
expect(yield* registry.execute(call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/declared",
})
@@ -195,7 +230,7 @@ describe("WebFetchTool registration", () => {
Effect.succeed(
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
)
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
expect(yield* registry.execute(call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
type: "error",
value: "Unable to fetch https://1.1.1.1/streamed",
})
@@ -207,16 +242,17 @@ describe("WebFetchTool registration", () => {
reset()
const registry = yield* ToolRegistry.Service
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
expect(yield* registry.execute(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* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
expect(yield* registry.execute(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([])
}),
)
@@ -232,7 +268,7 @@ describe("WebFetchTool registration", () => {
)
const registry = yield* ToolRegistry.Service
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text",
value: "ok",
})
@@ -247,10 +283,9 @@ describe("WebFetchTool registration", () => {
reset()
respond = () => Effect.never
const registry = yield* ToolRegistry.Service
const fiber = yield* executeTool(
registry,
call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }),
).pipe(Effect.forkChild)
const fiber = yield* registry
.execute(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" })
+61 -17
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) =>
@@ -18,7 +18,7 @@ const payload = (text: string) =>
describe("WebSearchTool provider selection", () => {
test("rejects out-of-range numeric controls", () => {
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
const decode = Schema.decodeUnknownSync(WebSearchTool.Parameters)
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
@@ -65,8 +65,11 @@ 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,
@@ -114,28 +117,40 @@ 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, websearch))
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch))
describe("WebSearchTool registration", () => {
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* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"])
expect(
yield* executeTool(registry, {
yield* registry.execute({
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-exa",
@@ -150,7 +165,7 @@ describe("WebSearchTool registration", () => {
},
}),
).toEqual({ type: "text", value: "exa results" })
expect(assertions).toMatchObject([
expect(assertions).toEqual([
{
sessionID,
action: "websearch",
@@ -198,9 +213,8 @@ describe("WebSearchTool registration", () => {
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* registry.settle({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
})
@@ -221,7 +235,7 @@ describe("WebSearchTool registration", () => {
expect(settled).toEqual({
result: { type: "text", value: "parallel results" },
output: {
structured: { provider: "parallel", text: "parallel results" },
structured: { provider: "parallel", text: "parallel results", truncated: false },
content: [{ type: "text", text: "parallel results" }],
},
})
@@ -237,9 +251,8 @@ describe("WebSearchTool registration", () => {
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
const settled = yield* registry.settle({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
})
@@ -257,15 +270,47 @@ describe("WebSearchTool registration", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
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
@@ -275,9 +320,8 @@ describe("WebSearchTool registration", () => {
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
yield* registry.execute({
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" })
+13 -27
View File
@@ -15,7 +15,6 @@ 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[] = []
@@ -65,20 +64,14 @@ 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(permission),
Layer.provide(resolution),
Layer.provide(mutation),
)
const write = WriteTool.layer.pipe(Layer.provide(registry), 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)))
}
const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "write", input },
})
@@ -92,8 +85,8 @@ describe("WriteTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* registry.settle(call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" },
output: {
@@ -109,7 +102,7 @@ describe("WriteTool", () => {
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
)
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
}),
)
@@ -125,7 +118,7 @@ describe("WriteTool", () => {
reset()
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
withTool(tmp.path, (registry) => registry.settle(call({ path: "existing.txt", content: "after" }))),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
@@ -156,11 +149,8 @@ describe("WriteTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* settleTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
)
yield* registry.settle(call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* registry.settle(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")
@@ -179,7 +169,7 @@ describe("WriteTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "absolute.txt")
return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
return withTool(tmp.path, (registry) => registry.execute(call({ path: target, content: "inside" }))).pipe(
Effect.andThen((result) =>
Effect.gen(function* () {
expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
@@ -199,9 +189,7 @@ describe("WriteTool", () => {
([active, outside]) => {
reset()
const target = path.join(outside.path, "external.txt")
return withTool(active.path, (registry) =>
settleTool(registry, call({ path: target, content: "external" })),
).pipe(
return withTool(active.path, (registry) => registry.settle(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")
@@ -239,9 +227,7 @@ describe("WriteTool", () => {
reset()
denyAction = "external_directory"
expect(
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ path: external, content: "blocked" })),
),
yield* withTool(active.path, (registry) => registry.execute(call({ path: external, content: "blocked" }))),
).toEqual({
type: "error",
value: `Unable to write ${external}`,
@@ -253,7 +239,7 @@ describe("WriteTool", () => {
denyAction = "edit"
expect(
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
registry.execute(call({ path: "denied.txt", content: "blocked" })),
),
).toEqual({
type: "error",
@@ -273,7 +259,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) => toolDefinitions(registry)),
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
)
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
+11 -11
View File
@@ -238,13 +238,13 @@ Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` f
Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices.
| Field | Current Purpose | Status | Notes |
| --------------- | --------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. |
| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. |
| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. |
| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ action, resource, effect }` rules. |
| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. |
| Field | Current Purpose | Status | Notes |
| --------------- | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. |
| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. |
| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. |
| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ permission, pattern, action }` rules. |
| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. |
Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together.
@@ -281,7 +281,7 @@ Retain `description`, `hidden`, and `steps`; they define an agent's discoverabil
"color": "warning",
"steps": 12,
"disabled": false,
"permissions": [{ "action": "edit", "resource": "*", "effect": "deny" }],
"permissions": [{ "permission": "edit", "pattern": "*", "action": "deny" }],
},
},
}
@@ -289,13 +289,13 @@ Retain `description`, `hidden`, and `steps`; they define an agent's discoverabil
Do not port `tools`, either as a top-level setting or as an agent-entry alias. The legacy loader already converts tool booleans into permission rules, including collapsing write-adjacent tool names into `edit`; v2 should avoid carrying that lossy compatibility input forward.
Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` effect in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries.
Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` action in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries.
```jsonc
{
"permissions": [
{ "action": "bash", "resource": "*", "effect": "ask" },
{ "action": "bash", "resource": "git status", "effect": "allow" },
{ "permission": "bash", "pattern": "*", "action": "ask" },
{ "permission": "bash", "pattern": "git status", "action": "allow" },
],
}
```
-186
View File
@@ -1,186 +0,0 @@
# V2 Tools
## Design
V2 has one opaque type for locally executable tools:
```ts
type Definition<Input, Output>
type AnyTool = Definition<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>
}) => Definition<Input, Output>
```
Application tools, built-ins, and statically authored plugin tools use this same constructor and execution contract.
`Tool.Definition` 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; callers normally rely on `Tool.make` inference rather than naming the carrier type.
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 registration.
- Closing the winner reveals the next-latest active registration.
- Mutating the caller's registration record later does not change the captured registration.
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. Returns the settlement and managed-output references to the runner, which persists them durably.
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; removing or replacing its registration 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 the channel actually sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths 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.
Session's current public result shape still exposes managed `outputPaths`. Extending storage encapsulation across the public Session API requires a separate opaque managed-output reference design; paths are not entirely internal today.