feat(core): bound v2 tool output (#30999)
This commit is contained in:
parent
760d523847
commit
a9094fd059
31 changed files with 387 additions and 552 deletions
|
|
@ -41,7 +41,7 @@ const Success = Schema.Struct({
|
|||
truncated: Schema.Boolean,
|
||||
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
timedOut: Schema.Boolean.pipe(Schema.optional),
|
||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
|
|
@ -121,6 +121,7 @@ export const layer = Layer.effectDiscard(
|
|||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
|
||||
|
|
@ -187,7 +188,7 @@ export const layer = Layer.effectDiscard(
|
|||
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
||||
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
|
||||
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
|
||||
? { resource: truncated.resource }
|
||||
? { outputPath: truncated.outputPath }
|
||||
: {}),
|
||||
}
|
||||
}).pipe(
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const toModelOutput = (output: Success) => {
|
|||
|
||||
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.",
|
||||
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
parameters: Parameters,
|
||||
success: LocationSearch.GrepResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ 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 "./registry"
|
||||
|
||||
export const name = "read"
|
||||
|
|
@ -18,17 +16,12 @@ const LocationInput = Schema.Struct({
|
|||
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 Input = LocationInput
|
||||
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
|
||||
|
||||
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.",
|
||||
"Read a text or binary file, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
|
||||
parameters: Input,
|
||||
success: Success,
|
||||
})
|
||||
|
|
@ -37,7 +30,6 @@ 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, {
|
||||
|
|
@ -45,8 +37,6 @@ export const layer = Layer.effectDiscard(
|
|||
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
|
||||
|
|
@ -81,7 +71,7 @@ export const layer = Layer.effectDiscard(
|
|||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to read ${"resource" in input ? input.resource : input.path}`,
|
||||
message: `Unable to read ${input.path}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
|
|
@ -96,5 +86,4 @@ export const locationLayer = layer.pipe(
|
|||
Layer.provideMerge(ToolRegistry.defaultLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
Layer.provideMerge(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { State } from "../state"
|
|||
import { SessionSchema } from "../session/schema"
|
||||
import type { SessionV2 } from "../session"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { AgentV2 } from "../agent"
|
||||
|
||||
export type ExecuteInput = {
|
||||
|
|
@ -57,6 +58,7 @@ export type Entry<
|
|||
readonly execute?: (
|
||||
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
readonly outputPaths?: (output: Schema.Schema.Type<Success>) => ReadonlyArray<string>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
|
|
@ -78,7 +80,11 @@ export interface Interface {
|
|||
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly definitions: () => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
|
||||
readonly settle: (input: ExecuteInput) => Effect.Effect<ToolSettlement>
|
||||
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement>
|
||||
}
|
||||
|
||||
export interface Settlement extends ToolSettlement {
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
|
|
@ -90,6 +96,7 @@ export const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const permission = yield* PermissionV2.Service
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ entries: new Map() }),
|
||||
editor: (draft) => ({
|
||||
|
|
@ -162,12 +169,16 @@ export const layer = Layer.effect(
|
|||
),
|
||||
),
|
||||
),
|
||||
Effect.map((value): ToolSettlement => {
|
||||
if (entry.tool._legacyResult && ToolResult.is(value))
|
||||
return { result: value, output: ToolOutput.fromResultValue(value) }
|
||||
const output = entry.tool._project(parameters, input.call.id, value)
|
||||
const result = ToolOutput.toResultValue(output)
|
||||
return result.type === "error" ? { result } : { result, output }
|
||||
Effect.map((value): Settlement => {
|
||||
const settled = (() => {
|
||||
if (entry.tool._legacyResult && ToolResult.is(value))
|
||||
return { result: value, output: ToolOutput.fromResultValue(value) }
|
||||
const output = entry.tool._project(parameters, input.call.id, value)
|
||||
const result = ToolOutput.toResultValue(output)
|
||||
return result.type === "error" ? { result } : { result, output }
|
||||
})()
|
||||
const retained = entry.outputPaths?.(value) ?? []
|
||||
return retained.length > 0 ? { ...settled, outputPaths: retained } : settled
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
@ -177,7 +188,25 @@ export const layer = Layer.effect(
|
|||
)
|
||||
})
|
||||
|
||||
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) => settleEntry(entry(input.call.name), input))
|
||||
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* restore(settleEntry(entry(input.call.name), input))
|
||||
if (!settled.output) return settled
|
||||
const bounded = yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
toolCallID: input.call.id,
|
||||
output: settled.output,
|
||||
})
|
||||
if (bounded.output === settled.output && bounded.outputPaths.length === 0) return settled
|
||||
const retained = [...(settled.outputPaths ?? []), ...bounded.outputPaths]
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
return result.type === "error"
|
||||
? { result, outputPaths: retained }
|
||||
: { result, output: bounded.output, outputPaths: retained }
|
||||
}),
|
||||
),
|
||||
)
|
||||
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
|
||||
return (yield* settle(input)).result
|
||||
})
|
||||
|
|
@ -195,4 +224,7 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(ApplicationTools.layer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const Success = Schema.Struct({
|
|||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const description = [
|
||||
|
|
@ -73,6 +73,7 @@ export const layer = Layer.effectDiscard(
|
|||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
|
|
@ -98,7 +99,7 @@ export const layer = Layer.effectDiscard(
|
|||
directory,
|
||||
output: output.content,
|
||||
truncated: output.truncated,
|
||||
...(output.truncated ? { resource: output.resource } : {}),
|
||||
...(output.truncated ? { outputPath: output.outputPath } : {}),
|
||||
}
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause)))))
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ 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.`
|
||||
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.`
|
||||
|
||||
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ const Success = Schema.Struct({
|
|||
format: Parameters.fields.format,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type Format = (typeof Parameters.Type)["format"]
|
||||
|
|
@ -141,6 +141,7 @@ export const layer = Layer.effectDiscard(
|
|||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const parsed = new URL(parameters.url)
|
||||
|
|
@ -178,7 +179,7 @@ export const layer = Layer.effectDiscard(
|
|||
format: parameters.format,
|
||||
output: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated ? { resource: truncated.resource } : {}),
|
||||
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ const Success = Schema.Struct({
|
|||
provider: Provider,
|
||||
text: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
resource: ToolOutputStore.Resource.pipe(Schema.optional),
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const definition = Tool.make({
|
||||
|
|
@ -199,6 +199,7 @@ export const layer = Layer.effectDiscard(
|
|||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) => {
|
||||
const provider = selectProvider(sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -239,7 +240,7 @@ export const layer = Layer.effectDiscard(
|
|||
provider,
|
||||
text: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated ? { resource: truncated.resource } : {}),
|
||||
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue