refactor(tool): Tool.Def.execute returns Effect, rename defineEffect → define (#21961)

This commit is contained in:
Kit Langton
2026-04-10 22:36:02 -04:00
committed by GitHub
parent f99812443c
commit c5fb6281f0
39 changed files with 674 additions and 721 deletions

View File

@@ -19,12 +19,13 @@ const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const ApplyPatchTool = Tool.defineEffect(
export const ApplyPatchTool = Tool.define(
"apply_patch",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const bus = yield* Bus.Service
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
if (!params.patchText) {
@@ -178,18 +179,16 @@ export const ApplyPatchTool = Tool.defineEffect(
// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
yield* Effect.promise(() =>
ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
}),
)
yield* ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
})
// Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
@@ -228,13 +227,13 @@ export const ApplyPatchTool = Tool.defineEffect(
if (edited) {
yield* format.file(edited)
Bus.publish(File.Event.Edited, { file: edited })
yield* bus.publish(File.Event.Edited, { file: edited })
}
}
// Publish file change events
for (const update of updates) {
Bus.publish(FileWatcher.Event.Updated, update)
yield* bus.publish(FileWatcher.Event.Updated, update)
}
// Notify LSP of file changes and collect diagnostics
@@ -281,9 +280,7 @@ export const ApplyPatchTool = Tool.defineEffect(
return {
description: DESCRIPTION,
parameters: PatchParams,
async execute(params: z.infer<typeof PatchParams>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
}
}),
)

View File

@@ -226,25 +226,21 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*")
})
yield* Effect.promise(() =>
ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
metadata: {},
}),
)
yield* ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
metadata: {},
})
}
if (scan.patterns.size === 0) return
yield* Effect.promise(() =>
ctx.ask({
permission: "bash",
patterns: Array.from(scan.patterns),
always: Array.from(scan.always),
metadata: {},
}),
)
yield* ctx.ask({
permission: "bash",
patterns: Array.from(scan.patterns),
always: Array.from(scan.always),
metadata: {},
})
})
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
@@ -294,7 +290,7 @@ const parser = lazy(async () => {
})
// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.defineEffect(
export const BashTool = Tool.define(
"bash",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
@@ -504,7 +500,7 @@ export const BashTool = Tool.defineEffect(
},
ctx,
)
}).pipe(Effect.orDie, Effect.runPromise),
}),
}
}
}),

View File

@@ -5,7 +5,7 @@ import { Tool } from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./codesearch.txt"
export const CodeSearchTool = Tool.defineEffect(
export const CodeSearchTool = Tool.define(
"codesearch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
@@ -29,17 +29,15 @@ export const CodeSearchTool = Tool.defineEffect(
}),
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
}),
)
yield* ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
const result = yield* McpExa.call(
http,
@@ -59,7 +57,7 @@ export const CodeSearchTool = Tool.defineEffect(
title: `Code search: ${params.query}`,
metadata: {},
}
}).pipe(Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -19,6 +19,7 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
@@ -40,11 +41,14 @@ const Parameters = z.object({
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
})
export const EditTool = Tool.defineEffect(
export const EditTool = Tool.define(
"edit",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const filetime = yield* FileTime.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const bus = yield* Bus.Service
return {
description: DESCRIPTION,
@@ -67,12 +71,53 @@ export const EditTool = Tool.defineEffect(
let diff = ""
let contentOld = ""
let contentNew = ""
yield* filetime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
yield* filetime.withLock(filePath, () =>
Effect.gen(function* () {
if (params.oldString === "") {
const existed = yield* afs.existsSafe(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
yield* afs.writeWithDirs(filePath, params.newString)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
yield* filetime.read(ctx.sessionID, filePath)
return
}
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) throw new Error(`File ${filePath} not found`)
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
yield* filetime.assert(ctx.sessionID, filePath)
contentOld = yield* afs.readFileString(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
@@ -81,65 +126,26 @@ export const EditTool = Tool.defineEffect(
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
yield* afs.writeWithDirs(filePath, contentNew)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
event: "change",
})
await FileTime.read(ctx.sessionID, filePath)
return
}
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
await FileTime.read(ctx.sessionID, filePath)
})
contentNew = yield* afs.readFileString(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* filetime.read(ctx.sessionID, filePath)
}).pipe(Effect.orDie),
)
const filediff: Snapshot.FileDiff = {
file: filePath,
@@ -176,7 +182,7 @@ export const EditTool = Tool.defineEffect(
title: `${path.relative(Instance.worktree, filePath)}`,
output,
}
}).pipe(Effect.orDie, Effect.runPromise),
}),
}
}),
)

View File

@@ -11,7 +11,11 @@ type Options = {
kind?: Kind
}
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
if (!target) return
if (options?.bypass) return
@@ -26,7 +30,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/")
await ctx.ask({
yield* ctx.ask({
permission: "external_directory",
patterns: [glob],
always: [glob],
@@ -35,12 +39,8 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
parentDir: dir,
},
})
}
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
})
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options))
}

View File

@@ -9,7 +9,7 @@ import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
export const GlobTool = Tool.defineEffect(
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
@@ -28,17 +28,15 @@ export const GlobTool = Tool.defineEffect(
}),
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
}),
)
yield* ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
})
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
@@ -90,7 +88,7 @@ export const GlobTool = Tool.defineEffect(
},
output: output.join("\n"),
}
}).pipe(Effect.orDie, Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -14,7 +14,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.defineEffect(
export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
@@ -32,18 +32,16 @@ export const GrepTool = Tool.defineEffect(
throw new Error("pattern is required")
}
yield* Effect.promise(() =>
ctx.ask({
permission: "grep",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
include: params.include,
},
}),
)
yield* ctx.ask({
permission: "grep",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
include: params.include,
},
})
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
@@ -171,7 +169,7 @@ export const GrepTool = Tool.defineEffect(
},
output: outputLines.join("\n"),
}
}).pipe(Effect.orDie, Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -1,17 +1,20 @@
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
export const InvalidTool = Tool.define("invalid", {
description: "Do not use",
parameters: z.object({
tool: z.string(),
error: z.string(),
export const InvalidTool = Tool.define(
"invalid",
Effect.succeed({
description: "Do not use",
parameters: z.object({
tool: z.string(),
error: z.string(),
}),
execute: (params: { tool: string; error: string }) =>
Effect.succeed({
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
metadata: {},
}),
}),
async execute(params) {
return {
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
metadata: {},
}
},
})
)

View File

@@ -37,7 +37,7 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100
export const ListTool = Tool.defineEffect(
export const ListTool = Tool.define(
"list",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
@@ -56,16 +56,14 @@ export const ListTool = Tool.defineEffect(
const searchPath = path.resolve(Instance.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
yield* Effect.promise(() =>
ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
}),
)
yield* ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
@@ -130,7 +128,7 @@ export const ListTool = Tool.defineEffect(
},
output,
}
}).pipe(Effect.orDie, Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -21,7 +21,7 @@ const operations = [
"outgoingCalls",
] as const
export const LspTool = Tool.defineEffect(
export const LspTool = Tool.define(
"lsp",
Effect.gen(function* () {
const lsp = yield* LSP.Service
@@ -42,7 +42,7 @@ export const LspTool = Tool.defineEffect(
Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file)
yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} }))
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 }
@@ -85,7 +85,7 @@ export const LspTool = Tool.defineEffect(
metadata: { result },
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
}
}).pipe(Effect.runPromise),
}),
}
}),
)

View File

@@ -6,7 +6,7 @@ import DESCRIPTION from "./multiedit.txt"
import path from "path"
import { Instance } from "../project/instance"
export const MultiEditTool = Tool.defineEffect(
export const MultiEditTool = Tool.define(
"multiedit",
Effect.gen(function* () {
const editInfo = yield* EditTool
@@ -37,16 +37,14 @@ export const MultiEditTool = Tool.defineEffect(
Effect.gen(function* () {
const results = []
for (const [, entry] of params.edits.entries()) {
const result = yield* Effect.promise(() =>
edit.execute(
{
filePath: params.filePath,
oldString: entry.oldString,
newString: entry.newString,
replaceAll: entry.replaceAll,
},
ctx,
),
const result = yield* edit.execute(
{
filePath: params.filePath,
oldString: entry.oldString,
newString: entry.newString,
replaceAll: entry.replaceAll,
},
ctx,
)
results.push(result)
}
@@ -57,7 +55,7 @@ export const MultiEditTool = Tool.defineEffect(
},
output: results.at(-1)!.output,
}
}).pipe(Effect.orDie, Effect.runPromise),
}),
}
}),
)

View File

@@ -17,7 +17,7 @@ function getLastModel(sessionID: SessionID) {
return undefined
}
export const PlanExitTool = Tool.defineEffect(
export const PlanExitTool = Tool.define(
"plan_exit",
Effect.gen(function* () {
const session = yield* Session.Service
@@ -74,7 +74,7 @@ export const PlanExitTool = Tool.defineEffect(
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
}
}).pipe(Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -12,7 +12,7 @@ type Metadata = {
answers: Question.Answer[]
}
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
"question",
Effect.gen(function* () {
const question = yield* Question.Service
@@ -39,7 +39,7 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
answers,
},
}
}).pipe(Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -25,7 +25,7 @@ const parameters = z.object({
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
})
export const ReadTool = Tool.defineEffect(
export const ReadTool = Tool.define(
"read",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
@@ -106,14 +106,12 @@ export const ReadTool = Tool.defineEffect(
kind: stat?.type === "Directory" ? "directory" : "file",
})
yield* Effect.promise(() =>
ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
}),
)
yield* ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
})
if (!stat) return yield* miss(filepath)
@@ -218,9 +216,7 @@ export const ReadTool = Tool.defineEffect(
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
}
}),
)

View File

@@ -44,6 +44,7 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
import { Bus } from "../bus"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
@@ -89,10 +90,12 @@ export namespace ToolRegistry {
| FileTime.Service
| Instruction.Service
| AppFileSystem.Service
| Bus.Service
| HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
| Format.Service
| Truncate.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -100,7 +103,9 @@ export namespace ToolRegistry {
const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const skill = yield* Skill.Service
const truncate = yield* Truncate.Service
const invalid = yield* InvalidTool
const task = yield* TaskTool
const read = yield* ReadTool
const question = yield* QuestionTool
@@ -127,23 +132,26 @@ export namespace ToolRegistry {
id,
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx: PluginToolContext = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
}
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
},
execute: (args, toolCtx) =>
Effect.gen(function* () {
const pluginCtx: PluginToolContext = {
...toolCtx,
ask: (req) => Effect.runPromise(toolCtx.ask(req)),
directory: ctx.directory,
worktree: ctx.worktree,
}
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent))
const out = yield* truncate.output(result, {}, agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
}),
}
}
@@ -174,7 +182,7 @@ export namespace ToolRegistry {
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
const tool = yield* Effect.all({
invalid: Tool.init(InvalidTool),
invalid: Tool.init(invalid),
bash: Tool.init(bash),
read: Tool.init(read),
glob: Tool.init(globtool),
@@ -328,10 +336,12 @@ export namespace ToolRegistry {
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Truncate.defaultLayer),
),
)

View File

@@ -11,7 +11,7 @@ const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.defineEffect(
export const SkillTool = Tool.define(
"skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
@@ -51,14 +51,12 @@ export const SkillTool = Tool.defineEffect(
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* Effect.promise(() =>
ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
}),
)
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
@@ -94,7 +92,7 @@ export const SkillTool = Tool.defineEffect(
dir,
},
}
}).pipe(Effect.orDie, Effect.runPromise),
}).pipe(Effect.orDie),
}
}
}),

View File

@@ -31,7 +31,7 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.defineEffect(
export const TaskTool = Tool.define(
id,
Effect.gen(function* () {
const agent = yield* Agent.Service
@@ -41,17 +41,15 @@ export const TaskTool = Tool.defineEffect(
const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) {
yield* Effect.promise(() =>
ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
}),
)
yield* ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
})
}
const next = yield* agent.get(params.subagent_type)
@@ -178,9 +176,7 @@ export const TaskTool = Tool.defineEffect(
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
}
}),
)

View File

@@ -12,7 +12,7 @@ type Metadata = {
todos: Todo.Info[]
}
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
"todowrite",
Effect.gen(function* () {
const todo = yield* Todo.Service
@@ -20,29 +20,28 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
return {
description: DESCRIPTION_WRITE,
parameters,
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
await todo
.update({
yield* todo.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
.pipe(Effect.runPromise)
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
},
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
}),
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}),
)

View File

@@ -23,22 +23,21 @@ export namespace Tool {
extra?: { [key: string]: any }
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
export interface ExecuteResult<M extends Metadata = Metadata> {
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(
args: z.infer<Parameters>,
ctx: Context,
): Promise<{
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}>
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
@@ -74,48 +73,41 @@ export namespace Tool {
return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
try {
toolInfo.parameters.parse(args)
} catch (error) {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
throw new Error(toolInfo.formatValidationError(error), { cause: error })
toolInfo.execute = (args, ctx) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => toolInfo.parameters.parse(args),
catch: (error) => {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
return new Error(toolInfo.formatValidationError(error), { cause: error })
}
return new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
},
})
const result = yield* execute(args, ctx)
if (result.metadata.truncated !== undefined) {
return result
}
throw new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
}
const result = await execute(args, ctx)
if (result.metadata.truncated !== undefined) {
return result
}
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
}
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
}
}).pipe(Effect.orDie)
return toolInfo
}
}
export function define<Parameters extends z.ZodType, Result extends Metadata, ID extends string = string>(
id: ID,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> & { id: ID } {
return {
id,
init: wrap(id, init),
}
}
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {

View File

@@ -18,7 +18,7 @@ const parameters = z.object({
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
})
export const WebFetchTool = Tool.defineEffect(
export const WebFetchTool = Tool.define(
"webfetch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
@@ -33,18 +33,16 @@ export const WebFetchTool = Tool.defineEffect(
throw new Error("URL must start with http:// or https://")
}
yield* Effect.promise(() =>
ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
}),
)
yield* ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
})
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
@@ -153,7 +151,7 @@ export const WebFetchTool = Tool.defineEffect(
default:
return { output: content, title, metadata: {} }
}
}).pipe(Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -24,7 +24,7 @@ const Parameters = z.object({
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
export const WebSearchTool = Tool.defineEffect(
export const WebSearchTool = Tool.define(
"websearch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
@@ -36,20 +36,18 @@ export const WebSearchTool = Tool.defineEffect(
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
}),
)
yield* ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
})
const result = yield* McpExa.call(
http,
@@ -70,7 +68,7 @@ export const WebSearchTool = Tool.defineEffect(
title: `Web search: ${params.query}`,
metadata: {},
}
}).pipe(Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -17,12 +17,14 @@ import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
export const WriteTool = Tool.defineEffect(
export const WriteTool = Tool.define(
"write",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
const filetime = yield* FileTime.Service
const bus = yield* Bus.Service
const format = yield* Format.Service
return {
description: DESCRIPTION,
@@ -42,27 +44,23 @@ export const WriteTool = Tool.defineEffect(
if (exists) yield* filetime.assert(ctx.sessionID, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
yield* Effect.promise(() =>
ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
diff,
},
}),
)
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
diff,
},
})
yield* fs.writeWithDirs(filepath, params.content)
yield* Effect.promise(() => Format.file(filepath))
Bus.publish(File.Event.Edited, { file: filepath })
yield* Effect.promise(() =>
Bus.publish(FileWatcher.Event.Updated, {
file: filepath,
event: exists ? "change" : "add",
}),
)
yield* format.file(filepath)
yield* bus.publish(File.Event.Edited, { file: filepath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filepath,
event: exists ? "change" : "add",
})
yield* filetime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully."
@@ -92,7 +90,7 @@ export const WriteTool = Tool.defineEffect(
},
output,
}
}).pipe(Effect.orDie, Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)