refactor(core): consolidate tool architecture
This commit is contained in:
parent
0fd73a2976
commit
8db7487c89
466 changed files with 9405 additions and 11071 deletions
|
|
@ -1,20 +1,22 @@
|
|||
# Core Tool Architecture
|
||||
|
||||
This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes.
|
||||
`src/tool.ts` owns the Location-scoped tool service, registrations, effective lookup, execution, and terminal outcomes. This folder contains its supporting runtime modules and built-in plugins.
|
||||
|
||||
## Representations
|
||||
|
||||
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output?, execute })` tool. Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same type.
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
|
||||
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
|
||||
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
|
||||
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
|
||||
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
|
||||
- Built-in tool plugins live in `tool/plugin`.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
|
||||
## Construction
|
||||
|
||||
Tool schemas use `input` and `output` terminology. A tool carries schemas and executable behavior without public identity. A registration binds its name, namespace, CodeMode placement, and optional catalog permission action.
|
||||
Tool schemas use `input` and `output` terminology. Each tool carries its name, options, schemas, and executable behavior in one object.
|
||||
|
||||
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:
|
||||
Location-scoped built-in layers acquire `Permission.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 = {
|
||||
|
|
@ -24,13 +26,11 @@ const source = {
|
|||
}
|
||||
```
|
||||
|
||||
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. User declines from `PermissionV2.assert` and question dismissals travel as defects beneath leaf `mapError` blankets and resurface as typed failures at `SessionModelRequest.executeTool`; leaves must never catch or convert them. A decline with feedback (`PermissionV2.CorrectedError`) stays typed so the leaf converts it into `ToolFailure` and the model continues.
|
||||
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. User declines from `Permission.assert` and question dismissals travel as defects beneath leaf `mapError` blankets and resurface as typed failures at `SessionModelRequest.executeTool`; leaves must never catch or convert them. A decline with feedback (`Permission.CorrectedError`) stays typed so the leaf converts it into `ToolFailure` and the model continues.
|
||||
|
||||
## Registration
|
||||
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
||||
namespace, which flattens direct model names to `<namespace>_<tool>`, and default into CodeMode (`codemode` defaults true;
|
||||
`codemode: false` keeps the tool on the provider's native tool list).
|
||||
Built-ins, plugins, and MCP install tools through `ToolRegistry.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
|
|
@ -38,21 +38,22 @@ Registrations are scoped:
|
|||
- Closing any registration removes only that registration and reveals the next active one.
|
||||
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
|
||||
|
||||
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
|
||||
|
||||
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
|
||||
## Permissions
|
||||
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action.
|
||||
The registry has no `Permission.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action.
|
||||
|
||||
Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution.
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
|
||||
|
||||
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`.
|
||||
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
|
||||
|
||||
## Current Gaps
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,196 +0,0 @@
|
|||
export * as ExecuteTool from "./execute"
|
||||
export type { Registration } from "./tool"
|
||||
|
||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import type { ToolContent } from "@opencode-ai/ai"
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.optionalKey(Schema.String),
|
||||
})
|
||||
|
||||
const ExecuteCall = Schema.Struct({
|
||||
tool: Schema.String,
|
||||
status: Schema.Literals(["running", "completed", "error"]),
|
||||
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
|
||||
})
|
||||
|
||||
type ExecuteCall = typeof ExecuteCall.Type
|
||||
|
||||
const ExecuteOutput = Schema.Struct({
|
||||
output: Schema.String,
|
||||
toolCalls: Schema.Array(ExecuteCall),
|
||||
error: Schema.optionalKey(Schema.Literal(true)),
|
||||
files: Schema.Array(ExecuteFile),
|
||||
})
|
||||
|
||||
type CollectedFiles = {
|
||||
readonly index: number
|
||||
readonly files: Array<typeof ExecuteFile.Type>
|
||||
}
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
].join("\n")
|
||||
|
||||
export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return make({
|
||||
description,
|
||||
input: CodeMode.Input,
|
||||
output: ExecuteOutput,
|
||||
execute: ({ code }, context) =>
|
||||
Effect.gen(function* () {
|
||||
const callIndex = yield* Ref.make(0)
|
||||
const files = yield* Ref.make<Array<CollectedFiles>>([])
|
||||
const calls = yield* Ref.make<Array<ExecuteCall>>([])
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
|
||||
lock.withPermit(
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
registrations,
|
||||
(name, registration, input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
const executed = yield* execute(registration.tool, input, {
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
progress: () => Effect.void,
|
||||
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||
const outputFileParts = outputFiles(executed.content)
|
||||
if (outputFileParts.length > 0)
|
||||
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
|
||||
return executed.output
|
||||
}),
|
||||
{
|
||||
onToolCallStart: ({ index, name, input }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
return next
|
||||
})
|
||||
},
|
||||
onToolCallEnd: ({ index, name, input, outcome }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = {
|
||||
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
|
||||
status: outcome === "success" ? "completed" : "error",
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
},
|
||||
).execute(code)
|
||||
const toolCalls = yield* Ref.get(calls)
|
||||
const collected = (yield* Ref.get(files))
|
||||
.toSorted((left, right) => left.index - right.index)
|
||||
.flatMap((item) => item.files)
|
||||
const output = formatResult(result)
|
||||
const value: typeof ExecuteOutput.Type = {
|
||||
output,
|
||||
toolCalls,
|
||||
files: collected,
|
||||
...(result.ok ? {} : { error: true }),
|
||||
}
|
||||
const content: [Content, ...Content[]] = [{ type: "text", text: value.output }]
|
||||
content.push(
|
||||
...value.files.map((file) => ({
|
||||
type: "file" as const,
|
||||
data: file.data,
|
||||
mime: file.mime,
|
||||
...(file.name === undefined ? {} : { name: file.name }),
|
||||
})),
|
||||
)
|
||||
const metadata: Metadata = {
|
||||
toolCalls: value.toolCalls,
|
||||
...(value.error ? { error: true } : {}),
|
||||
}
|
||||
return {
|
||||
output: value,
|
||||
content,
|
||||
metadata,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
|
||||
}
|
||||
|
||||
function runtime(
|
||||
registrations: ReadonlyMap<string, Registration>,
|
||||
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) {
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = toLLMDefinition(name, registration.tool)
|
||||
const path =
|
||||
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
output: child.outputSchema,
|
||||
execute: (input) => executeTool(name, registration, input),
|
||||
})
|
||||
}
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
|
||||
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input: input as typeof Schema.Json.Type }
|
||||
if (Object.keys(input).length === 0) return
|
||||
return input as Record<string, typeof Schema.Json.Type>
|
||||
}
|
||||
|
||||
function formatResult(result: CodeMode.Result) {
|
||||
const output = result.ok
|
||||
? formatValue(result.value)
|
||||
: [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))]
|
||||
.join("\n")
|
||||
.trim()
|
||||
const warnings =
|
||||
result.ok && result.warnings && result.warnings.length > 0
|
||||
? `Warnings:\n${result.warnings.map((item) => `- [${item.kind}] ${item.message}`).join("\n")}`
|
||||
: undefined
|
||||
const logs = result.logs && result.logs.length > 0 ? `Logs:\n${result.logs.join("\n")}` : undefined
|
||||
return [output, warnings, logs].filter((part) => part !== undefined && part !== "").join("\n\n")
|
||||
}
|
||||
|
||||
function formatValue(value: CodeMode.DataValue) {
|
||||
if (typeof value === "string") return value
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
}
|
||||
|
||||
function outputFiles(content: ReadonlyArray<ToolContent>): Array<typeof ExecuteFile.Type> {
|
||||
return content.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const prefix = `data:${part.mime};base64,`
|
||||
if (!part.uri.startsWith(prefix)) return []
|
||||
return [
|
||||
{
|
||||
data: part.uri.slice(prefix.length),
|
||||
mime: part.mime,
|
||||
...(part.name === undefined ? {} : { name: part.name }),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
export * as ToolHooks from "./hooks"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { State } from "../state"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { Tool } from "./tool"
|
||||
|
||||
export type BeforeEvent = Tool.ToolExecuteBeforeEvent
|
||||
|
||||
/** The canonical execution outcome. Hooks never observe the raw domain output. */
|
||||
export type AfterEvent = Tool.ToolExecuteAfterEvent
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
readonly before: (
|
||||
callback: (event: BeforeEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly after: (
|
||||
callback: (event: AfterEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
}
|
||||
readonly runBefore: (event: BeforeEvent) => Effect.Effect<BeforeEvent>
|
||||
readonly runAfter: (event: AfterEvent) => Effect.Effect<AfterEvent>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolHooks") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let beforeHooks: ((event: BeforeEvent) => Effect.Effect<void> | void)[] = []
|
||||
let afterHooks: ((event: AfterEvent) => Effect.Effect<void> | void)[] = []
|
||||
|
||||
const register = <Event>(
|
||||
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
|
||||
) =>
|
||||
Effect.fn("ToolHooks.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
|
||||
const scope = yield* Scope.Scope
|
||||
let active = true
|
||||
update([...hooks(), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
update(hooks().filter((item) => item !== callback))
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <Event>(
|
||||
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
|
||||
event: Event,
|
||||
) {
|
||||
for (const hook of hooks) {
|
||||
const result = hook(event)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
hook: {
|
||||
before: register(
|
||||
() => beforeHooks,
|
||||
(next) => (beforeHooks = next),
|
||||
),
|
||||
after: register(
|
||||
() => afterHooks,
|
||||
(next) => (afterHooks = next),
|
||||
),
|
||||
},
|
||||
runBefore: (event) => run(beforeHooks, event),
|
||||
runAfter: (event) => run(afterHooks, event),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
|
@ -4,13 +4,11 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { Bus } from "../bus"
|
||||
|
||||
import { MCP } from "../mcp"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Permission } from "../permission"
|
||||
import { Tool } from "../tool"
|
||||
|
||||
/**
|
||||
* Registry namespace and permission action names for MCP tools.
|
||||
|
|
@ -21,9 +19,9 @@ export const name = (server: string, tool: string) => `${namespace(server)}_${to
|
|||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const tools = yield* Tools.Service
|
||||
const events = yield* EventV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let current: Scope.Closeable | undefined
|
||||
|
|
@ -32,27 +30,25 @@ export const layer = Layer.effectDiscard(
|
|||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
tools: Record<string, Tool.Any>
|
||||
codemode: boolean
|
||||
}
|
||||
>()
|
||||
for (const tool of yield* mcp.tools()) {
|
||||
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
group.tools[tool.name] = Tool.make({
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const discovered = yield* mcp.tools()
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* tools
|
||||
.transform((draft) => {
|
||||
for (const tool of discovered) {
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
draft.add({
|
||||
name: tool.name,
|
||||
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
|
|
@ -90,31 +86,27 @@ export const layer = Layer.effectDiscard(
|
|||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
||||
: {
|
||||
type: "file" as const,
|
||||
uri: `data:${part.mimeType};base64,${part.data}`,
|
||||
mime: part.mimeType,
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
groups.set(tool.server, group)
|
||||
}
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* tools
|
||||
.registerBatch(
|
||||
Array.from(groups, ([server, group]) => ({
|
||||
tools: group.tools,
|
||||
options: { namespace: namespace(server), codemode: group.codemode },
|
||||
})),
|
||||
)
|
||||
.pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
|
|
@ -122,7 +114,7 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
|
||||
yield* reconcile.pipe(Effect.forkScoped)
|
||||
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
|
@ -132,5 +124,5 @@ export const layer = Layer.effectDiscard(
|
|||
export const node = makeLocationNode({
|
||||
name: "mcp-tools",
|
||||
layer,
|
||||
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
|
||||
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
/**
|
||||
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
|
||||
* Model-facing exact-edit leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval.
|
||||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
|
||||
export const name = "edit"
|
||||
|
||||
|
|
@ -78,12 +77,12 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
|
|||
"```",
|
||||
].join("\n")
|
||||
|
||||
/** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
|
||||
/** Deferred 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.
|
||||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
|
||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
|
|
@ -91,13 +90,14 @@ export const Plugin = {
|
|||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
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.",
|
||||
input: Input,
|
||||
|
|
@ -212,7 +212,6 @@ export const Plugin = {
|
|||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false, permission: "edit" },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
export * as GlobTool from "./glob"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { RelativePath } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Ripgrep } from "../../ripgrep"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { Permission } from "../../permission"
|
||||
|
||||
export const name = "glob"
|
||||
|
||||
|
|
@ -47,13 +46,14 @@ export const Plugin = {
|
|||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
|
|
@ -130,7 +130,6 @@ export const Plugin = {
|
|||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
export * as GrepTool from "./grep"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { RelativePath } from "../schema"
|
||||
import { Tool } from "./tool"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { Ripgrep } from "../../ripgrep"
|
||||
import { RelativePath } from "../../schema"
|
||||
|
||||
export const name = "grep"
|
||||
|
||||
|
|
@ -63,13 +62,14 @@ export const Plugin = {
|
|||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
|
||||
input: Input,
|
||||
|
|
@ -154,7 +154,6 @@ export const Plugin = {
|
|||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
export * as PatchTool from "./patch"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
|
|
@ -8,11 +8,10 @@ import { Effect, Schema } from "effect"
|
|||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./patch.txt"
|
||||
import { Permission } from "../../permission"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
|
||||
export const name = "patch"
|
||||
|
||||
|
|
@ -70,13 +69,14 @@ export const Plugin = {
|
|||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
|
|
@ -310,7 +310,6 @@ export const Plugin = {
|
|||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false, permission: "edit" },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
export * as QuestionTool from "./question"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../form"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { Tool } from "./tool"
|
||||
import { Form } from "../../form"
|
||||
import { Permission } from "../../permission"
|
||||
import { Question } from "../../question"
|
||||
|
||||
export const name = "question"
|
||||
|
||||
|
|
@ -22,11 +21,11 @@ Usage notes:
|
|||
- 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({
|
||||
questions: Schema.NonEmptyArray(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
||||
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
answers: Schema.Array(QuestionV2.Answer),
|
||||
answers: Schema.Array(Question.Answer),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
|
|
@ -37,8 +36,8 @@ export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Q
|
|||
}
|
||||
|
||||
export const toModelOutput = (
|
||||
questions: ReadonlyArray<QuestionV2.Prompt>,
|
||||
answers: ReadonlyArray<QuestionV2.Answer>,
|
||||
questions: ReadonlyArray<Question.Prompt>,
|
||||
answers: ReadonlyArray<Question.Answer>,
|
||||
) => {
|
||||
const formatted = questions
|
||||
.map(
|
||||
|
|
@ -53,13 +52,14 @@ export const Plugin = {
|
|||
id: "opencode.tool.question",
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const forms = yield* Form.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
|
|
@ -91,12 +91,12 @@ export const Plugin = {
|
|||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.flatMap((state) => {
|
||||
// Deliberate defect tunnel (see PermissionV2.assert): a dismissal must dodge
|
||||
// Deliberate defect tunnel (see Permission.assert): a dismissal must dodge
|
||||
// leaf `mapError` blankets so it never becomes model-facing tool output; it
|
||||
// resurfaces as a typed failure at SessionModelRequest.executeTool.
|
||||
if (state.status === "cancelled") return Effect.die(new CancelledError())
|
||||
const output = {
|
||||
answers: input.questions.map((_, index): QuestionV2.Answer => {
|
||||
answers: input.questions.map((_, index): Question.Answer => {
|
||||
const value = state.answer[`q${index}`]
|
||||
if (value === undefined) return []
|
||||
if (typeof value === "object") return Array.from(value)
|
||||
|
|
@ -111,14 +111,13 @@ export const Plugin = {
|
|||
}),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
}
|
||||
|
||||
function toField(question: QuestionV2.Prompt, index: number): Form.Field {
|
||||
function toField(question: Question.Prompt, index: number): Form.Field {
|
||||
return {
|
||||
key: `q${index}`,
|
||||
title: question.header,
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
export * as ReadTool from "./read"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { dirname } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { ReadToolFileSystem } from "./read-filesystem"
|
||||
import { Tool } from "./tool"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { SessionInstructions } from "../../session/instructions"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { ReadToolFileSystem } from "../read-filesystem"
|
||||
|
||||
export const name = "read"
|
||||
const FILENAME = "AGENTS.md"
|
||||
|
|
@ -34,7 +33,7 @@ export const Plugin = {
|
|||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
|
@ -42,8 +41,9 @@ export const Plugin = {
|
|||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
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 paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
|
||||
input: Input,
|
||||
|
|
@ -119,7 +119,12 @@ export const Plugin = {
|
|||
? SUPPORTED_IMAGE_MIMES.has(output.mime)
|
||||
? ([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
{
|
||||
type: "file",
|
||||
uri: `data:${output.mime};base64,${output.content}`,
|
||||
mime: output.mime,
|
||||
name: input.path,
|
||||
},
|
||||
] as const)
|
||||
: JSON.stringify({ ...output, content: "" }, null, 2)
|
||||
: JSON.stringify(output, null, 2)
|
||||
|
|
@ -136,7 +141,6 @@ export const Plugin = {
|
|||
)
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -2,16 +2,16 @@ export * as ShellTool from "./shell"
|
|||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Shell } from "../shell"
|
||||
import { Tool, type Content } from "./tool"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
import { NonNegativeInt } from "../../schema"
|
||||
import { SessionSchema } from "../../session/schema"
|
||||
import { Shell } from "../../shell"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
|
|
@ -64,14 +64,14 @@ const modelOutput = (output: Output): string | undefined => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
|
||||
* Minimal core shell boundary. Keep parity debt visible without pulling the
|
||||
* legacy shell runtime into core.
|
||||
*/
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
|
||||
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
|
||||
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
|
||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
|
|
@ -104,7 +104,7 @@ export const Plugin = {
|
|||
const fsUtil = yield* FSUtil.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
|
|
@ -142,8 +142,9 @@ export const Plugin = {
|
|||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
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. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
input: Input,
|
||||
output: Output,
|
||||
|
|
@ -267,7 +268,7 @@ export const Plugin = {
|
|||
return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) }
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
const content: [Content, ...Content[]] = [{ type: "text", text: output.output }]
|
||||
const content: Array<Content> = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
|
|
@ -286,7 +287,6 @@ export const Plugin = {
|
|||
),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,23 +1,22 @@
|
|||
export * as SkillTool from "./skill"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Skill } from "../../skill"
|
||||
import { Permission } from "../../permission"
|
||||
|
||||
export const name = "skill"
|
||||
const FILE_LIMIT = 10
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
id: SkillV2.ID.annotate({ description: "The ID of the skill from the available skills list" }),
|
||||
id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
name: SkillV2.Name,
|
||||
name: Skill.Name,
|
||||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
})
|
||||
|
|
@ -27,7 +26,7 @@ export const description = [
|
|||
"The skill ID must match one of the available skills in the instructions.",
|
||||
].join("\n")
|
||||
|
||||
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
|
||||
export const toModelOutput = (skill: Skill.Info, files: ReadonlyArray<string>) => {
|
||||
const directory = path.dirname(skill.location)
|
||||
return [
|
||||
`<skill_content name="${skill.name}">`,
|
||||
|
|
@ -53,13 +52,14 @@ export const Plugin = {
|
|||
id: "opencode.tool.skill",
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const skills = yield* Skill.Service
|
||||
const permission = yield* Permission.Service
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
|
|
@ -99,7 +99,6 @@ export const Plugin = {
|
|||
})),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
export * as SubagentTool from "./subagent"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Config } from "../config"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Tool } from "./tool"
|
||||
import { Agent } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
import { Permission } from "../../permission"
|
||||
import { SessionSchema } from "../../session/schema"
|
||||
|
||||
export const name = "subagent"
|
||||
|
||||
|
|
@ -42,9 +41,9 @@ export const Plugin = {
|
|||
id: "opencode.tool.subagent",
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
|
|
@ -109,8 +108,9 @@ export const Plugin = {
|
|||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
|
|
@ -165,7 +165,7 @@ export const Plugin = {
|
|||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
agent: AgentV2.ID.make(input.agent),
|
||||
agent: Agent.ID.make(input.agent),
|
||||
model,
|
||||
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
|
||||
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
|
||||
|
|
@ -238,7 +238,6 @@ export const Plugin = {
|
|||
})),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -254,7 +253,7 @@ export const Plugin = {
|
|||
(agent) =>
|
||||
agent.mode !== "primary" &&
|
||||
!agent.hidden &&
|
||||
PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny",
|
||||
Permission.evaluate(name, agent.id, selected.permissions).effect !== "deny",
|
||||
)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
if (available.length === 0) return
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
export * as WebFetchTool from "./webfetch"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { Tool } from "./tool"
|
||||
import { Permission } from "../../permission"
|
||||
import { collectBoundedResponseBody } from "../http-body"
|
||||
|
||||
export const name = "webfetch"
|
||||
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
|
|
@ -115,13 +114,14 @@ export const Plugin = {
|
|||
id: "opencode.tool.webfetch",
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
|
|
@ -173,7 +173,6 @@ export const Plugin = {
|
|||
return { output: result, content: result.output, metadata: { contentType: result.contentType } }
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
export * as WebSearchTool from "./websearch"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../form"
|
||||
import { KV } from "../kv"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { Form } from "../../form"
|
||||
import { KV } from "../../kv"
|
||||
import { Permission } from "../../permission"
|
||||
import { WebSearch } from "../../websearch"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
|
|
@ -26,15 +26,16 @@ const Output = Schema.Struct({
|
|||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const kv = yield* KV.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
{
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
|
|
@ -109,7 +110,6 @@ export const Plugin = {
|
|||
),
|
||||
),
|
||||
},
|
||||
{ codemode: false },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
/**
|
||||
* Model-facing V2 file-write leaf. Relative paths resolve within the active
|
||||
* Model-facing file-write leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval.
|
||||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
|
||||
export const name = "write"
|
||||
|
||||
|
|
@ -36,24 +35,25 @@ export type Output = typeof Output.Type
|
|||
export const toModelOutput = (output: Output) =>
|
||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||
|
||||
/** 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.
|
||||
/** Deferred write UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
|
||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.make({
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
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.",
|
||||
input: Input,
|
||||
|
|
@ -88,7 +88,6 @@ export const Plugin = {
|
|||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
{ codemode: false, permission: "edit" },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -1,377 +0,0 @@
|
|||
export * as ToolRegistry from "./registry"
|
||||
|
||||
import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import { CodeModeCatalog } from "../codemode/catalog"
|
||||
import { Image } from "../image"
|
||||
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 { CodeMode } from "../codemode"
|
||||
import { Tool, nonEmpty, registrationEntries, toLLMDefinition, validateName, validateNamespace } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { toSessionError } from "../session/to-session-error"
|
||||
|
||||
export type ExecuteInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Progress) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** Live replacement metadata for a running tool. */
|
||||
export type Progress = Tool.Metadata
|
||||
|
||||
export interface Interface {
|
||||
readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect<ToolSet>
|
||||
/** Internal registration capability exposed publicly only through Tools.Service. */
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.Any>>,
|
||||
options?: Tools.RegisterOptions,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
/** Internal atomic registration capability used by plugin transforms. */
|
||||
readonly registerBatch: (
|
||||
registrations: ReadonlyArray<{
|
||||
readonly tools: Readonly<Record<string, Tool.Any>>
|
||||
readonly options?: Tools.RegisterOptions
|
||||
}>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
/**
|
||||
* One request-scoped snapshot pairing the Code Mode catalog and advertised
|
||||
* definitions with captured tools. A model request executes exactly the tool
|
||||
* values it advertised even if registration changes while it is in flight.
|
||||
*/
|
||||
export interface ToolSet {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical outcome of one local tool execution. `output` is the validated
|
||||
* machine value for Code Mode and remains ephemeral; durable publication drops it.
|
||||
*/
|
||||
export type ToolOutcome =
|
||||
| (Extract<Tool.Outcome, { readonly status: "completed" }> & { readonly output?: unknown })
|
||||
| Extract<Tool.Outcome, { readonly status: "error" }>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
|
||||
const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const image = yield* Image.Service
|
||||
const codeMode = yield* CodeMode.Service
|
||||
|
||||
type NormalizedItem = ToolContent | "decode" | "size"
|
||||
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ReadonlyArray<ToolContent>) {
|
||||
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
|
||||
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
|
||||
// RFC 2397 permits parameters between the mime and ";base64".
|
||||
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
if (base64 === undefined) return Effect.succeed(item)
|
||||
const resource = item.name ?? `${item.mime} tool output`
|
||||
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
|
||||
Effect.map((result) => ({
|
||||
...item,
|
||||
uri: `data:${result.mime};base64,${result.content}`,
|
||||
mime: result.mime,
|
||||
})),
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
|
||||
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
|
||||
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
|
||||
)
|
||||
})
|
||||
const note = (reason: "decode" | "size", text: string) => {
|
||||
const count = normalized.filter((item) => item === reason).length
|
||||
if (count === 0) return []
|
||||
return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }]
|
||||
}
|
||||
return [
|
||||
...normalized.filter((item) => typeof item !== "string"),
|
||||
...note("decode", "could not be decoded."),
|
||||
...note("size", "could not be resized below the image size limit."),
|
||||
]
|
||||
})
|
||||
|
||||
// Invalid or oversized metadata is dropped with a warning; it never fails a
|
||||
// successful side-effecting tool.
|
||||
const validMetadata = Effect.fnUntraced(function* (tool: string, metadata: Tool.Metadata | undefined) {
|
||||
if (metadata === undefined) return undefined
|
||||
const limits = yield* resources.limits()
|
||||
const valid = Tool.jsonMetadata(metadata, limits.maxBytes)
|
||||
if (valid === undefined)
|
||||
yield* Effect.logWarning("dropping invalid or oversized tool metadata").pipe(Effect.annotateLogs({ tool }))
|
||||
return valid
|
||||
})
|
||||
|
||||
type Registration = Tool.Registration
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
const registrationLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const executeTool = Effect.fn("ToolRegistry.executeTool")(function* (input: ExecuteInput, tool: Tool.Any) {
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach executeTool.
|
||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
callID: input.call.id,
|
||||
input: input.call.input,
|
||||
}
|
||||
yield* toolHooks.runBefore(beforeEvent)
|
||||
const execution = yield* Tool.execute(tool, beforeEvent.input, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
callID: input.call.id,
|
||||
progress: (metadata) => {
|
||||
const progress = input.progress
|
||||
if (!progress) return Effect.void
|
||||
return validMetadata(input.call.name, metadata).pipe(
|
||||
Effect.flatMap((valid) => (valid === undefined ? Effect.void : progress(valid))),
|
||||
)
|
||||
},
|
||||
}).pipe(
|
||||
Effect.map((value) => ({ value })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ failure: toSessionError(failure) })),
|
||||
)
|
||||
|
||||
const outcome: ToolOutcome = yield* Effect.gen(function* () {
|
||||
if ("failure" in execution) return { status: "error" as const, error: execution.failure }
|
||||
const bounded = yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
callID: input.call.id,
|
||||
content: yield* normalizeImages(execution.value.content),
|
||||
})
|
||||
const metadata = yield* validMetadata(input.call.name, execution.value.metadata)
|
||||
return {
|
||||
status: "completed" as const,
|
||||
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
|
||||
content: nonEmpty(bounded.content) ?? execution.value.content,
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
...(bounded.outputPaths.length > 0 ? { outputPaths: bounded.outputPaths } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const base = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
callID: input.call.id,
|
||||
input: beforeEvent.input,
|
||||
}
|
||||
const afterEvent: ToolHooks.AfterEvent =
|
||||
outcome.status === "completed"
|
||||
? {
|
||||
...base,
|
||||
status: "completed",
|
||||
content: outcome.content,
|
||||
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
|
||||
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
status: "error",
|
||||
error: outcome.error,
|
||||
...(outcome.content === undefined ? {} : { content: outcome.content }),
|
||||
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
|
||||
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
|
||||
}
|
||||
yield* toolHooks.runAfter(afterEvent)
|
||||
const afterMetadata = yield* validMetadata(input.call.name, afterEvent.metadata)
|
||||
const afterContent = yield* Effect.gen(function* () {
|
||||
if (
|
||||
afterEvent.content === undefined ||
|
||||
(outcome.status === "completed" && afterEvent.content === outcome.content)
|
||||
)
|
||||
return { content: afterEvent.content, outputPaths: afterEvent.outputPaths }
|
||||
const bounded = yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
callID: input.call.id,
|
||||
content: yield* normalizeImages(afterEvent.content),
|
||||
})
|
||||
return {
|
||||
content: nonEmpty(bounded.content),
|
||||
outputPaths:
|
||||
bounded.outputPaths.length === 0
|
||||
? afterEvent.outputPaths
|
||||
: Array.from(new Set([...(afterEvent.outputPaths ?? []), ...bounded.outputPaths])),
|
||||
}
|
||||
})
|
||||
if (afterEvent.status === "completed")
|
||||
return {
|
||||
status: "completed" as const,
|
||||
...(outcome.status === "completed" && outcome.output !== undefined ? { output: outcome.output } : {}),
|
||||
content: afterContent.content ?? afterEvent.content,
|
||||
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
|
||||
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
|
||||
}
|
||||
return {
|
||||
status: "error" as const,
|
||||
error: afterEvent.error,
|
||||
...(afterContent.content === undefined ? {} : { content: afterContent.content }),
|
||||
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
|
||||
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
|
||||
}
|
||||
})
|
||||
|
||||
const registerBatch: Interface["registerBatch"] = Effect.fn("ToolRegistry.registerBatch")(
|
||||
function* (registrations) {
|
||||
const planned = yield* Effect.forEach(registrations, ({ tools, options }) =>
|
||||
Effect.gen(function* () {
|
||||
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
|
||||
const entries = registrationEntries(tools, options)
|
||||
yield* Effect.forEach(entries, (entry) => validateName(entry.name), { discard: true })
|
||||
const collision = entries.find(
|
||||
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
|
||||
)
|
||||
if (collision)
|
||||
return yield* Effect.fail(
|
||||
new Tool.RegistrationError({
|
||||
name: collision.key,
|
||||
message: `Duplicate normalized tool name: ${collision.key}`,
|
||||
}),
|
||||
)
|
||||
const codemode = options?.codemode ?? true
|
||||
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
if (reserved)
|
||||
return yield* Effect.fail(
|
||||
new Tool.RegistrationError({
|
||||
name: reserved.key,
|
||||
message: 'Tool name "execute" is reserved for CodeMode',
|
||||
}),
|
||||
)
|
||||
return { tools, options, entries, codemode }
|
||||
}),
|
||||
)
|
||||
// CodeMode registrations live in the CodeMode service; the registry keeps only direct tools.
|
||||
yield* Effect.forEach(
|
||||
planned.filter((plan) => plan.codemode && plan.entries.length > 0),
|
||||
(plan) => codeMode.register(plan.entries),
|
||||
{ discard: true },
|
||||
)
|
||||
const direct = planned.filter((plan) => !plan.codemode)
|
||||
if (direct.every((plan) => plan.entries.length === 0)) return
|
||||
yield* Effect.uninterruptible(
|
||||
registrationLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const { entries } of direct)
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [
|
||||
...(local.get(entry.key) ?? []),
|
||||
{
|
||||
token,
|
||||
registration: {
|
||||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
namespace: entry.namespace,
|
||||
permission: entry.permission,
|
||||
},
|
||||
},
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
registrationLock.withPermit(
|
||||
Effect.sync(() => {
|
||||
for (const { entries } of direct)
|
||||
for (const entry of entries) {
|
||||
const registrations =
|
||||
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(entry.key, registrations)
|
||||
else local.delete(entry.key)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ToolRegistry.register")((tools, options) =>
|
||||
registerBatch([
|
||||
{
|
||||
tools,
|
||||
...(options === undefined ? {} : { options }),
|
||||
},
|
||||
]),
|
||||
),
|
||||
registerBatch,
|
||||
snapshot: Effect.fn("ToolRegistry.snapshot")((permissions) =>
|
||||
registrationLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const direct = new Map<string, Registration>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
if (whollyDisabled(registration.permission, rules)) continue
|
||||
direct.set(name, registration)
|
||||
}
|
||||
const codeModeMaterialization = yield* codeMode.materialize(permissions)
|
||||
const codemodeTool = codeModeMaterialization.tool
|
||||
return {
|
||||
...(codeModeMaterialization.catalog === undefined
|
||||
? {}
|
||||
: { codeModeCatalog: codeModeMaterialization.catalog }),
|
||||
definitions: [
|
||||
// Definitions are prompt-cache prefix bytes, so order only after effective registrations settle.
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([name, registration]) => toLLMDefinition(name, registration.tool)),
|
||||
...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: ExecuteInput) => {
|
||||
if (input.call.name === "execute" && codemodeTool) return executeTool(input, codemodeTool)
|
||||
const registration = direct.get(input.call.name)
|
||||
if (registration) return executeTool(input, registration.tool)
|
||||
return Effect.succeed<ToolOutcome>({
|
||||
status: "error",
|
||||
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
|
||||
})
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const layer = Layer.effect(
|
||||
Tools.Service,
|
||||
Service.use((registry) =>
|
||||
Effect.succeed(Tools.Service.of({ register: registry.register, registerBatch: registry.registerBatch })),
|
||||
),
|
||||
).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 node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
|
||||
})
|
||||
125
packages/core/src/tool/runtime.ts
Normal file
125
packages/core/src/tool/runtime.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { ToolDefinition } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
|
||||
export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
|
||||
name: effectiveName(tool),
|
||||
description: tool.description,
|
||||
inputSchema: inputJsonSchema(tool.input),
|
||||
...(tool.output === undefined ? {} : { outputSchema: outputJsonSchema(tool.output) }),
|
||||
})
|
||||
|
||||
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* decodeInput(tool.input, input)
|
||||
const result = yield* tool.execute(decoded, context)
|
||||
if (tool.output === undefined) {
|
||||
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
|
||||
return {
|
||||
output: undefined,
|
||||
content: normalizeContent(result.content),
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
}
|
||||
}
|
||||
if (!("output" in result)) return yield* new Tool.Error({ message: "Tool did not return its declared output" })
|
||||
const output = yield* encodeOutput(tool.output, result.output)
|
||||
return {
|
||||
output,
|
||||
content: normalizeContent(result.content, output),
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
}
|
||||
})
|
||||
|
||||
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
if (Schema.isSchema(schema))
|
||||
return Schema.decodeUnknownEffect(schema)(value).pipe(
|
||||
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
|
||||
)
|
||||
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
|
||||
return Effect.succeed(value)
|
||||
}
|
||||
|
||||
const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
if (Schema.isSchema(schema))
|
||||
return Schema.encodeEffect(schema)(value).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new Tool.Error({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
|
||||
),
|
||||
)
|
||||
if (isStandardSchema(schema))
|
||||
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
|
||||
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new Tool.Error({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const isStandardSchema = (
|
||||
schema: Tool.ValueSchema<any>,
|
||||
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> => "~standard" in schema
|
||||
|
||||
const validateStandard = (
|
||||
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
|
||||
value: unknown,
|
||||
prefix: string,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* Effect.try({
|
||||
try: () => schema["~standard"].validate(value),
|
||||
catch: (error) => standardFailure(prefix, error),
|
||||
})
|
||||
const result =
|
||||
pending instanceof Promise
|
||||
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
|
||||
: pending
|
||||
if (result.issues)
|
||||
return yield* new Tool.Error({
|
||||
message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`,
|
||||
})
|
||||
return result.value
|
||||
})
|
||||
|
||||
const standardFailure = (prefix: string, error: unknown) =>
|
||||
new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
|
||||
|
||||
const inputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
|
||||
if (isStandardSchema(schema))
|
||||
return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
|
||||
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
|
||||
}
|
||||
|
||||
const outputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
|
||||
if (isStandardSchema(schema))
|
||||
return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
|
||||
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
|
||||
}
|
||||
|
||||
const 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 }
|
||||
}
|
||||
|
||||
export const normalizeContent = (value: string | ReadonlyArray<Tool.Content> | undefined, output?: unknown) => {
|
||||
if (typeof value === "string") return [{ type: "text" as const, text: value }]
|
||||
if (value !== undefined && value.length > 0) return [...value]
|
||||
return [{ type: "text" as const, text: stringify(output) }]
|
||||
}
|
||||
|
||||
const stringify = (value: unknown) => {
|
||||
if (typeof value === "string") return value
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
const effectiveName = (tool: Tool.Info) =>
|
||||
tool.options?.namespace === undefined
|
||||
? normalizedName(tool)
|
||||
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
export * as Tool from "./tool"
|
||||
export * from "@opencode-ai/plugin/v2/effect/tool"
|
||||
|
||||
import type { ToolContent } from "@opencode-ai/ai"
|
||||
import {
|
||||
decodeInput,
|
||||
encodeOutput,
|
||||
type Any,
|
||||
type Content,
|
||||
type Context,
|
||||
Failure,
|
||||
type Metadata,
|
||||
} from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
/** Non-empty canonical model content. */
|
||||
export type NonEmptyContent = readonly [ToolContent, ...ToolContent[]]
|
||||
|
||||
/**
|
||||
* The execution-local result of one tool call: the machine output for
|
||||
* Code Mode, canonical model content, and optional UI metadata. The typed
|
||||
* domain output never leaves this function.
|
||||
*/
|
||||
export type Execution = {
|
||||
readonly output?: unknown
|
||||
readonly content: NonEmptyContent
|
||||
readonly metadata?: Metadata
|
||||
}
|
||||
|
||||
export const execute = (tool: Any, input: unknown, context: Context): Effect.Effect<Execution, Failure> =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* decodeInput(tool.input, input)
|
||||
const result = yield* tool.execute(decoded, context)
|
||||
if (tool.output === undefined) {
|
||||
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
|
||||
return {
|
||||
content: contentFrom(result.content),
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
}
|
||||
}
|
||||
if (!("output" in result))
|
||||
return yield* Effect.fail(new Failure({ message: "Tool did not return its declared output" }))
|
||||
const encoded = yield* encodeOutput(tool.output, result.output)
|
||||
return {
|
||||
output: encoded,
|
||||
content: contentFrom(result.content, encoded),
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
}
|
||||
})
|
||||
|
||||
/** Model content from the tool's projection, falling back to the stringified encoded output. */
|
||||
const contentFrom = (projected: string | ReadonlyArray<Content> | undefined, encoded?: unknown): NonEmptyContent => {
|
||||
if (typeof projected === "string") return [textContent(projected)]
|
||||
if (projected !== undefined) {
|
||||
const mapped = nonEmpty(projected.map(toModelContent))
|
||||
if (mapped !== undefined) return mapped
|
||||
}
|
||||
return [textContent(stringify(encoded))]
|
||||
}
|
||||
|
||||
export const toModelContent = (part: Content): ToolContent =>
|
||||
part.type === "text"
|
||||
? { type: "text", text: part.text }
|
||||
: { type: "file", uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
|
||||
|
||||
export const nonEmpty = (content: ReadonlyArray<ToolContent>): NonEmptyContent | undefined =>
|
||||
content.length > 0 ? (content as NonEmptyContent) : undefined
|
||||
|
||||
const textContent = (text: string): ToolContent => ({ type: "text", text })
|
||||
|
||||
/** Human-readable text for an arbitrary value; strings pass through unchanged. */
|
||||
export const stringify = (value: unknown) => {
|
||||
if (typeof value === "string") return value
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const MetadataSchema = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
/** Defensive boundary: non-JSON or oversized metadata is dropped, never failing the producing call. */
|
||||
export const jsonMetadata = (value: unknown, maxBytes?: number): Metadata | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
const decoded = Schema.decodeUnknownOption(MetadataSchema)(value)
|
||||
if (decoded._tag === "None") return undefined
|
||||
if (maxBytes !== undefined && Buffer.byteLength(JSON.stringify(decoded.value), "utf-8") > maxBytes) return undefined
|
||||
return decoded.value
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
export * as Tools from "./tools"
|
||||
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export type RegisterOptions = Tool.RegisterOptions
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.Any>>,
|
||||
options?: Tool.RegisterOptions,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
/** Internal atomic registration capability used by plugin transforms. */
|
||||
readonly registerBatch: (
|
||||
registrations: ReadonlyArray<{
|
||||
readonly tools: Readonly<Record<string, Tool.Any>>
|
||||
readonly options?: Tool.RegisterOptions
|
||||
}>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
/** Narrow registration-only Location capability. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Tools") {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue