Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions packages/core/src/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
export * as Download from "./download"

import path from "node:path"
import { createHash } from "node:crypto"
import { open } from "node:fs/promises"
import { Effect, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "./fs-util"

export type Phase = "starting" | "downloading" | "verifying" | "completed"

export interface Progress {
readonly phase: Phase
readonly url: string
readonly filePath: string
readonly receivedBytes: number
readonly totalBytes?: number
readonly percent?: number
readonly bytesPerSecond: number
readonly elapsedMs: number
}

export interface Result extends Progress {
readonly phase: "completed"
readonly sha256: string
}

export interface Input {
readonly http: HttpClient.HttpClient
readonly fs: FSUtil.Interface
readonly url: string
readonly filePath: string
readonly temporaryID: string
readonly expectedSha256?: string
readonly overwrite?: boolean
readonly onProgress: (progress: Progress) => Effect.Effect<void, unknown>
}

const INTERVAL_MS = 250

const declaredLength = (response: HttpClientResponse.HttpClientResponse): number | undefined => {
const raw = response.headers["content-length"]
if (!raw) return
const size = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(size) || size < 0) return
return size
}

const writeAll = (handle: Awaited<ReturnType<typeof open>>, chunk: Uint8Array) =>
Effect.tryPromise({
try: async () => {
let offset = 0
while (offset < chunk.byteLength) {
const result = await handle.write(chunk, offset, chunk.byteLength - offset)
if (result.bytesWritten === 0) throw new Error("Download file stopped accepting data")
offset += result.bytesWritten
}
},
catch: (cause) => new Error("Unable to write downloaded data", { cause }),
})

/**
* Streams one HTTP(S) response to an adjacent temporary file, then renames it
* into place only after the body and optional checksum have completed.
*/
export const file = Effect.fn("Download.file")(function* (input: Input) {
const started = Date.now()
const hash = createHash("sha256")
const suffix = input.temporaryID.replaceAll(/[^A-Za-z0-9_-]/g, "_") || "download"
const temporary = `${input.filePath}.opencode-part-${suffix}`
let receivedBytes = 0
let totalBytes: number | undefined
let lastReportedAt = 0

const progress = (phase: Phase, force = false) => {
const now = Date.now()
if (!force && now - lastReportedAt < INTERVAL_MS) return Effect.void
lastReportedAt = now
const elapsedMs = Math.max(now - started, 1)
return input.onProgress({
phase,
url: input.url,
filePath: input.filePath,
receivedBytes,
...(totalBytes === undefined ? {} : { totalBytes }),
...(totalBytes && totalBytes > 0
? { percent: Math.min(100, Math.max(0, (receivedBytes / totalBytes) * 100)) }
: {}),
bytesPerSecond: Math.round((receivedBytes * 1000) / elapsedMs),
elapsedMs,
})
}

return yield* Effect.gen(function* () {
yield* input.fs.ensureDir(path.dirname(input.filePath))
if (!input.overwrite && (yield* input.fs.existsSafe(input.filePath)))
return yield* Effect.fail(new Error(`Destination already exists: ${input.filePath}`))
yield* input.fs.remove(temporary, { force: true }).pipe(Effect.ignore)
yield* progress("starting", true)

const request = HttpClientRequest.get(input.url).pipe(
HttpClientRequest.setHeaders({
"User-Agent": "opencode",
Accept: "*/*",
"Accept-Encoding": "identity",
}),
)
const response = yield* input.http.execute(request).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
totalBytes = declaredLength(response)
yield* progress("downloading", true)

yield* Effect.scoped(
Effect.acquireRelease(
Effect.tryPromise({
try: () => open(temporary, "wx"),
catch: (cause) => new Error(`Unable to create temporary download file: ${temporary}`, { cause }),
}),
(handle) => Effect.promise(() => handle.close()).pipe(Effect.ignore),
).pipe(
Effect.flatMap((handle) =>
Stream.runForEach(response.stream, (chunk) =>
writeAll(handle, chunk).pipe(
Effect.tap(() =>
Effect.sync(() => {
hash.update(chunk)
receivedBytes += chunk.byteLength
}),
),
Effect.andThen(progress("downloading")),
),
),
),
),
)

yield* progress("verifying", true)
const sha256 = hash.digest("hex")
if (input.expectedSha256 && sha256 !== input.expectedSha256.toLowerCase())
return yield* Effect.fail(
new Error(`SHA-256 mismatch: expected ${input.expectedSha256.toLowerCase()}, received ${sha256}`),
)
yield* input.fs.rename(temporary, input.filePath)
yield* progress("completed", true)

const elapsedMs = Math.max(Date.now() - started, 1)
return {
phase: "completed" as const,
url: input.url,
filePath: input.filePath,
receivedBytes,
...(totalBytes === undefined ? {} : { totalBytes }),
...(totalBytes && totalBytes > 0
? { percent: Math.min(100, Math.max(0, (receivedBytes / totalBytes) * 100)) }
: {}),
bytesPerSecond: Math.round((receivedBytes * 1000) / elapsedMs),
elapsedMs,
sha256,
} satisfies Result
}).pipe(Effect.ensuring(input.fs.remove(temporary, { force: true }).pipe(Effect.ignore)))
})
17 changes: 15 additions & 2 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ import { llmClient } from "../../effect/app-node-platform"
* - [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, attachment normalization,
* plugins, and cancellation settlement.
* - [x] Persist bounded tool progress updates through the tool execution context.
* - [ ] Add scoped runtime context, 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.
* - [ ] Continue for compaction or another continuation condition when required.
Expand Down Expand Up @@ -254,6 +254,19 @@ const layer = Layer.effect(
agent: agent.id,
assistantMessageID,
call: event,
progress: (input) =>
withPublication(
Effect.gen(function* () {
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: event.id,
structured: input.structured,
content: input.content ?? [],
})
}),
),
}),
).pipe(
Effect.flatMap((settlement) =>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/tool/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as BuiltInTools from "./builtins"
import { makeLocationNode } from "../effect/app-node"
import { Layer } from "effect"
import { BashTool } from "./bash"
import { DownloadTool } from "./download"
import { ApplyPatchTool } from "./apply-patch"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
Expand Down Expand Up @@ -34,6 +35,7 @@ export const node = makeLocationNode({
deps: [
ApplyPatchTool.node,
BashTool.node,
DownloadTool.node,
EditTool.node,
GlobTool.node,
GrepTool.node,
Expand Down
147 changes: 147 additions & 0 deletions packages/core/src/tool/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
export * as DownloadTool from "./download"

import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Download } from "../download"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool"
import { Tools } from "./tools"

export const name = "download"

export const description = `Download a large HTTP(S) file directly to disk with durable progress reporting.

Use this tool instead of webfetch, bash, curl, or wget when a file may be large or take more than a few seconds. The host streams bytes without returning control to the model, so do not poll or narrate progress. The next model turn starts only after the download completes or fails.`

export const Input = Schema.Struct({
url: Schema.String.annotate({ description: "HTTP or HTTPS URL of the file to download" }),
filePath: Schema.String.annotate({
description:
"Destination file path. Relative paths resolve within the active Location; external absolute paths require approval.",
}),
sha256: Schema.String.pipe(Schema.optional).annotate({
description: "Optional expected lowercase or uppercase SHA-256 checksum",
}),
overwrite: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Replace an existing destination file. Defaults to false.",
}),
})

const Progress = Schema.Struct({
phase: Schema.Literals(["starting", "downloading", "verifying", "completed"]),
url: Schema.String,
filePath: Schema.String,
receivedBytes: Schema.Number,
totalBytes: Schema.Number.pipe(Schema.optional),
percent: Schema.Number.pipe(Schema.optional),
bytesPerSecond: Schema.Number,
elapsedMs: Schema.Number,
})

export const Output = Schema.Struct({
download: Progress,
sha256: Schema.String,
})

const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const http = yield* HttpClient.HttpClient
const fs = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const permission = yield* PermissionV2.Service

yield* tools
.register({
[name]: Tool.make({
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [
{
type: "text",
text: `Downloaded ${output.download.receivedBytes} bytes to ${output.download.filePath}. SHA-256: ${output.sha256}`,
},
],
execute: (input, context) =>
Effect.gen(function* () {
const parsed = yield* Effect.try({ try: () => new URL(input.url), catch: (error) => error })
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
return yield* Effect.fail(new Error("URL must use http:// or https://"))
if (input.sha256 && !/^[a-fA-F0-9]{64}$/.test(input.sha256))
return yield* Effect.fail(new Error("sha256 must contain exactly 64 hexadecimal characters"))

const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
const target = yield* mutation.resolve({ path: input.filePath, kind: "file" })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.url],
save: ["*"],
metadata: { url: input.url, filePath: target.resource },
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})

const result = yield* Download.file({
http,
fs,
url: input.url,
filePath: target.canonical,
temporaryID: context.toolCallID,
expectedSha256: input.sha256,
overwrite: input.overwrite,
onProgress: (download) =>
context.progress?.({
structured: {
url: input.url,
filePath: target.canonical,
download,
},
}) ?? Effect.void,
})
return { download: result, sha256: result.sha256 }
}).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Download failed: ${error instanceof Error ? error.message : String(error)}`,
}),
),
),
}),
})
.pipe(Effect.orDie)
}),
)

export const node = makeLocationNode({
name: "tool/download",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, PermissionV2.node, LayerNodePlatform.httpClient],
})
12 changes: 11 additions & 1 deletion packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ApplicationTools } from "./application-tools"
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
import {
definition,
permission,
settle,
validateName,
type AnyTool,
type Context as ToolContext,
type RegistrationError,
} from "./tool"
import { Tools } from "./tools"
import { makeLocationNode } from "../effect/app-node"

Expand All @@ -18,6 +26,7 @@ export type ExecuteInput = {
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: ToolContext["progress"]
}

export interface Interface {
Expand Down Expand Up @@ -64,6 +73,7 @@ const registryLayer = Layer.effect(
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
...(input.progress ? { progress: input.progress } : {}),
}).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface Context {
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly progress?: (input: {
readonly structured: Record<string, unknown>
readonly content?: ReadonlyArray<{ readonly type: "text"; readonly text: string }>
}) => Effect.Effect<void, unknown>
}

export type SchemaType<A> = Schema.Codec<A, any, never, never>
Expand Down
Loading
Loading