fix(plugin): reload late SDK plugins (#35576)

This commit is contained in:
Kit Langton
2026-07-06 13:36:19 -04:00
committed by GitHub
parent 06dcf3f221
commit 2830176972
26 changed files with 1181 additions and 916 deletions
+25 -2
View File
@@ -111,13 +111,26 @@ export type LogItem = Payload | EventLog.Synced
export const isSynced = (item: LogItem): item is EventLog.Synced => item.type === "log.synced" export const isSynced = (item: LogItem): item is EventLog.Synced => item.type === "log.synced"
export type SubscribePayload<D extends readonly Definition[]> = D[number] extends infer Item
? Item extends Definition
? Payload<Item>
: never
: never
export interface Subscribe {
<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
<const D extends readonly [Definition, ...Definition[]]>(definitions: D): Stream.Stream<SubscribePayload<D>>
}
const isDefinition = (input: Definition | readonly Definition[]): input is Definition => !Array.isArray(input)
export interface Interface { export interface Interface {
readonly publish: <D extends Definition>( readonly publish: <D extends Definition>(
definition: D, definition: D,
data: Data<D>, data: Data<D>,
options?: PublishOptions, options?: PublishOptions,
) => Effect.Effect<Payload<D>> ) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>> readonly subscribe: Subscribe
/** /**
* Volatile live channel: every event published from now on, nothing before, * Volatile live channel: every event published from now on, nothing before,
* nothing across a disconnect. The only channel that carries non-durable * nothing across a disconnect. The only channel that carries non-durable
@@ -562,11 +575,21 @@ export const layerWith = (options?: LayerOptions) =>
), ),
) )
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> => const subscribeOne = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe( local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe(
Stream.map((event) => event as Payload<D>), Stream.map((event) => event as Payload<D>),
) )
function subscribe<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
function subscribe<const D extends readonly [Definition, ...Definition[]]>(
definitions: D,
): Stream.Stream<SubscribePayload<D>>
function subscribe(input: Definition | readonly Definition[]): Stream.Stream<Payload> {
if (isDefinition(input)) return subscribeOne(input)
const types = new Set(input.map((definition) => definition.type))
return streamLive().pipe(Stream.filter((event) => types.has(event.type)))
}
const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live)) const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
const readAfter = ( const readAfter = (
+21 -1
View File
@@ -18,6 +18,7 @@ import { ProviderV2 } from "../provider"
import { Reference } from "../reference" import { Reference } from "../reference"
import { AbsolutePath, type DeepMutable } from "../schema" import { AbsolutePath, type DeepMutable } from "../schema"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools" import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks" import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace" import { WorkspaceV2 } from "../workspace"
@@ -298,7 +299,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}), }),
}, },
tool: { tool: {
register: (input, options) => tools.register(input, options), transform: (callback) =>
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
yield* Effect.sync(() =>
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
}),
)
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
)
}),
execute: { execute: {
before: (callback) => before: (callback) =>
toolHooks.hook.before((event) => { toolHooks.hook.before((event) => {
+8 -5
View File
@@ -3,6 +3,9 @@ export * as SdkPlugins from "./sdk"
import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node" import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event"
export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} })
export interface Store { export interface Store {
readonly plugins: Map<string, Plugin> readonly plugins: Map<string, Plugin>
@@ -16,9 +19,8 @@ const defaultStore = makeStore()
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
* so `PluginSupervisor` can add them on every Location boot through the ordinary * so `PluginSupervisor` can add them on every Location boot through the ordinary
* generation path that `PluginSupervisor` uses for plugins discovered from * generation path that `PluginSupervisor` uses for plugins discovered from
* config. A plugin registered after a Location has booted only * config. Registration publishes an unlocated update so every booted Location
* applies to Locations booted afterward, matching config-plugin timing; * reloads its plugin generation from the shared store.
* embedders register at startup before creating Sessions.
* *
* The store is shared explicitly between the SDK construction graph and the * The store is shared explicitly between the SDK construction graph and the
* embedded route graph because `LocationServiceMap` builds Location layers lazily * embedded route graph because `LocationServiceMap` builds Location layers lazily
@@ -36,6 +38,7 @@ export const layerWithStore = (store: Store) =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.sync(() => { Effect.sync(() => {
store.plugins.clear() store.plugins.clear()
@@ -45,7 +48,7 @@ export const layerWithStore = (store: Store) =>
register: (plugin) => register: (plugin) =>
Effect.sync(() => { Effect.sync(() => {
store.plugins.set(plugin.id, plugin) store.plugins.set(plugin.id, plugin)
}), }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
all: () => [...store.plugins.values()], all: () => [...store.plugins.values()],
}) })
}), }),
@@ -53,4 +56,4 @@ export const layerWithStore = (store: Store) =>
export const layer = layerWithStore(defaultStore) export const layer = layerWithStore(defaultStore)
export const node = makeGlobalNode({ service: Service, layer, deps: [] }) export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] })
+1 -1
View File
@@ -276,7 +276,7 @@ const layer = Layer.effect(
}), }),
), ),
) )
yield* events.subscribe(Event.Updated).pipe( yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe(
Stream.runForEach(() => Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
), ),
+113 -110
View File
@@ -63,133 +63,136 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.withPermission( draft.add(
Tool.make({ name,
description: Tool.withPermission(
"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.", Tool.make({
input: Input, description:
output: Output, "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.",
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], input: Input,
execute: (input, context) => { output: Output,
const applied: Array<typeof Applied.Type> = [] toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
const fail = (path: string) => { execute: (input, context) => {
const prefix = const applied: Array<typeof Applied.Type> = []
applied.length === 0 const fail = (path: string) => {
? `Unable to apply patch at ${path}` const prefix =
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` applied.length === 0
return new ToolFailure({ message: prefix }) ? `Unable to apply patch at ${path}`
} : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return Effect.gen(function* () { return new ToolFailure({ message: prefix })
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
} }
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) return Effect.gen(function* () {
const hunks = yield* Effect.try({ const source = {
try: () => Patch.parse(input.patchText), type: "tool" as const,
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), messageID: context.assistantMessageID,
}) callID: context.toolCallID,
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 (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) 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" })
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks) for (const hunk of hunks)
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) }) targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>() const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { target } of targets) { for (const { target } of targets) {
const external = target.externalDirectory const external = target.externalDirectory
if (external) externalDirectories.set(external.resource, external) if (external) externalDirectories.set(external.resource, external)
} }
for (const external of externalDirectories.values()) { for (const external of externalDirectories.values()) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external), action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
}
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const prepared: Prepared[] = [] const prepared: Prepared[] = []
for (const { hunk, target } of targets) { for (const { hunk, target } of targets) {
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
if (hunk.type === "add") { if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after:
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
})
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") {
prepared.push({ ...hunk, target, before, after: "" })
return
}
const update = Patch.derive(hunk.path, hunk.chunks, original)
prepared.push({ prepared.push({
...hunk, ...hunk,
target, target,
before: "", source,
after: content: Patch.joinBom(update.content, update.bom),
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, before,
after: update.content,
}) })
return }).pipe(Effect.mapError(() => fail(hunk.path)))
} }
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") {
prepared.push({ ...hunk, target, before, after: "" })
return
}
const update = Patch.derive(hunk.path, hunk.chunks, original)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
const patchFiles = prepared.map(patchFile) const patchFiles = prepared.map(patchFile)
yield* Effect.forEach( yield* Effect.forEach(
prepared, prepared,
(change) => (change) =>
Effect.gen(function* () { Effect.gen(function* () {
if (change.type === "add") { if (change.type === "add") {
const result = yield* files.create({ 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, target: change.target,
content: expected: change.source,
change.contents.endsWith("\n") || change.contents === "" content: change.content,
? change.contents
: `${change.contents}\n`,
}) })
applied.push({ type: change.type, resource: result.resource, target: result.target }) applied.push({ type: change.type, resource: result.resource, target: result.target })
return }).pipe(Effect.mapError(() => fail(change.path))),
} { discard: true },
if (change.type === "delete") { )
const result = yield* files.remove({ target: change.target }) return { applied, files: patchFiles }
applied.push({ type: change.type, resource: result.resource, target: result.target }) }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
return },
} }),
const result = yield* files.writeIfUnchanged({ "edit",
target: change.target, ),
expected: change.source,
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
"edit",
), ),
}) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+108 -105
View File
@@ -94,122 +94,125 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.withPermission( draft.add(
Tool.make({ name,
description: Tool.withPermission(
"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.", Tool.make({
input: Input, description:
output: Output, "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.",
toModelOutput: ({ input, output }) => [ input: Input,
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) }, output: Output,
], toModelOutput: ({ input, output }) => [
execute: (input, context) => { { type: "text", text: toModelOutput(output, input.oldString, input.newString) },
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) => ],
effect.pipe( execute: (input, context) => {
Effect.mapError((error) => const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
error instanceof FileMutation.StaleContentError effect.pipe(
? new ToolFailure({ Effect.mapError((error) =>
message: "File changed after permission approval. Read it again before editing.", error instanceof FileMutation.StaleContentError
}) ? new ToolFailure({
: new ToolFailure({ message: `Unable to edit ${input.path}` }), message: "File changed after permission approval. Read it again before editing.",
), })
) : new ToolFailure({ message: `Unable to edit ${input.path}` }),
),
)
return Effect.gen(function* () { return Effect.gen(function* () {
const permissionSource = { const permissionSource = {
type: "tool" as const, type: "tool" as const,
messageID: context.assistantMessageID, messageID: context.assistantMessageID,
callID: context.toolCallID, callID: context.toolCallID,
} }
if (input.oldString === input.newString) { if (input.oldString === input.newString) {
return yield* new ToolFailure({ return yield* new ToolFailure({
message: "No changes to apply: oldString and newString are identical.", message: "No changes to apply: oldString and newString are identical.",
}) })
} }
if (input.oldString === "") { if (input.oldString === "") {
return yield* new ToolFailure({ return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.", 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,
}),
)
}
const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" }))
const external = target.externalDirectory
if (external) {
yield* unableToEdit( yield* unableToEdit(
permission.assert({ permission.assert({
...LocationMutation.externalDirectoryPermission(external), action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source: permissionSource, 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.",
})
}
yield* unableToEdit( const replaced =
permission.assert({ input.replaceAll === true
action: "edit", ? source.text.replaceAll(oldString, newString)
resources: [target.resource], : source.text.replace(oldString, newString)
save: ["*"], const counts = diffLines(source.text, replaced).reduce(
sessionID: context.sessionID, (result, item) => ({
agent: context.agent, additions: result.additions + (item.added ? (item.count ?? 0) : 0),
source: permissionSource, deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}), }),
) { additions: 0, deletions: 0 },
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical))) )
const ending = detectLineEnding(source.text) const next = splitBom(replaced)
const oldString = convertToLineEnding(input.oldString, ending) const result = yield* unableToEdit(
const newString = convertToLineEnding(input.newString, ending) files.writeIfUnchanged({
const replacements = countOccurrences(source.text, oldString) target,
if (replacements === 0) { expected: source.content,
return yield* new ToolFailure({ content: joinBom(next.text, source.bom || next.bom),
message: }),
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.", )
}) return {
} files: [
if (replacements > 1 && input.replaceAll !== true) { {
return yield* new ToolFailure({ file: result.resource,
message: patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", status: "modified" as const,
}) ...counts,
} },
],
const replaced = replacements,
input.replaceAll === true } satisfies Output
? source.text.replaceAll(oldString, newString) })
: source.text.replace(oldString, newString) },
const counts = diffLines(source.text, replaced).reduce( }),
(result, item) => ({ "edit",
additions: result.additions + (item.added ? (item.count ?? 0) : 0), ),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
target,
expected: source.content,
content: joinBom(next.text, source.bom || next.bom),
}),
)
return {
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
status: "modified" as const,
...counts,
},
],
replacements,
} satisfies Output
})
},
}),
"edit",
), ),
}) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+62 -59
View File
@@ -43,68 +43,71 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description: name,
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", Tool.make({
input: Input, description:
output: Output, "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
toModelOutput: ({ output }) => [ input: Input,
{ output: Output,
type: "text", toModelOutput: ({ output }) => [
text: toModelOutput( {
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })), type: "text",
), text: toModelOutput(
}, output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
], ),
execute: (input, context) => },
Effect.gen(function* () { ],
yield* permission.assert({ execute: (input, context) =>
action: name, Effect.gen(function* () {
resources: [input.pattern], yield* permission.assert({
save: ["*"], action: name,
metadata: { resources: [input.pattern],
root: input.path ?? ".", save: ["*"],
path: input.path, metadata: {
limit: input.limit, root: input.path ?? ".",
}, path: input.path,
sessionID: context.sessionID, limit: input.limit,
agent: context.agent, },
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, sessionID: context.sessionID,
}) agent: context.agent,
const cwd = path.resolve(location.directory, input.path ?? ".") source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
yield* fs
.stat(cwd)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
return yield* ripgrep
.glob({
cwd,
pattern: input.pattern,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
}) })
.pipe( const cwd = path.resolve(location.directory, input.path ?? ".")
Effect.map((result) => yield* fs
result.map((entry) => .stat(cwd)
FileSystem.Entry.make({ .pipe(
...entry, Effect.catchReason("PlatformError", "NotFound", () =>
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
}),
), ),
), )
) return yield* ripgrep
}).pipe( .glob({
Effect.mapError((error) => cwd,
error instanceof ToolFailure pattern: input.pattern,
? error limit: input.limit ?? Number.MAX_SAFE_INTEGER,
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), })
.pipe(
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
)
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }),
),
), ),
), }),
}), ),
}) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+76 -73
View File
@@ -57,85 +57,88 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description: name,
"Search file contents by regular expression within the active Location 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.", Tool.make({
input: Input, description:
output: Output, "Search file contents by regular expression within the active Location 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.",
toModelOutput: ({ output }) => [ input: Input,
{ output: Output,
type: "text", toModelOutput: ({ output }) => [
text: toModelOutput( {
output.map((match) => ({ type: "text",
...match, text: toModelOutput(
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) }, output.map((match) => ({
})), ...match,
), entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
}, })),
], ),
execute: (input, context) => },
Effect.gen(function* () { ],
yield* permission.assert({ execute: (input, context) =>
action: name, Effect.gen(function* () {
resources: [input.pattern], yield* permission.assert({
save: ["*"], action: name,
metadata: { resources: [input.pattern],
root: ".", save: ["*"],
path: input.path, metadata: {
include: input.include, root: ".",
limit: input.limit, path: input.path,
}, include: input.include,
sessionID: context.sessionID, limit: input.limit,
agent: context.agent, },
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, sessionID: context.sessionID,
}) agent: context.agent,
const target = path.resolve(location.directory, input.path ?? ".") source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
const info = yield* fs
.stat(target)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
return yield* ripgrep
.grep({
cwd: info?.type === "Directory" ? target : path.dirname(target),
pattern: input.pattern,
file: info?.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
}) })
.pipe( const target = path.resolve(location.directory, input.path ?? ".")
Effect.map((result) => const info = yield* fs
result.map((match) => .stat(target)
FileSystem.Match.make({ .pipe(
...match, Effect.catchReason("PlatformError", "NotFound", () =>
entry: FileSystem.Entry.make({ Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
...match.entry, ),
path: RelativePath.make( )
path.relative( return yield* ripgrep
location.directory, .grep({
path.resolve( cwd: info?.type === "Directory" ? target : path.dirname(target),
info?.type === "Directory" ? target : path.dirname(target), pattern: input.pattern,
match.entry.path, file: info?.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
})
.pipe(
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(
location.directory,
path.resolve(
info?.type === "Directory" ? target : path.dirname(target),
match.entry.path,
),
), ),
), ),
), }),
}), }),
}), ),
), ),
), )
) }).pipe(
}).pipe( Effect.mapError((error) =>
Effect.mapError((error) => error instanceof ToolFailure
error instanceof ToolFailure ? error
? error : new ToolFailure({ message: `Unable to grep for ${input.pattern}` }),
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), ),
), ),
), }),
}), ),
}) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+60 -57
View File
@@ -56,65 +56,68 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description, name,
input: Input, Tool.make({
output: Output, description,
toModelOutput: ({ input, output }) => [ input: Input,
{ type: "text", text: toModelOutput(input.questions, output.answers) }, output: Output,
], toModelOutput: ({ input, output }) => [
execute: (input, context) => { type: "text", text: toModelOutput(input.questions, output.answers) },
permission ],
.assert({ execute: (input, context) =>
action: "question", permission
resources: ["*"], .assert({
sessionID: context.sessionID, action: "question",
agent: context.agent, resources: ["*"],
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, sessionID: context.sessionID,
}) agent: context.agent,
.pipe( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), })
Effect.andThen( .pipe(
forms Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
.ask({ Effect.andThen(
sessionID: context.sessionID, forms
metadata: { .ask({
kind: "question", sessionID: context.sessionID,
tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, metadata: {
}, kind: "question",
mode: "form", tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
fields: input.questions.map( },
(question, index): Form.Field => ({ mode: "form",
key: `q${index}`, fields: input.questions.map(
title: question.header, (question, index): Form.Field => ({
description: question.question, key: `q${index}`,
type: question.multiple === true ? "multiselect" : "string", title: question.header,
options: question.options.map((option) => ({ description: question.question,
value: option.label, type: question.multiple === true ? "multiselect" : "string",
label: option.label, options: question.options.map((option) => ({
description: option.description, value: option.label,
})), label: option.label,
custom: true, description: option.description,
}), })),
), custom: true,
}),
),
})
.pipe(Effect.orDie),
),
Effect.flatMap((state) => {
if (state.status === "cancelled") return Effect.die(new CancelledError())
return Effect.succeed({
answers: input.questions.map((_, index): QuestionV2.Answer => {
const value = state.answer[`q${index}`]
if (value === undefined) return []
if (typeof value === "object") return Array.from(value)
return [String(value)]
}),
}) })
.pipe(Effect.orDie), }),
), ),
Effect.flatMap((state) => { }),
if (state.status === "cancelled") return Effect.die(new CancelledError()) ),
return Effect.succeed({ )
answers: input.questions.map((_, index): QuestionV2.Answer => {
const value = state.answer[`q${index}`]
if (value === undefined) return []
if (typeof value === "object") return Array.from(value)
return [String(value)]
}),
})
}),
),
}),
})
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+92 -87
View File
@@ -42,100 +42,105 @@ export const Plugin = {
const location = yield* Location.Service const location = yield* Location.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description: name,
"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.", Tool.make({
input: Input, description:
output: Output, "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.",
toModelOutput: ({ input, output }) => { input: Input,
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) output: Output,
return [] toModelOutput: ({ input, output }) => {
return [ if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
{ type: "text", text: "Image read successfully" }, return []
{ type: "file", data: output.content, mime: output.mime, name: input.path }, 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 source = { execute: (input, context) => {
type: "tool" as const, return Effect.gen(function* () {
messageID: context.assistantMessageID, const source = {
callID: context.toolCallID, type: "tool" as const,
} messageID: context.assistantMessageID,
const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) callID: context.toolCallID,
const external = target.externalDirectory }
if (external) const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
const type = yield* reader.inspect(absolute)
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external), action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
const resource = target.resource const content =
const absolute = AbsolutePath.make(target.canonical) type === "directory"
const type = yield* reader.inspect(absolute) ? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
yield* permission.assert({ : yield* reader.read(absolute, resource, {
action: name, offset: input.offset,
resources: [resource], limit: input.limit,
save: ["*"], })
sessionID: context.sessionID, // After a successful read, discover nearby AGENTS.md walking up to the Location
agent: context.agent, // root exclusive and inject them as durable synthetic instructions. For a
source, // directory listing the walk starts at the directory itself (so its own AGENTS.md
}) // is discovered); for a file it starts at the file's dirname. External reads are
const content = // skipped, and discovery failures never fail the read.
type === "directory" yield* Effect.gen(function* () {
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit }) if (target.externalDirectory !== undefined) return
: yield* reader.read(absolute, resource, { const resolved = yield* fs.resolve(target.canonical)
offset: input.offset, const root = yield* fs.resolve(location.directory)
limit: input.limit, // up() searches its stop directory, so the Location-root AGENTS.md (already
}) // supplied by the core/instructions baseline) is dropped by the dirname filter.
// After a successful read, discover nearby AGENTS.md walking up to the Location const discovered = yield* fs.up({
// root exclusive and inject them as durable synthetic instructions. For a targets: [FILENAME],
// directory listing the walk starts at the directory itself (so its own AGENTS.md start: type === "directory" ? resolved : dirname(resolved),
// is discovered); for a file it starts at the file's dirname. External reads are stop: root,
// skipped, and discovery failures never fail the read. })
yield* Effect.gen(function* () { const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
if (target.externalDirectory !== undefined) return (file) => dirname(file) !== root,
const resolved = yield* fs.resolve(target.canonical) )
const root = yield* fs.resolve(location.directory) if (candidates.length === 0) return
// up() searches its stop directory, so the Location-root AGENTS.md (already yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
// supplied by the core/instructions baseline) is dropped by the dirname filter. }).pipe(
const discovered = yield* fs.up({ Effect.catch(() => Effect.void),
targets: [FILENAME], Effect.catchDefect(() => Effect.void),
start: type === "directory" ? resolved : dirname(resolved), )
stop: root, if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
}) return yield* image
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root) .normalize(resource, { ...content, encoding: "base64" })
if (candidates.length === 0) return .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) }
if ("encoding" in content && content.encoding === "base64")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe( }).pipe(
Effect.catch(() => Effect.void), Effect.mapError((error) => {
Effect.catchDefect(() => Effect.void), const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message })
}),
) )
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { },
return yield* image }),
.normalize(resource, { ...content, encoding: "base64" }) ),
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) )
}
if ("encoding" in content && content.encoding === "base64")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message })
}),
)
},
}),
})
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+117 -112
View File
@@ -58,8 +58,7 @@ const modelOutput = (output: Output): string | undefined => {
const warnings = output.warnings?.length const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: "" : ""
if (output.status === "running") if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
} }
@@ -140,136 +139,142 @@ export const Plugin = {
}) })
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
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. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, name,
input: Input, Tool.make({
output: Output, 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. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
structured: StructuredOutput, input: Input,
toStructuredOutput: ({ output }) => ({ output: Output,
truncated: output.truncated, structured: StructuredOutput,
...(output.exit === undefined ? {} : { exit: output.exit }), toStructuredOutput: ({ output }) => ({
...(output.shellID === undefined ? {} : { shellID: output.shellID }), truncated: output.truncated,
...(output.timeout === undefined ? {} : { timeout: output.timeout }), ...(output.exit === undefined ? {} : { exit: output.exit }),
}), ...(output.shellID === undefined ? {} : { shellID: output.shellID }),
toModelOutput: ({ output }) => { ...(output.timeout === undefined ? {} : { timeout: output.timeout }),
const parts: Content[] = [{ type: "text", text: output.output }] }),
const model = modelOutput(output) toModelOutput: ({ output }) => {
if (model) parts.push({ type: "text", text: model }) const parts: Content[] = [{ type: "text", text: output.output }]
return parts const model = modelOutput(output)
}, if (model) parts.push({ type: "text", text: model })
execute: (input, context) => return parts
Effect.gen(function* () { },
const source = { execute: (input, context) =>
type: "tool" as const, Effect.gen(function* () {
messageID: context.assistantMessageID, const source = {
callID: context.toolCallID, type: "tool" as const,
} messageID: context.assistantMessageID,
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) callID: context.toolCallID,
const external = target.externalDirectory }
if (external) 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 = (yield* externalCommandDirectories(fsUtil, input.command, target.canonical)).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external), action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
const warnings = (yield* externalCommandDirectories(fsUtil, input.command, target.canonical)).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* permission.assert({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const info = yield* shell.create({ const info = yield* shell.create({
command: input.command, command: input.command,
cwd: target.canonical, cwd: target.canonical,
timeout, timeout,
metadata: { sessionID: context.sessionID }, metadata: { sessionID: context.sessionID },
}) })
const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id) const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") { if (final.status === "timeout") {
return {
exit: final.exit,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
status: "completed" as const,
}
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return { return {
exit: final.exit, exit: final.exit,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, output: `${body}${notice}`,
truncated: false, truncated,
timeout: true,
status: "completed" as const, status: "completed" as const,
} }
})
const run = settleShell().pipe(
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.toolCallID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
} }
const truncated = page.size > page.cursor const result = yield* runtime.job
const body = page.output || "(no output)" .block({ id: job.id, sessionID: context.sessionID })
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
return { if (result?.type === "backgrounded") {
exit: final.exit, yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
output: `${body}${notice}`, return {
truncated, output: BACKGROUND_STARTED,
status: "completed" as const, shellID: info.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
} }
}) if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
const run = settleShell().pipe(
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.toolCallID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return { return {
output: BACKGROUND_STARTED, ...(yield* settleShell()),
shellID: info.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}), ...(warnings.length ? { warnings } : {}),
} }
} }).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })),
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe( ),
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)), }),
) ),
if (result?.type === "backgrounded") { )
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
}
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return {
...(yield* settleShell()),
...(warnings.length ? { warnings } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}),
})
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+40 -37
View File
@@ -59,43 +59,46 @@ export const Plugin = {
const skills = yield* SkillV2.Service const skills = yield* SkillV2.Service
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description, name,
input: Input, Tool.make({
output: Output, description,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }], input: Input,
execute: (input, context) => output: Output,
Effect.gen(function* () { toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
const current = yield* skills.list() execute: (input, context) =>
const skill = current.find((skill) => skill.name === input.name) Effect.gen(function* () {
if (!skill) return yield* unableToLoad(input.name) const current = yield* skills.list()
return yield* Effect.gen(function* () { const skill = current.find((skill) => skill.name === input.name)
yield* permission.assert({ if (!skill) return yield* unableToLoad(input.name)
action: name, return yield* Effect.gen(function* () {
resources: [skill.name], yield* permission.assert({
save: [skill.name], action: name,
sessionID: context.sessionID, resources: [skill.name],
agent: context.agent, save: [skill.name],
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, sessionID: context.sessionID,
}) agent: context.agent,
const directory = path.dirname(skill.location) source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
const files = })
path.basename(skill.location) === "SKILL.md" const directory = path.dirname(skill.location)
? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) const files =
.filter((file) => path.basename(file) !== "SKILL.md") path.basename(skill.location) === "SKILL.md"
.toSorted() ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.slice(0, FILE_LIMIT) .filter((file) => path.basename(file) !== "SKILL.md")
: [] .toSorted()
return { .slice(0, FILE_LIMIT)
name: skill.name, : []
directory, return {
output: toModelOutput(skill, files), name: skill.name,
} directory,
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) output: toModelOutput(skill, files),
}), }
}), }).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
}) }),
}),
),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+78 -70
View File
@@ -93,80 +93,88 @@ export const Plugin = {
}) })
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description, name,
input: Input, Tool.make({
output: Output, description,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }], input: Input,
execute: (input, context) => output: Output,
Effect.gen(function* () { toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
const parent = yield* runtime.session execute: (input, context) =>
.get(context.sessionID) Effect.gen(function* () {
.pipe( const parent = yield* runtime.session
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })), .get(context.sessionID)
) .pipe(
const agent = yield* agents.resolve(input.agent) Effect.mapError(
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) () => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
if (agent.mode === "primary") ),
return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) )
const agent = yield* agents.resolve(input.agent)
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
if (agent.mode === "primary")
return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
// Model selection is policy/config/session state, not an LLM-facing tool argument. // Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model const model = agent.model ?? parent.model
const child = yield* runtime.session const child = yield* runtime.session
.create({ .create({
parentID: context.sessionID, parentID: context.sessionID,
title: input.description,
agent: AgentV2.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.
})
.pipe(
Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
),
)
const background = input.background === true
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.
yield* runtime.session.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
yield* runtime.session.resume(child.id)
return yield* latestAssistantText(child.id)
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
const info = yield* runtime.job.start({
id: child.id,
type: name,
title: input.description, title: input.description,
agent: AgentV2.ID.make(input.agent), metadata: {},
model, run,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
}) })
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })), if (background) {
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
}
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() =>
Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
discard: true,
}),
),
) )
if (result?.type === "backgrounded") {
const background = input.background === true yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
const run = Effect.gen(function* () { }
// The child session owns its agent/model (set at create); prompt only admits input. if (result?.info.status === "error")
yield* runtime.session.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false }) return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
yield* runtime.session.resume(child.id) if (result?.info.status === "cancelled")
return yield* latestAssistantText(child.id) return yield* new ToolFailure({ message: "Subagent cancelled" })
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id))) return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}),
const info = yield* runtime.job.start({ }),
id: child.id, ),
type: name, )
title: input.description,
metadata: {},
run,
})
if (background) {
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
}
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() =>
Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
discard: true,
}),
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
}
if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}),
}),
})
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+25 -22
View File
@@ -27,28 +27,31 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description: name,
"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.", Tool.make({
input: Input, description:
output: Output, "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.",
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], input: Input,
execute: (input, context) => output: Output,
Effect.gen(function* () { toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
yield* permission.assert({ execute: (input, context) =>
action: name, Effect.gen(function* () {
resources: ["*"], yield* permission.assert({
save: ["*"], action: name,
sessionID: context.sessionID, resources: ["*"],
agent: context.agent, save: ["*"],
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, sessionID: context.sessionID,
}) agent: context.agent,
yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
return { todos: input.todos } })
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), 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) .pipe(Effect.orDie)
}), }),
} }
+54 -51
View File
@@ -119,60 +119,63 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description, name,
input: Input, Tool.make({
output: Output, description,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }], input: Input,
execute: (input, context) => output: Output,
Effect.gen(function* () { toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
yield* Effect.try({ execute: (input, context) =>
try: () => assertHttpUrl(new URL(input.url)), Effect.gen(function* () {
catch: (error) => error, yield* Effect.try({
}) try: () => assertHttpUrl(new URL(input.url)),
catch: (error) => error,
})
yield* permission.assert({ yield* permission.assert({
action: name, action: name,
resources: [input.url], resources: [input.url],
save: ["*"], save: ["*"],
metadata: input, metadata: input,
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
}) })
const { body, contentType } = yield* Effect.gen(function* () { const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, input.url, input.format).pipe( const response = yield* execute(http, input.url, input.format).pipe(
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")), Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
)
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)
if (isImageAttachment(mime))
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
if (!isTextualMime(mime))
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
return { body: yield* collectBody(response), contentType }
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.fail(new Error("Request timed out")),
}),
) )
const contentType = response.headers["content-type"] || "" const content = new TextDecoder().decode(body)
const mime = mimeFrom(contentType) const output = yield* Effect.try({
if (isImageAttachment(mime)) try: () => convert(content, contentType, input.format),
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`)) catch: (error) => error,
if (!isTextualMime(mime)) })
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`)) return {
return { body: yield* collectBody(response), contentType } url: input.url,
}).pipe( contentType,
Effect.timeoutOrElse({ format: input.format,
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS), output,
orElse: () => Effect.fail(new Error("Request timed out")), }
}), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
) }),
const content = new TextDecoder().decode(body) ),
const output = yield* Effect.try({ )
try: () => convert(content, contentType, input.format),
catch: (error) => error,
})
return {
url: input.url,
contentType,
format: input.format,
output,
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
}),
})
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+56 -51
View File
@@ -195,58 +195,63 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.make({ draft.add(
description, name,
input: Input, Tool.make({
output: Output, description,
toModelOutput: ({ output }) => [{ type: "text", text: output.text }], input: Input,
execute: (input, context) => { output: Output,
const provider = selectProvider(context.sessionID, config, config.provider) toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
return Effect.gen(function* () { execute: (input, context) => {
yield* permission.assert({ const provider = selectProvider(context.sessionID, config, config.provider)
action: name, return Effect.gen(function* () {
resources: [input.query], yield* permission.assert({
save: ["*"], action: name,
metadata: { ...input, provider }, resources: [input.query],
sessionID: context.sessionID, save: ["*"],
agent: context.agent, metadata: { ...input, provider },
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, sessionID: context.sessionID,
}) agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const text = const text =
provider === "exa" provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: input.query, query: input.query,
type: input.type || "auto", type: input.type || "auto",
numResults: input.numResults || 8, numResults: input.numResults || 8,
livecrawl: input.livecrawl || "fallback", livecrawl: input.livecrawl || "fallback",
contextMaxCharacters: input.contextMaxCharacters, contextMaxCharacters: input.contextMaxCharacters,
}) })
: yield* callMcp( : yield* callMcp(
http, http,
PARALLEL_URL, PARALLEL_URL,
"web_search", "web_search",
ParallelArgs, ParallelArgs,
{ {
objective: input.query, objective: input.query,
search_queries: [input.query], search_queries: [input.query],
session_id: context.sessionID, session_id: context.sessionID,
// V2 invocation context does not safely expose the model yet. // V2 invocation context does not safely expose the model yet.
}, },
{ {
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
}, },
) )
return { return {
provider, provider,
text: text ?? NO_RESULTS, text: text ?? NO_RESULTS,
} }
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))) }).pipe(
}, Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })),
}), )
}) },
}),
),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
} }
+35 -32
View File
@@ -50,44 +50,47 @@ export const Plugin = {
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
[name]: Tool.withPermission( draft.add(
Tool.make({ name,
description: Tool.withPermission(
"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.", Tool.make({
input: Input, description:
output: Output, "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.",
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], input: Input,
execute: (input, context) => output: Output,
Effect.gen(function* () { toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
const source = { execute: (input, context) =>
type: "tool" as const, Effect.gen(function* () {
messageID: context.assistantMessageID, const source = {
callID: context.toolCallID, type: "tool" as const,
} messageID: context.assistantMessageID,
const target = yield* mutation.resolve({ path: input.path, kind: "file" }) callID: context.toolCallID,
const external = target.externalDirectory }
if (external) 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({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external), action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
yield* permission.assert({ return yield* files.writeTextPreservingBom({ target, content: input.content })
action: "edit", }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
resources: [target.resource], }),
save: ["*"], "edit",
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) .pipe(Effect.orDie)
}), }),
} }
+26
View File
@@ -61,6 +61,12 @@ const GlobalMessage = EventV2.ephemeral({
text: Schema.String, text: Schema.String,
}, },
}) })
const CountMessage = EventV2.ephemeral({
type: "test.count",
schema: {
count: Schema.Number,
},
})
const VersionedMessage = EventV2.durable({ const VersionedMessage = EventV2.durable({
type: "test.versioned", type: "test.versioned",
@@ -90,6 +96,26 @@ const it = testEffect(
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node]))) const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
describe("EventV2", () => { describe("EventV2", () => {
it.effect("subscribes to multiple event definitions with a discriminated payload union", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
// @ts-expect-error multi-definition subscriptions require at least one definition
events.subscribe([])
const fiber = yield* events
.subscribe([Message, CountMessage])
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* events.publish(Message, { text: "hello" })
yield* events.publish(CountMessage, { count: 2 })
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
event.type === "test.message" ? event.data.text : event.data.count,
)
expect(received).toEqual(["hello", 2])
}),
)
it.effect("publishes events with the current location", () => it.effect("publishes events with the current location", () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
+19 -1
View File
@@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission" import type { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tool } from "@opencode-ai/core/tool/tool"
import { Tools } from "@opencode-ai/core/tool/tools" import { Tools } from "@opencode-ai/core/tool/tools"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, type Scope } from "effect" import { Effect, type Scope } from "effect"
@@ -49,7 +50,24 @@ export const registerToolPlugin = <R>(plugin: {
const tools = yield* Tools.Service const tools = yield* Tools.Service
const context: Pick<PluginContext, "tool"> = { const context: Pick<PluginContext, "tool"> = {
tool: { tool: {
register: tools.register, transform: (callback) =>
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
})
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
)
}),
execute: { execute: {
before: () => Effect.die("registerToolPlugin does not support tool hooks"), before: () => Effect.die("registerToolPlugin does not support tool hooks"),
after: () => Effect.die("registerToolPlugin does not support tool hooks"), after: () => Effect.die("registerToolPlugin does not support tool hooks"),
+29 -23
View File
@@ -165,14 +165,17 @@ describe("PluginV2", () => {
id: "tool-plugin", id: "tool-plugin",
effect: (ctx) => effect: (ctx) =>
ctx.tool ctx.tool
.register({ .transform((draft) =>
plugin_tool: Tool.make({ draft.add(
description: "Plugin tool", "plugin_tool",
input: Schema.Struct({}), Tool.make({
output: Schema.Struct({ ok: Schema.Boolean }), description: "Plugin tool",
execute: () => Effect.succeed({ ok: true }), input: Schema.Struct({}),
}), output: Schema.Struct({ ok: Schema.Boolean }),
}) execute: () => Effect.succeed({ ok: true }),
}),
),
)
.pipe(Effect.orDie), .pipe(Effect.orDie),
}) })
@@ -202,13 +205,13 @@ describe("PluginV2", () => {
const plugin = define({ const plugin = define({
id: "grouped-tools", id: "grouped-tools",
effect: (ctx) => effect: (ctx) =>
Effect.gen(function* () { ctx.tool
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie) .transform((draft) => {
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie) draft.add("plain", tool("Plain"))
yield* ctx.tool draft.add("look/up", tool("Lookup"), { group: "context 7" })
.register({ search: tool("Search") }, { group: "context 7", deferred: true }) draft.add("search", tool("Search"), { group: "context 7", deferred: true })
.pipe(Effect.orDie) })
}), .pipe(Effect.orDie),
}) })
yield* plugins.activate([{ plugin }]) yield* plugins.activate([{ plugin }])
@@ -236,14 +239,17 @@ describe("PluginV2", () => {
effect: (ctx) => effect: (ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* ctx.tool yield* ctx.tool
.register({ .transform((draft) =>
echo: Tool.make({ draft.add(
description: "Echo", "echo",
input: Schema.Struct({ text: Schema.String }), Tool.make({
output: Schema.Struct({ text: Schema.String }), description: "Echo",
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })), input: Schema.Struct({ text: Schema.String }),
}), output: Schema.Struct({ text: Schema.String }),
}) execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
}),
),
)
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.tool.execute yield* ctx.tool.execute
+1 -1
View File
@@ -71,7 +71,7 @@ export function host(overrides: Overrides = {}): PluginContext {
reload: () => Effect.die("unused skill.reload"), reload: () => Effect.die("unused skill.reload"),
}, },
tool: overrides.tool ?? { tool: overrides.tool ?? {
register: () => Effect.die("unused tool.register"), transform: () => Effect.die("unused tool.transform"),
execute: { execute: {
before: () => Effect.die("unused tool.execute.before"), before: () => Effect.die("unused tool.execute.before"),
after: () => Effect.die("unused tool.execute.after"), after: () => Effect.die("unused tool.execute.after"),
+1 -1
View File
@@ -10,5 +10,5 @@ export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration
export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
export type { SkillDraft, SkillHooks } from "./skill.js" export type { SkillDraft, SkillHooks } from "./skill.js"
export * as Tool from "./tool.js" export * as Tool from "./tool.js"
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js" export type { ToolDomain, ToolDraft, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
export type { SessionHooks } from "./runtime.js" export type { SessionHooks } from "./runtime.js"
+5 -4
View File
@@ -249,10 +249,11 @@ export interface RegisterOptions {
readonly deferred?: boolean readonly deferred?: boolean
} }
export interface ToolDraft {
add(name: string, tool: AnyTool, options?: RegisterOptions): void
}
export interface ToolDomain { export interface ToolDomain {
readonly register: ( readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect<void, RegistrationError, Scope.Scope>
tools: Readonly<Record<string, AnyTool>>,
options?: RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }>
} }
+1 -1
View File
@@ -11,7 +11,7 @@ const opencode = yield * OpenCode.create()
const session = yield * opencode.sessions.get({ sessionID }) const session = yield * opencode.sessions.get({ sessionID })
``` ```
It also exports `Tool` for plugins that register tools with `ctx.tool.register(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. `sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
+2 -1
View File
@@ -1,6 +1,7 @@
import { OpenCode } from "@opencode-ai/client/effect" import { OpenCode } from "@opencode-ai/client/effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
@@ -13,7 +14,7 @@ export const create = Effect.fn("OpenCode.create")(function* () {
const memoMap = yield* Layer.makeMemoMap const memoMap = yield* Layer.makeMemoMap
const sdkPlugins = SdkPlugins.makeStore() const sdkPlugins = SdkPlugins.makeStore()
const context = yield* Layer.buildWithMemoMap( const context = yield* Layer.buildWithMemoMap(
AppNodeBuilder.build(LayerNode.group([PermissionSaved.node, Project.node, SdkPlugins.node]), [ AppNodeBuilder.build(LayerNode.group([EventV2.node, PermissionSaved.node, Project.node, SdkPlugins.node]), [
[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)], [SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)],
]), ]),
memoMap, memoMap,
+126 -9
View File
@@ -1,6 +1,8 @@
import fs from "fs/promises"
import path from "path"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Deferred, Effect, Latch, Layer, Option, Schema, Stream } from "effect" import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { testEffect } from "../../core/test/lib/effect" import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir" import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src" import type { OpenCodeEvent } from "../src"
@@ -26,6 +28,118 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
const location = (fixture: Fixture) => const location = (fixture: Fixture) =>
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) }) fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
it.live(
"reloads every booted Location after SDK plugin registration",
() =>
withEmbedded("opencode-embedded-plugin-reload-", (fixture) =>
Effect.gen(function* () {
const opencode = yield* fixture.sdk.OpenCode.create()
const booted = yield* Deferred.make<void>()
const activated = yield* Deferred.make<boolean>()
const bootCount = yield* Ref.make(0)
const activationCount = yield* Ref.make(0)
const secondDirectory = path.join(fixture.directory, "second")
yield* Effect.promise(() => fs.mkdir(secondDirectory))
const refs = [
location(fixture),
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(secondDirectory) }),
]
const bootstrapID = `bootstrap-sdk-${crypto.randomUUID()}`
const id = `late-sdk-${crypto.randomUUID()}`
yield* opencode.plugin({
id: bootstrapID,
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add(
"bootstrap_sdk_tool",
fixture.sdk.Tool.make({
description: "Marks the initial Location plugin generation",
input: Schema.Struct({}),
output: Schema.Void,
execute: () => Effect.void,
}),
),
)
.pipe(Effect.orDie)
if (yield* Ref.updateAndGet(bootCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))) {
yield* Deferred.succeed(booted, undefined)
}
}),
})
yield* Effect.all(
refs.map((ref) => opencode.plugin.list({ location: ref })),
{ discard: true },
)
yield* Deferred.await(booted).pipe(Effect.timeout("4 seconds"))
yield* opencode.plugin({
id,
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add(
"late_sdk_tool",
fixture.sdk.Tool.make({
description: "Tool registered after Location boot",
input: Schema.Struct({}),
output: Schema.Void,
execute: () => Effect.void,
}),
),
)
.pipe(Effect.orDie)
if (
yield* Ref.updateAndGet(activationCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))
) {
yield* Deferred.succeed(activated, true)
}
}),
})
expect(yield* Deferred.await(activated).pipe(Effect.timeout("10 seconds"))).toBe(true)
}),
),
25_000,
)
it.live(
"keeps SDK plugin registration isolated between embedded hosts",
() =>
withEmbedded("opencode-embedded-plugin-isolation-", (fixture) =>
Effect.gen(function* () {
const first = yield* fixture.sdk.OpenCode.create()
const second = yield* fixture.sdk.OpenCode.create()
const firstReady = yield* Deferred.make<void>()
const secondReady = yield* Deferred.make<void>()
const activated = yield* Deferred.make<void>()
const ref = location(fixture)
const id = `isolated-sdk-${crypto.randomUUID()}`
yield* first.plugin({
id: `first-ready-${crypto.randomUUID()}`,
effect: () => Deferred.succeed(firstReady, undefined),
})
yield* second.plugin({
id: `second-ready-${crypto.randomUUID()}`,
effect: () => Deferred.succeed(secondReady, undefined),
})
yield* Effect.all([first.plugin.list({ location: ref }), second.plugin.list({ location: ref })], {
discard: true,
})
yield* Effect.all([Deferred.await(firstReady), Deferred.await(secondReady)], { discard: true })
yield* first.plugin({ id, effect: () => Deferred.succeed(activated, undefined) })
yield* Deferred.await(activated).pipe(Effect.timeout("5 seconds"))
expect((yield* second.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).not.toContain(id)
}),
),
15_000,
)
it.live( it.live(
"embedded client uses the real router and handlers", "embedded client uses the real router and handlers",
() => () =>
@@ -42,14 +156,17 @@ it.live(
id: `embedded-tools-${crypto.randomUUID()}`, id: `embedded-tools-${crypto.randomUUID()}`,
effect: (ctx) => effect: (ctx) =>
ctx.tool ctx.tool
.register({ .transform((draft) =>
embedded_tool: fixture.sdk.Tool.make({ draft.add(
description: "Embedded test tool", "embedded_tool",
input: Schema.Struct({}), fixture.sdk.Tool.make({
output: Schema.Struct({ ok: Schema.Boolean }), description: "Embedded test tool",
execute: () => Effect.succeed({ ok: true }), input: Schema.Struct({}),
}), output: Schema.Struct({ ok: Schema.Boolean }),
}) execute: () => Effect.succeed({ ok: true }),
}),
),
)
.pipe(Effect.orDie), .pipe(Effect.orDie),
}) })