155 lines
6.5 KiB
TypeScript
155 lines
6.5 KiB
TypeScript
export * as ToolRegistry from "./registry"
|
|
|
|
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
|
import { Context, Effect, Layer, Scope } from "effect"
|
|
import type { AgentV2 } from "../agent"
|
|
import { PermissionV2 } from "../permission"
|
|
import { SessionMessage } from "../session/message"
|
|
import { SessionSchema } from "../session/schema"
|
|
import { ToolOutputStore } from "../tool-output-store"
|
|
import { Wildcard } from "../util/wildcard"
|
|
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
|
import { Tools } from "./tools"
|
|
import { makeLocationNode } from "../effect/app-node"
|
|
|
|
export type ExecuteInput = {
|
|
readonly sessionID: SessionSchema.ID
|
|
readonly agent: AgentV2.ID
|
|
readonly assistantMessageID: SessionMessage.ID
|
|
readonly call: ToolCall
|
|
}
|
|
|
|
export interface Interface {
|
|
readonly materialize: (input: MaterializeInput) => 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>
|
|
}
|
|
|
|
export interface MaterializeInput {
|
|
readonly model: { readonly id: string; readonly provider: string }
|
|
readonly permissions?: PermissionV2.Ruleset
|
|
}
|
|
|
|
export interface Materialization {
|
|
readonly definitions: ReadonlyArray<ToolDefinition>
|
|
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
|
|
}
|
|
|
|
export interface Settlement {
|
|
readonly result: ToolResultValue
|
|
readonly output?: ToolOutput
|
|
readonly outputPaths?: ReadonlyArray<string>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
|
|
|
const registryLayer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
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 settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
|
const registration = local.get(input.call.name)?.at(-1)?.registration
|
|
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 })),
|
|
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 }
|
|
})
|
|
|
|
return Service.of({
|
|
register: Effect.fn("ToolRegistry.register")(function* (tools) {
|
|
const entries = registrationEntries(tools)
|
|
if (entries.length === 0) return
|
|
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* (input) {
|
|
const registrations = new Map<string, Registration>()
|
|
for (const [name, entries] of local) {
|
|
const registration = entries.at(-1)?.registration
|
|
if (registration) registrations.set(name, registration)
|
|
}
|
|
// OpenAI/GPT models use apply_patch; every other model uses edit and write.
|
|
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
|
|
for (const [name, registration] of registrations) {
|
|
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
|
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.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}` } })
|
|
},
|
|
}
|
|
}),
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const layer = Layer.effect(
|
|
Tools.Service,
|
|
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
|
|
).pipe(Layer.provideMerge(registryLayer))
|
|
|
|
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
|
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
|
|
return rule?.resource === "*" && rule.effect === "deny"
|
|
}
|
|
|
|
export const defaultLayer = layer.pipe(Layer.provide(ToolOutputStore.defaultLayer))
|
|
|
|
export const node = makeLocationNode({
|
|
service: Service,
|
|
layer,
|
|
deps: [ToolOutputStore.node],
|
|
})
|
|
|
|
export const toolsNode = makeLocationNode({
|
|
service: Tools.Service,
|
|
layer,
|
|
deps: [ToolOutputStore.node],
|
|
})
|