refactor(session): extract prompt parts

This commit is contained in:
Aiden Cline
2026-05-18 20:16:47 -05:00
parent 2e1593dea5
commit 1c8f84c4be
2 changed files with 622 additions and 544 deletions
+15 -544
View File
@@ -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<PromptInput["parts"]> = [{ type: "text", text: template }]
const files = ConfigMarkdown.files(template)
const seen = new Set<string>()
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> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
...part,
id: part.id ? PartID.make(part.id) : PartID.ascending(),
})
const referenceContextFromFilePart = Effect.fnUntraced(function* (
part: Extract<PromptInput["parts"][number], { type: "file" }>,
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<Draft<MessageV2.Part>[]> = 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<MessageV2.Part>[] = [
{
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<typeof read.execute>[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<MessageV2.Part>[] = [
...(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<MessageV2.WithParts, Image.Error> = 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),
})
}),
)
@@ -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> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
...part,
id: part.id ? PartID.make(part.id) : PartID.ascending(),
})
const referenceContextFromFilePart = Effect.fnUntraced(function* (
part: Extract<PromptInput["parts"][number], { type: "file" }>,
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<Draft<MessageV2.Part>[]> = 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<MessageV2.Part>[] = [
{
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<typeof read.execute>[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<MessageV2.Part>[] = [
...(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<PromptInput["parts"]> = [{ type: "text", text: template }]
const files = ConfigMarkdown.files(template)
const seen = new Set<string>()
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"