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
|
|
@ -3,7 +3,7 @@ export { Agent } from "./agent"
|
|||
export { Model } from "./model"
|
||||
export { OpenCode } from "./opencode"
|
||||
export { Session } from "./session"
|
||||
export { Tool } from "./tool"
|
||||
export * as Tool from "./tool"
|
||||
export { Location } from "./location"
|
||||
export { Prompt } from "../session/prompt"
|
||||
export { AbsolutePath } from "../schema"
|
||||
|
|
|
|||
|
|
@ -13,11 +13,10 @@ import { SessionProjector } from "../session/projector"
|
|||
import { SessionStore } from "../session/store"
|
||||
import { ApplicationTools } from "../tool/application-tools"
|
||||
import { Session } from "./session"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export interface Interface {
|
||||
readonly sessions: Session.Interface
|
||||
readonly tools: Tool.Service
|
||||
readonly tools: import("./tool").Service
|
||||
}
|
||||
|
||||
/** Intentional public native API for Effect applications embedding OpenCode. */
|
||||
|
|
@ -88,7 +87,7 @@ export const layer = Layer.effect(
|
|||
const tools = yield* ApplicationTools.Service
|
||||
const validation = yield* SessionModelValidation
|
||||
return Service.of({
|
||||
tools: { attach: tools.attach },
|
||||
tools: { register: tools.register },
|
||||
sessions: {
|
||||
create: (input) =>
|
||||
sessions.create({
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
export * as Tool from "./tool"
|
||||
|
||||
import { Effect, Scope } from "effect"
|
||||
import type { NativeTool } from "../tool/native"
|
||||
|
||||
export { Failure, make } from "../tool/native"
|
||||
export type { Any, Content, Context, Executable } from "../tool/native"
|
||||
export { Failure, RegistrationError, make } from "../tool/tool"
|
||||
export type { AnyTool, Content, Context, Tool } from "../tool/tool"
|
||||
|
||||
export interface Service {
|
||||
/**
|
||||
* Attach same-process tools to this OpenCode instance for the current Scope.
|
||||
* Register same-process tools on this OpenCode instance for the current Scope.
|
||||
* Location tools with the same name take precedence where they are installed.
|
||||
* Closing the Scope removes the tools immediately, so calls that have not
|
||||
* started settling may fail because the tool is no longer available.
|
||||
*/
|
||||
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, import("../tool/tool").AnyTool>>,
|
||||
) => Effect.Effect<void, import("../tool/tool").RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
|||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
||||
"SessionRunner.StepLimitExceededError",
|
||||
|
|
@ -24,6 +25,7 @@ export type RunError =
|
|||
| StepLimitExceededError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index"
|
|||
import { SystemContextRegistry } from "../../system-context/registry"
|
||||
import { SkillGuidance } from "../../skill/guidance"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
import { SessionContextEpoch } from "../context-epoch"
|
||||
import { SessionCompaction } from "../compaction"
|
||||
import { SessionEvent } from "../event"
|
||||
|
|
@ -63,7 +64,7 @@ import { toLLMMessages } from "./to-llm-message"
|
|||
* - [x] Authorize and execute recorded local calls through a core-owned registry hook.
|
||||
* - [x] Persist typed success, failure, and provider-executed tool outcomes.
|
||||
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
|
||||
* - [ ] Add scoped runtime context, progress updates, output truncation, attachment normalization,
|
||||
* - [ ] Add scoped runtime context, progress updates, attachment normalization,
|
||||
* plugins, and cancellation settlement.
|
||||
* - [x] Reload projected history and start the next explicit provider turn after local tool results.
|
||||
* - [x] Continue for durable user steering accepted during an active provider turn.
|
||||
|
|
@ -131,7 +132,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
})
|
||||
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, never>) =>
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
|
||||
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
||||
|
||||
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
|
||||
|
|
@ -185,7 +186,7 @@ export const layer = Layer.effect(
|
|||
session.location,
|
||||
agent.id,
|
||||
).pipe(retryAgentMismatch(promotion))
|
||||
const toolFibers = yield* FiberSet.make<void, never>()
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
let needsContinuation = false
|
||||
if (promotion) {
|
||||
const cutoff = yield* SessionInput.latestSeq(db, session.id)
|
||||
|
|
@ -211,6 +212,7 @@ export const layer = Layer.effect(
|
|||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
|
|
@ -219,7 +221,7 @@ export const layer = Layer.effect(
|
|||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: toLLMMessages(context, model),
|
||||
tools: yield* tools.definitions(agent.info?.permissions),
|
||||
tools: toolMaterialization.definitions,
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
|
|
@ -251,16 +253,16 @@ export const layer = Layer.effect(
|
|||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause)
|
||||
return Effect.succeed({
|
||||
result: { type: "error" as const, value: String(Cause.squash(cause)) },
|
||||
output: undefined,
|
||||
outputPaths: [],
|
||||
})
|
||||
restore(
|
||||
toolMaterialization.settle({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
assistantMessageID,
|
||||
call: event,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
|
|
@ -322,8 +324,8 @@ export const layer = Layer.effect(
|
|||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
const attempt = stream._tag === "Failure" ? stream : settled
|
||||
if (attempt._tag === "Failure") return yield* Effect.failCause(attempt.cause)
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
return !publisher.hasProviderError() && needsContinuation
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -218,6 +218,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
}
|
||||
})
|
||||
|
||||
const assistantMessageIDForTool = (callID: string) => {
|
||||
const tool = tools.get(callID)
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||
event: LLMEvent,
|
||||
outputPaths: ReadonlyArray<string> = [],
|
||||
|
|
@ -408,5 +413,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as ToolOutputStore from "./tool-output-store"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
|
|
@ -11,27 +11,11 @@ import type { ToolOutput } from "@opencode-ai/llm"
|
|||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const MAX_INLINE_MEDIA_BYTES = 5 * 1024 * 1024
|
||||
export const RETENTION = Duration.days(7)
|
||||
|
||||
export const MANAGED_DIRECTORY = "tool-output"
|
||||
|
||||
export interface WriteInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly toolCallID: string
|
||||
readonly content: string
|
||||
readonly mime?: string
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
export interface TruncateInput extends WriteInput {
|
||||
readonly maxLines?: number
|
||||
readonly maxBytes?: number
|
||||
}
|
||||
|
||||
export type TruncateResult =
|
||||
| { readonly content: string; readonly truncated: false }
|
||||
| { readonly content: string; readonly truncated: true; readonly outputPath: string }
|
||||
|
||||
export interface BoundInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly toolCallID: string
|
||||
|
|
@ -43,11 +27,22 @@ export interface BoundResult {
|
|||
readonly outputPaths: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
|
||||
operation: Schema.Literals(["encode", "write"]),
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export class MediaLimitError extends Schema.TaggedErrorClass<MediaLimitError>()("ToolOutputStore.MediaLimitError", {
|
||||
mime: Schema.String,
|
||||
bytes: Schema.Int,
|
||||
limit: Schema.Int,
|
||||
}) {}
|
||||
|
||||
export type Error = StorageError | MediaLimitError
|
||||
|
||||
export interface Interface {
|
||||
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<string>
|
||||
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
|
||||
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
|
||||
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +104,12 @@ const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes
|
|||
return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
|
||||
}
|
||||
|
||||
const lineCount = (text: string) => {
|
||||
let count = 1
|
||||
for (const char of text) if (char === "\n") count++
|
||||
return count
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -116,7 +117,6 @@ export const layer = Layer.effect(
|
|||
const global = yield* Global.Service
|
||||
const config = yield* Effect.serviceOption(Config.Service)
|
||||
const directory = path.join(global.data, MANAGED_DIRECTORY)
|
||||
|
||||
const limits = Effect.fn("ToolOutputStore.limits")(function* () {
|
||||
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
|
||||
const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
|
||||
|
|
@ -127,68 +127,54 @@ export const layer = Layer.effect(
|
|||
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
|
||||
})
|
||||
|
||||
const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
|
||||
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
|
||||
const file = path.join(directory, `tool_${Identifier.ascending()}`)
|
||||
yield* fs.ensureDir(directory).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie)
|
||||
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||
yield* fs
|
||||
.writeFileString(file, content, { flag: "wx" })
|
||||
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||
return file
|
||||
})
|
||||
|
||||
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
|
||||
const configured = yield* limits()
|
||||
const maxLines = input.maxLines ?? configured.maxLines
|
||||
const maxBytes = input.maxBytes ?? configured.maxBytes
|
||||
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
|
||||
return { content: input.content, truncated: false } as const
|
||||
}
|
||||
const outputPath = yield* write(input)
|
||||
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
||||
return {
|
||||
content: boundedPreview(input.content, marker, maxLines, maxBytes),
|
||||
truncated: true,
|
||||
outputPath,
|
||||
} as const
|
||||
})
|
||||
|
||||
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
|
||||
const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n")
|
||||
const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe(
|
||||
Effect.catch(() => Effect.succeed(String(input.output.structured))),
|
||||
)
|
||||
const content = text || input.output.content.length > 0 ? text : structured
|
||||
if (content === undefined) return { output: input.output, outputPaths: [] }
|
||||
const outputLimits = yield* limits()
|
||||
const media = input.output.content.filter((item) => item.type === "file")
|
||||
let mediaBytes = 0
|
||||
for (const item of media) {
|
||||
if (item.source.type !== "data") continue
|
||||
mediaBytes += Buffer.byteLength(item.source.data, "utf-8")
|
||||
if (mediaBytes > MAX_INLINE_MEDIA_BYTES)
|
||||
return yield* new MediaLimitError({ mime: item.mime, bytes: mediaBytes, limit: MAX_INLINE_MEDIA_BYTES })
|
||||
}
|
||||
const contextual = {
|
||||
structured: media.length > 0 ? {} : input.output.structured,
|
||||
content: input.output.content.filter((item) => item.type === "text"),
|
||||
}
|
||||
const encoded = yield* Effect.try({
|
||||
try: () => JSON.stringify(contextual, null, 2),
|
||||
catch: (cause) => new StorageError({ operation: "encode", cause }),
|
||||
})
|
||||
if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes)
|
||||
return {
|
||||
output: { structured: contextual.structured, content: input.output.content },
|
||||
outputPaths: [],
|
||||
}
|
||||
|
||||
const truncated = yield* truncate({
|
||||
sessionID: input.sessionID,
|
||||
toolCallID: input.toolCallID,
|
||||
content,
|
||||
mime: "text/plain",
|
||||
name: `${input.toolCallID}.txt`,
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("Unable to retain complete tool output", cause).pipe(
|
||||
Effect.andThen(limits()),
|
||||
Effect.map(({ maxLines, maxBytes }) => {
|
||||
const marker = "... output truncated; omitted content could not be retained ..."
|
||||
return {
|
||||
content: boundedPreview(content, marker, maxLines, maxBytes),
|
||||
truncated: true as const,
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!truncated.truncated) return { output: input.output, outputPaths: [] }
|
||||
const outputPath = yield* write(encoded)
|
||||
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
||||
|
||||
return {
|
||||
output: {
|
||||
structured: input.output.structured,
|
||||
structured: {},
|
||||
content: [
|
||||
{ type: "text" as const, text: truncated.content },
|
||||
...input.output.content.filter((item) => item.type === "file"),
|
||||
{
|
||||
type: "text" as const,
|
||||
text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
||||
},
|
||||
...media,
|
||||
],
|
||||
},
|
||||
outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [],
|
||||
outputPaths: [outputPath],
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -207,7 +193,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
})
|
||||
|
||||
return Service.of({ limits, write, truncate, bound, cleanup })
|
||||
return Service.of({ limits, bound, cleanup })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,139 +1,58 @@
|
|||
# Core Tool Architecture
|
||||
|
||||
This folder owns Core-native tool definition, contribution, effective lookup, and execution. Keep those concerns distinct even though `ToolRegistry` brings them together at runtime.
|
||||
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
|
||||
|
||||
## Current Architecture
|
||||
## Representations
|
||||
|
||||
```txt
|
||||
Public Tool.make NativeTool value ApplicationTools Location built-ins Location ToolRegistry Session runner
|
||||
│ │ │ │ │ │
|
||||
├─ construct ─────────▶ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─ scoped attach ─────▶ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ├─ scoped contributions ──▶ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ├─ shared current entries ───────────────────────▶ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ ├─ effective definitions and settlement ──▶
|
||||
│ │ │ │ │ │
|
||||
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Application tools and shipped built-ins use the same type.
|
||||
- `application-tools.ts` stores process-scoped application registrations.
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical tools, overlays Location registrations over application registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
|
||||
## Construction
|
||||
|
||||
Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
|
||||
|
||||
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
|
||||
|
||||
```ts
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
```
|
||||
|
||||
There are three relevant representations:
|
||||
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive.
|
||||
|
||||
- `native.ts` defines the plain Core-native executable value exposed publicly as `Tool.make(...)`. It combines an `@opencode-ai/llm` model-facing definition with a Session-aware handler.
|
||||
- `application-tools.ts` stores process-scoped application contributions. It owns availability and scoped attachment, but it does not execute tools.
|
||||
- `registry.ts` is the single execution registry. Each Location owns one registry, its built-in contributions, effective precedence, input/output validation, permissions, and settlement.
|
||||
## Registration
|
||||
|
||||
`ToolRegistry.Entry` is intentionally more powerful than the public native tool value. Internal Location tools may use Core-owned capabilities such as `assertPermission`; embedding applications receive only the narrow public execution context.
|
||||
Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`.
|
||||
|
||||
## Placement And Layers
|
||||
Both are scoped:
|
||||
|
||||
- `ApplicationTools.Service` is process-scoped and must be shared by current and future Locations.
|
||||
- `ToolRegistry.Service` is Location-scoped because built-in handlers close over Location services such as filesystem, permissions, and tool-output storage.
|
||||
- `LocationServiceMap` constructs fresh Location services while receiving the shared `ApplicationTools.Service` as a dependency.
|
||||
- `OpenCode.layer` exposes the same shared application-tool service through `opencode.tools.attach(...)`.
|
||||
- `ToolRegistry.defaultLayer` creates isolated application-tool state. It is suitable for self-contained consumers and tests, but not when attachments must be shared with a separately constructed `LocationServiceMap`.
|
||||
- The latest active same-placement registration wins.
|
||||
- Closing any registration removes only that registration and reveals the next active one.
|
||||
- Location registrations take precedence over application registrations.
|
||||
- An invocation captures the effective tool once settlement starts.
|
||||
|
||||
Do not make `ToolRegistry` process-global. Do not move Location resources into `ApplicationTools`. Do not construct independent `ApplicationTools.layer` instances when the caller expects one attachment to appear across Locations.
|
||||
`ApplicationTools.Service` is process-scoped and shared by all Locations. `ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
|
||||
## Contribution And Precedence
|
||||
## Permissions
|
||||
|
||||
Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`.
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action.
|
||||
|
||||
Both contribution mechanisms use `State` scoped transforms:
|
||||
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
|
||||
|
||||
- Closing a contribution Scope rebuilds state without that contribution.
|
||||
- A later same-name application attachment wins while active.
|
||||
- Closing that later attachment reveals the earlier active application contribution.
|
||||
- A Location tool always takes precedence over an application tool with the same name.
|
||||
- Application attachment inputs are captured before registering the replayable transform; later caller mutation must not alter a contribution during an unrelated rebuild.
|
||||
## Output
|
||||
|
||||
Do not introduce another application-specific tool type or registry. Plugins should contribute existing native tools or internal registry entries at the lifetime they actually own.
|
||||
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
|
||||
## Dynamic Removal Semantics
|
||||
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
|
||||
|
||||
Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach.
|
||||
## Current Gaps
|
||||
|
||||
```txt
|
||||
Embedding App ApplicationTools Location ToolRegistry Session Runner
|
||||
│ │ │ │
|
||||
├─ attach({ opencord_run }) ──▶ │ │
|
||||
│ │ │ │
|
||||
│ │ ◀─ definitions() ──────────────────┤
|
||||
│ │ │ │
|
||||
│ ◀─ entries() ────────────┤ │
|
||||
│ │ │ │
|
||||
│ │ ├─ current effective definitions ──▶
|
||||
│ │ │ │
|
||||
├─ attachment Scope closes ───▶ │ │
|
||||
│ │ │ │
|
||||
│ │ ◀─ settle(opencord_run) ───────────┤
|
||||
│ │ │ │
|
||||
│ ◀─ current lookup ───────┤ │
|
||||
│ │ │ │
|
||||
│ │ ├─ Unknown tool ───────────────────▶
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
Consequences of this choice:
|
||||
|
||||
- Closing an attachment Scope revokes the tool immediately for calls that have not started settling.
|
||||
- A call produced from an earlier advertised definition may fail as unknown.
|
||||
- If a same-name replacement is currently active, a later call may execute that replacement.
|
||||
- An execution that already resolved its entry continues with the handler it captured.
|
||||
- Attachment Scope closure does not wait for already-started executions. Applications whose handlers depend on scoped resources must coordinate graceful shutdown themselves.
|
||||
|
||||
These are deliberate simplifications. Do not add snapshots, semaphores, leases, or deferred finalizers without a concrete requirement for stronger consistency or graceful draining.
|
||||
|
||||
## File Roles
|
||||
|
||||
```txt
|
||||
tool/
|
||||
native.ts plain public/Core-native executable tool value
|
||||
application-tools.ts process-scoped State-backed application contributions
|
||||
registry.ts Location-scoped effective lookup, validation, and execution
|
||||
builtins.ts shipped Location tool layer composition
|
||||
read.ts, bash.ts, ... individual Location-scoped built-in contributions
|
||||
```
|
||||
|
||||
Keep model/provider-neutral tool schemas and output projection in `@opencode-ai/llm`. Keep Session identity, permissions, Location precedence, and settlement in Core.
|
||||
|
||||
## Future Directions
|
||||
|
||||
Tool availability may eventually gain a real third scope, such as Session-specific or plugin-owned contributions:
|
||||
|
||||
```txt
|
||||
╭─────────────────╮
|
||||
│ Tool definition │
|
||||
╰────────┬────────╯
|
||||
╭────────────────────────────────────────╰╮─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╮
|
||||
│ │
|
||||
▼ ▼ ▼
|
||||
╭───────────────────────╮ ╭────────────────────────╮ ╭───────────────────────╮
|
||||
│ Process contributions │ │ Location contributions │ │ Session contributions │
|
||||
╰───────────┬───────────╯ ╰────────────┬───────────╯ ╰───────────┬───────────╯
|
||||
│ │ │
|
||||
│ │
|
||||
╰─────────────────────────────────────────◀─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╯
|
||||
╭──────────────────────╮
|
||||
│ Effective resolution │
|
||||
╭─────────╰───────────┬──────────╯────────────╮
|
||||
│ │ │
|
||||
▼ ▼
|
||||
╭───────────────────────────────╮ ╭─────────────────────────╮
|
||||
│ Advertise current definitions │ │ Execute current handler │
|
||||
╰───────────────────────────────╯ ╰─────────────────────────╯
|
||||
```
|
||||
|
||||
Prefer these directions only when a concrete use requires them:
|
||||
|
||||
- **Contextual availability:** Add Session/agent/plugin filtering at effective resolution. Keep tool definitions independent from where they are enabled.
|
||||
- **Hierarchical overlays:** If a third contribution scope becomes real, consider one registry abstraction with process, Location, and Session overlays rather than adding another special registry service.
|
||||
- **Plugin tools:** Reuse the existing native tool value for restricted handlers and `ToolRegistry.Entry` for trusted Core-owned capabilities. Choose process or Location contribution lifetime explicitly.
|
||||
- **Stale-call rejection:** If executing a same-name replacement is unsafe, attach an identity/version to advertised definitions and reject stale calls without retaining removed handlers.
|
||||
- **Pinned provider turns:** If exact advertisement-to-execution consistency becomes necessary, snapshot effective entries for one provider turn. This weakens immediate revocation.
|
||||
- **Graceful plugin unload:** If attachment-owned resources must outlive started executions, add explicit execution draining. Keep this separate from whether new calls can discover the tool.
|
||||
- **Cluster placement:** `ApplicationTools` is process-global, not cluster-global. Cluster-wide contribution and execution ownership require a separate durable design.
|
||||
|
||||
When choosing stronger semantics, state which property matters: immediate revocation, stale-call rejection, exact handler pinning, or graceful resource draining. They are different guarantees and should not arrive as one bundled lifecycle mechanism.
|
||||
- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
|
||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
export * as ApplicationTools from "./application-tools"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { castDraft, enableMapSet } from "immer"
|
||||
import { enableMapSet } from "immer"
|
||||
import { State } from "../state"
|
||||
import { NativeTool } from "./native"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
type Data = {
|
||||
readonly entries: Map<string, NativeTool.Any>
|
||||
readonly entries: Map<string, Entry>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
readonly set: (name: string, tool: NativeTool.Any) => void
|
||||
readonly set: (name: string, entry: Entry) => void
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly identity: object
|
||||
readonly tool: Tool.AnyTool
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly entries: () => ReadonlyMap<string, NativeTool.Any>
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
readonly entries: () => ReadonlyMap<string, Entry>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
|
||||
|
|
@ -29,20 +36,20 @@ export const layer = Layer.effect(
|
|||
initial: () => ({ entries: new Map() }),
|
||||
editor: (draft) => ({
|
||||
set: (name, tool) => {
|
||||
draft.entries.set(
|
||||
name,
|
||||
castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
|
||||
)
|
||||
draft.entries.set(name, tool)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
attach: Effect.fn("ApplicationTools.attach")(function* (tools) {
|
||||
register: Effect.fn("ApplicationTools.register")(function* (tools) {
|
||||
const entries = Object.entries(tools)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
|
||||
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
|
||||
const transform = yield* state.transform()
|
||||
yield* transform((editor) => {
|
||||
for (const [name, tool] of entries) editor.set(name, tool)
|
||||
for (const [name, entry] of registrations) editor.set(name, entry)
|
||||
})
|
||||
}),
|
||||
entries: () => state.get().entries,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
export * as ApplyPatchTool from "./apply-patch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { 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 "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "apply_patch"
|
||||
|
||||
|
|
@ -33,14 +35,6 @@ export const toModelOutput = (output: Success) =>
|
|||
),
|
||||
].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 Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
|
|
@ -51,116 +45,133 @@ type Prepared =
|
|||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* PermissionV2.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 targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
|
||||
for (const hunk of hunks)
|
||||
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
|
||||
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
|
||||
for (const { target } of targets) {
|
||||
const external = 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(targets.map(({ target }) => target.resource))],
|
||||
save: ["*"],
|
||||
})
|
||||
|
||||
const prepared: Prepared[] = []
|
||||
for (const { hunk, target } of targets) {
|
||||
yield* Effect.gen(function* () {
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({ ...hunk, target })
|
||||
return
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.withPermission(
|
||||
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.",
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string) => {
|
||||
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 })
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
if ((yield* fs.stat(target.canonical)).type !== "File")
|
||||
yield* fail(hunk.path, new Error("Target file does not exist"))
|
||||
if (hunk.type === "delete") {
|
||||
prepared.push({ ...hunk, target })
|
||||
return
|
||||
}
|
||||
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({
|
||||
...hunk,
|
||||
target,
|
||||
source,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.try({
|
||||
try: () => Patch.parse(input.patchText),
|
||||
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
|
||||
})
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(hunk.path, Cause.squash(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" })
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
const result = yield* files.create({
|
||||
target: change.target,
|
||||
content:
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
const result = yield* files.remove({ target: change.target })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
const result = yield* files.writeIfUnchanged({
|
||||
target: change.target,
|
||||
expected: change.source,
|
||||
content: change.content,
|
||||
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
|
||||
for (const hunk of hunks)
|
||||
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
|
||||
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
|
||||
for (const { target } of targets) {
|
||||
const external = target.externalDirectory
|
||||
if (external) externalDirectories.set(external.resource, external)
|
||||
}
|
||||
for (const external of externalDirectories.values()) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.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))
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map(({ target }) => target.resource))],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
const prepared: Prepared[] = []
|
||||
for (const { hunk, target } of targets) {
|
||||
yield* Effect.gen(function* () {
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({ ...hunk, target })
|
||||
return
|
||||
}
|
||||
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
|
||||
if (hunk.type === "delete") {
|
||||
prepared.push({ ...hunk, target })
|
||||
return
|
||||
}
|
||||
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({
|
||||
...hunk,
|
||||
target,
|
||||
source,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
})
|
||||
}).pipe(Effect.mapError(() => fail(hunk.path)))
|
||||
}
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
const result = yield* files.create({
|
||||
target: change.target,
|
||||
content:
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
const result = yield* files.remove({ target: change.target })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
const result = yield* files.writeIfUnchanged({
|
||||
target: change.target,
|
||||
expected: change.source,
|
||||
content: change.content,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
}).pipe(Effect.mapError(() => fail(change.path))),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ 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
|
||||
* MCP and plugin tools later use separate scoped canonical registrations, 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.
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@
|
|||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "edit"
|
||||
|
||||
|
|
@ -78,16 +80,6 @@ export const toModelOutput = (output: Success, oldString: string, newString: str
|
|||
"```",
|
||||
].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.
|
||||
|
|
@ -97,80 +89,112 @@ const definition = Tool.make({
|
|||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* PermissionV2.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 }),
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.withPermission(
|
||||
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.",
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
|
||||
],
|
||||
execute: (input, context) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof FileMutation.StaleContentError
|
||||
? new ToolFailure({
|
||||
message: "File changed after permission approval. Read it again before editing.",
|
||||
})
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
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.",
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
}
|
||||
|
||||
const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" }))
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* unableToEdit(
|
||||
permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
yield* unableToEdit(
|
||||
permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
}),
|
||||
)
|
||||
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
|
||||
const ending = detectLineEnding(source.text)
|
||||
const oldString = convertToLineEnding(input.oldString, ending)
|
||||
const newString = convertToLineEnding(input.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 && input.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
})
|
||||
}
|
||||
|
||||
const replaced =
|
||||
input.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({
|
||||
target,
|
||||
expected: source.content,
|
||||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return { ...result, replacements } satisfies Success
|
||||
})
|
||||
}
|
||||
|
||||
const target = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external)))
|
||||
}
|
||||
|
||||
yield* unableToEdit(assertPermission({ action: "edit", resources: [target.resource], save: ["*"] }))
|
||||
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.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({
|
||||
target,
|
||||
expected: source.content,
|
||||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return { ...result, replacements } satisfies Success
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
export * as GlobTool from "./glob"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { LocationSearch } from "../location-search"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "glob"
|
||||
|
||||
|
|
@ -36,14 +38,6 @@ export const toModelOutput = (output: ModelOutput) => {
|
|||
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 supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and traversal.
|
||||
|
|
@ -52,39 +46,42 @@ const definition = Tool.make({
|
|||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const search = yield* LocationSearch.Service
|
||||
const permission = yield* PermissionV2.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)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to find files matching ${parameters.pattern}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: 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.",
|
||||
input: Parameters,
|
||||
output: LocationSearch.FilesResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference })
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
return yield* search.files(input)
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
export * as GrepTool from "./grep"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { LocationSearch } from "../location-search"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "grep"
|
||||
|
||||
|
|
@ -51,14 +53,6 @@ export const toModelOutput = (output: Success) => {
|
|||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"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) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and ripgrep execution.
|
||||
|
|
@ -67,40 +61,49 @@ const definition = Tool.make({
|
|||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const search = yield* LocationSearch.Service
|
||||
const permission = yield* PermissionV2.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)
|
||||
}).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 }))
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"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.",
|
||||
input: Parameters,
|
||||
output: LocationSearch.GrepResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot(input)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
return yield* search.grep(input)
|
||||
}).pipe(
|
||||
Effect.mapError((error) => {
|
||||
const message =
|
||||
error instanceof Ripgrep.InvalidPatternError
|
||||
? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}`
|
||||
: `Unable to grep for ${input.pattern}`
|
||||
return new ToolFailure({ message })
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
export * as NativeTool from "./native"
|
||||
|
||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { SessionSchema } from "../session/schema"
|
||||
|
||||
export interface Context {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export type SchemaType<A> = Schema.Codec<A, any, never, never>
|
||||
|
||||
export interface Executable<Parameters extends SchemaType<any>, Success extends SchemaType<any>> {
|
||||
readonly definition: Tool.Tool<Parameters, Success>
|
||||
readonly execute: (
|
||||
parameters: Schema.Schema.Type<Parameters>,
|
||||
context: Context,
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
}
|
||||
|
||||
export type Any = Executable<any, any>
|
||||
|
||||
export const Failure = ToolFailure
|
||||
export type Failure = ToolFailure
|
||||
|
||||
export type Content =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
export function make<Parameters extends SchemaType<any>, Success extends SchemaType<any>>(config: {
|
||||
readonly description: string
|
||||
readonly parameters: Parameters
|
||||
readonly success: Success
|
||||
readonly execute: (
|
||||
parameters: Schema.Schema.Type<Parameters>,
|
||||
context: Context,
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
readonly toModelOutput?: (input: {
|
||||
readonly callID: string
|
||||
readonly parameters: Schema.Schema.Type<Parameters>
|
||||
readonly output: Success["Encoded"]
|
||||
}) => ReadonlyArray<Content>
|
||||
}): Executable<Parameters, Success> {
|
||||
const toModelOutput = config.toModelOutput
|
||||
return {
|
||||
definition: Tool.make({
|
||||
description: config.description,
|
||||
parameters: config.parameters,
|
||||
success: config.success,
|
||||
toModelOutput: toModelOutput
|
||||
? (input) =>
|
||||
toModelOutput(input).map((content) =>
|
||||
content.type === "text"
|
||||
? content
|
||||
: {
|
||||
type: "file",
|
||||
source: { type: "data", data: content.data },
|
||||
mime: content.mime,
|
||||
name: content.name,
|
||||
},
|
||||
)
|
||||
: undefined,
|
||||
}),
|
||||
execute: config.execute,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
export * as QuestionTool from "./question"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "question"
|
||||
|
||||
|
|
@ -40,42 +42,45 @@ export const toModelOutput = (
|
|||
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 tools = yield* Tools.Service
|
||||
const question = yield* QuestionV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
permission: { action: "question", resource: "*" },
|
||||
authorize: ({ assertPermission }) =>
|
||||
assertPermission({ action: "question", resources: ["*"] }).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||
),
|
||||
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,
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }),
|
||||
],
|
||||
execute: (input, context) =>
|
||||
permission
|
||||
.assert({
|
||||
action: "question",
|
||||
resources: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||
Effect.andThen(
|
||||
question
|
||||
.ask({
|
||||
sessionID: context.sessionID,
|
||||
questions: input.questions,
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.map((answers) => ({ answers })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
export * as ReadTool from "./read"
|
||||
|
||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
|
||||
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { Config } from "../config"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "read"
|
||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||
|
|
@ -53,34 +54,12 @@ const LocationInput = Schema.Struct({
|
|||
const Input = LocationInput
|
||||
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Read a text file or supported image, 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,
|
||||
toStructuredOutput: (output) =>
|
||||
"type" in output && output.type === "binary" && SUPPORTED_IMAGE_MIMES.has(output.mime)
|
||||
? { type: "media", mime: output.mime }
|
||||
: output,
|
||||
toModelOutput: ({ parameters, output }) => {
|
||||
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
|
||||
return [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{
|
||||
type: "file",
|
||||
source: { type: "data", data: output.content },
|
||||
mime: output.mime,
|
||||
name: parameters.path,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const loadPhoton = yield* Effect.cached(
|
||||
Effect.sync(() => {
|
||||
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
|
||||
|
|
@ -88,54 +67,113 @@ export const layer = Layer.effectDiscard(
|
|||
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
|
||||
)
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const input = parameters
|
||||
return Effect.gen(function* () {
|
||||
const resolved = yield* filesystem.resolveReadPath(input)
|
||||
if (resolved.type === "directory") {
|
||||
yield* assertPermission({ action: name, resources: [resolved.resource], save: ["*"] })
|
||||
return yield* filesystem.listPage(input)
|
||||
}
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [resolved.resource],
|
||||
save: ["*"],
|
||||
})
|
||||
const content = yield* filesystem.readTool(input, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||
const mime = content.mime
|
||||
const base64 = content.content
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
||||
),
|
||||
)
|
||||
const limits = {
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
|
||||
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
|
||||
}
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
|
||||
catch: () => new ImageDecodeError(resolved.resource),
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Read a text file or supported image, 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.",
|
||||
input: Input,
|
||||
output: Success,
|
||||
toModelOutput: ({ input, output }) => {
|
||||
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
|
||||
return [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
]
|
||||
},
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const resolved = yield* filesystem.resolveReadPath(input)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resolved.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
try {
|
||||
const width = decoded.get_width()
|
||||
const height = decoded.get_height()
|
||||
const bytes = Buffer.byteLength(base64, "utf-8")
|
||||
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
|
||||
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
|
||||
if (!limits.autoResize)
|
||||
return yield* Effect.die(
|
||||
if (resolved.type === "directory") return yield* filesystem.listPage(input)
|
||||
const content = yield* filesystem.readTool(input, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||
const mime = content.mime
|
||||
const base64 = content.content
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
||||
),
|
||||
)
|
||||
const limits = {
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
|
||||
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
|
||||
}
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
|
||||
catch: () => new ImageDecodeError(resolved.resource),
|
||||
})
|
||||
try {
|
||||
const width = decoded.get_width()
|
||||
const height = decoded.get_height()
|
||||
const bytes = Buffer.byteLength(base64, "utf-8")
|
||||
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
|
||||
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
|
||||
if (!limits.autoResize)
|
||||
return yield* Effect.fail(
|
||||
new ImageSizeError(
|
||||
resolved.resource,
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
limits.maxWidth,
|
||||
limits.maxHeight,
|
||||
limits.maxBase64Bytes,
|
||||
),
|
||||
)
|
||||
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
|
||||
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
|
||||
const previous = acc.at(-1) ?? {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
const next =
|
||||
acc.length === 0
|
||||
? previous
|
||||
: {
|
||||
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
|
||||
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
|
||||
}
|
||||
return acc.some((item) => item.width === next.width && item.height === next.height)
|
||||
? acc
|
||||
: [...acc, next]
|
||||
}, [])
|
||||
for (const size of sizes) {
|
||||
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
|
||||
try {
|
||||
const candidate = [
|
||||
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
|
||||
...JPEG_QUALITIES.map((quality) => ({
|
||||
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
|
||||
mime: "image/jpeg",
|
||||
})),
|
||||
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
|
||||
if (candidate)
|
||||
return new FileSystem.BinaryContent({
|
||||
type: "binary",
|
||||
content: candidate.content,
|
||||
encoding: "base64",
|
||||
mime: candidate.mime,
|
||||
})
|
||||
} finally {
|
||||
resized.free()
|
||||
}
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
new ImageSizeError(
|
||||
resolved.resource,
|
||||
width,
|
||||
|
|
@ -146,65 +184,15 @@ export const layer = Layer.effectDiscard(
|
|||
limits.maxBase64Bytes,
|
||||
),
|
||||
)
|
||||
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
|
||||
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
|
||||
const previous = acc.at(-1) ?? {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
const next =
|
||||
acc.length === 0
|
||||
? previous
|
||||
: {
|
||||
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
|
||||
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
|
||||
}
|
||||
return acc.some((item) => item.width === next.width && item.height === next.height)
|
||||
? acc
|
||||
: [...acc, next]
|
||||
}, [])
|
||||
for (const size of sizes) {
|
||||
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
|
||||
try {
|
||||
const candidate = [
|
||||
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
|
||||
...JPEG_QUALITIES.map((quality) => ({
|
||||
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
|
||||
mime: "image/jpeg",
|
||||
})),
|
||||
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
|
||||
if (candidate)
|
||||
return new FileSystem.BinaryContent({
|
||||
type: "binary",
|
||||
content: candidate.content,
|
||||
encoding: "base64",
|
||||
mime: candidate.mime,
|
||||
})
|
||||
} finally {
|
||||
resized.free()
|
||||
}
|
||||
} finally {
|
||||
decoded.free()
|
||||
}
|
||||
return yield* Effect.die(
|
||||
new ImageSizeError(
|
||||
resolved.resource,
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
limits.maxWidth,
|
||||
limits.maxHeight,
|
||||
limits.maxBase64Bytes,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
decoded.free()
|
||||
}
|
||||
}
|
||||
if (content.type === "binary") return yield* Effect.die(new FileSystem.BinaryFileError(resolved.resource))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.gen(function* () {
|
||||
const error = Cause.squash(cause)
|
||||
if (content.type === "binary")
|
||||
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.mapError((error) => {
|
||||
const message =
|
||||
error instanceof FileSystem.BinaryFileError ||
|
||||
error instanceof FileSystem.MediaIngestLimitError ||
|
||||
|
|
@ -212,18 +200,12 @@ export const layer = Layer.effectDiscard(
|
|||
error instanceof ImageSizeError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return yield* new ToolFailure({ message, error })
|
||||
return new ToolFailure({ message })
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
)
|
||||
},
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(ToolRegistry.defaultLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,91 +1,35 @@
|
|||
export * as ToolRegistry from "./registry"
|
||||
|
||||
import {
|
||||
Tool,
|
||||
ToolFailure,
|
||||
ToolOutput,
|
||||
ToolResultValue as ToolResult,
|
||||
type Tool as TypedTool,
|
||||
type ToolCall,
|
||||
type ToolResultValue,
|
||||
type ToolSchema,
|
||||
type ToolSettlement,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft, enableMapSet } from "immer"
|
||||
import { PermissionV2 } from "../permission"
|
||||
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 { Tool as LlmTool, ToolOutput, type ToolCall, type ToolSettlement } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export type ExecuteInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent?: AgentV2.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow cross-cutting context for one registry invocation. Leaf tools retain
|
||||
* ownership of sequence-sensitive policy decisions; the registry only binds
|
||||
* identity and shared helper behavior consistently.
|
||||
*
|
||||
* TODO: Add `source` when the runner can pass the durable owning assistant
|
||||
* message ID alongside the call ID. Do not infer it from the tool call alone.
|
||||
* TODO: Add cancellation and progress only when the runner exposes a real
|
||||
* signal and durable/live progress sink.
|
||||
*/
|
||||
export type Invocation = ExecuteInput & {
|
||||
readonly source?: PermissionV2.Source
|
||||
readonly assertPermission: (
|
||||
input: Omit<PermissionV2.AssertInput, "sessionID" | "agent" | "source">,
|
||||
) => Effect.Effect<void, PermissionV2.Error | SessionV2.NotFoundError>
|
||||
}
|
||||
|
||||
/** Kept as the leaf entry input name for backwards-compatible execute usage. */
|
||||
export type AuthorizeInput<Parameters = unknown> = Invocation & {
|
||||
readonly parameters: Parameters
|
||||
}
|
||||
|
||||
export type Entry<
|
||||
Parameters extends ToolSchema<any> = ToolSchema<any>,
|
||||
Success extends ToolSchema<any> = ToolSchema<any>,
|
||||
> = {
|
||||
readonly tool: TypedTool<Parameters, Success>
|
||||
/** Catalog visibility only. Execution authorization remains leaf-owned. */
|
||||
readonly permission?: { readonly action: string; readonly resource: "*" }
|
||||
readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
|
||||
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 = {
|
||||
readonly entries: Map<string, Entry>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
readonly list: () => ReadonlyArray<readonly [string, Entry]>
|
||||
readonly get: (name: string) => Entry | undefined
|
||||
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(
|
||||
name: string,
|
||||
entry: Entry<Parameters, Success>,
|
||||
) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly definitions: (
|
||||
permissions?: PermissionV2.Ruleset,
|
||||
) => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
|
||||
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement>
|
||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||
/** Internal registration capability exposed publicly only through Tools.Service. */
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
export interface Materialization {
|
||||
readonly definitions: ReadonlyArray<ReturnType<typeof LlmTool.toDefinitions>[number]>
|
||||
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
|
||||
}
|
||||
|
||||
export interface Settlement extends ToolSettlement {
|
||||
|
|
@ -94,153 +38,94 @@ export interface Settlement extends ToolSettlement {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const registryLayer = Layer.effect(
|
||||
Service,
|
||||
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) => ({
|
||||
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
|
||||
get: (name) => draft.entries.get(name) as Entry | undefined,
|
||||
set: (name, entry) => {
|
||||
draft.entries.set(
|
||||
name,
|
||||
castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
|
||||
)
|
||||
},
|
||||
remove: (name) => {
|
||||
draft.entries.delete(name)
|
||||
},
|
||||
}),
|
||||
})
|
||||
type Registration = { readonly identity: object; readonly tool: Tool.AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
const definitions = Effect.fn("ToolRegistry.definitions")(function* (permissions: PermissionV2.Ruleset = []) {
|
||||
const tools = new Map(state.get().entries)
|
||||
// Location tools own their names. Application tools fill otherwise-unclaimed names.
|
||||
for (const [name, tool] of applications.entries()) {
|
||||
if (!tools.has(name)) tools.set(name, { tool: tool.definition })
|
||||
}
|
||||
return Tool.toDefinitions(
|
||||
Object.fromEntries(
|
||||
Array.from(tools)
|
||||
.filter(([name, entry]) => !whollyDisabled(entry.permission ?? defaultPermission(name), permissions))
|
||||
.map(([name, entry]) => [name, entry.tool]),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const entry = (name: string): Entry | undefined => {
|
||||
const local = state.get().entries.get(name)
|
||||
if (local !== undefined) return local
|
||||
const tool = applications.entries().get(name)
|
||||
if (tool === undefined) return
|
||||
return {
|
||||
tool: tool.definition,
|
||||
execute: ({ parameters, sessionID, call }) =>
|
||||
tool.execute(parameters, { sessionID, id: call.id, name: call.name }),
|
||||
}
|
||||
}
|
||||
|
||||
const invocation = (input: ExecuteInput): Invocation => ({
|
||||
...input,
|
||||
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
|
||||
assertPermission: (request) =>
|
||||
permission.assert({ ...request, sessionID: input.sessionID, ...(input.agent ? { agent: input.agent } : {}) }),
|
||||
})
|
||||
|
||||
const settleEntry = Effect.fn("ToolRegistry.settleEntry")(function* (
|
||||
entry: Entry | undefined,
|
||||
input: ExecuteInput,
|
||||
) {
|
||||
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
|
||||
if (!entry.execute && !entry.tool.execute)
|
||||
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
|
||||
|
||||
return yield* entry.tool._decode(input.call.input).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||
Effect.flatMap((parameters) => {
|
||||
const context = { ...invocation(input), parameters }
|
||||
const execute =
|
||||
entry.execute?.(context) ?? entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
|
||||
return (
|
||||
entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))
|
||||
).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
entry.tool._encode(value).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `Tool returned an invalid value for its success schema: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
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
|
||||
}),
|
||||
)
|
||||
}),
|
||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
||||
const registration =
|
||||
local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name)
|
||||
if (!registration)
|
||||
return {
|
||||
result: {
|
||||
type: "error" as const,
|
||||
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
|
||||
},
|
||||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
const pending = yield* Tool.settle(registration.tool, input.call, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
if ("result" in pending) return pending
|
||||
const output = pending.output
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
if (result.type === "error")
|
||||
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
|
||||
return bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
|
||||
const transform = yield* state.transform()
|
||||
yield* transform(update)
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools) {
|
||||
const entries = Object.entries(tools)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
|
||||
const token = {}
|
||||
for (const [name, tool] of entries)
|
||||
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const [name] of entries) {
|
||||
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(name, registrations)
|
||||
else local.delete(name)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) {
|
||||
const registrations = new Map(applications.entries())
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (registration) registrations.set(name, registration)
|
||||
}
|
||||
for (const [name, registration] of registrations)
|
||||
if (whollyDisabled(Tool.permission(registration.tool, name), permissions)) registrations.delete(name)
|
||||
return {
|
||||
definitions: Array.from(registrations, ([name, registration]) => Tool.definition(name, registration.tool)),
|
||||
settle: (input) => {
|
||||
const registration = registrations.get(input.call.name)
|
||||
if (registration) return settleWith(input, registration.identity)
|
||||
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
|
||||
},
|
||||
}
|
||||
}),
|
||||
definitions,
|
||||
execute,
|
||||
settle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function defaultPermission(name: string) {
|
||||
return { action: ["edit", "write", "apply_patch"].includes(name) ? "edit" : name, resource: "*" as const }
|
||||
}
|
||||
export const layer = Layer.effect(
|
||||
Tools.Service,
|
||||
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
|
||||
).pipe(Layer.provideMerge(registryLayer))
|
||||
|
||||
function whollyDisabled(permission: { readonly action: string; readonly resource: "*" }, rules: PermissionV2.Ruleset) {
|
||||
const rule = rules.findLast((rule) => Wildcard.match(permission.action, rule.action))
|
||||
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
|
||||
return rule?.resource === "*" && rule.effect === "deny"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ 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 { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { 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 "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "skill"
|
||||
const FILE_LIMIT = 10
|
||||
|
|
@ -21,8 +22,6 @@ export const Success = Schema.Struct({
|
|||
name: Schema.String,
|
||||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const description = [
|
||||
|
|
@ -57,53 +56,50 @@ const unableToLoad = (name: string, error?: unknown) =>
|
|||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
yield* boot.wait()
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
})
|
||||
|
||||
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()
|
||||
const skill = current.find((skill) => skill.name === parameters.name)
|
||||
if (!skill) return yield* unableToLoad(parameters.name)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] })
|
||||
const directory = path.dirname(skill.location)
|
||||
const files =
|
||||
path.basename(skill.location) === "SKILL.md"
|
||||
? (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 ? { outputPath: output.outputPath } : {}),
|
||||
}
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause)))))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.name === input.name)
|
||||
if (!skill) return yield* unableToLoad(input.name)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [skill.name],
|
||||
save: [skill.name],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
const directory = path.dirname(skill.location)
|
||||
const files =
|
||||
path.basename(skill.location) === "SKILL.md"
|
||||
? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
||||
.filter((file) => path.basename(file) !== "SKILL.md")
|
||||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
: []
|
||||
return {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: toModelOutput(skill, files),
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
export * as TodoWriteTool from "./todowrite"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "todowrite"
|
||||
|
||||
|
|
@ -18,33 +20,35 @@ 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 tools = yield* Tools.Service
|
||||
const todos = yield* SessionTodo.Service
|
||||
const permission = yield* PermissionV2.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) })),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: 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.",
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
||||
return { todos: input.todos }
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
136
packages/core/src/tool/tool.ts
Normal file
136
packages/core/src/tool/tool.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
export * as Tool from "./tool"
|
||||
|
||||
import { Tool as LlmTool, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import type { SessionMessage } from "../session/message"
|
||||
import type { SessionSchema } from "../session/schema"
|
||||
|
||||
export interface Context {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
}
|
||||
|
||||
export type SchemaType<A> = Schema.Codec<A, any, never, never>
|
||||
|
||||
declare const TypeId: unique symbol
|
||||
|
||||
export interface Tool<Input extends SchemaType<any>, Output extends SchemaType<any>> {
|
||||
readonly [TypeId]: {
|
||||
readonly _Input: Input
|
||||
readonly _Output: Output
|
||||
}
|
||||
}
|
||||
|
||||
export type AnyTool = Tool<any, any>
|
||||
export const Failure = ToolFailure
|
||||
export type Failure = ToolFailure
|
||||
|
||||
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type Content =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
||||
|
||||
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
|
||||
readonly description: string
|
||||
readonly input: Input
|
||||
readonly output: Output
|
||||
readonly execute: (
|
||||
input: Schema.Schema.Type<Input>,
|
||||
context: Context,
|
||||
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
|
||||
readonly toModelOutput?: (input: {
|
||||
readonly input: Schema.Schema.Type<Input>
|
||||
readonly output: Output["Encoded"]
|
||||
}) => ReadonlyArray<Content>
|
||||
}
|
||||
|
||||
type Runtime = {
|
||||
readonly permission?: string
|
||||
readonly definition: (name: string) => ReturnType<typeof LlmTool.toDefinitions>[number]
|
||||
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
|
||||
}
|
||||
|
||||
const runtimes = new WeakMap<AnyTool, Runtime>()
|
||||
|
||||
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||
config: Config<Input, Output>,
|
||||
): Tool<Input, Output> {
|
||||
const tool = Object.freeze({}) as Tool<Input, Output>
|
||||
const definitions = new Map<string, ReturnType<typeof LlmTool.toDefinitions>[number]>()
|
||||
runtimes.set(tool, {
|
||||
definition: (name) => {
|
||||
const cached = definitions.get(name)
|
||||
if (cached) return cached
|
||||
const definition = LlmTool.toDefinitions({
|
||||
[name]: LlmTool.make({ description: config.description, parameters: config.input, success: config.output }),
|
||||
})[0]
|
||||
definitions.set(name, definition)
|
||||
return definition
|
||||
},
|
||||
settle: (call, context) =>
|
||||
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||
Effect.flatMap((input) =>
|
||||
config.execute(input, context).pipe(
|
||||
Effect.flatMap((output) =>
|
||||
Schema.encodeEffect(config.output)(output).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `Tool returned an invalid value for its output schema: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map((output) =>
|
||||
ToolOutput.make(
|
||||
output,
|
||||
config.toModelOutput?.({ input, output }).map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
source: { type: "data" as const, data: part.data },
|
||||
mime: part.mime,
|
||||
name: part.name,
|
||||
},
|
||||
) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
return tool
|
||||
}
|
||||
|
||||
export const validateName = (name: string) =>
|
||||
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
|
||||
? Effect.void
|
||||
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
|
||||
|
||||
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||
tool: Tool<Input, Output>,
|
||||
permission: string,
|
||||
) => {
|
||||
const decorated = Object.freeze({}) as Tool<Input, Output>
|
||||
runtimes.set(decorated, { ...runtimeOf(tool), permission })
|
||||
return decorated
|
||||
}
|
||||
|
||||
export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
|
||||
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name)
|
||||
export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
|
||||
|
||||
function runtimeOf(tool: AnyTool) {
|
||||
const runtime = runtimes.get(tool)
|
||||
if (!runtime) throw new TypeError("Invalid Core Tool value")
|
||||
return runtime
|
||||
}
|
||||
13
packages/core/src/tool/tools.ts
Normal file
13
packages/core/src/tool/tools.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export * as Tools from "./tools"
|
||||
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
/** Narrow registration-only Location capability. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Tools") {}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
export * as WebFetchTool from "./webfetch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { 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 "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "webfetch"
|
||||
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
|
|
@ -34,8 +35,6 @@ const Success = Schema.Struct({
|
|||
contentType: Schema.String,
|
||||
format: Parameters.fields.format,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type Format = (typeof Parameters.Type)["format"]
|
||||
|
|
@ -49,6 +48,7 @@ const acceptHeader = (format: Format) => {
|
|||
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"
|
||||
}
|
||||
return "*/*"
|
||||
}
|
||||
|
||||
const headers = (format: Format, userAgent: string) => ({
|
||||
|
|
@ -89,15 +89,17 @@ 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)`))
|
||||
return yield* Effect.fail(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(() => {
|
||||
Effect.gen(function* () {
|
||||
size += chunk.byteLength
|
||||
if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)
|
||||
if (size > MAX_RESPONSE_BYTES)
|
||||
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
|
||||
chunks.push(chunk)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
return Buffer.concat(chunks, size)
|
||||
|
|
@ -115,9 +117,6 @@ const isTextualMime = (mime: string) =>
|
|||
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)
|
||||
|
|
@ -125,71 +124,64 @@ const convert = (content: string, contentType: string, format: Format) => {
|
|||
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 tools = yield* Tools.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const resources = yield* ToolOutputStore.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 parsed = new URL(parameters.url)
|
||||
assertHttpUrl(parsed)
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
try: () => assertHttpUrl(new URL(input.url)),
|
||||
catch: (error) => error,
|
||||
})
|
||||
|
||||
yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters })
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.url],
|
||||
save: ["*"],
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
|
||||
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 { body, contentType } = yield* Effect.gen(function* () {
|
||||
const response = yield* execute(http, input.url, input.format).pipe(
|
||||
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
|
||||
)
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = mimeFrom(contentType)
|
||||
if (isImageAttachment(mime))
|
||||
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
|
||||
if (!isTextualMime(mime))
|
||||
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
|
||||
return { body: yield* collectBody(response), contentType }
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
|
||||
orElse: () => Effect.fail(new Error("Request timed out")),
|
||||
}),
|
||||
)
|
||||
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 ? { outputPath: truncated.outputPath } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to fetch ${parameters.url}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const content = convert(new TextDecoder().decode(body), contentType, input.format)
|
||||
return {
|
||||
url: input.url,
|
||||
contentType,
|
||||
format: input.format,
|
||||
output: content,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
export * as WebSearchTool from "./websearch"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { 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 "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { checksum } from "../util/encode"
|
||||
|
||||
export const name = "websearch"
|
||||
|
|
@ -165,12 +166,12 @@ const callMcp = <F extends Schema.Struct.Fields>(
|
|||
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* Effect.fail(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`)),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -178,82 +179,68 @@ const callMcp = <F extends Schema.Struct.Fields>(
|
|||
const Success = Schema.Struct({
|
||||
provider: Provider,
|
||||
text: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
outputPath: Schema.String.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 tools = yield* Tools.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const resources = yield* ToolOutputStore.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 }) => {
|
||||
const provider = selectProvider(sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.query],
|
||||
save: ["*"],
|
||||
metadata: { ...parameters, provider },
|
||||
})
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: { ...input, provider },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
|
||||
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 ? { outputPath: truncated.outputPath } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to search the web for ${parameters.query}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
const text =
|
||||
provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: input.query,
|
||||
type: input.type || "auto",
|
||||
numResults: input.numResults || 8,
|
||||
livecrawl: input.livecrawl || "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
return {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
|
||||
},
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@
|
|||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "write"
|
||||
|
||||
|
|
@ -35,14 +37,6 @@ 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.
|
||||
|
|
@ -51,28 +45,50 @@ const definition = Tool.make({
|
|||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: parameters.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
yield* assertPermission({ action: "edit", resources: [target.resource], save: ["*"] })
|
||||
return yield* files.writeTextPreservingBom({ target, content: parameters.content })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to write ${parameters.path}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.withPermission(
|
||||
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.",
|
||||
input: Parameters,
|
||||
output: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(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.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue