refactor(core): unify v2 tool architecture (#31168)
This commit is contained in:
parent
effd27b239
commit
660a00d317
46 changed files with 2297 additions and 2041 deletions
|
|
@ -1,16 +1,17 @@
|
|||
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 { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { 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 { PermissionV2 } from "../permission"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "bash"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
|
|
@ -41,7 +42,6 @@ const Success = Schema.Struct({
|
|||
truncated: Schema.Boolean,
|
||||
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
timedOut: Schema.Boolean.pipe(Schema.optional),
|
||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
|
|
@ -59,6 +59,7 @@ 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]"
|
||||
return undefined
|
||||
}
|
||||
|
||||
const modelOutput = (output: Success) => {
|
||||
|
|
@ -72,13 +73,6 @@ const modelOutput = (output: Success) => {
|
|||
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.
|
||||
|
|
@ -112,96 +106,101 @@ const externalCommandDirectories = (command: string, cwd: string) => {
|
|||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
const warnings = externalCommandDirectories(parameters.command, 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] })
|
||||
|
||||
if ((yield* fs.stat(target.canonical)).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 } : {}),
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const warnings = externalCommandDirectories(input.command, 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* permission.assert({
|
||||
action: name,
|
||||
resources: [input.command],
|
||||
save: [input.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
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
|
||||
? { outputPath: truncated.outputPath }
|
||||
: {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to execute command: ${parameters.command}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
if ((yield* fs.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(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(input.command, [], {
|
||||
cwd: target.canonical,
|
||||
shell,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
})
|
||||
const timeout = input.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: input.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)
|
||||
return {
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
exitCode: result.exitCode,
|
||||
output: notice ? `${compact}\n\n${notice}` : compact,
|
||||
truncated: result.stdoutTruncated || result.stderrTruncated,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
||||
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue