diff --git a/packages/core/src/download.ts b/packages/core/src/download.ts new file mode 100644 index 000000000000..77c3f11bf225 --- /dev/null +++ b/packages/core/src/download.ts @@ -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 +} + +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>, 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))) +}) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 72c761e10d93..b730646ff429 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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. @@ -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) => diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index a558124378df..7589c2927b10 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -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" @@ -34,6 +35,7 @@ export const node = makeLocationNode({ deps: [ ApplyPatchTool.node, BashTool.node, + DownloadTool.node, EditTool.node, GlobTool.node, GrepTool.node, diff --git a/packages/core/src/tool/download.ts b/packages/core/src/tool/download.ts new file mode 100644 index 000000000000..440c79cabe24 --- /dev/null +++ b/packages/core/src/tool/download.ts @@ -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], +}) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1c2dfe7ab459..307b586c77c3 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -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" @@ -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 { @@ -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) => diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 1d9a82e9522d..d80687c5307e 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -11,6 +11,10 @@ export interface Context { readonly agent: AgentV2.ID readonly assistantMessageID: SessionMessage.ID readonly toolCallID: string + readonly progress?: (input: { + readonly structured: Record + readonly content?: ReadonlyArray<{ readonly type: "text"; readonly text: string }> + }) => Effect.Effect } export type SchemaType = Schema.Codec diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index d3889d6a7ab3..45375a60404a 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -20,7 +20,7 @@ export const MAX_TIMEOUT_SECONDS = 120 export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default. -Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.` +Use a more targeted tool when one is available. Use the download tool instead when saving a file that may be large or take more than a few seconds. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.` const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS)) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index e28e758c87b8..ce0e9fba9255 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -107,6 +107,7 @@ describe("LocationServiceMap", () => { "application_context", "apply_patch", "bash", + "download", "edit", "glob", "grep", @@ -124,6 +125,7 @@ describe("LocationServiceMap", () => { "application_context", "apply_patch", "bash", + "download", "edit", "glob", "grep", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0515d55cf5be..fc9619d8fda9 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -567,8 +567,9 @@ describe("SessionRunnerLLM", () => { input: Schema.Struct({ query: Schema.String }), output: Schema.Struct({ answer: Schema.String }), execute: ({ query }, context) => - Effect.sync(() => { + Effect.gen(function* () { contexts.push(context) + yield* (context.progress?.({ structured: { phase: "checkpoint" } }) ?? Effect.void).pipe(Effect.ignore) return { answer: query.toUpperCase() } }), }), @@ -593,8 +594,17 @@ describe("SessionRunnerLLM", () => { agent: AgentV2.ID.make("build"), assistantMessageID: expect.stringMatching(/^msg_/), toolCallID: "call-application", + progress: expect.any(Function), }, ]) + const { db } = yield* Database.Service + const eventTypes = yield* db + .select({ type: EventTable.type }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .all() + .pipe(Effect.orDie) + expect(eventTypes.map((event) => event.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1)) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Use application context" }, { diff --git a/packages/core/test/tool-download.test.ts b/packages/core/test/tool-download.test.ts new file mode 100644 index 000000000000..fff20ff0554d --- /dev/null +++ b/packages/core/test/tool-download.test.ts @@ -0,0 +1,166 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { createHash } from "node:crypto" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { Location } from "@opencode-ai/core/location" +import { LocationMutation } from "@opencode-ai/core/location-mutation" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { DownloadTool } from "@opencode-ai/core/tool/download" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" +import { settleTool, toolDefinitions, toolIdentity } from "./lib/tool" + +const sessionID = SessionV2.ID.make("ses_download_tool_test") +const payload = new TextEncoder().encode("large-file-chunk".repeat(16_384)) +const checksum = createHash("sha256").update(payload).digest("hex") +const assertions: PermissionV2.AssertInput[] = [] + +const downloadPhase = (update: Record) => { + const download = update.download + if (!download || typeof download !== "object" || Array.isArray(download)) return + const phase: unknown = Reflect.get(download, "phase") + return typeof phase === "string" ? phase : undefined +} + +const permission = Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: (input) => Effect.sync(() => assertions.push(input)), + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) + +const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(payload, { + headers: { "content-length": String(payload.byteLength), "content-type": "application/octet-stream" }, + }), + ), + ), + ), +) + +const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { + const activeLocation = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(directory) })), + ) + return Effect.gen(function* () { + return yield* body(yield* ToolRegistry.Service) + }).pipe( + Effect.provide( + AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, DownloadTool.node]), + [ + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [LayerNodePlatform.httpClient, http], + ], + ), + ), + ) +} + +const call = ( + input: typeof DownloadTool.Input.Type, + progress: NonNullable, + id = "call-download", +) => ({ + sessionID, + ...toolIdentity, + call: { type: "tool-call" as const, id, name: "download", input }, + progress, +}) + +const it = testEffect(Layer.empty) + +describe("DownloadTool", () => { + it.live("streams to disk, publishes progress, verifies SHA-256, and settles only after completion", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + assertions.length = 0 + const updates: Record[] = [] + return withTool(tmp.path, (registry) => + Effect.gen(function* () { + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["download"]) + const settled = yield* settleTool( + registry, + call( + { url: "https://example.com/archive.bin", filePath: "artifacts/archive.bin", sha256: checksum }, + (update) => Effect.sync(() => updates.push(update.structured)), + ), + ) + const target = path.join(tmp.path, "artifacts", "archive.bin") + expect(new Uint8Array(yield* Effect.promise(() => fs.readFile(target)))).toEqual(payload) + expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining(checksum) }) + expect(settled.output?.structured).toMatchObject({ + sha256: checksum, + download: { phase: "completed", receivedBytes: payload.byteLength, totalBytes: payload.byteLength }, + }) + expect(updates.map(downloadPhase)).toEqual(["starting", "downloading", "verifying", "completed"]) + expect(assertions.map((input) => input.action)).toEqual(["download", "edit"]) + expect( + (yield* Effect.promise(() => fs.readdir(path.dirname(target)))).some((name) => + name.includes(".opencode-part-"), + ), + ).toBe(false) + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + it.live("reports checksum failures and removes the partial file", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + withTool(tmp.path, (registry) => + Effect.gen(function* () { + const target = path.join(tmp.path, "bad.bin") + const settled = yield* settleTool( + registry, + call( + { url: "https://example.com/bad.bin", filePath: "bad.bin", sha256: "0".repeat(64) }, + () => Effect.void, + "call-bad-checksum", + ), + ) + expect(settled.result).toMatchObject({ type: "error", value: expect.stringContaining("SHA-256 mismatch") }) + expect( + yield* Effect.promise(() => + fs.stat(target).then( + () => true, + () => false, + ), + ), + ).toBe(false) + expect( + (yield* Effect.promise(() => fs.readdir(tmp.path))).some((name) => name.includes(".opencode-part-")), + ).toBe(false) + }), + ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 959a303dc964..6f722805d3b4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -70,11 +70,20 @@ export const SummarizePayload = Schema.Struct({ export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])) export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])) export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])) +export const BtwPayload = Schema.Struct(Struct.omit(SessionPrompt.BtwInput.fields, ["sessionID"])) export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])) export const PermissionResponsePayload = Schema.Struct({ response: PermissionV1.Reply, }) +export class BtwApiError extends Schema.ErrorClass("BtwError")( + { + name: Schema.Literal("BtwError"), + data: Schema.Struct({ message: Schema.String }), + }, + { httpApiStatus: 500 }, +) {} + export const SessionPaths = { list: root, status: `${root}/status`, @@ -89,6 +98,7 @@ export const SessionPaths = { update: `${root}/:sessionID`, fork: `${root}/:sessionID/fork`, abort: `${root}/:sessionID/abort`, + btw: `${root}/:sessionID/btw`, share: `${root}/:sessionID/share`, init: `${root}/:sessionID/init`, summarize: `${root}/:sessionID/summarize`, @@ -262,6 +272,20 @@ export const SessionApi = HttpApi.make("session") description: "Abort an active session and stop any ongoing AI processing or command execution.", }), ), + HttpApiEndpoint.post("btw", SessionPaths.btw, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: BtwPayload, + success: described(SessionPrompt.BtwResult, "Ephemeral side answer"), + error: [HttpApiError.BadRequest, ApiNotFoundError, BtwApiError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.btw", + summary: "Ask a side question", + description: + "Ask an ephemeral, tool-free question using the current session context without changing its message history or execution state.", + }), + ), HttpApiEndpoint.post("init", SessionPaths.init, { params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 662585020a64..366303e49f8d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -23,6 +23,8 @@ import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { + BtwApiError, + BtwPayload, CommandPayload, DiffQuery, ForkPayload, @@ -38,6 +40,7 @@ import { } from "../groups/session" import { PermissionNotFoundError } from "../errors" import * as SessionError from "./session-errors" +import { errorMessage } from "@/util/error" const tryParseJson = (text: string) => Effect.try({ @@ -234,6 +237,29 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return true }) + const btw = Effect.fn("SessionHttpApi.btw")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof BtwPayload.Type + }) { + yield* requireSession(ctx.params.sessionID) + if (!ctx.payload.question.trim()) return yield* new HttpApiError.BadRequest({}) + return yield* promptSvc + .btw({ + sessionID: ctx.params.sessionID, + question: ctx.payload.question, + exchanges: ctx.payload.exchanges, + }) + .pipe( + Effect.mapError( + (error) => + new BtwApiError({ + name: "BtwError", + data: { message: errorMessage(error) }, + }), + ), + ) + }) + const init = Effect.fn("SessionHttpApi.init")(function* (ctx: { params: { sessionID: SessionID } payload: typeof InitPayload.Type @@ -424,6 +450,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", .handle("update", update) .handleRaw("fork", forkRaw) .handle("abort", abort) + .handle("btw", btw) .handle("init", init) .handle("share", share) .handle("unshare", unshare) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..bf3029853fb4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -10,7 +10,7 @@ import { Session } from "./session" import { Agent } from "../agent/agent" import { Provider } from "@/provider/provider" -import { type Tool as AITool, tool, jsonSchema } from "ai" +import { type ModelMessage, type Tool as AITool, tool, jsonSchema } from "ai" import type { JSONSchema7 } from "@ai-sdk/provider" import { SessionCompaction } from "./compaction" import { SystemPrompt } from "./system" @@ -81,6 +81,46 @@ IMPORTANT: const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` +const BTW_SYSTEM_PROMPT = `You are answering an ephemeral /btw side question about the current conversation. + +The main task may still be running. Do not continue, alter, or claim to control that work. Answer only the latest /btw question, using the supplied conversation and prior /btw exchanges as context. + +You have no tools in this request. Never emit tool-call syntax, function-call markup, DSML, shell commands intended for execution, or requests to invoke a tool. Do not claim to inspect files, run commands, browse, or perform actions. If the answer depends on fresh tool output, say what is missing. Respond only with the natural-language answer to the side question. Keep the answer focused and useful.` + +const BTW_TOOL_RETRY_PROMPT = `Your previous attempt tried to call a tool or emitted tool-call protocol instead of answering. This is a text-only side conversation and no tools exist. Answer the user's latest side question directly in ordinary natural language. Do not reproduce or discuss the attempted tool call.` + +function btwContext(messages: SessionV1.WithParts[]) { + return messages + .map( + (message): SessionV1.WithParts => ({ + info: structuredClone(message.info), + // Preserve visible conversation and user attachments without replaying + // tool calls, tool output, or hidden reasoning into a tool-free request. + parts: message.parts + .filter((part) => part.type === "text" || (message.info.role === "user" && part.type === "file")) + .map((part) => structuredClone(part)), + }), + ) + .filter((message) => message.parts.length > 0) +} + +function looksLikeBtwToolProtocol(value: string) { + const prefix = value + .trimStart() + .slice(0, 1_000) + .replace(/^[`]+/, "") + .toLowerCase() + .replace(/[\s||¦]+/g, "") + return ( + prefix.startsWith(" 8_000) return yield* Effect.fail(new Error("The /btw question is too long")) + + const session = yield* sessions.get(input.sessionID) + const snapshot = MessageV2.filterCompacted(yield* sessions.messages({ sessionID: input.sessionID })) + // In-flight assistant content can be incomplete. Share complete turns + // and the current user prompt without treating partial work as final. + .filter((message) => message.info.role === "user" || Boolean(message.info.finish)) + .map((message) => structuredClone(message)) + const lastUser = snapshot.findLast((message) => message.info.role === "user") + if (!lastUser || lastUser.info.role !== "user") + return yield* Effect.fail(new Error("The session has no conversation context for /btw")) + + const agent = (yield* agents.get(lastUser.info.agent)) ?? (yield* agents.defaultInfo()) + const modelRef = yield* currentModel(input.sessionID) + const model = yield* getModel(modelRef.providerID, modelRef.modelID, input.sessionID) + + // Plugins may rewrite message objects, so only expose the cloned snapshot. + // The side request must never mutate the persisted parent conversation. + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: snapshot }) + + const [env, instructions, modelMessages] = yield* Effect.all([ + sys.environment(model), + instruction.system().pipe(Effect.orDie), + MessageV2.toModelMessagesEffect(btwContext(snapshot), model), + ]) + const system = [...env, ...instructions, BTW_SYSTEM_PROMPT] + const exchanges = (input.exchanges ?? []).slice(-8) + const sideMessages: ModelMessage[] = exchanges.flatMap((exchange) => [ + { role: "user" as const, content: exchange.question.slice(0, 8_000) }, + { role: "assistant" as const, content: exchange.answer.slice(0, 20_000) }, + ]) + sideMessages.push({ role: "user", content: question }) + + const user: SessionV1.User = { + ...lastUser.info, + id: MessageID.ascending(), + time: { created: Date.now() }, + model: { + providerID: modelRef.providerID, + modelID: modelRef.modelID, + variant: "variant" in modelRef && typeof modelRef.variant === "string" ? modelRef.variant : undefined, + }, + tools: { "*": false }, + format: { type: "text" }, + } + const sideAgent: Agent.Info = { ...agent, prompt: BTW_SYSTEM_PROMPT } + + for (let attempt = 0; attempt < 2; attempt++) { + const output = yield* Stream.runFold( + llm.stream({ + user, + sessionID: input.sessionID, + parentSessionID: session.parentID, + model, + agent: sideAgent, + permission: session.permission, + system: attempt === 0 ? system : [...system, BTW_TOOL_RETRY_PROMPT], + messages: [...modelMessages, ...sideMessages], + tools: {}, + toolChoice: "none", + retries: 2, + }), + () => ({ text: "", error: undefined as string | undefined, toolAttempted: false }), + (result, event) => { + if (LLMEvent.is.textDelta(event)) result.text += event.text + if (LLMEvent.is.toolCall(event)) result.toolAttempted = true + if (LLMEvent.is.providerError(event)) result.error = event.message + return result + }, + ) + if (output.error) return yield* Effect.fail(new Error(output.error)) + const answer = output.text.trim() + const invalid = output.toolAttempted || looksLikeBtwToolProtocol(answer) + if (answer && !invalid) return BtwResult.make({ answer, providerID: model.providerID, modelID: model.id }) + if (attempt === 0 && invalid) continue + if (!answer) return yield* Effect.fail(new Error("The /btw model returned an empty response")) + return yield* Effect.fail(new Error("The /btw model attempted to call a tool instead of answering")) + } + return yield* Effect.fail(new Error("The /btw model did not return a usable answer")) + }) + const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { const agentName = input.agent const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() @@ -1482,6 +1607,7 @@ const layer = Layer.effect( return Service.of({ cancel, + btw, prompt, loop, shell, @@ -1496,6 +1622,25 @@ const ModelRef = Schema.Struct({ modelID: ModelV2.ID, }) +export const BtwExchange = Schema.Struct({ + question: Schema.String, + answer: Schema.String, +}).annotate({ identifier: "BtwExchange" }) +export type BtwExchange = Schema.Schema.Type + +export const BtwInput = Schema.Struct({ + sessionID: SessionID, + question: Schema.String, + exchanges: Schema.optional(Schema.Array(BtwExchange)), +}) +export type BtwInput = Schema.Schema.Type + +export class BtwResult extends Schema.Class("BtwResult")({ + answer: Schema.String, + providerID: ProviderV2.ID, + modelID: ModelV2.ID, +}) {} + export const PromptInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), diff --git a/packages/opencode/src/tool/download.ts b/packages/opencode/src/tool/download.ts new file mode 100644 index 000000000000..ad06f01bcc63 --- /dev/null +++ b/packages/opencode/src/tool/download.ts @@ -0,0 +1,87 @@ +import path from "node:path" +import { Download } from "@opencode-ai/core/download" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect, Schema } from "effect" +import { HttpClient } from "effect/unstable/http" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "./external-directory" +import * as Tool from "./tool" +import DESCRIPTION from "./download.txt" + +export const Parameters = Schema.Struct({ + url: Schema.String.annotate({ description: "HTTP or HTTPS URL of the file to download" }), + filePath: Schema.String.annotate({ + description: "Destination path. Relative paths resolve from the current project directory.", + }), + sha256: Schema.String.pipe(Schema.optional).annotate({ + description: "Optional expected SHA-256 checksum (64 hexadecimal characters)", + }), + overwrite: Schema.Boolean.pipe(Schema.optional).annotate({ + description: "Replace an existing destination file. Defaults to false.", + }), +}) + +export const DownloadTool = Tool.define( + "download", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const fs = yield* FSUtil.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (input: Schema.Schema.Type, ctx: Tool.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 instance = yield* InstanceState.context + const filePath = path.isAbsolute(input.filePath) + ? input.filePath + : path.resolve(instance.directory, input.filePath) + yield* assertExternalDirectoryEffect(ctx, filePath) + yield* ctx.ask({ + permission: "download", + patterns: [input.url], + always: ["*"], + metadata: { url: input.url, filePath }, + }) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(instance.worktree, filePath)], + always: ["*"], + metadata: { filePath }, + }) + + const result = yield* Download.file({ + http, + fs, + url: input.url, + filePath, + temporaryID: ctx.callID ?? `${ctx.messageID}-download`, + expectedSha256: input.sha256, + overwrite: input.overwrite, + onProgress: (download) => + ctx.metadata({ + title: path.relative(instance.worktree, filePath), + metadata: { url: input.url, filePath, download }, + }), + }) + + return { + title: path.relative(instance.worktree, filePath), + metadata: { + url: input.url, + filePath, + download: result, + sha256: result.sha256, + }, + output: `Downloaded ${result.receivedBytes} bytes to ${filePath}. SHA-256: ${result.sha256}`, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/download.txt b/packages/opencode/src/tool/download.txt new file mode 100644 index 000000000000..fdecea113f72 --- /dev/null +++ b/packages/opencode/src/tool/download.txt @@ -0,0 +1,5 @@ +Download a large HTTP(S) file directly to disk with live progress reporting. + +Use this tool instead of webfetch, shell, curl, or wget when a file may be large or take more than a few seconds. Do not poll or narrate progress: the host streams the file and keeps the current tool call open, so the next model turn starts only after the download completes or fails. + +The download is written to a temporary adjacent file and moved into place after the body and optional SHA-256 checksum complete. Existing destination files are preserved unless overwrite is explicitly true. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 15acc757f3d4..d8a222cace3b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -13,6 +13,7 @@ import { TaskTool } from "./task" import { Database } from "@opencode-ai/core/database/database" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" +import { DownloadTool } from "./download" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" @@ -101,6 +102,7 @@ const layer = Layer.effect( const lsptool = yield* LspTool const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool + const download = yield* DownloadTool const websearch = yield* WebSearchTool const shell = yield* ShellTool const globtool = yield* GlobTool @@ -211,6 +213,7 @@ const layer = Layer.effect( write: Tool.init(writetool), task: Tool.init(task), fetch: Tool.init(webfetch), + download: Tool.init(download), todo: Tool.init(todo), search: Tool.init(websearch), skill: Tool.init(skilltool), @@ -234,6 +237,7 @@ const layer = Layer.effect( tool.write, tool.task, tool.fetch, + tool.download, tool.todo, tool.search, tool.skill, diff --git a/packages/opencode/src/tool/webfetch.txt b/packages/opencode/src/tool/webfetch.txt index 169aadefa368..6607432d77a0 100644 --- a/packages/opencode/src/tool/webfetch.txt +++ b/packages/opencode/src/tool/webfetch.txt @@ -6,6 +6,7 @@ Usage notes: - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one. + - IMPORTANT: use the download tool instead when saving a file that may be large or take more than a few seconds; do not use webfetch, shell, curl, or wget for that job. - The URL must be a fully-formed valid URL - HTTP URLs will be automatically upgraded to HTTPS - Format options: "markdown" (default), "text", or "html" diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf47..3e175f48f4b7 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -442,6 +442,92 @@ const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { return { prompt, run, sessions, chat } }) +it.instance("btw answers from shared context without changing session history", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id) + const before = yield* sessions.messages({ sessionID: chat.id }) + + yield* llm.text("ephemeral answer") + const result = yield* prompt.btw({ + sessionID: chat.id, + question: "what is the current goal?", + exchanges: [{ question: "what was the greeting?", answer: "It was hello." }], + }) + + expect(result.answer).toBe("ephemeral answer") + expect(String(result.providerID)).toBe("test") + expect(String(result.modelID)).toBe("test-model") + expect(yield* sessions.messages({ sessionID: chat.id })).toEqual(before) + + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(1) + expect(JSON.stringify(inputs[0]?.messages)).toContain("hello") + expect(JSON.stringify(inputs[0]?.messages)).not.toContain("hi there") + expect(JSON.stringify(inputs[0]?.messages)).toContain("what was the greeting?") + expect(JSON.stringify(inputs[0]?.messages)).toContain("what is the current goal?") + }), +) + +it.instance("btw strips tool history from its text-only context", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const seeded = yield* seed(chat.id, { finish: "stop" }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: seeded.assistant.id, + sessionID: chat.id, + type: "tool", + callID: "call-secret", + tool: "bash", + state: { + status: "completed", + input: { command: "curl https://example.com/private.zip" }, + output: "private tool output", + title: "Bash", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }) + + yield* llm.text("plain answer") + const result = yield* prompt.btw({ sessionID: chat.id, question: "what happened?" }) + + expect(result.answer).toBe("plain answer") + const inputs = yield* llm.inputs + const body = JSON.stringify(inputs[0]?.messages) + expect(body).toContain("hi there") + expect(body).not.toContain("curl https://example.com/private.zip") + expect(body).not.toContain("private tool output") + expect(body).not.toContain("tool_calls") + }), +) + +it.instance("btw retries when a model emits DSML as plain text", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id) + + yield* llm.text('<|DSML|tool_calls><|DSML|invoke name="bash">') + yield* llm.text("Profiling builds are mainly intended for diagnostics, not ordinary daily development.") + const result = yield* prompt.btw({ sessionID: chat.id, question: "is the profiling build for daily use?" }) + + expect(result.answer).toBe("Profiling builds are mainly intended for diagnostics, not ordinary daily development.") + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(2) + expect(JSON.stringify(inputs[1]?.messages)).toContain("text-only side conversation") + }), +) + // Loop semantics noLLMServer.instance( diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index babdbc9c517e..127986b14d5b 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -15,6 +15,7 @@ import type { AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + BtwExchange, CommandListErrors, CommandListResponses, Config as Config3, @@ -177,6 +178,8 @@ import type { QuestionV2Reply, SessionAbortErrors, SessionAbortResponses, + SessionBtwErrors, + SessionBtwResponses, SessionChildrenErrors, SessionChildrenResponses, SessionCommandErrors, @@ -3939,6 +3942,47 @@ export class Session2 extends HeyApiClient { }) } + /** + * Ask a side question + * + * Ask an ephemeral, tool-free question using the current session context without changing its message history or execution state. + */ + public btw( + parameters: { + sessionID: string + directory?: string + workspace?: string + question?: string + exchanges?: Array + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "question" }, + { in: "body", key: "exchanges" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/btw", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Initialize session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f0db3236eabb..66dbea3e0080 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2543,6 +2543,24 @@ export type NotFoundError = { } } +export type BtwExchange = { + question: string + answer: string +} + +export type BtwResult = { + answer: string + providerID: string + modelID: string +} + +export type BtwError = { + name: "BtwError" + data: { + message: string + } +} + export type TextPartInput = { id?: string type: "text" @@ -9983,6 +10001,47 @@ export type SessionAbortResponses = { export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] +export type SessionBtwData = { + body?: { + question: string + exchanges?: Array + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/btw" +} + +export type SessionBtwErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError + /** + * BtwError + */ + 500: BtwError +} + +export type SessionBtwError = SessionBtwErrors[keyof SessionBtwErrors] + +export type SessionBtwResponses = { + /** + * Ephemeral side answer + */ + 200: BtwResult +} + +export type SessionBtwResponse = SessionBtwResponses[keyof SessionBtwResponses] + export type SessionInitData = { body?: { modelID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6150b75e64c6..f0bf6f2f52c5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6731,6 +6731,118 @@ ] } }, + "/session/{sessionID}/btw": { + "post": { + "tags": ["session"], + "operationId": "session.btw", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Ephemeral side answer", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BtwResult" + } + } + } + }, + "400": { + "description": "BadRequest | InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "404": { + "description": "NotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + }, + "500": { + "description": "BtwError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BtwError" + } + } + } + } + }, + "description": "Ask an ephemeral, tool-free question using the current session context without changing its message history or execution state.", + "summary": "Ask a side question", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "question": { + "type": "string" + }, + "exchanges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BtwExchange" + } + } + }, + "required": ["question"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.btw({\n ...\n})" + } + ] + } + }, "/session/{sessionID}/init": { "post": { "tags": ["session"], @@ -23065,6 +23177,56 @@ } } }, + "BtwExchange": { + "type": "object", + "properties": { + "question": { + "type": "string" + }, + "answer": { + "type": "string" + } + }, + "required": ["question", "answer"], + "additionalProperties": false + }, + "BtwResult": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["answer", "providerID", "modelID"], + "additionalProperties": false + }, + "BtwError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["BtwError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "TextPartInput": { "type": "object", "properties": { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 00efcbed2887..a86ffb794979 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -57,6 +57,7 @@ import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useLocation } from "../../context/location" +import { parseBtwCommand } from "../../util/btw" registerOpencodeSpinner() @@ -65,6 +66,7 @@ export type PromptProps = { visible?: boolean disabled?: boolean onSubmit?: () => void + onBtw?: (question: string) => void ref?: (ref: PromptRef | undefined) => void hint?: JSX.Element right?: JSX.Element @@ -1055,7 +1057,11 @@ export function Prompt(props: PromptProps) { ] : [] - if (store.mode === "shell") { + const btwQuestion = currentMode === "normal" && props.onBtw ? parseBtwCommand(inputText) : undefined + + if (btwQuestion !== undefined) { + props.onBtw?.(btwQuestion) + } else if (store.mode === "shell") { move.startSubmit() void sdk.client.session.shell({ sessionID, diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index b67923f3c5d1..1295062a8ba4 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -6,6 +6,7 @@ import SidebarFiles from "./sidebar/files" import SidebarFooter from "./sidebar/footer" import SidebarLsp from "./sidebar/lsp" import SidebarMcp from "./sidebar/mcp" +import SidebarSubagents from "./sidebar/subagents" import SidebarTodo from "./sidebar/todo" import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" @@ -25,6 +26,7 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean SidebarContext, SidebarMcp, SidebarLsp, + SidebarSubagents, SidebarTodo, SidebarFiles, SidebarFooter, diff --git a/packages/tui/src/feature-plugins/sidebar/subagents.tsx b/packages/tui/src/feature-plugins/sidebar/subagents.tsx new file mode 100644 index 000000000000..dc3d0d0368ae --- /dev/null +++ b/packages/tui/src/feature-plugins/sidebar/subagents.tsx @@ -0,0 +1,142 @@ +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { BuiltinTuiPlugin } from "../builtins" +import { createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js" +import { Locale } from "../../util/locale" + +const id = "internal:sidebar-subagents" + +type Session = NonNullable> + +export function formatSubagentDuration(input: number) { + const total = Math.max(0, Math.floor(input / 1_000)) + const seconds = total % 60 + const minutes = Math.floor(total / 60) % 60 + const hours = Math.floor(total / 3_600) + const paddedSeconds = seconds.toString().padStart(2, "0") + + if (hours > 0) return `${hours}h ${minutes}m ${paddedSeconds}s` + if (minutes > 0) return `${minutes}m ${paddedSeconds}s` + return `${seconds}s` +} + +export function subagentLabel(session: Pick) { + const titleAgent = session.title.match(/\(@(.+?) subagent\)$/i)?.[1] + return Locale.titlecase((session.agent ?? titleAgent ?? "Subagent").replaceAll(/[-_]+/g, " ")) +} + +export function isSubagentActive(status: ReturnType) { + return status?.type === "busy" || status?.type === "retry" +} + +function hash(value: string) { + let result = 0 + for (const character of value) result = (result * 31 + character.charCodeAt(0)) >>> 0 + return result +} + +function Card(props: { api: TuiPluginApi; item: Session; now: number }) { + const theme = () => props.api.theme.current + const status = createMemo(() => props.api.state.session.status(props.item.id)) + const running = createMemo(() => isSubagentActive(status())) + const duration = createMemo(() => { + const end = running() ? props.now : Math.max(props.item.time.updated, props.item.time.created) + return formatSubagentDuration(end - props.item.time.created) + }) + const color = createMemo(() => { + if (status()?.type === "retry") return theme().error + const colors = [theme().primary, theme().success, theme().accent, theme().warning, theme().info, theme().secondary] + return colors[hash(props.item.agent ?? props.item.id) % colors.length] + }) + + return ( + props.api.route.navigate("session", { sessionID: props.item.id })} + > + + {Locale.truncate(subagentLabel(props.item), 30)} + + + ◷ {duration()} + + + ) +} + +function View(props: { api: TuiPluginApi; session_id: string }) { + const theme = () => props.api.theme.current + const rootID = createMemo(() => props.api.state.session.get(props.session_id)?.parentID ?? props.session_id) + const [now, setNow] = createSignal(Date.now()) + const [children, { refetch }] = createResource(rootID, async (sessionID) => { + const response = await props.api.client.session.children({ sessionID }) + return (response.data ?? []) as Session[] + }) + const list = createMemo(() => + (children() ?? []) + .filter((item) => isSubagentActive(props.api.state.session.status(item.id))) + .toSorted((first, second) => second.time.created - first.time.created), + ) + + const relevant = (session: Session) => session.parentID === rootID() || session.id === rootID() + const stops = [ + props.api.event.on("session.created", (event) => { + if (relevant(event.properties.info)) void refetch() + }), + props.api.event.on("session.updated", (event) => { + if (relevant(event.properties.info)) void refetch() + }), + props.api.event.on("session.deleted", (event) => { + if (relevant(event.properties.info)) void refetch() + }), + props.api.event.on("session.idle", (event) => { + if (event.properties.sessionID === rootID() || list().some((item) => item.id === event.properties.sessionID)) { + void refetch() + } + }), + ] + const timer = setInterval(() => setNow(Date.now()), 1_000) + + onCleanup(() => { + for (const stop of stops) stop() + clearInterval(timer) + }) + + return ( + + + Subagents + + Loading subagents…}> + Unable to load subagents}> + 0} fallback={No subagents yet}> + {(item) => } + + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 310, + slots: { + sidebar_content(_ctx, props) { + return + }, + }, + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/tui/src/routes/session/dialog-btw.tsx b/packages/tui/src/routes/session/dialog-btw.tsx new file mode 100644 index 000000000000..b27719e404bf --- /dev/null +++ b/packages/tui/src/routes/session/dialog-btw.tsx @@ -0,0 +1,211 @@ +import { ScrollBoxRenderable, TextareaRenderable, TextAttributes } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { useSDK } from "../../context/sdk" +import { useTheme } from "../../context/theme" +import { Spinner } from "../../component/spinner" +import { useDialog } from "../../ui/dialog" +import { useTuiConfig } from "../../config" +import { useBindings } from "../../keymap" +import { errorMessage } from "../../util/error" +import { getScrollAcceleration } from "../../util/scroll" + +export type BtwExchange = { + question: string + answer: string + providerID: string + modelID: string +} + +export function DialogBtw(props: { + sessionID: string + initialQuestion?: string + exchanges: readonly BtwExchange[] + onChange: (exchanges: BtwExchange[]) => void +}) { + const sdk = useSDK() + const dialog = useDialog() + const dimensions = useTerminalDimensions() + const { theme, syntax } = useTheme() + const tuiConfig = useTuiConfig() + const scrollAcceleration = () => getScrollAcceleration(tuiConfig) + const [textareaTarget, setTextareaTarget] = createSignal() + const [exchanges, setExchanges] = createSignal([...props.exchanges]) + const [busy, setBusy] = createSignal(false) + const [pending, setPending] = createSignal("") + const [failure, setFailure] = createSignal("") + let textarea: TextareaRenderable + let scroll: ScrollBoxRenderable + let request: AbortController | undefined + + const height = () => Math.max(14, Math.min(36, dimensions().height - 8)) + + function focusInput() { + setTimeout(() => { + if (!textarea || textarea.isDestroyed || busy()) return + textarea.focus() + }, 1) + } + + async function ask(value?: string) { + const question = (value ?? textarea?.plainText ?? "").trim() + if (!question || busy()) return + + setFailure("") + setPending(question) + setBusy(true) + if (textarea && !textarea.isDestroyed) { + textarea.clear() + textarea.blur() + } + + const ctrl = new AbortController() + request = ctrl + try { + const response = await sdk.client.session.btw( + { + sessionID: props.sessionID, + question, + exchanges: exchanges() + .slice(-8) + .map((exchange) => ({ question: exchange.question, answer: exchange.answer })), + }, + { throwOnError: true, signal: ctrl.signal }, + ) + if (!response.data) throw new Error("The /btw request returned no answer") + const next = [ + ...exchanges(), + { + question, + answer: response.data.answer, + providerID: response.data.providerID, + modelID: response.data.modelID, + }, + ].slice(-12) + setExchanges(next) + props.onChange(next) + setTimeout(() => scroll?.scrollBy(100_000), 1) + } catch (error) { + if (!ctrl.signal.aborted) { + setFailure(errorMessage(error)) + if (textarea && !textarea.isDestroyed) textarea.setText(question) + } + } finally { + if (request === ctrl) request = undefined + if (!ctrl.signal.aborted) { + setBusy(false) + setPending("") + focusInput() + } + } + } + + useBindings(() => ({ + target: textareaTarget, + enabled: textareaTarget() !== undefined && !busy(), + priority: 1, + commands: [ + { + name: "dialog.prompt.submit", + title: "Ask BTW question", + category: "Dialog", + run: () => void ask(), + }, + ], + bindings: tuiConfig.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]), + })) + + onMount(() => { + dialog.setSize("large") + const initial = props.initialQuestion?.trim() + if (initial) void ask(initial) + else focusInput() + }) + + createEffect(() => { + if (!textarea || textarea.isDestroyed) return + textarea.traits = busy() ? { suspend: true, status: "BUSY" } : {} + }) + + onCleanup(() => request?.abort()) + + return ( + + + + BTW · side conversation + + esc return to main + + same context · independent model call · no tools · main task keeps running + + (scroll = value)} + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + verticalScrollbarOptions={{ + trackOptions: { backgroundColor: theme.backgroundPanel, foregroundColor: theme.borderActive }, + }} + > + + + + Ask a quick question about the current work. Answers stay out of the main history. + + + + {(exchange) => ( + + + You · + {exchange.question} + + + + + + {exchange.providerID}/{exchange.modelID} + + + )} + + + + + You · + {pending()} + + Asking side model… + + + + + + + {failure()} + +