From 28301769723fda788205a223c7b21c3f5f4d275e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 6 Jul 2026 13:36:19 -0400 Subject: [PATCH] fix(plugin): reload late SDK plugins (#35576) --- packages/core/src/event.ts | 27 ++- packages/core/src/plugin/host.ts | 22 ++- packages/core/src/plugin/sdk.ts | 13 +- packages/core/src/plugin/supervisor.ts | 2 +- packages/core/src/tool/apply-patch.ts | 223 +++++++++++------------ packages/core/src/tool/edit.ts | 213 +++++++++++----------- packages/core/src/tool/glob.ts | 121 +++++++------ packages/core/src/tool/grep.ts | 149 +++++++-------- packages/core/src/tool/question.ts | 117 ++++++------ packages/core/src/tool/read.ts | 179 +++++++++--------- packages/core/src/tool/shell.ts | 229 ++++++++++++------------ packages/core/src/tool/skill.ts | 77 ++++---- packages/core/src/tool/subagent.ts | 148 +++++++-------- packages/core/src/tool/todowrite.ts | 47 ++--- packages/core/src/tool/webfetch.ts | 105 +++++------ packages/core/src/tool/websearch.ts | 107 +++++------ packages/core/src/tool/write.ts | 67 +++---- packages/core/test/event.test.ts | 26 +++ packages/core/test/lib/tool.ts | 20 ++- packages/core/test/plugin.test.ts | 52 +++--- packages/core/test/plugin/host.ts | 2 +- packages/plugin/src/v2/effect/index.ts | 2 +- packages/plugin/src/v2/effect/tool.ts | 9 +- packages/sdk-next/README.md | 2 +- packages/sdk-next/src/opencode.ts | 3 +- packages/sdk-next/test/embedded.test.ts | 135 +++++++++++++- 26 files changed, 1181 insertions(+), 916 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 6b6d7813e5..03c539642b 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -111,13 +111,26 @@ export type LogItem = Payload | EventLog.Synced export const isSynced = (item: LogItem): item is EventLog.Synced => item.type === "log.synced" +export type SubscribePayload = D[number] extends infer Item + ? Item extends Definition + ? Payload + : never + : never + +export interface Subscribe { + (definition: D): Stream.Stream> + (definitions: D): Stream.Stream> +} + +const isDefinition = (input: Definition | readonly Definition[]): input is Definition => !Array.isArray(input) + export interface Interface { readonly publish: ( definition: D, data: Data, options?: PublishOptions, ) => Effect.Effect> - readonly subscribe: (definition: D) => Stream.Stream> + readonly subscribe: Subscribe /** * Volatile live channel: every event published from now on, nothing before, * nothing across a disconnect. The only channel that carries non-durable @@ -562,11 +575,21 @@ export const layerWith = (options?: LayerOptions) => ), ) - const subscribe = (definition: D): Stream.Stream> => + const subscribeOne = (definition: D): Stream.Stream> => local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe( Stream.map((event) => event as Payload), ) + function subscribe(definition: D): Stream.Stream> + function subscribe( + definitions: D, + ): Stream.Stream> + function subscribe(input: Definition | readonly Definition[]): Stream.Stream { + 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 => local(Stream.fromPubSub(pubsub.live)) const readAfter = ( diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 2e58c6b144..03c9285414 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -18,6 +18,7 @@ import { ProviderV2 } from "../provider" import { Reference } from "../reference" import { AbsolutePath, type DeepMutable } from "../schema" import { SkillV2 } from "../skill" +import { Tool } from "../tool/tool" import { Tools } from "../tool/tools" import { ToolHooks } from "../tool/hooks" import { WorkspaceV2 } from "../workspace" @@ -298,7 +299,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), }, 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: { before: (callback) => toolHooks.hook.before((event) => { diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index 221fb5fae9..f6ceb06a8f 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -3,6 +3,9 @@ export * as SdkPlugins from "./sdk" import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { Context, Effect, Layer } from "effect" import { makeGlobalNode } from "../effect/app-node" +import { EventV2 } from "../event" + +export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} }) export interface Store { readonly plugins: Map @@ -16,9 +19,8 @@ const defaultStore = makeStore() * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * so `PluginSupervisor` can add them on every Location boot through the ordinary * generation path that `PluginSupervisor` uses for plugins discovered from - * config. A plugin registered after a Location has booted only - * applies to Locations booted afterward, matching config-plugin timing; - * embedders register at startup before creating Sessions. + * config. Registration publishes an unlocated update so every booted Location + * reloads its plugin generation from the shared store. * * The store is shared explicitly between the SDK construction graph and the * embedded route graph because `LocationServiceMap` builds Location layers lazily @@ -36,6 +38,7 @@ export const layerWithStore = (store: Store) => Layer.effect( Service, Effect.gen(function* () { + const events = yield* EventV2.Service yield* Effect.addFinalizer(() => Effect.sync(() => { store.plugins.clear() @@ -45,7 +48,7 @@ export const layerWithStore = (store: Store) => register: (plugin) => Effect.sync(() => { store.plugins.set(plugin.id, plugin) - }), + }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), all: () => [...store.plugins.values()], }) }), @@ -53,4 +56,4 @@ export const layerWithStore = (store: Store) => export const layer = layerWithStore(defaultStore) -export const node = makeGlobalNode({ service: Service, layer, deps: [] }) +export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 4d648a7d6f..4c4e71be17 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -276,7 +276,7 @@ const layer = Layer.effect( }), ), ) - yield* events.subscribe(Event.Updated).pipe( + yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe( Stream.runForEach(() => reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), ), diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 6700e6e1ba..2853a9130b 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -63,133 +63,136 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.withPermission( - Tool.make({ - description: - "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", - input: Input, - output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], - execute: (input, context) => { - const applied: Array = [] - const fail = (path: string) => { - const prefix = - applied.length === 0 - ? `Unable to apply patch at ${path}` - : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` - return new ToolFailure({ message: prefix }) - } - return Effect.gen(function* () { - const source = { - type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, + .transform((draft) => + draft.add( + name, + Tool.withPermission( + Tool.make({ + description: + "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], + execute: (input, context) => { + const applied: Array = [] + const fail = (path: string) => { + const prefix = + applied.length === 0 + ? `Unable to apply patch at ${path}` + : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` + return new ToolFailure({ message: prefix }) } - if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) - const hunks = yield* Effect.try({ - try: () => Patch.parse(input.patchText), - catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), - }) - if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) - const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) - if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) + return Effect.gen(function* () { + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) + const hunks = yield* Effect.try({ + try: () => Patch.parse(input.patchText), + catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), + }) + if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) + const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) + if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) - const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] - for (const hunk of hunks) - targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) }) - const externalDirectories = new Map() - for (const { target } of targets) { - const external = target.externalDirectory - if (external) externalDirectories.set(external.resource, external) - } - for (const external of externalDirectories.values()) { + const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] + for (const hunk of hunks) + targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) }) + const externalDirectories = new Map() + for (const { target } of targets) { + const external = target.externalDirectory + if (external) externalDirectories.set(external.resource, external) + } + for (const external of externalDirectories.values()) { + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + } yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), + action: "edit", + resources: [...new Set(targets.map(({ target }) => target.resource))], + save: ["*"], sessionID: context.sessionID, agent: context.agent, source, }) - } - yield* permission.assert({ - action: "edit", - resources: [...new Set(targets.map(({ target }) => target.resource))], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source, - }) - const prepared: Prepared[] = [] - for (const { hunk, target } of targets) { - yield* Effect.gen(function* () { - if (hunk.type === "add") { + const prepared: Prepared[] = [] + for (const { hunk, target } of targets) { + yield* Effect.gen(function* () { + 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({ ...hunk, target, - before: "", - after: - hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, + source, + content: Patch.joinBom(update.content, update.bom), + before, + after: update.content, }) - 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({ - ...hunk, - target, - source, - content: Patch.joinBom(update.content, update.bom), - before, - after: update.content, - }) - }).pipe(Effect.mapError(() => fail(hunk.path))) - } + }).pipe(Effect.mapError(() => fail(hunk.path))) + } - const patchFiles = prepared.map(patchFile) - yield* Effect.forEach( - prepared, - (change) => - Effect.gen(function* () { - if (change.type === "add") { - const result = yield* files.create({ + const patchFiles = prepared.map(patchFile) + yield* Effect.forEach( + prepared, + (change) => + Effect.gen(function* () { + if (change.type === "add") { + const result = yield* files.create({ + target: change.target, + content: + change.contents.endsWith("\n") || change.contents === "" + ? change.contents + : `${change.contents}\n`, + }) + applied.push({ type: change.type, resource: result.resource, target: result.target }) + return + } + if (change.type === "delete") { + const result = yield* files.remove({ target: change.target }) + applied.push({ type: change.type, resource: result.resource, target: result.target }) + return + } + const result = yield* files.writeIfUnchanged({ target: change.target, - content: - change.contents.endsWith("\n") || change.contents === "" - ? change.contents - : `${change.contents}\n`, + expected: change.source, + content: change.content, }) applied.push({ type: change.type, resource: result.resource, target: result.target }) - return - } - if (change.type === "delete") { - const result = yield* files.remove({ target: change.target }) - applied.push({ type: change.type, resource: result.resource, target: result.target }) - return - } - const result = yield* files.writeIfUnchanged({ - target: change.target, - expected: change.source, - content: change.content, - }) - applied.push({ type: change.type, resource: result.resource, target: result.target }) - }).pipe(Effect.mapError(() => fail(change.path))), - { discard: true }, - ) - return { applied, files: patchFiles } - }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) - }, - }), - "edit", + }).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) }), } diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index bb9bb4dee9..88feac6c34 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -94,122 +94,125 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.withPermission( - Tool.make({ - description: - "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", - input: Input, - output: Output, - toModelOutput: ({ input, output }) => [ - { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, - ], - execute: (input, context) => { - const unableToEdit = (effect: Effect.Effect) => - effect.pipe( - Effect.mapError((error) => - error instanceof FileMutation.StaleContentError - ? new ToolFailure({ - message: "File changed after permission approval. Read it again before editing.", - }) - : new ToolFailure({ message: `Unable to edit ${input.path}` }), - ), - ) + .transform((draft) => + draft.add( + name, + Tool.withPermission( + Tool.make({ + description: + "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", + input: Input, + output: Output, + toModelOutput: ({ input, output }) => [ + { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, + ], + execute: (input, context) => { + const unableToEdit = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error) => + error instanceof FileMutation.StaleContentError + ? new ToolFailure({ + message: "File changed after permission approval. Read it again before editing.", + }) + : new ToolFailure({ message: `Unable to edit ${input.path}` }), + ), + ) - return Effect.gen(function* () { - const permissionSource = { - type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, - } - if (input.oldString === input.newString) { - return yield* new ToolFailure({ - message: "No changes to apply: oldString and newString are identical.", - }) - } - if (input.oldString === "") { - return yield* new ToolFailure({ - message: "oldString must not be empty. Use write to create or overwrite a file.", - }) - } + return Effect.gen(function* () { + const permissionSource = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + if (input.oldString === input.newString) { + return yield* new ToolFailure({ + message: "No changes to apply: oldString and newString are identical.", + }) + } + if (input.oldString === "") { + return yield* new ToolFailure({ + message: "oldString must not be empty. Use write to create or overwrite a file.", + }) + } + + const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" })) + const external = target.externalDirectory + if (external) { + yield* unableToEdit( + permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source: permissionSource, + }), + ) + } - const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" })) - const external = target.externalDirectory - if (external) { yield* unableToEdit( permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), + action: "edit", + resources: [target.resource], + save: ["*"], sessionID: context.sessionID, agent: context.agent, source: permissionSource, }), ) - } + const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical))) + const ending = detectLineEnding(source.text) + const oldString = convertToLineEnding(input.oldString, ending) + const newString = convertToLineEnding(input.newString, ending) + const replacements = countOccurrences(source.text, oldString) + if (replacements === 0) { + return yield* new ToolFailure({ + message: + "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", + }) + } + if (replacements > 1 && input.replaceAll !== true) { + return yield* new ToolFailure({ + message: + "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", + }) + } - yield* unableToEdit( - permission.assert({ - action: "edit", - resources: [target.resource], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source: permissionSource, - }), - ) - const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical))) - const ending = detectLineEnding(source.text) - const oldString = convertToLineEnding(input.oldString, ending) - const newString = convertToLineEnding(input.newString, ending) - const replacements = countOccurrences(source.text, oldString) - if (replacements === 0) { - return yield* new ToolFailure({ - message: - "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", - }) - } - if (replacements > 1 && input.replaceAll !== true) { - return yield* new ToolFailure({ - message: - "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", - }) - } - - const replaced = - input.replaceAll === true - ? source.text.replaceAll(oldString, newString) - : source.text.replace(oldString, newString) - const counts = diffLines(source.text, replaced).reduce( - (result, item) => ({ - 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", + const replaced = + input.replaceAll === true + ? source.text.replaceAll(oldString, newString) + : source.text.replace(oldString, newString) + const counts = diffLines(source.text, replaced).reduce( + (result, item) => ({ + 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) }), } diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 753e36cab5..80f0ca2da4 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -43,68 +43,71 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.make({ - description: - "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.", - input: Input, - output: Output, - toModelOutput: ({ output }) => [ - { - type: "text", - text: toModelOutput( - output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })), - ), - }, - ], - execute: (input, context) => - Effect.gen(function* () { - yield* permission.assert({ - action: name, - resources: [input.pattern], - save: ["*"], - metadata: { - root: input.path ?? ".", - path: input.path, - limit: input.limit, - }, - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) - const cwd = path.resolve(location.directory, input.path ?? ".") - 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, + .transform((draft) => + draft.add( + name, + Tool.make({ + description: + "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.", + input: Input, + output: Output, + toModelOutput: ({ output }) => [ + { + type: "text", + text: toModelOutput( + output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })), + ), + }, + ], + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [input.pattern], + save: ["*"], + metadata: { + root: input.path ?? ".", + path: input.path, + limit: input.limit, + }, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) - .pipe( - Effect.map((result) => - result.map((entry) => - FileSystem.Entry.make({ - ...entry, - path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), - }), + const cwd = path.resolve(location.directory, input.path ?? ".") + yield* fs + .stat(cwd) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => + Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), ), - ), - ) - }).pipe( - Effect.mapError((error) => - error instanceof ToolFailure - ? error - : new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), + ) + return yield* ripgrep + .glob({ + cwd, + pattern: input.pattern, + limit: input.limit ?? Number.MAX_SAFE_INTEGER, + }) + .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) }), } diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 685925aa04..9efce1712a 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -57,85 +57,88 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.make({ - description: - "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.", - input: Input, - output: Output, - toModelOutput: ({ output }) => [ - { - type: "text", - text: toModelOutput( - output.map((match) => ({ - ...match, - entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) }, - })), - ), - }, - ], - execute: (input, context) => - Effect.gen(function* () { - yield* permission.assert({ - action: name, - resources: [input.pattern], - save: ["*"], - metadata: { - root: ".", - path: input.path, - include: input.include, - limit: input.limit, - }, - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) - const target = path.resolve(location.directory, input.path ?? ".") - 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, + .transform((draft) => + draft.add( + name, + Tool.make({ + description: + "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.", + input: Input, + output: Output, + toModelOutput: ({ output }) => [ + { + type: "text", + text: toModelOutput( + output.map((match) => ({ + ...match, + entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) }, + })), + ), + }, + ], + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [input.pattern], + save: ["*"], + metadata: { + root: ".", + path: input.path, + include: input.include, + limit: input.limit, + }, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) - .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, + const target = path.resolve(location.directory, input.path ?? ".") + 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( + 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( - Effect.mapError((error) => - error instanceof ToolFailure - ? error - : new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), + ) + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), + ), ), - ), - }), - }) + }), + ), + ) .pipe(Effect.orDie) }), } diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index dbabb01747..e19706f4ae 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -56,65 +56,68 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.make({ - description, - input: Input, - output: Output, - toModelOutput: ({ input, output }) => [ - { type: "text", text: toModelOutput(input.questions, output.answers) }, - ], - execute: (input, context) => - permission - .assert({ - action: "question", - resources: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) - .pipe( - Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), - Effect.andThen( - forms - .ask({ - sessionID: context.sessionID, - metadata: { - kind: "question", - tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, - }, - mode: "form", - fields: input.questions.map( - (question, index): Form.Field => ({ - key: `q${index}`, - title: question.header, - description: question.question, - type: question.multiple === true ? "multiselect" : "string", - options: question.options.map((option) => ({ - value: option.label, - label: option.label, - description: option.description, - })), - custom: true, - }), - ), + .transform((draft) => + draft.add( + name, + Tool.make({ + description, + input: Input, + output: Output, + toModelOutput: ({ input, output }) => [ + { type: "text", text: toModelOutput(input.questions, output.answers) }, + ], + execute: (input, context) => + permission + .assert({ + action: "question", + resources: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + .pipe( + Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), + Effect.andThen( + forms + .ask({ + sessionID: context.sessionID, + metadata: { + kind: "question", + tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, + }, + mode: "form", + fields: input.questions.map( + (question, index): Form.Field => ({ + key: `q${index}`, + title: question.header, + description: question.question, + type: question.multiple === true ? "multiselect" : "string", + options: question.options.map((option) => ({ + value: option.label, + label: option.label, + 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) }), } diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index de03a97974..9ee5b4c42f 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -42,100 +42,105 @@ export const Plugin = { const location = yield* Location.Service yield* ctx.tool - .register({ - [name]: Tool.make({ - description: - "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", - input: Input, - output: Output, - toModelOutput: ({ input, output }) => { - if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) - return [] - return [ - { type: "text", text: "Image read successfully" }, - { type: "file", data: output.content, mime: output.mime, name: input.path }, - ] - }, - execute: (input, context) => { - return Effect.gen(function* () { - const source = { - type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, - } - const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) - const external = target.externalDirectory - if (external) + .transform((draft) => + draft.add( + name, + Tool.make({ + description: + "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", + input: Input, + output: Output, + toModelOutput: ({ input, output }) => { + if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) + return [] + return [ + { type: "text", text: "Image read successfully" }, + { type: "file", data: output.content, mime: output.mime, name: input.path }, + ] + }, + execute: (input, context) => { + return Effect.gen(function* () { + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + 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({ - ...LocationMutation.externalDirectoryPermission(external), + action: name, + resources: [resource], + save: ["*"], 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({ - action: name, - resources: [resource], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source, - }) - const content = - type === "directory" - ? yield* reader.list(absolute, { offset: input.offset, limit: input.limit }) - : yield* reader.read(absolute, resource, { - offset: input.offset, - limit: input.limit, - }) - // After a successful read, discover nearby AGENTS.md walking up to the Location - // root exclusive and inject them as durable synthetic instructions. For a - // 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 - // skipped, and discovery failures never fail the read. - yield* Effect.gen(function* () { - if (target.externalDirectory !== undefined) return - const resolved = yield* fs.resolve(target.canonical) - const root = yield* fs.resolve(location.directory) - // up() searches its stop directory, so the Location-root AGENTS.md (already - // supplied by the core/instructions baseline) is dropped by the dirname filter. - const discovered = yield* fs.up({ - targets: [FILENAME], - start: type === "directory" ? resolved : dirname(resolved), - stop: root, - }) - const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter((file) => dirname(file) !== root) - if (candidates.length === 0) return - yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) + const content = + type === "directory" + ? yield* reader.list(absolute, { offset: input.offset, limit: input.limit }) + : yield* reader.read(absolute, resource, { + offset: input.offset, + limit: input.limit, + }) + // After a successful read, discover nearby AGENTS.md walking up to the Location + // root exclusive and inject them as durable synthetic instructions. For a + // 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 + // skipped, and discovery failures never fail the read. + yield* Effect.gen(function* () { + if (target.externalDirectory !== undefined) return + const resolved = yield* fs.resolve(target.canonical) + const root = yield* fs.resolve(location.directory) + // up() searches its stop directory, so the Location-root AGENTS.md (already + // supplied by the core/instructions baseline) is dropped by the dirname filter. + const discovered = yield* fs.up({ + targets: [FILENAME], + start: type === "directory" ? resolved : dirname(resolved), + stop: root, + }) + const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter( + (file) => dirname(file) !== root, + ) + if (candidates.length === 0) return + yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) + }).pipe( + Effect.catch(() => Effect.void), + Effect.catchDefect(() => Effect.void), + ) + 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.catch(() => Effect.void), - Effect.catchDefect(() => Effect.void), + 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 }) + }), ) - 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) }), } diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index cd0c428a61..c4a2820c16 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -58,8 +58,7 @@ const modelOutput = (output: Output): string | undefined => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" - if (output.status === "running") - return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}` + if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}` 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}.` } @@ -140,136 +139,142 @@ export const Plugin = { }) yield* ctx.tool - .register({ - [name]: Tool.make({ - description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, - input: Input, - output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ - truncated: output.truncated, - ...(output.exit === undefined ? {} : { exit: output.exit }), - ...(output.shellID === undefined ? {} : { shellID: output.shellID }), - ...(output.timeout === undefined ? {} : { timeout: output.timeout }), - }), - toModelOutput: ({ output }) => { - const parts: Content[] = [{ type: "text", text: output.output }] - const model = modelOutput(output) - if (model) parts.push({ type: "text", text: model }) - return parts - }, - execute: (input, context) => - Effect.gen(function* () { - const source = { - type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, - } - const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) - const external = target.externalDirectory - if (external) + .transform((draft) => + draft.add( + name, + Tool.make({ + description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, + input: Input, + output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + truncated: output.truncated, + ...(output.exit === undefined ? {} : { exit: output.exit }), + ...(output.shellID === undefined ? {} : { shellID: output.shellID }), + ...(output.timeout === undefined ? {} : { timeout: output.timeout }), + }), + toModelOutput: ({ output }) => { + const parts: Content[] = [{ type: "text", text: output.output }] + const model = modelOutput(output) + if (model) parts.push({ type: "text", text: model }) + return parts + }, + execute: (input, context) => + Effect.gen(function* () { + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + const warnings = (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({ - ...LocationMutation.externalDirectoryPermission(external), + action: name, + resources: [input.command], + save: [input.command], 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({ - action: name, - resources: [input.command], - save: [input.command], - sessionID: context.sessionID, - agent: context.agent, - source, - }) - if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") - return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) + if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") + return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) - const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS - const info = yield* shell.create({ - command: input.command, - cwd: target.canonical, - timeout, - metadata: { sessionID: context.sessionID }, - }) + const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS + const info = yield* shell.create({ + command: input.command, + cwd: target.canonical, + timeout, + metadata: { sessionID: context.sessionID }, + }) - const settleShell = Effect.fn("ShellTool.settleShell")(function* () { - const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + const settleShell = Effect.fn("ShellTool.settleShell")(function* () { + const final = yield* shell.wait(info.id) + 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 { 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, + output: `${body}${notice}`, + truncated, 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 body = page.output || "(no output)" - const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" - return { - exit: final.exit, - output: `${body}${notice}`, - truncated, - status: "completed" as const, + 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")) - 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, + ...(yield* settleShell()), ...(warnings.length ? { warnings } : {}), } - } - - 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.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })), + ), + }), + ), + ) .pipe(Effect.orDie) }), } diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 669e36c4c8..d3a2407f32 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -59,43 +59,46 @@ export const Plugin = { const skills = yield* SkillV2.Service const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.make({ - description, - input: Input, - output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], - execute: (input, context) => - Effect.gen(function* () { - const current = yield* skills.list() - const skill = current.find((skill) => skill.name === input.name) - if (!skill) return yield* unableToLoad(input.name) - return yield* Effect.gen(function* () { - yield* permission.assert({ - action: name, - resources: [skill.name], - save: [skill.name], - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) - const directory = path.dirname(skill.location) - const files = - path.basename(skill.location) === "SKILL.md" - ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) - .filter((file) => path.basename(file) !== "SKILL.md") - .toSorted() - .slice(0, FILE_LIMIT) - : [] - return { - name: skill.name, - directory, - output: toModelOutput(skill, files), - } - }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) - }), - }), - }) + .transform((draft) => + draft.add( + name, + Tool.make({ + description, + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: output.output }], + execute: (input, context) => + Effect.gen(function* () { + const current = yield* skills.list() + const skill = current.find((skill) => skill.name === input.name) + if (!skill) return yield* unableToLoad(input.name) + return yield* Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [skill.name], + save: [skill.name], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + const directory = path.dirname(skill.location) + const files = + path.basename(skill.location) === "SKILL.md" + ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) + .filter((file) => path.basename(file) !== "SKILL.md") + .toSorted() + .slice(0, FILE_LIMIT) + : [] + return { + name: skill.name, + directory, + output: toModelOutput(skill, files), + } + }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) + }), + }), + ), + ) .pipe(Effect.orDie) }), } diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 50db674ef2..c5932fce8b 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -93,80 +93,88 @@ export const Plugin = { }) yield* ctx.tool - .register({ - [name]: Tool.make({ - description, - input: Input, - output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], - execute: (input, context) => - Effect.gen(function* () { - const parent = yield* runtime.session - .get(context.sessionID) - .pipe( - Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })), - ) - 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` }) + .transform((draft) => + draft.add( + name, + Tool.make({ + description, + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: output.output }], + execute: (input, context) => + Effect.gen(function* () { + const parent = yield* runtime.session + .get(context.sessionID) + .pipe( + Effect.mapError( + () => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }), + ), + ) + 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. - const model = agent.model ?? parent.model - const child = yield* runtime.session - .create({ - parentID: context.sessionID, + // Model selection is policy/config/session state, not an LLM-facing tool argument. + const model = agent.model ?? parent.model + const child = yield* runtime.session + .create({ + 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, - 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. + metadata: {}, + run, }) - .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, + }), + ), ) - - 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, - 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 } - }), - }), - }) + 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) }), } diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index 279792e38a..f763be0429 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -27,28 +27,31 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.make({ - description: - "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.", - input: Input, - output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], - execute: (input, context) => - Effect.gen(function* () { - yield* permission.assert({ - action: name, - resources: ["*"], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) - yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) - return { todos: input.todos } - }).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), - }), - }) + .transform((draft) => + draft.add( + name, + Tool.make({ + description: + "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.", + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: ["*"], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) + yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) + return { todos: input.todos } + }).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), + }), + ), + ) .pipe(Effect.orDie) }), } diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index b729fb43f7..0e046daff9 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -119,60 +119,63 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.make({ - description, - input: Input, - output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], - execute: (input, context) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => assertHttpUrl(new URL(input.url)), - catch: (error) => error, - }) + .transform((draft) => + draft.add( + name, + Tool.make({ + description, + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: output.output }], + execute: (input, context) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => assertHttpUrl(new URL(input.url)), + catch: (error) => error, + }) - yield* permission.assert({ - action: name, - resources: [input.url], - save: ["*"], - metadata: input, - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) + yield* permission.assert({ + action: name, + resources: [input.url], + save: ["*"], + metadata: input, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) - const { body, contentType } = yield* Effect.gen(function* () { - const response = yield* execute(http, input.url, input.format).pipe( - Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")), + const { body, contentType } = yield* Effect.gen(function* () { + const response = yield* execute(http, input.url, input.format).pipe( + Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")), + ) + const contentType = response.headers["content-type"] || "" + const mime = mimeFrom(contentType) + if (isImageAttachment(mime)) + return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`)) + if (!isTextualMime(mime)) + return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`)) + return { body: yield* collectBody(response), contentType } + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS), + orElse: () => Effect.fail(new Error("Request timed out")), + }), ) - const contentType = response.headers["content-type"] || "" - const mime = mimeFrom(contentType) - if (isImageAttachment(mime)) - 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 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}` }))), - }), - }) + 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) }), } diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index d0c984b89a..bca404e185 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -195,58 +195,63 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.make({ - description, - input: Input, - output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], - execute: (input, context) => { - const provider = selectProvider(context.sessionID, config, config.provider) - return Effect.gen(function* () { - yield* permission.assert({ - action: name, - resources: [input.query], - save: ["*"], - metadata: { ...input, provider }, - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, - }) + .transform((draft) => + draft.add( + name, + Tool.make({ + description, + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + execute: (input, context) => { + const provider = selectProvider(context.sessionID, config, config.provider) + return Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [input.query], + save: ["*"], + metadata: { ...input, provider }, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, + }) - const text = - provider === "exa" - ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { - query: input.query, - type: input.type || "auto", - numResults: input.numResults || 8, - livecrawl: input.livecrawl || "fallback", - contextMaxCharacters: input.contextMaxCharacters, - }) - : yield* callMcp( - http, - PARALLEL_URL, - "web_search", - ParallelArgs, - { - objective: input.query, - search_queries: [input.query], - session_id: context.sessionID, - // V2 invocation context does not safely expose the model yet. - }, - { - "User-Agent": `opencode/${InstallationVersion}`, - ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), - }, - ) - return { - provider, - text: text ?? NO_RESULTS, - } - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` }))) - }, - }), - }) + const text = + provider === "exa" + ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { + query: input.query, + type: input.type || "auto", + numResults: input.numResults || 8, + livecrawl: input.livecrawl || "fallback", + contextMaxCharacters: input.contextMaxCharacters, + }) + : yield* callMcp( + http, + PARALLEL_URL, + "web_search", + ParallelArgs, + { + objective: input.query, + search_queries: [input.query], + session_id: context.sessionID, + // V2 invocation context does not safely expose the model yet. + }, + { + "User-Agent": `opencode/${InstallationVersion}`, + ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), + }, + ) + return { + provider, + text: text ?? NO_RESULTS, + } + }).pipe( + Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })), + ) + }, + }), + ), + ) .pipe(Effect.orDie) }), } diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index ca89665be4..8e8f1b563b 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -50,44 +50,47 @@ export const Plugin = { const permission = yield* PermissionV2.Service yield* ctx.tool - .register({ - [name]: Tool.withPermission( - Tool.make({ - description: - "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", - input: Input, - output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], - execute: (input, context) => - Effect.gen(function* () { - const source = { - type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, - } - const target = yield* mutation.resolve({ path: input.path, kind: "file" }) - const external = target.externalDirectory - if (external) + .transform((draft) => + draft.add( + name, + Tool.withPermission( + Tool.make({ + description: + "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", + input: Input, + output: Output, + toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], + execute: (input, context) => + Effect.gen(function* () { + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + const target = yield* mutation.resolve({ path: input.path, kind: "file" }) + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), + action: "edit", + resources: [target.resource], + save: ["*"], sessionID: context.sessionID, agent: context.agent, source, }) - yield* permission.assert({ - action: "edit", - resources: [target.resource], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source, - }) - return yield* files.writeTextPreservingBom({ target, content: input.content }) - }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))), - }), - "edit", + return yield* files.writeTextPreservingBom({ target, content: input.content }) + }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))), + }), + "edit", + ), ), - }) + ) .pipe(Effect.orDie) }), } diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 96e767084f..1f0e872063 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -61,6 +61,12 @@ const GlobalMessage = EventV2.ephemeral({ text: Schema.String, }, }) +const CountMessage = EventV2.ephemeral({ + type: "test.count", + schema: { + count: Schema.Number, + }, +}) const VersionedMessage = EventV2.durable({ type: "test.versioned", @@ -90,6 +96,26 @@ const it = testEffect( const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node]))) 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", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 74c1b710ab..5677e288ef 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -2,6 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import type { PermissionV2 } from "@opencode-ai/core/permission" import { SessionMessage } from "@opencode-ai/core/session/message" 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 type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { Effect, type Scope } from "effect" @@ -49,7 +50,24 @@ export const registerToolPlugin = (plugin: { const tools = yield* Tools.Service const context: Pick = { 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: { before: () => Effect.die("registerToolPlugin does not support tool hooks"), after: () => Effect.die("registerToolPlugin does not support tool hooks"), diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index e437feb066..5d87102b7d 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -165,14 +165,17 @@ describe("PluginV2", () => { id: "tool-plugin", effect: (ctx) => ctx.tool - .register({ - plugin_tool: Tool.make({ - description: "Plugin tool", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), - }), - }) + .transform((draft) => + draft.add( + "plugin_tool", + Tool.make({ + description: "Plugin tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + ), + ) .pipe(Effect.orDie), }) @@ -202,13 +205,13 @@ describe("PluginV2", () => { const plugin = define({ id: "grouped-tools", effect: (ctx) => - Effect.gen(function* () { - yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie) - yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie) - yield* ctx.tool - .register({ search: tool("Search") }, { group: "context 7", deferred: true }) - .pipe(Effect.orDie) - }), + ctx.tool + .transform((draft) => { + draft.add("plain", tool("Plain")) + draft.add("look/up", tool("Lookup"), { group: "context 7" }) + draft.add("search", tool("Search"), { group: "context 7", deferred: true }) + }) + .pipe(Effect.orDie), }) yield* plugins.activate([{ plugin }]) @@ -236,14 +239,17 @@ describe("PluginV2", () => { effect: (ctx) => Effect.gen(function* () { yield* ctx.tool - .register({ - echo: Tool.make({ - description: "Echo", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })), - }), - }) + .transform((draft) => + draft.add( + "echo", + Tool.make({ + description: "Echo", + 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) yield* ctx.tool.execute diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 8ef98c6530..1f37dab47b 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -71,7 +71,7 @@ export function host(overrides: Overrides = {}): PluginContext { reload: () => Effect.die("unused skill.reload"), }, tool: overrides.tool ?? { - register: () => Effect.die("unused tool.register"), + transform: () => Effect.die("unused tool.transform"), execute: { before: () => Effect.die("unused tool.execute.before"), after: () => Effect.die("unused tool.execute.after"), diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 2ebd0215b5..5468dcd82f 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -10,5 +10,5 @@ export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration export type { ReferenceDraft, ReferenceHooks } from "./reference.js" export type { SkillDraft, SkillHooks } from "./skill.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" diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 98efaa3ce2..bb5931af16 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -249,10 +249,11 @@ export interface RegisterOptions { readonly deferred?: boolean } +export interface ToolDraft { + add(name: string, tool: AnyTool, options?: RegisterOptions): void +} + export interface ToolDomain { - readonly register: ( - tools: Readonly>, - options?: RegisterOptions, - ) => Effect.Effect + readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> } diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index 545606b096..a349afb774 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -11,7 +11,7 @@ const opencode = yield * OpenCode.create() 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. diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 087f689366..bcb06f5319 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,6 +1,7 @@ import { OpenCode } from "@opencode-ai/client/effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" 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 { SdkPlugins } from "@opencode-ai/core/plugin/sdk" 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 sdkPlugins = SdkPlugins.makeStore() 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)], ]), memoMap, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index a6184da837..72edfdbc91 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -1,6 +1,8 @@ +import fs from "fs/promises" +import path from "path" import { expect } from "bun:test" 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 { tmpdir } from "../../core/test/fixture/tmpdir" import type { OpenCodeEvent } from "../src" @@ -26,6 +28,118 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create() const location = (fixture: Fixture) => 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() + const activated = yield* Deferred.make() + 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() + const secondReady = yield* Deferred.make() + const activated = yield* Deferred.make() + 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( "embedded client uses the real router and handlers", () => @@ -42,14 +156,17 @@ it.live( id: `embedded-tools-${crypto.randomUUID()}`, effect: (ctx) => ctx.tool - .register({ - embedded_tool: fixture.sdk.Tool.make({ - description: "Embedded test tool", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), - }), - }) + .transform((draft) => + draft.add( + "embedded_tool", + fixture.sdk.Tool.make({ + description: "Embedded test tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + }), + ), + ) .pipe(Effect.orDie), })