feat(core): add embedded v2 session runtime and tool foundation (#30632)
This commit is contained in:
parent
c35267776a
commit
76ee87ead8
215 changed files with 31398 additions and 3332 deletions
131
packages/core/src/tool/apply-patch.ts
Normal file
131
packages/core/src/tool/apply-patch.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
export * as ApplyPatchTool from "./apply-patch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { Patch } from "../patch"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "apply_patch"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
patchText: Schema.String.annotate({ description: "The full patch text describing add, update, and delete operations" }),
|
||||
})
|
||||
|
||||
export const Applied = Schema.Struct({
|
||||
type: Schema.Literals(["add", "update", "delete"]),
|
||||
resource: Schema.String,
|
||||
target: Schema.String,
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Success) =>
|
||||
["Applied patch sequentially:", ...output.applied.map((item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`)].join("\n")
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
type Planned = { readonly hunk: Patch.Hunk; readonly plan: LocationMutation.Plan }
|
||||
type Prepared =
|
||||
| { readonly type: "add"; readonly hunk: Extract<Patch.Hunk, { readonly type: "add" }>; readonly plan: LocationMutation.Plan }
|
||||
| { readonly type: "delete"; readonly hunk: Extract<Patch.Hunk, { readonly type: "delete" }>; readonly plan: LocationMutation.Plan }
|
||||
| { readonly type: "update"; readonly hunk: Extract<Patch.Hunk, { readonly type: "update" }>; readonly plan: LocationMutation.Plan; readonly source: Uint8Array; readonly content: string }
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string, cause: unknown) => {
|
||||
const prefix = applied.length === 0
|
||||
? `Unable to apply patch at ${path}`
|
||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||
return new ToolFailure({ message: prefix, error: cause })
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
if (!parameters.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.try({
|
||||
try: () => Patch.parse(parameters.patchText),
|
||||
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
|
||||
})
|
||||
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
|
||||
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
|
||||
|
||||
const planned: Planned[] = []
|
||||
for (const hunk of hunks) planned.push({ hunk, plan: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
|
||||
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
|
||||
for (const { plan } of planned) {
|
||||
const external = plan.target.externalDirectory
|
||||
if (external) externalDirectories.set(external.resource, external)
|
||||
}
|
||||
for (const external of externalDirectories.values()) {
|
||||
yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
}
|
||||
yield* assertPermission({ action: "edit", resources: [...new Set(planned.map(({ plan }) => plan.target.resource))], save: ["*"] })
|
||||
|
||||
const prepared: Prepared[] = []
|
||||
for (const { hunk, plan } of planned) {
|
||||
if (hunk.type === "add") {
|
||||
const target = yield* mutation.revalidate(plan)
|
||||
if (target.exists) return yield* fail(hunk.path, new Error("Target file already exists"))
|
||||
prepared.push({ type: hunk.type, hunk, plan })
|
||||
continue
|
||||
}
|
||||
const target = yield* mutation.revalidate(plan)
|
||||
if (!target.exists || target.type !== "File") return yield* fail(hunk.path, new Error("Target file does not exist"))
|
||||
if (hunk.type === "delete") {
|
||||
prepared.push({ type: hunk.type, hunk, plan })
|
||||
continue
|
||||
}
|
||||
const source = yield* fs.readFile(target.canonical)
|
||||
const update = Patch.derive(hunk.path, hunk.chunks, new TextDecoder("utf-8", { ignoreBOM: true }).decode(source))
|
||||
prepared.push({ type: hunk.type, hunk, plan, source, content: Patch.joinBom(update.content, update.bom) })
|
||||
}
|
||||
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.forEach(prepared, (change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
const result = yield* files.create({ plan: change.plan, content: change.hunk.contents.endsWith("\n") || change.hunk.contents === "" ? change.hunk.contents : `${change.hunk.contents}\n` })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
const result = yield* files.remove({ plan: change.plan })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
const result = yield* files.writeIfUnchanged({ plan: change.plan, expected: change.source, content: change.content })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.hunk.path, Cause.squash(cause))))),
|
||||
{ discard: true }),
|
||||
)
|
||||
return { applied }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(error instanceof ToolFailure ? error : fail("patch", error))
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
206
packages/core/src/tool/bash.ts
Normal file
206
packages/core/src/tool/bash.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
export * as BashTool from "./bash"
|
||||
|
||||
import path from "path"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "../config"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { AppProcess } from "../process"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "bash"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
workdir: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
|
||||
}),
|
||||
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
|
||||
}),
|
||||
description: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Concise description of the command's purpose",
|
||||
}),
|
||||
})
|
||||
|
||||
const Success = Schema.Struct({
|
||||
command: Schema.String,
|
||||
cwd: Schema.String,
|
||||
exitCode: Schema.Number.pipe(Schema.optional),
|
||||
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
timedOut: Schema.Boolean.pipe(Schema.optional),
|
||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type Success = typeof Success.Type
|
||||
|
||||
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
|
||||
|
||||
const compactOutput = (stdout: string, stderr: string) => {
|
||||
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
|
||||
return output || "(no output)"
|
||||
}
|
||||
|
||||
const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
|
||||
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
|
||||
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
|
||||
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
|
||||
}
|
||||
|
||||
const modelOutput = (output: Success) => {
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.`
|
||||
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.`
|
||||
}
|
||||
|
||||
const isTimeout = (error: AppProcess.AppProcessError) =>
|
||||
error.cause instanceof Error && error.cause.message === "Timed out"
|
||||
|
||||
const definition = Tool.make({
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
|
||||
* legacy shell runtime into core.
|
||||
*/
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
|
||||
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
|
||||
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
|
||||
// TODO: Persist background job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Re-add model-facing background launch only with owner-bound get/wait/cancel tools and completion delivery.
|
||||
// TODO: Add HTTP background-job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
|
||||
|
||||
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
|
||||
const externalCommandDirectories = (command: string, cwd: string) => {
|
||||
const directories = new Set<string>()
|
||||
for (const token of shellTokens(command)) {
|
||||
const value = unquote(token).replace(/[;,|&]+$/, "")
|
||||
if (!path.isAbsolute(value)) continue
|
||||
const resolved = FSUtil.resolve(value)
|
||||
if (FSUtil.contains(cwd, resolved)) continue
|
||||
directories.add(FSUtil.resolve(path.dirname(resolved)))
|
||||
}
|
||||
return [...directories]
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
|
||||
const external = plan.target.externalDirectory
|
||||
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
const warnings = externalCommandDirectories(parameters.command, plan.target.canonical).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
)
|
||||
yield* assertPermission({ action: name, resources: [parameters.command], save: [parameters.command] })
|
||||
|
||||
const target = yield* mutation.revalidate(plan)
|
||||
if (!target.exists || target.type !== "Directory")
|
||||
throw new Error(`Working directory is not a directory: ${target.canonical}`)
|
||||
|
||||
const entries = yield* config.entries()
|
||||
const shell =
|
||||
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))).shell ??
|
||||
defaultShell()
|
||||
const command = ChildProcess.make(parameters.command, [], {
|
||||
cwd: target.canonical,
|
||||
shell,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
})
|
||||
const timeout = parameters.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
const result = yield* appProcess
|
||||
.run(command, {
|
||||
timeout: Duration.millis(timeout),
|
||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||
maxErrorBytes: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("AppProcessError", (error) =>
|
||||
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
|
||||
),
|
||||
)
|
||||
if (!result) {
|
||||
return {
|
||||
command: parameters.command,
|
||||
cwd: target.canonical,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timedOut: true,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
|
||||
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
|
||||
const truncated = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content: notice ? `${compact}\n\n${notice}` : compact,
|
||||
})
|
||||
return {
|
||||
command: parameters.command,
|
||||
cwd: target.canonical,
|
||||
exitCode: result.exitCode,
|
||||
output: truncated.content,
|
||||
truncated: truncated.truncated || result.stdoutTruncated || result.stderrTruncated,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
||||
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
|
||||
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
|
||||
? { resource: truncated.resource }
|
||||
: {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to execute command: ${parameters.command}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
43
packages/core/src/tool/builtins.ts
Normal file
43
packages/core/src/tool/builtins.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export * as BuiltInTools from "./builtins"
|
||||
|
||||
import { Layer } from "effect"
|
||||
import { BashTool } from "./bash"
|
||||
import { ApplyPatchTool } from "./apply-patch"
|
||||
import { EditTool } from "./edit"
|
||||
import { GlobTool } from "./glob"
|
||||
import { GrepTool } from "./grep"
|
||||
import { QuestionTool } from "./question"
|
||||
import { ReadTool } from "./read"
|
||||
import { SkillTool } from "./skill"
|
||||
import { TodoWriteTool } from "./todowrite"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { WriteTool } from "./write"
|
||||
|
||||
/**
|
||||
* Composes only the shipped Location-scoped built-in tool contributions.
|
||||
* Each tool retains its implementation and focused tests independently. Dynamic
|
||||
* MCP and plugin tools later use separate scoped ToolRegistry transforms, while
|
||||
* provider/model filtering belongs to a future materialization phase rather
|
||||
* than this static list. The caller intentionally supplies shared Location
|
||||
* services once to this merged set.
|
||||
*
|
||||
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
|
||||
* parity, task, LSP,
|
||||
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
|
||||
* contributions separate from this static built-in list.
|
||||
*/
|
||||
export const locationLayer = Layer.mergeAll(
|
||||
ApplyPatchTool.layer,
|
||||
BashTool.layer,
|
||||
EditTool.layer,
|
||||
GlobTool.layer,
|
||||
GrepTool.layer,
|
||||
QuestionTool.layer,
|
||||
ReadTool.layer,
|
||||
SkillTool.layer,
|
||||
TodoWriteTool.layer,
|
||||
WebFetchTool.layer,
|
||||
WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)),
|
||||
WriteTool.layer,
|
||||
)
|
||||
169
packages/core/src/tool/edit.ts
Normal file
169
packages/core/src/tool/edit.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "edit"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
}),
|
||||
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
|
||||
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
|
||||
replaceAll: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Replace all exact occurrences of oldString (default false)",
|
||||
}),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
operation: Schema.Literal("write"),
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
replacements: Schema.Number,
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
|
||||
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
|
||||
const convertToLineEnding = (text: string, ending: "\n" | "\r\n") =>
|
||||
ending === "\n" ? normalizeLineEndings(text) : normalizeLineEndings(text).replaceAll("\n", "\r\n")
|
||||
|
||||
const splitBom = (text: string) =>
|
||||
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
||||
const joinBom = (text: string, bom: boolean) => (bom ? `\uFEFF${text}` : text)
|
||||
const decodeUtf8 = (content: Uint8Array) => {
|
||||
const bom = content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
return { bom, content, text: new TextDecoder().decode(bom ? content.slice(3) : content) }
|
||||
}
|
||||
|
||||
const countOccurrences = (content: string, search: string) => {
|
||||
if (search === "") return content.length + 1
|
||||
let count = 0
|
||||
let offset = 0
|
||||
while ((offset = content.indexOf(search, offset)) !== -1) {
|
||||
count++
|
||||
offset += search.length
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
const previewLines = (value: string, prefix: "+" | "-") => {
|
||||
const lines = normalizeLineEndings(value).split("\n")
|
||||
const shown = lines.slice(0, 6).map((line) => `${prefix}${line.length > 240 ? `${line.slice(0, 240)}...` : line}`)
|
||||
if (lines.length > shown.length) shown.push(`${prefix}...`)
|
||||
return shown
|
||||
}
|
||||
|
||||
export const toModelOutput = (output: Success, oldString: string, newString: string) =>
|
||||
[
|
||||
`Edited file successfully: ${output.resource}`,
|
||||
`Replacements: ${output.replacements}`,
|
||||
"```diff",
|
||||
...previewLines(oldString, "-"),
|
||||
...previewLines(newString, "+"),
|
||||
"```",
|
||||
].join("\n")
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ parameters, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(output, parameters.oldString, parameters.newString) }),
|
||||
],
|
||||
})
|
||||
|
||||
/** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(
|
||||
error instanceof FileMutation.StaleContentError
|
||||
? new ToolFailure({ message: "File changed after permission approval. Read it again before editing." })
|
||||
: new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
if (parameters.oldString === parameters.newString) {
|
||||
return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." })
|
||||
}
|
||||
if (parameters.oldString === "") {
|
||||
return yield* new ToolFailure({ message: "oldString must not be empty. Use write to create or overwrite a file." })
|
||||
}
|
||||
|
||||
const plan = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
|
||||
const external = plan.target.externalDirectory
|
||||
if (external) {
|
||||
yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external)))
|
||||
}
|
||||
|
||||
yield* unableToEdit(assertPermission({ action: "edit", resources: [plan.target.resource], save: ["*"] }))
|
||||
const readable = yield* unableToEdit(mutation.revalidate(plan))
|
||||
const source = decodeUtf8(yield* unableToEdit(fs.readFile(readable.canonical)))
|
||||
const ending = detectLineEnding(source.text)
|
||||
const oldString = convertToLineEnding(parameters.oldString, ending)
|
||||
const newString = convertToLineEnding(parameters.newString, ending)
|
||||
const replacements = countOccurrences(source.text, oldString)
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && parameters.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
})
|
||||
}
|
||||
|
||||
const replaced =
|
||||
parameters.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({ plan, expected: source.content, content: joinBom(next.text, source.bom || next.bom) }),
|
||||
)
|
||||
return { ...result, replacements } satisfies Success
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
75
packages/core/src/tool/glob.ts
Normal file
75
packages/core/src/tool/glob.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
export * as GlobTool from "./glob"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { LocationSearch } from "../location-search"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "glob"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
|
||||
path: LocationSearch.FilesInput.fields.path.annotate({ description: "Relative directory to search. Defaults to the active Location." }),
|
||||
reference: LocationSearch.FilesInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }),
|
||||
limit: LocationSearch.FilesInput.fields.limit.annotate({ description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }),
|
||||
})
|
||||
|
||||
type ModelOutput = typeof LocationSearch.FilesResult.Encoded
|
||||
|
||||
/** Format raw Location search results into the concise line-oriented output models expect. */
|
||||
export const toModelOutput = (output: ModelOutput) => {
|
||||
const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
|
||||
if (output.truncated) {
|
||||
lines.push("", `(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`)
|
||||
}
|
||||
if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description: "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
parameters: Parameters,
|
||||
success: LocationSearch.FilesResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Location-scoped glob leaf. FileSystem selects a canonical root for
|
||||
* permission metadata; LocationSearch owns containment and traversal.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot({ path: parameters.path, reference: parameters.reference })
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: parameters.reference,
|
||||
path: parameters.path,
|
||||
limit: parameters.limit,
|
||||
},
|
||||
})
|
||||
return yield* search.files(parameters, root)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(new ToolFailure({ message: `Unable to find files matching ${parameters.pattern}`, error: Cause.squash(cause) })),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
91
packages/core/src/tool/grep.ts
Normal file
91
packages/core/src/tool/grep.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
export * as GrepTool from "./grep"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { LocationSearch } from "../location-search"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "grep"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: LocationSearch.GrepInput.fields.pattern.annotate({ description: "Regex pattern to search for in file contents" }),
|
||||
path: LocationSearch.GrepInput.fields.path.annotate({ description: "Relative file or directory to search. Defaults to the active Location." }),
|
||||
reference: LocationSearch.GrepInput.fields.reference.annotate({ description: "Named project reference to search instead of the active Location" }),
|
||||
include: LocationSearch.GrepInput.fields.include.annotate({ description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")' }),
|
||||
limit: LocationSearch.GrepInput.fields.limit.annotate({ description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})` }),
|
||||
})
|
||||
|
||||
type Success = typeof LocationSearch.GrepResult.Encoded
|
||||
|
||||
/** Format raw Location search matches into the familiar concise model output. */
|
||||
export const toModelOutput = (output: Success) => {
|
||||
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
|
||||
let current = ""
|
||||
for (const match of output.items) {
|
||||
if (current !== match.resource) {
|
||||
if (current) lines.push("")
|
||||
current = match.resource
|
||||
lines.push(`${match.resource}:`)
|
||||
}
|
||||
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
|
||||
}
|
||||
if (output.truncated) {
|
||||
lines.push("", `(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`)
|
||||
}
|
||||
if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description: "Search file contents by regular expression within the active Location or a named project reference. Use a relative path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise relative file resources, line numbers, and bounded line previews.",
|
||||
parameters: Parameters,
|
||||
success: LocationSearch.GrepResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Location-scoped grep leaf. FileSystem selects a canonical root for
|
||||
* permission metadata; LocationSearch owns containment and ripgrep execution.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot(parameters)
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: parameters.reference,
|
||||
path: parameters.path,
|
||||
include: parameters.include,
|
||||
limit: parameters.limit,
|
||||
},
|
||||
})
|
||||
return yield* search.grep(parameters, root)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
const message = error instanceof Ripgrep.InvalidPatternError
|
||||
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
|
||||
: `Unable to grep for ${parameters.pattern}`
|
||||
return Effect.fail(new ToolFailure({ message, error }))
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
76
packages/core/src/tool/question.ts
Normal file
76
packages/core/src/tool/question.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
export * as QuestionTool from "./question"
|
||||
|
||||
import { Tool, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "question"
|
||||
|
||||
export const description = `Use this tool when you need to ask the user questions during execution. This allows you to:
|
||||
1. Gather user preferences or requirements
|
||||
2. Clarify ambiguous instructions
|
||||
3. Get decisions on implementation choices as you work
|
||||
4. Offer choices to the user about what direction to take.
|
||||
|
||||
Usage notes:
|
||||
- When \`custom\` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options
|
||||
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
answers: Schema.Array(QuestionV2.Answer),
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (
|
||||
questions: ReadonlyArray<QuestionV2.Prompt>,
|
||||
answers: ReadonlyArray<QuestionV2.Answer>,
|
||||
) => {
|
||||
const formatted = questions
|
||||
.map(
|
||||
(question, index) =>
|
||||
`"${question.question}"="${answers[index]?.length ? answers[index].join(", ") : "Unanswered"}"`,
|
||||
)
|
||||
.join(", ")
|
||||
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ parameters, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(parameters.questions, output.answers) }),
|
||||
],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const question = yield* QuestionV2.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, source }) =>
|
||||
question
|
||||
.ask({
|
||||
sessionID,
|
||||
questions: parameters.questions,
|
||||
// The registry intentionally leaves source absent until it owns the durable assistant message ID.
|
||||
tool: source?.type === "tool" ? { messageID: source.messageID, callID: source.callID } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((answers) => ({ answers })),
|
||||
// V1 treats a dismissed question as an interrupted tool invocation rather than model-facing text.
|
||||
Effect.orDie,
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
96
packages/core/src/tool/read.ts
Normal file
96
packages/core/src/tool/read.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
export * as ReadTool from "./read"
|
||||
|
||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { NonNegativeInt, PositiveInt } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "read"
|
||||
const LocationInput = Schema.Struct({
|
||||
...FileSystem.ReadInput.fields,
|
||||
offset: FileSystem.ListPageInput.fields.offset.annotate({
|
||||
description: "The 1-based directory entry or text line offset to start reading from",
|
||||
}),
|
||||
limit: FileSystem.ListPageInput.fields.limit.annotate({
|
||||
description: "The maximum number of directory entries or text lines to read",
|
||||
}),
|
||||
})
|
||||
const ResourceInput = Schema.Struct({
|
||||
resource: Schema.String,
|
||||
offset: NonNegativeInt.pipe(Schema.optional),
|
||||
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(ToolOutputStore.MAX_READ_BYTES)).pipe(Schema.optional),
|
||||
})
|
||||
const Input = Schema.Union([LocationInput, ResourceInput])
|
||||
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage, ToolOutputStore.Page])
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Read a text or binary file, page through a large UTF-8 text file by line offset, list a directory page relative to the current location, or page through a managed tool-output resource by opaque URI.",
|
||||
parameters: Input,
|
||||
success: Success,
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, assertPermission }) => {
|
||||
const input = parameters
|
||||
return Effect.gen(function* () {
|
||||
if ("resource" in input)
|
||||
return yield* resources.read({ sessionID, uri: input.resource, offset: input.offset, limit: input.limit })
|
||||
const resolved = yield* filesystem.resolveReadPath(input)
|
||||
if (resolved.type === "directory") {
|
||||
const { offset, limit } = input
|
||||
const target = resolved.target
|
||||
yield* assertPermission({ action: name, resources: [target.resource], save: ["*"] })
|
||||
const final = yield* filesystem.resolveReadPath(input)
|
||||
if (
|
||||
final.type !== "directory" ||
|
||||
final.target.resource !== target.resource ||
|
||||
final.target.real !== target.real
|
||||
)
|
||||
return yield* Effect.die(new Error("Directory changed after permission approval"))
|
||||
return yield* filesystem.listPageResolved(final.target, { offset, limit })
|
||||
}
|
||||
const target = resolved.target
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
})
|
||||
const final = yield* filesystem.resolveReadPath(input)
|
||||
if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real)
|
||||
return yield* Effect.die(new Error("File changed after permission approval"))
|
||||
if (final.target.size > FileSystem.MAX_READ_BYTES || input.offset !== undefined || input.limit !== undefined)
|
||||
return yield* filesystem.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
|
||||
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to read ${"resource" in input ? input.resource : input.path}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(ToolRegistry.layer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
Layer.provideMerge(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
119
packages/core/src/tool/skill.ts
Normal file
119
packages/core/src/tool/skill.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
export * as SkillTool from "./skill"
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "skill"
|
||||
const FILE_LIMIT = 10
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
name: Schema.String,
|
||||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const description = (skills: ReadonlyArray<SkillV2.Info>) =>
|
||||
[
|
||||
"Load a specialized skill when the task at hand matches one of the available skills listed below.",
|
||||
"",
|
||||
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
|
||||
"",
|
||||
"The skill name must match one of the available skills listed below:",
|
||||
"",
|
||||
...(skills.length
|
||||
? skills.map((skill) => `- **${skill.name}**: ${skill.description ?? "No description provided."}`)
|
||||
: ["No skills are currently available."]),
|
||||
].join("\n")
|
||||
|
||||
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
|
||||
const directory = path.dirname(skill.location)
|
||||
return [
|
||||
`<skill_content name="${skill.name}">`,
|
||||
`# Skill: ${skill.name}`,
|
||||
"",
|
||||
skill.content.trim(),
|
||||
"",
|
||||
`Base directory for this skill: ${pathToFileURL(directory).href}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
...files.map((file) => `<file>${file}</file>`),
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const notFound = (name: string, skills: ReadonlyArray<SkillV2.Info>) =>
|
||||
new ToolFailure({
|
||||
message: `Skill "${name}" not found. Available skills: ${skills.map((skill) => skill.name).join(", ") || "none"}`,
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
yield* boot.wait()
|
||||
const available = yield* skills.list()
|
||||
const definition = Tool.make({
|
||||
description: description(available),
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
})
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.name === parameters.name)
|
||||
if (!skill) return yield* notFound(parameters.name, current)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] })
|
||||
const directory = path.dirname(skill.location)
|
||||
const files = (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
||||
.filter((file) => path.basename(file) !== "SKILL.md")
|
||||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
const output = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content: toModelOutput(skill, files),
|
||||
})
|
||||
return {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: output.content,
|
||||
truncated: output.truncated,
|
||||
...(output.truncated ? { resource: output.resource } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to load skill ${parameters.name}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
50
packages/core/src/tool/todowrite.ts
Normal file
50
packages/core/src/tool/todowrite.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export * as TodoWriteTool from "./todowrite"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "todowrite"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
todos: Schema.Array(SessionTodo.Info),
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2)
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const todos = yield* SessionTodo.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* assertPermission({ action: name, resources: ["*"], save: ["*"] })
|
||||
yield* todos.update({ sessionID, todos: parameters.todos })
|
||||
return { todos: parameters.todos }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(new ToolFailure({ message: "Unable to update todos", error: Cause.squash(cause) })),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
222
packages/core/src/tool/webfetch.ts
Normal file
222
packages/core/src/tool/webfetch.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
export * as WebFetchTool from "./webfetch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "webfetch"
|
||||
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
export const DEFAULT_TIMEOUT_SECONDS = 30
|
||||
export const MAX_TIMEOUT_SECONDS = 120
|
||||
|
||||
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
|
||||
|
||||
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated with an opaque managed resource URI for paging.`
|
||||
|
||||
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
|
||||
format: Schema.Literals(["text", "markdown", "html"])
|
||||
.annotate({ description: "The format to return the content in. Defaults to markdown." })
|
||||
.pipe(Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
|
||||
timeout: Timeout.pipe(Schema.optional).annotate({
|
||||
description: `Optional timeout in seconds (maximum: ${MAX_TIMEOUT_SECONDS})`,
|
||||
}),
|
||||
})
|
||||
|
||||
const Success = Schema.Struct({
|
||||
url: Schema.String,
|
||||
contentType: Schema.String,
|
||||
format: Parameters.fields.format,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type Format = (typeof Parameters.Type)["format"]
|
||||
|
||||
const acceptHeader = (format: Format) => {
|
||||
switch (format) {
|
||||
case "markdown":
|
||||
return "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
|
||||
case "text":
|
||||
return "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
|
||||
case "html":
|
||||
return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
|
||||
}
|
||||
}
|
||||
|
||||
const headers = (format: Format, userAgent: string) => ({
|
||||
"User-Agent": userAgent,
|
||||
Accept: acceptHeader(format),
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
})
|
||||
|
||||
const browserUserAgent =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
|
||||
const isCloudflareChallenge = (error: unknown) => {
|
||||
if (!error || typeof error !== "object" || !("reason" in error)) return false
|
||||
const reason = error.reason
|
||||
if (
|
||||
!reason ||
|
||||
typeof reason !== "object" ||
|
||||
!("_tag" in reason) ||
|
||||
reason._tag !== "StatusCodeError" ||
|
||||
!("response" in reason)
|
||||
)
|
||||
return false
|
||||
const response = reason.response as HttpClientResponse.HttpClientResponse
|
||||
return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
|
||||
}
|
||||
|
||||
const request = (url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers(format, userAgent)))
|
||||
|
||||
const assertHttpUrl = (url: URL) => {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http:// or https://")
|
||||
}
|
||||
|
||||
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
|
||||
|
||||
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
const contentLength = response.headers["content-length"]
|
||||
if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
|
||||
return yield* Effect.die(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
|
||||
}
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
yield* Stream.runForEach(response.stream, (chunk) =>
|
||||
Effect.sync(() => {
|
||||
size += chunk.byteLength
|
||||
if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)
|
||||
chunks.push(chunk)
|
||||
}),
|
||||
)
|
||||
return Buffer.concat(chunks, size)
|
||||
})
|
||||
|
||||
const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
const isImageAttachment = (mime: string) =>
|
||||
mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||
const isTextualMime = (mime: string) =>
|
||||
!mime ||
|
||||
mime.startsWith("text/") ||
|
||||
mime === "application/json" ||
|
||||
mime.endsWith("+json") ||
|
||||
mime === "application/xml" ||
|
||||
mime.endsWith("+xml") ||
|
||||
mime === "application/javascript" ||
|
||||
mime === "application/x-javascript"
|
||||
const outputMime = (format: Format) =>
|
||||
format === "markdown" ? "text/markdown" : format === "html" ? "text/html" : "text/plain"
|
||||
|
||||
const convert = (content: string, contentType: string, format: Format) => {
|
||||
if (!contentType.includes("text/html")) return content
|
||||
if (format === "markdown") return convertHTMLToMarkdown(content)
|
||||
if (format === "text") return extractTextFromHTML(content)
|
||||
return content
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const parsed = new URL(parameters.url)
|
||||
assertHttpUrl(parsed)
|
||||
|
||||
yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters })
|
||||
|
||||
const { body, contentType } = yield* Effect.gen(function* () {
|
||||
const response = yield* execute(http, parameters.url, parameters.format).pipe(
|
||||
Effect.catchIf(isCloudflareChallenge, () => execute(http, parameters.url, parameters.format, "opencode")),
|
||||
)
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = mimeFrom(contentType)
|
||||
if (isImageAttachment(mime)) throw new Error(`Unsupported fetched image content type: ${mime}`)
|
||||
if (!isTextualMime(mime)) throw new Error(`Unsupported fetched file content type: ${mime}`)
|
||||
return { body: yield* collectBody(response), contentType }
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(parameters.timeout ?? DEFAULT_TIMEOUT_SECONDS),
|
||||
orElse: () => Effect.die(new Error("Request timed out")),
|
||||
}),
|
||||
)
|
||||
const content = convert(new TextDecoder().decode(body), contentType, parameters.format)
|
||||
const truncated = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content,
|
||||
mime: outputMime(parameters.format),
|
||||
})
|
||||
return {
|
||||
url: parameters.url,
|
||||
contentType,
|
||||
format: parameters.format,
|
||||
output: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated ? { resource: truncated.resource } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to fetch ${parameters.url}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export function extractTextFromHTML(html: string) {
|
||||
let text = ""
|
||||
let skipDepth = 0
|
||||
const parser = new Parser({
|
||||
onopentag(name) {
|
||||
if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) skipDepth++
|
||||
},
|
||||
ontext(input) {
|
||||
if (skipDepth === 0) text += input
|
||||
},
|
||||
onclosetag() {
|
||||
if (skipDepth > 0) skipDepth--
|
||||
},
|
||||
})
|
||||
parser.write(html)
|
||||
parser.end()
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
export function convertHTMLToMarkdown(html: string) {
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
hr: "---",
|
||||
bulletListMarker: "-",
|
||||
codeBlockStyle: "fenced",
|
||||
emDelimiter: "*",
|
||||
})
|
||||
turndown.remove(["script", "style", "meta", "link"])
|
||||
return turndown.turndown(html)
|
||||
}
|
||||
244
packages/core/src/tool/websearch.ts
Normal file
244
packages/core/src/tool/websearch.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
export * as WebSearchTool from "./websearch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
import { checksum } from "../util/encode"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
export const EXA_URL = "https://mcp.exa.ai/mcp"
|
||||
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||
export const MAX_NUM_RESULTS = 20
|
||||
export const MAX_CONTEXT_CHARACTERS = 50_000
|
||||
export const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
|
||||
/**
|
||||
* Provider-independent local web search retained in V2 core for launch parity.
|
||||
* This invokes the legacy Exa/Parallel product backends itself. It is distinct
|
||||
* from provider-hosted web search tools, which remain route-owned and execute
|
||||
* at the model provider. Ownership of this compromise can be revisited later.
|
||||
*/
|
||||
export const description = `Search the web using the session's local web search provider. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
This is a provider-independent local tool backed by Exa or Parallel. Provider-hosted web search tools are separate and execute at the model provider.
|
||||
|
||||
Optional controls support result count, live crawling ('fallback' or 'preferred'), search type ('auto', 'fast', or 'deep'), and maximum context characters.
|
||||
|
||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({ description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})` }),
|
||||
livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({
|
||||
description:
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
}),
|
||||
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(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_CONTEXT_CHARACTERS))).annotate({
|
||||
description: `Maximum characters for context string optimized for models (default: 10000, maximum: ${MAX_CONTEXT_CHARACTERS})`,
|
||||
}),
|
||||
})
|
||||
|
||||
export const Provider = Schema.Literals(["exa", "parallel"])
|
||||
export type Provider = typeof Provider.Type
|
||||
|
||||
export interface Config {
|
||||
readonly provider?: Provider
|
||||
readonly enableExa: boolean
|
||||
readonly enableParallel: boolean
|
||||
readonly exaApiKey?: string
|
||||
readonly parallelApiKey?: string
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, Config>()("@opencode/v2/WebSearchConfig") {}
|
||||
|
||||
/** Isolates the retained product environment contract from the generic tool implementation. */
|
||||
export const defaultConfigLayer = Layer.sync(ConfigService, () =>
|
||||
ConfigService.of({
|
||||
provider: process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel"
|
||||
? process.env.OPENCODE_WEBSEARCH_PROVIDER
|
||||
: undefined,
|
||||
enableExa:
|
||||
truthy("OPENCODE_EXPERIMENTAL") ||
|
||||
truthy("OPENCODE_ENABLE_EXA") ||
|
||||
truthy("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
exaApiKey: process.env.EXA_API_KEY,
|
||||
parallelApiKey: process.env.PARALLEL_API_KEY,
|
||||
}),
|
||||
)
|
||||
|
||||
export function selectProvider(
|
||||
sessionID: string,
|
||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
||||
override?: Provider,
|
||||
): Provider {
|
||||
if (override) return override
|
||||
if (flags.enableParallel) return "parallel"
|
||||
if (flags.enableExa) return "exa"
|
||||
return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
|
||||
}
|
||||
|
||||
const McpResult = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.String })),
|
||||
}),
|
||||
})
|
||||
const decodeMcpResult = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
||||
|
||||
const parsePayload = (payload: string) =>
|
||||
Effect.gen(function* () {
|
||||
const trimmed = payload.trim()
|
||||
if (!trimmed.startsWith("{")) return undefined
|
||||
return (yield* decodeMcpResult(trimmed)).result.content.find((item) => item.text)?.text
|
||||
})
|
||||
|
||||
export const parseResponse = Effect.fn("WebSearchTool.parseResponse")(function* (body: string) {
|
||||
const trimmed = body.trim()
|
||||
const direct = trimmed ? yield* parsePayload(trimmed) : undefined
|
||||
if (direct) return direct
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* parsePayload(line.substring(6))
|
||||
if (data) return data
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const ExaArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
const ParallelArgs = Schema.Struct({
|
||||
objective: Schema.String,
|
||||
search_queries: Schema.Array(Schema.String),
|
||||
session_id: Schema.String,
|
||||
})
|
||||
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({ name: Schema.String, arguments: args }),
|
||||
})
|
||||
|
||||
const exaUrl = (apiKey: string | undefined) => {
|
||||
if (!apiKey) return EXA_URL
|
||||
const url = new URL(EXA_URL)
|
||||
url.searchParams.set("exaApiKey", apiKey)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const callMcp = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
value: Schema.Struct.Type<F>,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* response.text
|
||||
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
|
||||
return yield* parseResponse(body)
|
||||
}).pipe(Effect.timeoutOrElse({ duration: Duration.seconds(25), orElse: () => Effect.die(new Error(`${tool} request timed out`)) }))
|
||||
})
|
||||
|
||||
const Success = Schema.Struct({
|
||||
provider: Provider,
|
||||
text: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) => {
|
||||
const provider = selectProvider(sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.query],
|
||||
save: ["*"],
|
||||
metadata: { ...parameters, provider },
|
||||
})
|
||||
|
||||
const text = provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: parameters.query,
|
||||
type: parameters.type || "auto",
|
||||
numResults: parameters.numResults || 8,
|
||||
livecrawl: parameters.livecrawl || "fallback",
|
||||
contextMaxCharacters: parameters.contextMaxCharacters,
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: parameters.query,
|
||||
search_queries: [parameters.query],
|
||||
session_id: sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
const truncated = yield* resources.truncate({ sessionID, toolCallID: call.id, content: text ?? NO_RESULTS })
|
||||
return {
|
||||
provider,
|
||||
text: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated ? { resource: truncated.resource } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(new ToolFailure({ message: `Unable to search the web for ${parameters.query}`, error: Cause.squash(cause) })),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
78
packages/core/src/tool/write.ts
Normal file
78
packages/core/src/tool/write.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Model-facing V2 file-write leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ToolRegistry } from "../tool-registry"
|
||||
|
||||
export const name = "write"
|
||||
|
||||
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
|
||||
export const Parameters = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
}),
|
||||
content: Schema.String.annotate({ description: "Content to write to the file" }),
|
||||
})
|
||||
|
||||
export const Success = Schema.Struct({
|
||||
operation: Schema.Literal("write"),
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
})
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Success) =>
|
||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/** Deferred V2 write UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* mutation.resolve({ path: parameters.path, kind: "file" })
|
||||
const external = plan.target.externalDirectory
|
||||
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
yield* assertPermission({ action: "edit", resources: [plan.target.resource], save: ["*"] })
|
||||
return yield* files.writeTextPreservingBom({ plan, content: parameters.content })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to write ${parameters.path}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue