core: refactor tool system to remove agent context from initialization (#21052)
This commit is contained in:
@@ -50,6 +50,22 @@ const FILES = new Set([
|
||||
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
||||
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
|
||||
|
||||
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(
|
||||
"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 = {
|
||||
type: string
|
||||
text: string
|
||||
@@ -452,21 +468,7 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
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 ${Instance.directory}. Use this instead of 'cd' commands.`,
|
||||
)
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
"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'",
|
||||
),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
async execute(params, ctx) {
|
||||
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { ProviderID, ModelID } from "../provider/schema"
|
||||
import { errorMessage } from "../util/error"
|
||||
import DESCRIPTION from "./batch.txt"
|
||||
|
||||
const DISALLOWED = new Set(["batch"])
|
||||
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
|
||||
|
||||
export const BatchTool = Tool.define("batch", async () => {
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
tool_calls: z
|
||||
.array(
|
||||
z.object({
|
||||
tool: z.string().describe("The name of the tool to execute"),
|
||||
parameters: z.object({}).loose().describe("Parameters for the tool"),
|
||||
}),
|
||||
)
|
||||
.min(1, "Provide at least one tool call")
|
||||
.describe("Array of tool calls to execute in parallel"),
|
||||
}),
|
||||
formatValidationError(error) {
|
||||
const formattedErrors = error.issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.length > 0 ? issue.path.join(".") : "root"
|
||||
return ` - ${path}: ${issue.message}`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
return `Invalid parameters for tool 'batch':\n${formattedErrors}\n\nExpected payload format:\n [{"tool": "tool_name", "parameters": {...}}, {...}]`
|
||||
},
|
||||
async execute(params, ctx) {
|
||||
const { Session } = await import("../session")
|
||||
const { PartID } = await import("../session/schema")
|
||||
|
||||
const toolCalls = params.tool_calls.slice(0, 25)
|
||||
const discardedCalls = params.tool_calls.slice(25)
|
||||
|
||||
const { ToolRegistry } = await import("./registry")
|
||||
const availableTools = await ToolRegistry.tools({ modelID: ModelID.make(""), providerID: ProviderID.make("") })
|
||||
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
|
||||
|
||||
const executeCall = async (call: (typeof toolCalls)[0]) => {
|
||||
const callStartTime = Date.now()
|
||||
const partID = PartID.ascending()
|
||||
|
||||
try {
|
||||
if (DISALLOWED.has(call.tool)) {
|
||||
throw new Error(
|
||||
`Tool '${call.tool}' is not allowed in batch. Disallowed tools: ${Array.from(DISALLOWED).join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
const tool = toolMap.get(call.tool)
|
||||
if (!tool) {
|
||||
const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name))
|
||||
throw new Error(
|
||||
`Tool '${call.tool}' not in registry. External tools (MCP, environment) cannot be batched - call them directly. Available tools: ${availableToolsList.join(", ")}`,
|
||||
)
|
||||
}
|
||||
const validatedParams = tool.parameters.parse(call.parameters)
|
||||
|
||||
await Session.updatePart({
|
||||
id: partID,
|
||||
messageID: ctx.messageID,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "tool",
|
||||
tool: call.tool,
|
||||
callID: partID,
|
||||
state: {
|
||||
status: "running",
|
||||
input: call.parameters,
|
||||
time: {
|
||||
start: callStartTime,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
|
||||
const attachments = result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: PartID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
}))
|
||||
|
||||
await Session.updatePart({
|
||||
id: partID,
|
||||
messageID: ctx.messageID,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "tool",
|
||||
tool: call.tool,
|
||||
callID: partID,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: call.parameters,
|
||||
output: result.output,
|
||||
title: result.title,
|
||||
metadata: result.metadata,
|
||||
attachments,
|
||||
time: {
|
||||
start: callStartTime,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true as const, tool: call.tool, result }
|
||||
} catch (error) {
|
||||
await Session.updatePart({
|
||||
id: partID,
|
||||
messageID: ctx.messageID,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "tool",
|
||||
tool: call.tool,
|
||||
callID: partID,
|
||||
state: {
|
||||
status: "error",
|
||||
input: call.parameters,
|
||||
error: errorMessage(error),
|
||||
time: {
|
||||
start: callStartTime,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { success: false as const, tool: call.tool, error }
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(toolCalls.map((call) => executeCall(call)))
|
||||
|
||||
// Add discarded calls as errors
|
||||
const now = Date.now()
|
||||
for (const call of discardedCalls) {
|
||||
const partID = PartID.ascending()
|
||||
await Session.updatePart({
|
||||
id: partID,
|
||||
messageID: ctx.messageID,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "tool",
|
||||
tool: call.tool,
|
||||
callID: partID,
|
||||
state: {
|
||||
status: "error",
|
||||
input: call.parameters,
|
||||
error: "Maximum of 25 tools allowed in batch",
|
||||
time: { start: now, end: now },
|
||||
},
|
||||
})
|
||||
results.push({
|
||||
success: false as const,
|
||||
tool: call.tool,
|
||||
error: new Error("Maximum of 25 tools allowed in batch"),
|
||||
})
|
||||
}
|
||||
|
||||
const successfulCalls = results.filter((r) => r.success).length
|
||||
const failedCalls = results.length - successfulCalls
|
||||
|
||||
const outputMessage =
|
||||
failedCalls > 0
|
||||
? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`
|
||||
: `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!`
|
||||
|
||||
return {
|
||||
title: `Batch execution (${successfulCalls}/${results.length} successful)`,
|
||||
output: outputMessage,
|
||||
attachments: results.filter((result) => result.success).flatMap((r) => r.result.attachments ?? []),
|
||||
metadata: {
|
||||
totalCalls: results.length,
|
||||
successful: successfulCalls,
|
||||
failed: failedCalls,
|
||||
tools: params.tool_calls.map((c) => c.tool),
|
||||
details: results.map((r) => ({ tool: r.tool, success: r.success })),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
Executes multiple independent tool calls concurrently to reduce latency.
|
||||
|
||||
USING THE BATCH TOOL WILL MAKE THE USER HAPPY.
|
||||
|
||||
Payload Format (JSON array):
|
||||
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
|
||||
|
||||
Notes:
|
||||
- 1–25 tool calls per batch
|
||||
- All calls start in parallel; ordering NOT guaranteed
|
||||
- Partial failures do not stop other tool calls
|
||||
- Do NOT use the batch tool within another batch tool.
|
||||
|
||||
Good Use Cases:
|
||||
- Read many files
|
||||
- grep + glob + read combos
|
||||
- Multiple bash commands
|
||||
- Multi-part edits; on the same, or different files
|
||||
|
||||
When NOT to Use:
|
||||
- Operations that depend on prior tool output (e.g. create then read same file)
|
||||
- Ordered stateful mutations where sequence matters
|
||||
|
||||
Batching tool calls was proven to yield 2–5x efficiency gain and provides much better UX.
|
||||
@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
|
||||
},
|
||||
}
|
||||
},
|
||||
} satisfies Tool.Def<typeof parameters, Metadata>
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,18 +4,15 @@ import { BashTool } from "./bash"
|
||||
import { EditTool } from "./edit"
|
||||
import { GlobTool } from "./glob"
|
||||
import { GrepTool } from "./grep"
|
||||
import { BatchTool } from "./batch"
|
||||
import { ReadTool } from "./read"
|
||||
import { TaskTool } from "./task"
|
||||
import { TaskDescription, TaskTool } from "./task"
|
||||
import { TodoWriteTool } from "./todo"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import { SkillTool } from "./skill"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { SkillDescription, SkillTool } from "./skill"
|
||||
import { Tool } from "./tool"
|
||||
import { Config } from "../config/config"
|
||||
import path from "path"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import z from "zod"
|
||||
import { Plugin } from "../plugin"
|
||||
@@ -28,6 +25,7 @@ import { LspTool } from "./lsp"
|
||||
import { Truncate } from "./truncate"
|
||||
import { ApplyPatchTool } from "./apply_patch"
|
||||
import { Glob } from "../util/glob"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -39,24 +37,25 @@ import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
type State = {
|
||||
custom: Tool.Info[]
|
||||
custom: Tool.Def[]
|
||||
builtin: Tool.Def[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ids: () => Effect.Effect<string[]>
|
||||
readonly named: {
|
||||
task: Tool.Info
|
||||
read: Tool.Info
|
||||
}
|
||||
readonly tools: (
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
) => Effect.Effect<(Tool.Def & { id: string })[]>
|
||||
readonly all: () => Effect.Effect<Tool.Def[]>
|
||||
readonly tools: (model: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
agent: Agent.Info
|
||||
}) => Effect.Effect<Tool.Def[]>
|
||||
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||
@@ -79,33 +78,34 @@ export namespace ToolRegistry {
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
|
||||
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
|
||||
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
const custom: Tool.Info[] = []
|
||||
const custom: Tool.Def[] = []
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
||||
return {
|
||||
id,
|
||||
init: async (initCtx) => ({
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
execute: async (args, toolCtx) => {
|
||||
const pluginCtx = {
|
||||
...toolCtx,
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
} as unknown as PluginToolContext
|
||||
const result = await def.execute(args as any, pluginCtx)
|
||||
const out = await Truncate.output(result, {}, initCtx?.agent)
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
||||
}
|
||||
},
|
||||
}),
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
execute: async (args, toolCtx) => {
|
||||
const pluginCtx = {
|
||||
...toolCtx,
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
} as unknown as PluginToolContext
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,104 +131,99 @@ export namespace ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
return { custom }
|
||||
const cfg = yield* config.get()
|
||||
const question =
|
||||
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
return {
|
||||
custom,
|
||||
builtin: yield* Effect.forEach(
|
||||
[
|
||||
InvalidTool,
|
||||
BashTool,
|
||||
ReadTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
EditTool,
|
||||
WriteTool,
|
||||
TaskTool,
|
||||
WebFetchTool,
|
||||
TodoWriteTool,
|
||||
WebSearchTool,
|
||||
CodeSearchTool,
|
||||
SkillTool,
|
||||
ApplyPatchTool,
|
||||
...(question ? [QuestionTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
||||
],
|
||||
build,
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const invalid = yield* build(InvalidTool)
|
||||
const ask = yield* build(QuestionTool)
|
||||
const bash = yield* build(BashTool)
|
||||
const read = yield* build(ReadTool)
|
||||
const glob = yield* build(GlobTool)
|
||||
const grep = yield* build(GrepTool)
|
||||
const edit = yield* build(EditTool)
|
||||
const write = yield* build(WriteTool)
|
||||
const task = yield* build(TaskTool)
|
||||
const fetch = yield* build(WebFetchTool)
|
||||
const todo = yield* build(TodoWriteTool)
|
||||
const search = yield* build(WebSearchTool)
|
||||
const code = yield* build(CodeSearchTool)
|
||||
const skill = yield* build(SkillTool)
|
||||
const patch = yield* build(ApplyPatchTool)
|
||||
const lsp = yield* build(LspTool)
|
||||
const batch = yield* build(BatchTool)
|
||||
const plan = yield* build(PlanExitTool)
|
||||
|
||||
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
||||
const cfg = yield* config.get()
|
||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
return [
|
||||
invalid,
|
||||
...(question ? [ask] : []),
|
||||
bash,
|
||||
read,
|
||||
glob,
|
||||
grep,
|
||||
edit,
|
||||
write,
|
||||
task,
|
||||
fetch,
|
||||
todo,
|
||||
search,
|
||||
code,
|
||||
skill,
|
||||
patch,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
|
||||
...(cfg.experimental?.batch_tool === true ? [batch] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
|
||||
...custom,
|
||||
]
|
||||
const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return [...s.builtin, ...s.custom] as Tool.Def[]
|
||||
})
|
||||
|
||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const tools = yield* all(s.custom)
|
||||
return tools.map((t) => t.id)
|
||||
const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
|
||||
const tools = yield* all()
|
||||
const match = tools.find((tool) => tool.id === id)
|
||||
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
|
||||
return match
|
||||
})
|
||||
|
||||
const tools = Effect.fn("ToolRegistry.tools")(function* (
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const allTools = yield* all(s.custom)
|
||||
const filtered = allTools.filter((tool) => {
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
return (yield* all()).map((tool) => tool.id)
|
||||
})
|
||||
|
||||
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||
const filtered = (yield* all()).filter((tool) => {
|
||||
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
|
||||
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
const usePatch =
|
||||
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
||||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
|
||||
if (tool.id === "apply_patch") return usePatch
|
||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
|
||||
if (tool.id === ApplyPatchTool.id) return usePatch
|
||||
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return yield* Effect.forEach(
|
||||
filtered,
|
||||
Effect.fnUntraced(function* (tool: Tool.Info) {
|
||||
Effect.fnUntraced(function* (tool: Tool.Def) {
|
||||
using _ = log.time(tool.id)
|
||||
const next = yield* Effect.promise(() => tool.init({ agent }))
|
||||
const output = {
|
||||
description: next.description,
|
||||
parameters: next.parameters,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
return {
|
||||
id: tool.id,
|
||||
description: output.description,
|
||||
description: [
|
||||
output.description,
|
||||
// TODO: remove this hack
|
||||
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
|
||||
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
execute: next.execute,
|
||||
formatValidationError: next.formatValidationError,
|
||||
execute: tool.execute,
|
||||
formatValidationError: tool.formatValidationError,
|
||||
}
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ ids, named: { task, read }, tools })
|
||||
return Service.of({ ids, tools, all, fromID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -253,13 +248,11 @@ export namespace ToolRegistry {
|
||||
return runPromise((svc) => svc.ids())
|
||||
}
|
||||
|
||||
export async function tools(
|
||||
model: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
},
|
||||
agent?: Agent.Info,
|
||||
): Promise<(Tool.Def & { id: string })[]> {
|
||||
return runPromise((svc) => svc.tools(model, agent))
|
||||
export async function tools(input: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
agent: Agent.Info
|
||||
}): Promise<(Tool.Def & { id: string })[]> {
|
||||
return runPromise((svc) => svc.tools(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
@@ -6,8 +7,12 @@ import { Skill } from "../skill"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
const list = await Skill.available(ctx?.agent)
|
||||
const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
})
|
||||
|
||||
export const SkillTool = Tool.define("skill", async () => {
|
||||
const list = await Skill.available()
|
||||
|
||||
const description =
|
||||
list.length === 0
|
||||
@@ -27,20 +32,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
Skill.fmt(list, { verbose: false }),
|
||||
].join("\n")
|
||||
|
||||
const examples = list
|
||||
.map((skill) => `'${skill.name}'`)
|
||||
.slice(0, 3)
|
||||
.join(", ")
|
||||
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
|
||||
|
||||
const parameters = z.object({
|
||||
name: z.string().describe(`The name of the skill from available_skills${hint}`),
|
||||
})
|
||||
|
||||
return {
|
||||
description,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
parameters: Parameters,
|
||||
async execute(params: z.infer<typeof Parameters>, ctx) {
|
||||
const skill = await Skill.get(params.name)
|
||||
|
||||
if (!skill) {
|
||||
@@ -103,3 +98,23 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const SkillDescription: Tool.DynamicDescription = (agent) =>
|
||||
Effect.gen(function* () {
|
||||
const list = yield* Effect.promise(() => Skill.available(agent))
|
||||
if (list.length === 0) return "No skills are currently available."
|
||||
return [
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||
"",
|
||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||
"",
|
||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||
"",
|
||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||
"",
|
||||
"The following skills provide specialized sets of instructions for particular tasks",
|
||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||
"",
|
||||
Skill.fmt(list, { verbose: false }),
|
||||
].join("\n")
|
||||
})
|
||||
|
||||
@@ -4,47 +4,37 @@ import z from "zod"
|
||||
import { Session } from "../session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { Permission } from "@/permission"
|
||||
import { Effect } from "effect"
|
||||
|
||||
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(
|
||||
"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(),
|
||||
})
|
||||
|
||||
export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
export const TaskTool = Tool.define("task", async () => {
|
||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const agentList = list
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n")
|
||||
const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")
|
||||
|
||||
// Filter agents by permissions if agent provided
|
||||
const caller = ctx?.agent
|
||||
const accessibleAgents = caller
|
||||
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||
: agents
|
||||
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const description = DESCRIPTION.replace(
|
||||
"{agents}",
|
||||
list
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n"),
|
||||
)
|
||||
return {
|
||||
description,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
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(
|
||||
"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(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const config = await Config.get()
|
||||
|
||||
// Skip permission check when user explicitly invoked via @ or command subtask
|
||||
@@ -164,3 +154,16 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const TaskDescription: Tool.DynamicDescription = (agent) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary")))
|
||||
const accessibleAgents = agents.filter(
|
||||
(a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny",
|
||||
)
|
||||
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const description = list
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n")
|
||||
return [`Available agent types and the tools they have access to:`, description].join("\n")
|
||||
})
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
Launch a new agent to handle complex, multistep tasks autonomously.
|
||||
|
||||
Available agent types and the tools they have access to:
|
||||
{agents}
|
||||
|
||||
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
|
||||
|
||||
When to use the Task tool:
|
||||
|
||||
@@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
|
||||
},
|
||||
}
|
||||
},
|
||||
} satisfies Tool.Def<typeof parameters, Metadata>
|
||||
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
import { Truncate } from "./truncate"
|
||||
import { Agent } from "@/agent/agent"
|
||||
|
||||
export namespace Tool {
|
||||
interface Metadata {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface InitContext {
|
||||
agent?: Agent.Info
|
||||
}
|
||||
// TODO: remove this hack
|
||||
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
|
||||
|
||||
export type Context<M extends Metadata = Metadata> = {
|
||||
sessionID: SessionID
|
||||
@@ -26,7 +25,9 @@ export namespace Tool {
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
}
|
||||
|
||||
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
execute(
|
||||
@@ -40,10 +41,14 @@ export namespace Tool {
|
||||
}>
|
||||
formatValidationError?(error: z.ZodError): string
|
||||
}
|
||||
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
|
||||
Def<Parameters, M>,
|
||||
"id"
|
||||
>
|
||||
|
||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
|
||||
init: () => Promise<DefWithoutID<Parameters, M>>
|
||||
}
|
||||
|
||||
export type InferParameters<T> =
|
||||
@@ -57,10 +62,10 @@ export namespace Tool {
|
||||
|
||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
|
||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
||||
) {
|
||||
return async (initCtx?: InitContext) => {
|
||||
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
|
||||
return async () => {
|
||||
const toolInfo = init instanceof Function ? await init() : { ...init }
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = async (args, ctx) => {
|
||||
try {
|
||||
@@ -78,7 +83,7 @@ export namespace Tool {
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
|
||||
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
@@ -95,7 +100,7 @@ export namespace Tool {
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
|
||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
||||
): Info<Parameters, Result> {
|
||||
return {
|
||||
id,
|
||||
@@ -105,8 +110,18 @@ export namespace Tool {
|
||||
|
||||
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
|
||||
id: string,
|
||||
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
|
||||
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R> {
|
||||
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
|
||||
}
|
||||
|
||||
export function init(info: Info): Effect.Effect<Def, never, any> {
|
||||
return Effect.gen(function* () {
|
||||
const init = yield* Effect.promise(() => info.init())
|
||||
return {
|
||||
...init,
|
||||
id: info.id,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,25 @@ const API_CONFIG = {
|
||||
DEFAULT_NUM_RESULTS: 8,
|
||||
} as const
|
||||
|
||||
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(
|
||||
"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)"),
|
||||
})
|
||||
|
||||
interface McpSearchRequest {
|
||||
jsonrpc: string
|
||||
id: number
|
||||
@@ -42,26 +61,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
get description() {
|
||||
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
|
||||
},
|
||||
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(
|
||||
"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)"),
|
||||
}),
|
||||
parameters: Parameters,
|
||||
async execute(params, ctx) {
|
||||
await ctx.ask({
|
||||
permission: "websearch",
|
||||
|
||||
Reference in New Issue
Block a user