diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c09ee8628..acae29fdf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,5 +1,3 @@ -import path from "path" -import os from "os" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" @@ -24,13 +22,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Stream from "effect/Stream" import { Command } from "../command" -import { pathToFileURL, fileURLToPath } from "url" import { Config } from "@/config/config" import { ConfigMarkdown } from "@/config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/core/util/error" import { SessionProcessor } from "./processor" -import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" @@ -39,36 +35,30 @@ import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { Image } from "@/image/image" -import { decodeDataUrl } from "@/util/data-url" +import { Reference } from "@/reference/reference" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" +import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { RuntimeFlags } from "@/effect/runtime-flags" -import { EventV2 } from "@opencode-ai/core/event" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session-event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt" -import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" import * as Database from "@/storage/db" import { SessionTable } from "./session.sql" -import { referencePromptMetadata, referenceTextPart } from "./prompt/reference" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" +import { SessionPromptParts } from "./prompt/parts" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false -const decodeMessageInfo = Schema.decodeUnknownExit(MessageV2.Info) -const decodeMessagePart = Schema.decodeUnknownExit(MessageV2.Part) - const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. IMPORTANT: @@ -124,10 +114,11 @@ export const layer = Layer.effect( const references = yield* Reference.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const promptPartServices = { agents, bus, fsys, image, lsp, mcp, plugin, provider, references, registry } const ops = Effect.fn("SessionPrompt.ops")(function* () { return { cancel: (sessionID: SessionID) => cancel(sessionID), - resolvePromptParts: (template: string) => resolvePromptParts(template), + resolvePromptParts: (template: string) => SessionPromptParts.resolvePromptParts(template, promptPartServices), prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)), loop: (input: LoopInput) => loop(input), } satisfies TaskPromptOps @@ -138,102 +129,6 @@ export const layer = Layer.effect( yield* state.cancel(sessionID) }) - const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { - const ctx = yield* InstanceState.context - const parts: Types.DeepMutable = [{ type: "text", text: template }] - const files = ConfigMarkdown.files(template) - const seen = new Set() - const mentionSource = (match: RegExpMatchArray) => { - const start = match.index ?? 0 - return { value: match[0], start, end: start + match[0].length } - } - yield* Effect.forEach( - files, - Effect.fnUntraced(function* (match) { - const name = match[1] - if (!name) return - if (seen.has(name)) return - seen.add(name) - - const slash = name.indexOf("/") - const alias = slash === -1 ? name : name.slice(0, slash) - const reference = yield* references.get(alias) - if (reference) { - const source = mentionSource(match) - if (reference.kind === "invalid") { - parts.push( - referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }), - ) - return - } - - yield* references.ensure(reference.path) - if (slash === -1) { - parts.push(referenceTextPart({ reference, source })) - return - } - - const target = name.slice(slash + 1) - const targetPath = path.resolve(reference.path, target) - if (!AppFileSystem.contains(reference.path, targetPath)) { - parts.push( - referenceTextPart({ - reference, - source, - target, - targetPath, - problem: `Path escapes configured reference @${alias}: ${target}`, - }), - ) - return - } - - const info = yield* fsys.stat(targetPath).pipe(Effect.option) - if (Option.isNone(info)) { - parts.push( - referenceTextPart({ - reference, - source, - target, - targetPath, - problem: `Path does not exist inside configured reference @${alias}: ${target}`, - }), - ) - return - } - - parts.push({ - type: "file", - url: pathToFileURL(targetPath).href, - filename: name, - mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain", - }) - return - } - - const filepath = name.startsWith("~/") - ? path.join(os.homedir(), name.slice(2)) - : path.resolve(ctx.worktree, name) - - const info = yield* fsys.stat(filepath).pipe(Effect.option) - if (Option.isNone(info)) { - const found = yield* agents.get(name) - if (found) parts.push({ type: "agent", name: found.name }) - return - } - const stat = info.value - parts.push({ - type: "file", - url: pathToFileURL(filepath).href, - filename: name, - mime: stat.type === "Directory" ? "application/x-directory" : "text/plain", - }) - }), - { concurrency: "unbounded", discard: true }, - ) - return parts - }) - const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: { session: Session.Info history: MessageV2.WithParts[] @@ -754,447 +649,23 @@ export const layer = Layer.effect( } yield* Effect.addFinalizer(() => instruction.clear(info.id)) - - type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never - const assign = (part: Draft): MessageV2.Part => ({ - ...part, - id: part.id ? PartID.make(part.id) : PartID.ascending(), - }) - - const referenceContextFromFilePart = Effect.fnUntraced(function* ( - part: Extract, - filepath: string, - ) { - const name = part.filename?.replace(/#\d+(?:-\d*)?$/, "") - if (!name) return - const slash = name.indexOf("/") - if (slash === -1) return - - const reference = yield* references.get(name.slice(0, slash)) - if (!reference || reference.kind === "invalid") return - if (!AppFileSystem.contains(reference.path, filepath)) return - - const target = path.relative(reference.path, filepath).split(path.sep).join("/") - if (!target || target.startsWith("../") || target === "..") return - - return referenceTextPart({ - reference, - source: part.source?.text ?? { value: `@${name}`, start: 0, end: name.length + 1 }, - target, - targetPath: filepath, - }) - }) - - const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[]> = Effect.fn( - "SessionPrompt.resolveUserPart", - )(function* (part) { - if (part.type === "file") { - if (part.source?.type === "resource") { - const { clientName, uri } = part.source - log.info("mcp resource", { clientName, uri, mime: part.mime }) - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Reading MCP resource: ${part.filename} (${uri})`, - }, - ] - const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit) - if (Exit.isSuccess(exit)) { - const content = exit.value - if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) - const items = Array.isArray(content.contents) ? content.contents : [content.contents] - for (const c of items) { - if ("text" in c && c.text) { - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: c.text, - }) - } else if ("blob" in c && c.blob) { - const mime = "mimeType" in c ? c.mimeType : part.mime - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mime}]`, - }) - } - } - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) - } else { - const error = Cause.squash(exit.cause) - log.error("failed to read MCP resource", { error, clientName, uri }) - const message = error instanceof Error ? error.message : String(error) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Failed to read MCP resource ${part.filename}: ${message}`, - }) - } - return pieces - } - const url = new URL(part.url) - switch (url.protocol) { - case "data:": - if (part.mime === "text/plain") { - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: decodeDataUrl(part.url), - }, - { ...part, messageID: info.id, sessionID: input.sessionID }, - ] - } - break - case "file:": { - log.info("file", { mime: part.mime }) - const filepath = fileURLToPath(part.url) - const referenceContext = yield* referenceContextFromFilePart(part, filepath) - const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime - - const { read } = yield* registry.named() - const execRead = (args: Parameters[0], extra?: Tool.Context["extra"]) => { - const controller = new AbortController() - return read - .execute(args, { - sessionID: input.sessionID, - abort: controller.signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true, ...extra }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }) - .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort()))) - } - - if (mime === "text/plain") { - let offset: number | undefined - let limit: number | undefined - const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } - if (range.start != null) { - const filePathURI = part.url.split("?")[0] - let start = parseInt(range.start) - let end = range.end ? parseInt(range.end) : undefined - if (start === end) { - const symbols = yield* lsp.documentSymbol(filePathURI).pipe(Effect.catch(() => Effect.succeed([]))) - for (const symbol of symbols) { - let r: LSP.Range | undefined - if ("range" in symbol) r = symbol.range - else if ("location" in symbol) r = symbol.location.range - if (r?.start?.line && r?.start?.line === start) { - start = r.start.line - end = r?.end?.line ?? start - break - } - } - } - offset = Math.max(start, 1) - if (end) limit = end - (offset - 1) - } - const args = { filePath: filepath, offset, limit } - const pieces: Draft[] = [ - ...(referenceContext - ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] - : []), - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - ] - const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe( - Effect.flatMap((mdl) => execRead(args, { model: mdl })), - Effect.exit, - ) - if (Exit.isSuccess(exit)) { - const result = exit.value - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }) - if (result.attachments?.length) { - pieces.push( - ...result.attachments.map((a) => ({ - ...a, - synthetic: true, - filename: a.filename ?? part.filename, - messageID: info.id, - sessionID: input.sessionID, - })), - ) - } else { - pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID }) - } - } else { - const error = Cause.squash(exit.cause) - log.error("failed to read file", { error }) - const message = error instanceof Error ? error.message : String(error) - yield* bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message }).toObject(), - }) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Read tool failed to read ${filepath} with the following error: ${message}`, - }) - } - return pieces - } - - if (mime === "application/x-directory") { - const args = { filePath: filepath } - const exit = yield* execRead(args).pipe(Effect.exit) - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause) - log.error("failed to read directory", { error }) - const message = error instanceof Error ? error.message : String(error) - yield* bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message }).toObject(), - }) - return [ - ...(referenceContext - ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] - : []), - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Read tool failed to read ${filepath} with the following error: ${message}`, - }, - ] - } - return [ - ...(referenceContext - ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] - : []), - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: exit.value.output, - }, - { ...part, mime, messageID: info.id, sessionID: input.sessionID }, - ] - } - - return [ - ...(referenceContext ? [{ ...referenceContext, messageID: info.id, sessionID: input.sessionID }] : []), - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, - }, - { - id: part.id, - messageID: info.id, - sessionID: input.sessionID, - type: "file", - url: - `data:${mime};base64,` + - Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"), - mime, - filename: part.filename!, - source: part.source, - }, - ] - } - } - } - - if (part.type === "agent") { - const perm = Permission.evaluate("task", part.name, ag.permission) - const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" - return [ - { ...part, messageID: info.id, sessionID: input.sessionID }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: - " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + - hint, - }, - ] - } - - return [{ ...part, messageID: info.id, sessionID: input.sessionID }] - }) - - const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( - Effect.map((x) => x.flat().map(assign)), - ) - - yield* plugin.trigger( - "chat.message", - { - sessionID: input.sessionID, - agent: input.agent, - model: input.model, - messageID: input.messageID, - variant: input.variant, - }, - { message: info, parts: resolvedParts }, - ) - - const parts = yield* Effect.forEach(resolvedParts, (part) => - part.type === "file" && part.mime.startsWith("image/") - ? image.normalize(part).pipe( - Effect.catchIf( - (error) => error instanceof Image.ResizerUnavailableError, - () => Effect.succeed(part), - ), - ) - : Effect.succeed(part), - ) - - const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" }) - if (Exit.isFailure(parsed)) { - log.error("invalid user message before save", { - sessionID: input.sessionID, - messageID: info.id, - agent: info.agent, - model: info.model, - cause: Cause.pretty(parsed.cause), - }) - } - parts.forEach((part, index) => { - const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" }) - if (Exit.isSuccess(p)) return - log.error("invalid user part before save", { - sessionID: input.sessionID, - messageID: info.id, - partID: part.id, - partType: part.type, - index, - cause: Cause.pretty(p.cause), - part, - }) - }) - + const resolved = yield* SessionPromptParts.resolveMessageParts({ input, info, agent: ag }, promptPartServices) yield* sessions.updateMessage(info) - for (const part of parts) yield* sessions.updatePart(part) - const nextPrompt = parts.reduce( - (result, part) => { - if (part.type === "text") { - if (part.synthetic) result.synthetic.push(part.text) - else result.text.push(part.text) - const reference = referencePromptMetadata(part.metadata?.reference) - if (reference) { - result.references.push( - new ReferenceAttachment({ - name: reference.name, - kind: reference.kind, - uri: reference.path ? pathToFileURL(reference.path).href : undefined, - repository: reference.repository, - branch: reference.branch, - target: reference.target, - targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined, - problem: reference.problem, - source: new Source({ - start: reference.source.start, - end: reference.source.end, - text: reference.source.value, - }), - }), - ) - } - } - if (part.type === "file") { - result.files.push( - new FileAttachment({ - uri: part.url, - mime: part.mime, - name: part.filename, - source: part.source - ? new Source({ - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - }) - : undefined, - }), - ) - } - if (part.type === "agent") { - result.agents.push( - new AgentAttachment({ - name: part.name, - source: part.source - ? new Source({ - start: part.source.start, - end: part.source.end, - text: part.source.value, - }) - : undefined, - }), - ) - } - return result - }, - { - text: [] as string[], - files: [] as FileAttachment[], - agents: [] as AgentAttachment[], - references: [] as ReferenceAttachment[], - synthetic: [] as string[], - }, - ) + for (const part of resolved.parts) yield* sessions.updatePart(part) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Prompted, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), prompt: { - text: nextPrompt.text.join("\n"), - files: nextPrompt.files, - agents: nextPrompt.agents, - references: nextPrompt.references, + text: resolved.prompt.text.join("\n"), + files: resolved.prompt.files, + agents: resolved.prompt.agents, + references: resolved.prompt.references, }, }) } - for (const text of nextPrompt.synthetic) { + for (const text of resolved.prompt.synthetic) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Synthetic, { @@ -1205,7 +676,7 @@ export const layer = Layer.effect( } } - return { info, parts } + return { info, parts: resolved.parts } }, Effect.scoped) const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( @@ -1567,7 +1038,7 @@ export const layer = Layer.effect( throw error } - const templateParts = yield* resolvePromptParts(template) + const templateParts = yield* SessionPromptParts.resolvePromptParts(template, promptPartServices) const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true const parts = isSubtask ? [ @@ -1618,7 +1089,7 @@ export const layer = Layer.effect( loop, shell, command, - resolvePromptParts, + resolvePromptParts: (template) => SessionPromptParts.resolvePromptParts(template, promptPartServices), }) }), ) diff --git a/packages/opencode/src/session/prompt/parts.ts b/packages/opencode/src/session/prompt/parts.ts new file mode 100644 index 000000000..6f83719c4 --- /dev/null +++ b/packages/opencode/src/session/prompt/parts.ts @@ -0,0 +1,607 @@ +import path from "path" +import os from "os" +import { fileURLToPath, pathToFileURL } from "url" +import { Agent } from "@/agent/agent" +import { Bus } from "@/bus" +import { ConfigMarkdown } from "@/config/markdown" +import { Image } from "@/image/image" +import { LSP } from "@/lsp/lsp" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" +import { Provider } from "@/provider/provider" +import { Reference } from "@/reference/reference" +import { InstanceState } from "@/effect/instance-state" +import { Tool } from "@/tool/tool" +import { ToolRegistry } from "@/tool/registry" +import { decodeDataUrl } from "@/util/data-url" +import { NamedError } from "@opencode-ai/core/util/error" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import * as Log from "@opencode-ai/core/util/log" +import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt" +import { Cause, Effect, Exit, Option, Schema, Types } from "effect" +import { PartID } from "../schema" +import * as Session from "../session" +import { MessageV2 } from "../message-v2" +import { referencePromptMetadata, referenceTextPart } from "./reference" +import type { PromptInput } from "../prompt" + +const log = Log.create({ service: "session.prompt.parts" }) +const decodeMessageInfo = Schema.decodeUnknownExit(MessageV2.Info) +const decodeMessagePart = Schema.decodeUnknownExit(MessageV2.Part) + +export type ResolvedPrompt = { + text: string[] + files: FileAttachment[] + agents: AgentAttachment[] + references: ReferenceAttachment[] + synthetic: string[] +} + +type ResolvePromptPartsServices = { + agents: Agent.Interface + fsys: AppFileSystem.Interface + references: Reference.Interface +} + +type ResolveMessagePartsServices = ResolvePromptPartsServices & { + bus: Bus.Interface + image: Image.Interface + lsp: LSP.Interface + mcp: MCP.Interface + plugin: Plugin.Interface + provider: Provider.Interface + registry: ToolRegistry.Interface +} + +export const resolvePromptParts = Effect.fn("SessionPromptParts.resolvePromptParts")(function* ( + template: string, + services: ResolvePromptPartsServices, +) { + return yield* resolveTemplateParts(template, services) +}) + +export const resolveMessageParts = Effect.fn("SessionPromptParts.resolveMessageParts")(function* ( + input: { + input: PromptInput + info: MessageV2.User + agent: Agent.Info + }, + services: ResolveMessagePartsServices, +) { + type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never + const assign = (part: Draft): MessageV2.Part => ({ + ...part, + id: part.id ? PartID.make(part.id) : PartID.ascending(), + }) + + const referenceContextFromFilePart = Effect.fnUntraced(function* ( + part: Extract, + filepath: string, + ) { + const name = part.filename?.replace(/#\d+(?:-\d*)?$/, "") + if (!name) return + const slash = name.indexOf("/") + if (slash === -1) return + + const reference = yield* services.references.get(name.slice(0, slash)) + if (!reference || reference.kind === "invalid") return + if (!AppFileSystem.contains(reference.path, filepath)) return + + const target = path.relative(reference.path, filepath).split(path.sep).join("/") + if (!target || target.startsWith("../") || target === "..") return + + return referenceTextPart({ + reference, + source: part.source?.text ?? { value: `@${name}`, start: 0, end: name.length + 1 }, + target, + targetPath: filepath, + }) + }) + + const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[]> = Effect.fn( + "SessionPromptParts.resolveUserPart", + )(function* (part) { + if (part.type === "file") { + if (part.source?.type === "resource") { + const { clientName, uri } = part.source + log.info("mcp resource", { clientName, uri, mime: part.mime }) + const pieces: Draft[] = [ + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `Reading MCP resource: ${part.filename} (${uri})`, + }, + ] + const exit = yield* services.mcp.readResource(clientName, uri).pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + const content = exit.value + if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) + const items = Array.isArray(content.contents) ? content.contents : [content.contents] + for (const c of items) { + if ("text" in c && c.text) { + pieces.push({ + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: c.text, + }) + } else if ("blob" in c && c.blob) { + const mime = "mimeType" in c ? c.mimeType : part.mime + pieces.push({ + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `[Binary content: ${mime}]`, + }) + } + } + pieces.push({ ...part, messageID: input.info.id, sessionID: input.input.sessionID }) + } else { + const error = Cause.squash(exit.cause) + log.error("failed to read MCP resource", { error, clientName, uri }) + const message = error instanceof Error ? error.message : String(error) + pieces.push({ + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `Failed to read MCP resource ${part.filename}: ${message}`, + }) + } + return pieces + } + const url = new URL(part.url) + switch (url.protocol) { + case "data:": + if (part.mime === "text/plain") { + return [ + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, + }, + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: decodeDataUrl(part.url), + }, + { ...part, messageID: input.info.id, sessionID: input.input.sessionID }, + ] + } + break + case "file:": { + log.info("file", { mime: part.mime }) + const filepath = fileURLToPath(part.url) + const referenceContext = yield* referenceContextFromFilePart(part, filepath) + const mime = (yield* services.fsys.isDir(filepath)) ? "application/x-directory" : part.mime + + const { read } = yield* services.registry.named() + const execRead = (args: Parameters[0], extra?: Tool.Context["extra"]) => { + const controller = new AbortController() + return read + .execute(args, { + sessionID: input.input.sessionID, + abort: controller.signal, + agent: input.info.agent, + messageID: input.info.id, + extra: { bypassCwdCheck: true, ...extra }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }) + .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort()))) + } + + if (mime === "text/plain") { + let offset: number | undefined + let limit: number | undefined + const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } + if (range.start != null) { + const filePathURI = part.url.split("?")[0] + let start = parseInt(range.start) + let end = range.end ? parseInt(range.end) : undefined + if (start === end) { + const symbols = yield* services.lsp + .documentSymbol(filePathURI) + .pipe(Effect.catch(() => Effect.succeed([]))) + for (const symbol of symbols) { + let r: LSP.Range | undefined + if ("range" in symbol) r = symbol.range + else if ("location" in symbol) r = symbol.location.range + if (r?.start?.line && r?.start?.line === start) { + start = r.start.line + end = r?.end?.line ?? start + break + } + } + } + offset = Math.max(start, 1) + if (end) limit = end - (offset - 1) + } + const args = { filePath: filepath, offset, limit } + const pieces: Draft[] = [ + ...(referenceContext + ? [{ ...referenceContext, messageID: input.info.id, sessionID: input.input.sessionID }] + : []), + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, + ] + const exit = yield* services.provider.getModel(input.info.model.providerID, input.info.model.modelID).pipe( + Effect.flatMap((mdl) => execRead(args, { model: mdl })), + Effect.exit, + ) + if (Exit.isSuccess(exit)) { + const result = exit.value + pieces.push({ + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: result.output, + }) + if (result.attachments?.length) { + pieces.push( + ...result.attachments.map((a) => ({ + ...a, + synthetic: true, + filename: a.filename ?? part.filename, + messageID: input.info.id, + sessionID: input.input.sessionID, + })), + ) + } else { + pieces.push({ ...part, mime, messageID: input.info.id, sessionID: input.input.sessionID }) + } + } else { + const error = Cause.squash(exit.cause) + log.error("failed to read file", { error }) + const message = error instanceof Error ? error.message : String(error) + yield* services.bus.publish(Session.Event.Error, { + sessionID: input.input.sessionID, + error: new NamedError.Unknown({ message }).toObject(), + }) + pieces.push({ + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }) + } + return pieces + } + + if (mime === "application/x-directory") { + const args = { filePath: filepath } + const exit = yield* execRead(args).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + log.error("failed to read directory", { error }) + const message = error instanceof Error ? error.message : String(error) + yield* services.bus.publish(Session.Event.Error, { + sessionID: input.input.sessionID, + error: new NamedError.Unknown({ message }).toObject(), + }) + return [ + ...(referenceContext + ? [{ ...referenceContext, messageID: input.info.id, sessionID: input.input.sessionID }] + : []), + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `Read tool failed to read ${filepath} with the following error: ${message}`, + }, + ] + } + return [ + ...(referenceContext + ? [{ ...referenceContext, messageID: input.info.id, sessionID: input.input.sessionID }] + : []), + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, + }, + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: exit.value.output, + }, + { ...part, mime, messageID: input.info.id, sessionID: input.input.sessionID }, + ] + } + + return [ + ...(referenceContext + ? [{ ...referenceContext, messageID: input.info.id, sessionID: input.input.sessionID }] + : []), + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, + }, + { + id: part.id, + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "file", + url: + `data:${mime};base64,` + + Buffer.from(yield* services.fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"), + mime, + filename: part.filename!, + source: part.source, + }, + ] + } + } + } + + if (part.type === "agent") { + const perm = Permission.evaluate("task", part.name, input.agent.permission) + const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" + return [ + { ...part, messageID: input.info.id, sessionID: input.input.sessionID }, + { + messageID: input.info.id, + sessionID: input.input.sessionID, + type: "text", + synthetic: true, + text: + " Use the above message and context to generate a prompt and call the task tool with subagent: " + + part.name + + hint, + }, + ] + } + + return [{ ...part, messageID: input.info.id, sessionID: input.input.sessionID }] + }) + + const resolvedParts = yield* Effect.forEach(input.input.parts, resolvePart, { concurrency: "unbounded" }).pipe( + Effect.map((x) => x.flat().map(assign)), + ) + + yield* services.plugin.trigger( + "chat.message", + { + sessionID: input.input.sessionID, + agent: input.input.agent, + model: input.input.model, + messageID: input.input.messageID, + variant: input.input.variant, + }, + { message: input.info, parts: resolvedParts }, + ) + + const parts = yield* Effect.forEach(resolvedParts, (part) => + part.type === "file" && part.mime.startsWith("image/") + ? services.image.normalize(part).pipe( + Effect.catchIf( + (error) => error instanceof Image.ResizerUnavailableError, + () => Effect.succeed(part), + ), + ) + : Effect.succeed(part), + ) + + validate(input.info, parts) + return { parts, prompt: promptPayload(parts) } +}) + +function resolveTemplateParts(template: string, services: ResolvePromptPartsServices) { + return Effect.gen(function* () { + const ctx = yield* InstanceState.context + const parts: Types.DeepMutable = [{ type: "text", text: template }] + const files = ConfigMarkdown.files(template) + const seen = new Set() + const mentionSource = (match: RegExpMatchArray) => { + const start = match.index ?? 0 + return { value: match[0], start, end: start + match[0].length } + } + yield* Effect.forEach( + files, + Effect.fnUntraced(function* (match) { + const name = match[1] + if (!name) return + if (seen.has(name)) return + seen.add(name) + + const slash = name.indexOf("/") + const alias = slash === -1 ? name : name.slice(0, slash) + const reference = yield* services.references.get(alias) + if (reference) { + const source = mentionSource(match) + if (reference.kind === "invalid") { + parts.push( + referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }), + ) + return + } + + yield* services.references.ensure(reference.path) + if (slash === -1) { + parts.push(referenceTextPart({ reference, source })) + return + } + + const target = name.slice(slash + 1) + const targetPath = path.resolve(reference.path, target) + if (!AppFileSystem.contains(reference.path, targetPath)) { + parts.push( + referenceTextPart({ + reference, + source, + target, + targetPath, + problem: `Path escapes configured reference @${alias}: ${target}`, + }), + ) + return + } + + const info = yield* services.fsys.stat(targetPath).pipe(Effect.option) + if (Option.isNone(info)) { + parts.push( + referenceTextPart({ + reference, + source, + target, + targetPath, + problem: `Path does not exist inside configured reference @${alias}: ${target}`, + }), + ) + return + } + + parts.push({ + type: "file", + url: pathToFileURL(targetPath).href, + filename: name, + mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain", + }) + return + } + + const filepath = name.startsWith("~/") + ? path.join(os.homedir(), name.slice(2)) + : path.resolve(ctx.worktree, name) + + const info = yield* services.fsys.stat(filepath).pipe(Effect.option) + if (Option.isNone(info)) { + const found = yield* services.agents.get(name) + if (found) parts.push({ type: "agent", name: found.name }) + return + } + const stat = info.value + parts.push({ + type: "file", + url: pathToFileURL(filepath).href, + filename: name, + mime: stat.type === "Directory" ? "application/x-directory" : "text/plain", + }) + }), + { concurrency: "unbounded", discard: true }, + ) + return parts + }) +} + +function validate(info: MessageV2.User, parts: MessageV2.Part[]) { + const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" }) + if (Exit.isFailure(parsed)) { + log.error("invalid user message before save", { + sessionID: info.sessionID, + messageID: info.id, + agent: info.agent, + model: info.model, + cause: Cause.pretty(parsed.cause), + }) + } + parts.forEach((part, index) => { + const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" }) + if (Exit.isSuccess(p)) return + log.error("invalid user part before save", { + sessionID: info.sessionID, + messageID: info.id, + partID: part.id, + partType: part.type, + index, + cause: Cause.pretty(p.cause), + part, + }) + }) +} + +function promptPayload(parts: MessageV2.Part[]): ResolvedPrompt { + return parts.reduce( + (result, part) => { + if (part.type === "text") { + if (part.synthetic) result.synthetic.push(part.text) + else result.text.push(part.text) + const reference = referencePromptMetadata(part.metadata?.reference) + if (reference) { + result.references.push( + new ReferenceAttachment({ + name: reference.name, + kind: reference.kind, + uri: reference.path ? pathToFileURL(reference.path).href : undefined, + repository: reference.repository, + branch: reference.branch, + target: reference.target, + targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined, + problem: reference.problem, + source: new Source({ + start: reference.source.start, + end: reference.source.end, + text: reference.source.value, + }), + }), + ) + } + } + if (part.type === "file") { + result.files.push( + new FileAttachment({ + uri: part.url, + mime: part.mime, + name: part.filename, + source: part.source + ? new Source({ + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + }) + : undefined, + }), + ) + } + if (part.type === "agent") { + result.agents.push( + new AgentAttachment({ + name: part.name, + source: part.source + ? new Source({ + start: part.source.start, + end: part.source.end, + text: part.source.value, + }) + : undefined, + }), + ) + } + return result + }, + { + text: [] as string[], + files: [] as FileAttachment[], + agents: [] as AgentAttachment[], + references: [] as ReferenceAttachment[], + synthetic: [] as string[], + }, + ) +} + +export * as SessionPromptParts from "./parts"