mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-20 21:00:29 +08:00
refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema (#23293)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolRegistry } from "@/tool"
|
||||
import { Worktree } from "@/worktree"
|
||||
@@ -213,7 +214,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
parameters: z.toJSONSchema(t.parameters),
|
||||
parameters: EffectZod.toJsonSchema(t.parameters),
|
||||
})),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Log } from "../util"
|
||||
@@ -403,7 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
@@ -15,8 +14,8 @@ import DESCRIPTION from "./apply_patch.txt"
|
||||
import { File } from "../file"
|
||||
import { Format } from "../format"
|
||||
|
||||
export const Parameters = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
export const Parameters = Schema.Struct({
|
||||
patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }),
|
||||
})
|
||||
|
||||
export const ApplyPatchTool = Tool.define(
|
||||
@@ -27,7 +26,7 @@ export const ApplyPatchTool = Tool.define(
|
||||
const format = yield* Format.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof Parameters>, ctx: Tool.Context) {
|
||||
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
|
||||
if (!params.patchText) {
|
||||
return yield* Effect.fail(new Error("patchText is required"))
|
||||
}
|
||||
@@ -288,7 +287,7 @@ export const ApplyPatchTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import os from "os"
|
||||
import { createWriteStream } from "node:fs"
|
||||
import * as Tool from "./tool"
|
||||
@@ -50,20 +50,16 @@ const FILES = new Set([
|
||||
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
||||
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
|
||||
|
||||
export const Parameters = z.object({
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
|
||||
workdir: z
|
||||
.string()
|
||||
.describe(
|
||||
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
||||
)
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
export const Parameters = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "The command to execute" }),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }),
|
||||
workdir: Schema.optional(Schema.String).annotate({
|
||||
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
||||
}),
|
||||
description: Schema.String.annotate({
|
||||
description:
|
||||
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
type Part = {
|
||||
@@ -587,7 +583,7 @@ export const BashTool = Tool.define(
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = params.workdir
|
||||
? yield* resolvePath(params.workdir, Instance.directory, shell)
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import * as McpExa from "./mcp-exa"
|
||||
import DESCRIPTION from "./codesearch.txt"
|
||||
|
||||
export const Parameters = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({
|
||||
description:
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
),
|
||||
tokensNum: z
|
||||
.number()
|
||||
.min(1000)
|
||||
.max(50000)
|
||||
.default(5000)
|
||||
.describe(
|
||||
}),
|
||||
tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000))
|
||||
.check(Schema.isLessThanOrEqualTo(50000))
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
|
||||
.annotate({
|
||||
description:
|
||||
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export const CodeSearchTool = Tool.define(
|
||||
@@ -47,7 +44,7 @@ export const CodeSearchTool = Tool.define(
|
||||
McpExa.CodeArgs,
|
||||
{
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum || 5000,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
"30 seconds",
|
||||
)
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
|
||||
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
@@ -32,11 +31,15 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
|
||||
return text.replaceAll("\n", "\r\n")
|
||||
}
|
||||
|
||||
export const Parameters = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
export const Parameters = Schema.Struct({
|
||||
filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }),
|
||||
oldString: Schema.String.annotate({ description: "The text to replace" }),
|
||||
newString: Schema.String.annotate({
|
||||
description: "The text to replace it with (must be different from oldString)",
|
||||
}),
|
||||
replaceAll: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Replace all occurrences of oldString (default false)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const EditTool = Tool.define(
|
||||
@@ -50,7 +53,7 @@ export const EditTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
@@ -9,14 +8,11 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
export const Parameters = z.object({
|
||||
pattern: z.string().describe("The glob pattern to match files against"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
),
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description: `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
||||
}),
|
||||
})
|
||||
|
||||
export const GlobTool = Tool.define(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
@@ -10,10 +10,14 @@ import * as Tool from "./tool"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const Parameters = z.object({
|
||||
pattern: z.string().describe("The regex pattern to search for in file contents"),
|
||||
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description: "The directory to search in. Defaults to the current working directory.",
|
||||
}),
|
||||
include: Schema.optional(Schema.String).annotate({
|
||||
description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
|
||||
}),
|
||||
})
|
||||
|
||||
export const GrepTool = Tool.define(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
export const Parameters = z.object({
|
||||
tool: z.string(),
|
||||
error: z.string(),
|
||||
export const Parameters = Schema.Struct({
|
||||
tool: Schema.String,
|
||||
error: Schema.String,
|
||||
})
|
||||
|
||||
export const InvalidTool = Tool.define(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import path from "path"
|
||||
import { LSP } from "../lsp"
|
||||
@@ -21,11 +20,15 @@ const operations = [
|
||||
"outgoingCalls",
|
||||
] as const
|
||||
|
||||
export const Parameters = z.object({
|
||||
operation: z.enum(operations).describe("The LSP operation to perform"),
|
||||
filePath: z.string().describe("The absolute or relative path to the file"),
|
||||
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
|
||||
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
|
||||
export const Parameters = Schema.Struct({
|
||||
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
|
||||
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
|
||||
line: Schema.Number.check(Schema.isInt())
|
||||
.check(Schema.isGreaterThanOrEqualTo(1))
|
||||
.annotate({ description: "The line number (1-based, as shown in editors)" }),
|
||||
character: Schema.Number.check(Schema.isInt())
|
||||
.check(Schema.isGreaterThanOrEqualTo(1))
|
||||
.annotate({ description: "The character offset (1-based, as shown in editors)" }),
|
||||
})
|
||||
|
||||
export const LspTool = Tool.define(
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { EditTool } from "./edit"
|
||||
import DESCRIPTION from "./multiedit.txt"
|
||||
import path from "path"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
export const Parameters = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
export const Parameters = Schema.Struct({
|
||||
filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }),
|
||||
edits: Schema.mutable(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }),
|
||||
oldString: Schema.String.annotate({ description: "The text to replace" }),
|
||||
newString: Schema.String.annotate({
|
||||
description: "The text to replace it with (must be different from oldString)",
|
||||
}),
|
||||
)
|
||||
.describe("Array of edit operations to perform sequentially on the file"),
|
||||
replaceAll: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Replace all occurrences of oldString (default false)",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).annotate({ description: "Array of edit operations to perform sequentially on the file" }),
|
||||
})
|
||||
|
||||
export const MultiEditTool = Tool.define(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { Question } from "../question"
|
||||
import { Session } from "../session"
|
||||
@@ -17,7 +16,7 @@ function getLastModel(sessionID: SessionID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const Parameters = z.object({})
|
||||
export const Parameters = Schema.Struct({})
|
||||
|
||||
export const PlanExitTool = Tool.define(
|
||||
"plan_exit",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { Question } from "../question"
|
||||
import DESCRIPTION from "./question.txt"
|
||||
|
||||
export const Parameters = z.object({
|
||||
questions: z.array(Question.Prompt.zod).describe("Questions to ask"),
|
||||
export const Parameters = Schema.Struct({
|
||||
questions: Schema.mutable(Schema.Array(Question.Prompt)).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
@@ -20,7 +19,7 @@ export const QuestionTool = Tool.define<typeof Parameters, Metadata, Question.Se
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const answers = yield* question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect, Scope } from "effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { createReadStream } from "fs"
|
||||
import { open } from "fs/promises"
|
||||
import * as path from "path"
|
||||
@@ -18,10 +17,19 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
const MAX_BYTES = 50 * 1024
|
||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||
|
||||
export const Parameters = z.object({
|
||||
filePath: z.string().describe("The absolute path to the file or directory to read"),
|
||||
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
|
||||
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
|
||||
// `offset` and `limit` were originally `z.coerce.number()` — the runtime
|
||||
// coercion was useful when the tool was called from a shell but serves no
|
||||
// purpose in the LLM tool-call path (the model emits typed JSON). The JSON
|
||||
// Schema output is identical (`type: "number"`), so the LLM view is
|
||||
// unchanged; purely CLI-facing uses must now send numbers rather than strings.
|
||||
export const Parameters = Schema.Struct({
|
||||
filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }),
|
||||
offset: Schema.optional(Schema.Number).annotate({
|
||||
description: "The line number to start reading from (1-indexed)",
|
||||
}),
|
||||
limit: Schema.optional(Schema.Number).annotate({
|
||||
description: "The maximum number of lines to read (defaults to 2000)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const ReadTool = Tool.define(
|
||||
@@ -77,7 +85,7 @@ export const ReadTool = Tool.define(
|
||||
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
})
|
||||
|
||||
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof Parameters>, ctx: Tool.Context) {
|
||||
const run = Effect.fn("ReadTool.execute")(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
|
||||
if (params.offset !== undefined && params.offset < 1) {
|
||||
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
|
||||
}
|
||||
@@ -213,7 +221,7 @@ export const ReadTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,9 @@ import { SkillTool } from "./skill"
|
||||
import * as Tool from "./tool"
|
||||
import { Config } from "../config"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@/util/effect-zod"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Provider } from "../provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
@@ -120,9 +122,17 @@ export const layer: Layer.Layer<
|
||||
const custom: Tool.Def[] = []
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
||||
// Plugin tools define their args as a raw Zod shape. Wrap the
|
||||
// derived Zod object in a `Schema.declare` so it slots into the
|
||||
// Schema-typed framework, and annotate with `ZodOverride` so the
|
||||
// walker emits the original Zod object for LLM JSON Schema.
|
||||
const zodParams = z.object(def.args)
|
||||
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
|
||||
[ZodOverride]: zodParams,
|
||||
})
|
||||
return {
|
||||
id,
|
||||
parameters: z.object(def.args),
|
||||
parameters,
|
||||
description: def.description,
|
||||
execute: (args, toolCtx) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { EffectLogger } from "@/effect"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Skill } from "../skill"
|
||||
import * as Tool from "./tool"
|
||||
|
||||
export const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
export const Parameters = Schema.Struct({
|
||||
name: Schema.String.annotate({ description: "The name of the skill from available_skills" }),
|
||||
})
|
||||
|
||||
export const SkillTool = Tool.define(
|
||||
@@ -43,7 +42,7 @@ export const SkillTool = Tool.define(
|
||||
return {
|
||||
description,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
if (!info) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import z from "zod"
|
||||
import { Session } from "../session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Agent } from "../agent/agent"
|
||||
import type { SessionPrompt } from "../session/prompt"
|
||||
import { Config } from "../config"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export interface TaskPromptOps {
|
||||
cancel(sessionID: SessionID): void
|
||||
@@ -17,17 +16,15 @@ export interface TaskPromptOps {
|
||||
|
||||
const id = "task"
|
||||
|
||||
export const Parameters = z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: z.string().describe("The task for the agent to perform"),
|
||||
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
|
||||
task_id: z
|
||||
.string()
|
||||
.describe(
|
||||
export const Parameters = Schema.Struct({
|
||||
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
|
||||
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
task_id: Schema.optional(Schema.String).annotate({
|
||||
description:
|
||||
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
|
||||
)
|
||||
.optional(),
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
}),
|
||||
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
|
||||
})
|
||||
|
||||
export const TaskTool = Tool.define(
|
||||
@@ -37,7 +34,7 @@ export const TaskTool = Tool.define(
|
||||
const config = yield* Config.Service
|
||||
const sessions = yield* Session.Service
|
||||
|
||||
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof Parameters>, ctx: Tool.Context) {
|
||||
const run = Effect.fn("TaskTool.execute")(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
@@ -169,7 +166,7 @@ export const TaskTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION_WRITE from "./todowrite.txt"
|
||||
import { Todo } from "../session/todo"
|
||||
|
||||
export const Parameters = z.object({
|
||||
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
|
||||
// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape
|
||||
// here rather than referencing its `.shape` — the LLM-visible JSON Schema is
|
||||
// identical, and it removes the last zod dependency from this tool.
|
||||
const TodoItem = Schema.Struct({
|
||||
content: Schema.String.annotate({ description: "Brief description of the task" }),
|
||||
status: Schema.String.annotate({ description: "Current status of the task: pending, in_progress, completed, cancelled" }),
|
||||
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
|
||||
})
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
@@ -20,7 +28,7 @@ export const TodoWriteTool = Tool.define<typeof Parameters, Metadata, Todo.Servi
|
||||
return {
|
||||
description: DESCRIPTION_WRITE,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "todowrite",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
@@ -32,29 +31,33 @@ export interface ExecuteResult<M extends Metadata = Metadata> {
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
}
|
||||
|
||||
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
export interface Def<Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
|
||||
formatValidationError?(error: z.ZodError): string
|
||||
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
|
||||
formatValidationError?(error: unknown): string
|
||||
}
|
||||
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
|
||||
export type DefWithoutID<Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>, M extends Metadata = Metadata> = Omit<
|
||||
Def<Parameters, M>,
|
||||
"id"
|
||||
>
|
||||
|
||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
export interface Info<Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
|
||||
}
|
||||
|
||||
type Init<Parameters extends z.ZodType, M extends Metadata> =
|
||||
type Init<Parameters extends Schema.Decoder<unknown>, M extends Metadata> =
|
||||
| DefWithoutID<Parameters, M>
|
||||
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
|
||||
|
||||
export type InferParameters<T> =
|
||||
T extends Info<infer P, any> ? z.infer<P> : T extends Effect.Effect<Info<infer P, any>, any, any> ? z.infer<P> : never
|
||||
T extends Info<infer P, any>
|
||||
? Schema.Schema.Type<P>
|
||||
: T extends Effect.Effect<Info<infer P, any>, any, any>
|
||||
? Schema.Schema.Type<P>
|
||||
: never
|
||||
export type InferMetadata<T> =
|
||||
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
|
||||
|
||||
@@ -65,7 +68,7 @@ export type InferDef<T> =
|
||||
? Def<P, M>
|
||||
: never
|
||||
|
||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(
|
||||
id: string,
|
||||
init: Init<Parameters, Result>,
|
||||
truncate: Truncate.Interface,
|
||||
@@ -74,6 +77,10 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
|
||||
// Compile the parser closure once per tool init; `decodeUnknownEffect`
|
||||
// allocates a new closure per call, so hoisting avoids re-closing it for
|
||||
// every LLM tool invocation.
|
||||
const decode = Schema.decodeUnknownEffect(toolInfo.parameters)
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = (args, ctx) => {
|
||||
const attrs = {
|
||||
@@ -83,19 +90,17 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
|
||||
}
|
||||
return 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(
|
||||
const decoded = yield* decode(args).pipe(
|
||||
Effect.mapError((error) =>
|
||||
toolInfo.formatValidationError
|
||||
? new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
: 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)
|
||||
const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
@@ -116,7 +121,7 @@ function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
})
|
||||
}
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||
export function define<Parameters extends Schema.Decoder<unknown>, Result extends Metadata, R, ID extends string = string>(
|
||||
id: ID,
|
||||
init: Effect.Effect<Init<Parameters, Result>, never, R>,
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
|
||||
@@ -131,7 +136,7 @@ export function define<Parameters extends z.ZodType, Result extends Metadata, R,
|
||||
)
|
||||
}
|
||||
|
||||
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
||||
export function init<P extends Schema.Decoder<unknown>, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
||||
return Effect.gen(function* () {
|
||||
const init = yield* info.init()
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
@@ -9,13 +8,14 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||
|
||||
export const Parameters = z.object({
|
||||
url: z.string().describe("The URL to fetch content from"),
|
||||
format: z
|
||||
.enum(["text", "markdown", "html"])
|
||||
.default("markdown")
|
||||
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
|
||||
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
|
||||
export const Parameters = Schema.Struct({
|
||||
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
|
||||
format: Schema.Literals(["text", "markdown", "html"])
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const)))
|
||||
.annotate({
|
||||
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }),
|
||||
})
|
||||
|
||||
export const WebFetchTool = Tool.define(
|
||||
@@ -27,7 +27,7 @@ export const WebFetchTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import * as McpExa from "./mcp-exa"
|
||||
import DESCRIPTION from "./websearch.txt"
|
||||
|
||||
export const Parameters = z.object({
|
||||
query: z.string().describe("Websearch query"),
|
||||
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
|
||||
livecrawl: z
|
||||
.enum(["fallback", "preferred"])
|
||||
.optional()
|
||||
.describe(
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(Schema.Number).annotate({
|
||||
description: "Number of search results to return (default: 8)",
|
||||
}),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
),
|
||||
type: z
|
||||
.enum(["auto", "fast", "deep"])
|
||||
.optional()
|
||||
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
|
||||
contextMaxCharacters: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
|
||||
}),
|
||||
type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({
|
||||
description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
}),
|
||||
contextMaxCharacters: Schema.optional(Schema.Number).annotate({
|
||||
description: "Maximum characters for context string optimized for LLMs (default: 10000)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const WebSearchTool = Tool.define(
|
||||
@@ -34,7 +31,7 @@ export const WebSearchTool = Tool.define(
|
||||
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
|
||||
},
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "websearch",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import * as path from "path"
|
||||
import { Effect } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
@@ -16,9 +16,11 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
|
||||
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
||||
|
||||
export const Parameters = z.object({
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
export const Parameters = Schema.Struct({
|
||||
content: Schema.String.annotate({ description: "The content to write to the file" }),
|
||||
filePath: Schema.String.annotate({
|
||||
description: "The absolute path to the file to write (must be absolute, not relative)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const WriteTool = Tool.define(
|
||||
|
||||
@@ -59,6 +59,16 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
|
||||
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a JSON Schema for a tool/route parameter schema — derives the zod form
|
||||
* via the walker so Effect Schema inputs flow through the same zod-openapi
|
||||
* pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what
|
||||
* `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper.
|
||||
*/
|
||||
export function toJsonSchema<S extends Schema.Top>(schema: S) {
|
||||
return z.toJSONSchema(zod(schema), { io: "input" })
|
||||
}
|
||||
|
||||
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const cached = walkCache.get(ast)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import z from "zod"
|
||||
import { Result, Schema } from "effect"
|
||||
import { toJsonSchema } from "../../src/util/effect-zod"
|
||||
|
||||
// Each tool exports its parameters schema at module scope so this test can
|
||||
// import them without running the tool's Effect-based init. The JSON Schema
|
||||
// snapshot captures what the LLM sees; the parse assertions pin down the
|
||||
// accepts/rejects contract. Both must survive any future migration (e.g. from
|
||||
// zod to Effect Schema via the effect-zod walker) byte-for-byte.
|
||||
// accepts/rejects contract. `toJsonSchema` is the same helper `session/
|
||||
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
|
||||
// byte-identical regardless of whether a tool has migrated from zod to Schema.
|
||||
|
||||
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
|
||||
import { Parameters as Bash } from "../../src/tool/bash"
|
||||
@@ -26,10 +28,11 @@ import { Parameters as WebFetch } from "../../src/tool/webfetch"
|
||||
import { Parameters as WebSearch } from "../../src/tool/websearch"
|
||||
import { Parameters as Write } from "../../src/tool/write"
|
||||
|
||||
// Helper: the JSON Schema the LLM sees at tool registration time
|
||||
// (session/prompt.ts runs `z.toJSONSchema(tool.parameters)` with the AI SDK's
|
||||
// default `io` mode). Snapshots pin the exact wire shape.
|
||||
const toJsonSchema = (schema: z.ZodType) => z.toJSONSchema(schema, { io: "input" })
|
||||
const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"] =>
|
||||
Schema.decodeUnknownSync(schema)(input)
|
||||
|
||||
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
|
||||
Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
|
||||
|
||||
describe("tool parameters", () => {
|
||||
describe("JSON Schema (wire shape)", () => {
|
||||
@@ -55,53 +58,53 @@ describe("tool parameters", () => {
|
||||
|
||||
describe("apply_patch", () => {
|
||||
test("accepts patchText", () => {
|
||||
expect(ApplyPatch.parse({ patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
|
||||
expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
|
||||
patchText: "*** Begin Patch\n*** End Patch",
|
||||
})
|
||||
})
|
||||
test("rejects missing patchText", () => {
|
||||
expect(ApplyPatch.safeParse({}).success).toBe(false)
|
||||
expect(accepts(ApplyPatch, {})).toBe(false)
|
||||
})
|
||||
test("rejects non-string patchText", () => {
|
||||
expect(ApplyPatch.safeParse({ patchText: 123 }).success).toBe(false)
|
||||
expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("bash", () => {
|
||||
test("accepts minimum: command + description", () => {
|
||||
expect(Bash.parse({ command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
|
||||
expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
|
||||
})
|
||||
test("accepts optional timeout + workdir", () => {
|
||||
const parsed = Bash.parse({ command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
|
||||
const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
|
||||
expect(parsed.timeout).toBe(5000)
|
||||
expect(parsed.workdir).toBe("/tmp")
|
||||
})
|
||||
test("rejects missing description (required by zod)", () => {
|
||||
expect(Bash.safeParse({ command: "ls" }).success).toBe(false)
|
||||
expect(accepts(Bash, { command: "ls" })).toBe(false)
|
||||
})
|
||||
test("rejects missing command", () => {
|
||||
expect(Bash.safeParse({ description: "list" }).success).toBe(false)
|
||||
expect(accepts(Bash, { description: "list" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("codesearch", () => {
|
||||
test("accepts query; tokensNum defaults to 5000", () => {
|
||||
expect(CodeSearch.parse({ query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 })
|
||||
expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 })
|
||||
})
|
||||
test("accepts override tokensNum", () => {
|
||||
expect(CodeSearch.parse({ query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000)
|
||||
expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000)
|
||||
})
|
||||
test("rejects tokensNum under 1000", () => {
|
||||
expect(CodeSearch.safeParse({ query: "x", tokensNum: 500 }).success).toBe(false)
|
||||
expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false)
|
||||
})
|
||||
test("rejects tokensNum over 50000", () => {
|
||||
expect(CodeSearch.safeParse({ query: "x", tokensNum: 60000 }).success).toBe(false)
|
||||
expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edit", () => {
|
||||
test("accepts all four fields", () => {
|
||||
expect(Edit.parse({ filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({
|
||||
expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({
|
||||
filePath: "/a",
|
||||
oldString: "x",
|
||||
newString: "y",
|
||||
@@ -109,72 +112,72 @@ describe("tool parameters", () => {
|
||||
})
|
||||
})
|
||||
test("replaceAll is optional", () => {
|
||||
const parsed = Edit.parse({ filePath: "/a", oldString: "x", newString: "y" })
|
||||
const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" })
|
||||
expect(parsed.replaceAll).toBeUndefined()
|
||||
})
|
||||
test("rejects missing filePath", () => {
|
||||
expect(Edit.safeParse({ oldString: "x", newString: "y" }).success).toBe(false)
|
||||
expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("glob", () => {
|
||||
test("accepts pattern-only", () => {
|
||||
expect(Glob.parse({ pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
|
||||
expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
|
||||
})
|
||||
test("accepts optional path", () => {
|
||||
expect(Glob.parse({ pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp")
|
||||
expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp")
|
||||
})
|
||||
test("rejects missing pattern", () => {
|
||||
expect(Glob.safeParse({}).success).toBe(false)
|
||||
expect(accepts(Glob, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("grep", () => {
|
||||
test("accepts pattern-only", () => {
|
||||
expect(Grep.parse({ pattern: "TODO" })).toEqual({ pattern: "TODO" })
|
||||
expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" })
|
||||
})
|
||||
test("accepts optional path + include", () => {
|
||||
const parsed = Grep.parse({ pattern: "TODO", path: "/tmp", include: "*.ts" })
|
||||
const parsed = parse(Grep, { pattern: "TODO", path: "/tmp", include: "*.ts" })
|
||||
expect(parsed.path).toBe("/tmp")
|
||||
expect(parsed.include).toBe("*.ts")
|
||||
})
|
||||
test("rejects missing pattern", () => {
|
||||
expect(Grep.safeParse({}).success).toBe(false)
|
||||
expect(accepts(Grep, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid", () => {
|
||||
test("accepts tool + error", () => {
|
||||
expect(Invalid.parse({ tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" })
|
||||
expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" })
|
||||
})
|
||||
test("rejects missing fields", () => {
|
||||
expect(Invalid.safeParse({ tool: "foo" }).success).toBe(false)
|
||||
expect(Invalid.safeParse({ error: "bar" }).success).toBe(false)
|
||||
expect(accepts(Invalid, { tool: "foo" })).toBe(false)
|
||||
expect(accepts(Invalid, { error: "bar" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lsp", () => {
|
||||
test("accepts all fields", () => {
|
||||
const parsed = Lsp.parse({ operation: "hover", filePath: "/a.ts", line: 1, character: 1 })
|
||||
const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 })
|
||||
expect(parsed.operation).toBe("hover")
|
||||
})
|
||||
test("rejects line < 1", () => {
|
||||
expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 0, character: 1 }).success).toBe(false)
|
||||
expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false)
|
||||
})
|
||||
test("rejects character < 1", () => {
|
||||
expect(Lsp.safeParse({ operation: "hover", filePath: "/a.ts", line: 1, character: 0 }).success).toBe(false)
|
||||
expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false)
|
||||
})
|
||||
test("rejects unknown operation", () => {
|
||||
expect(Lsp.safeParse({ operation: "bogus", filePath: "/a.ts", line: 1, character: 1 }).success).toBe(false)
|
||||
expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiedit", () => {
|
||||
test("accepts empty edits array", () => {
|
||||
expect(MultiEdit.parse({ filePath: "/a", edits: [] }).edits).toEqual([])
|
||||
expect(parse(MultiEdit, { filePath: "/a", edits: [] }).edits).toEqual([])
|
||||
})
|
||||
test("accepts an edit entry", () => {
|
||||
const parsed = MultiEdit.parse({
|
||||
const parsed = parse(MultiEdit, {
|
||||
filePath: "/a",
|
||||
edits: [{ filePath: "/a", oldString: "x", newString: "y" }],
|
||||
})
|
||||
@@ -184,13 +187,13 @@ describe("tool parameters", () => {
|
||||
|
||||
describe("plan", () => {
|
||||
test("accepts empty object", () => {
|
||||
expect(Plan.parse({})).toEqual({})
|
||||
expect(parse(Plan, {})).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("question", () => {
|
||||
test("accepts questions array", () => {
|
||||
const parsed = Question.parse({
|
||||
const parsed = parse(Question, {
|
||||
questions: [
|
||||
{
|
||||
question: "pick one",
|
||||
@@ -203,16 +206,16 @@ describe("tool parameters", () => {
|
||||
expect(parsed.questions.length).toBe(1)
|
||||
})
|
||||
test("rejects missing questions", () => {
|
||||
expect(Question.safeParse({}).success).toBe(false)
|
||||
expect(accepts(Question, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("read", () => {
|
||||
test("accepts filePath-only", () => {
|
||||
expect(Read.parse({ filePath: "/a" }).filePath).toBe("/a")
|
||||
expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a")
|
||||
})
|
||||
test("accepts optional offset + limit", () => {
|
||||
const parsed = Read.parse({ filePath: "/a", offset: 10, limit: 100 })
|
||||
const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 })
|
||||
expect(parsed.offset).toBe(10)
|
||||
expect(parsed.limit).toBe(100)
|
||||
})
|
||||
@@ -220,53 +223,53 @@ describe("tool parameters", () => {
|
||||
|
||||
describe("skill", () => {
|
||||
test("accepts name", () => {
|
||||
expect(Skill.parse({ name: "foo" }).name).toBe("foo")
|
||||
expect(parse(Skill, { name: "foo" }).name).toBe("foo")
|
||||
})
|
||||
test("rejects missing name", () => {
|
||||
expect(Skill.safeParse({}).success).toBe(false)
|
||||
expect(accepts(Skill, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("task", () => {
|
||||
test("accepts description + prompt + subagent_type", () => {
|
||||
const parsed = Task.parse({ description: "d", prompt: "p", subagent_type: "general" })
|
||||
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" })
|
||||
expect(parsed.subagent_type).toBe("general")
|
||||
})
|
||||
test("rejects missing prompt", () => {
|
||||
expect(Task.safeParse({ description: "d", subagent_type: "general" }).success).toBe(false)
|
||||
expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("todo", () => {
|
||||
test("accepts todos array", () => {
|
||||
const parsed = Todo.parse({
|
||||
const parsed = parse(Todo, {
|
||||
todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
|
||||
})
|
||||
expect(parsed.todos.length).toBe(1)
|
||||
})
|
||||
test("rejects missing todos", () => {
|
||||
expect(Todo.safeParse({}).success).toBe(false)
|
||||
expect(accepts(Todo, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("webfetch", () => {
|
||||
test("accepts url-only", () => {
|
||||
expect(WebFetch.parse({ url: "https://example.com" }).url).toBe("https://example.com")
|
||||
expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com")
|
||||
})
|
||||
})
|
||||
|
||||
describe("websearch", () => {
|
||||
test("accepts query", () => {
|
||||
expect(WebSearch.parse({ query: "opencode" }).query).toBe("opencode")
|
||||
expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode")
|
||||
})
|
||||
})
|
||||
|
||||
describe("write", () => {
|
||||
test("accepts content + filePath", () => {
|
||||
expect(Write.parse({ content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" })
|
||||
expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" })
|
||||
})
|
||||
test("rejects missing filePath", () => {
|
||||
expect(Write.safeParse({ content: "hi" }).success).toBe(false)
|
||||
expect(accepts(Write, { content: "hi" })).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ManagedRuntime, Schema } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Tool } from "../../src/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
|
||||
|
||||
const params = z.object({ input: z.string() })
|
||||
const params = Schema.Struct({ input: Schema.String })
|
||||
|
||||
function makeTool(id: string, executeFn?: () => void) {
|
||||
return {
|
||||
@@ -56,4 +56,44 @@ describe("Tool.define", () => {
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
test("execute receives decoded parameters", async () => {
|
||||
const parameters = Schema.Struct({
|
||||
count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))),
|
||||
})
|
||||
const calls: Array<Schema.Schema.Type<typeof parameters>> = []
|
||||
const info = await runtime.runPromise(
|
||||
Tool.define(
|
||||
"test-decoded",
|
||||
Effect.succeed({
|
||||
description: "test tool",
|
||||
parameters,
|
||||
execute(args: Schema.Schema.Type<typeof parameters>) {
|
||||
calls.push(args)
|
||||
return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: SessionID.descending(),
|
||||
messageID: MessageID.ascending(),
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata() {
|
||||
return Effect.void
|
||||
},
|
||||
ask() {
|
||||
return Effect.void
|
||||
},
|
||||
}
|
||||
const tool = await Effect.runPromise(info.init())
|
||||
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
|
||||
|
||||
await Effect.runPromise(execute({}, ctx))
|
||||
await Effect.runPromise(execute({ count: "7" }, ctx))
|
||||
|
||||
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user