Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
414df63b25 | ||
|
|
2d900e8c86 | ||
|
|
1c8f84c4be |
@@ -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 { PromptParts } 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 services = { 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) => PromptParts.fromTemplate(template, services),
|
||||
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* PromptParts.fromPrompt({ prompt: input, info, agent: ag }, services)
|
||||
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.nextPrompt.text.join("\n"),
|
||||
files: resolved.nextPrompt.files,
|
||||
agents: resolved.nextPrompt.agents,
|
||||
references: resolved.nextPrompt.references,
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const text of nextPrompt.synthetic) {
|
||||
for (const text of resolved.nextPrompt.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* PromptParts.fromTemplate(template, services)
|
||||
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) => PromptParts.fromTemplate(template, services),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
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)
|
||||
|
||||
type Services = {
|
||||
agents: Agent.Interface
|
||||
bus: Bus.Interface
|
||||
fsys: AppFileSystem.Interface
|
||||
image: Image.Interface
|
||||
lsp: LSP.Interface
|
||||
mcp: MCP.Interface
|
||||
plugin: Plugin.Interface
|
||||
provider: Provider.Interface
|
||||
references: Reference.Interface
|
||||
registry: ToolRegistry.Interface
|
||||
}
|
||||
|
||||
export const fromTemplate = Effect.fn("PromptParts.fromTemplate")(function* (template: string, services: Services) {
|
||||
return yield* resolveTemplateParts(template, services)
|
||||
})
|
||||
|
||||
export const fromPrompt = Effect.fn("PromptParts.fromPrompt")(function* (
|
||||
input: {
|
||||
prompt: PromptInput
|
||||
info: MessageV2.User
|
||||
agent: Agent.Info
|
||||
},
|
||||
services: Services,
|
||||
) {
|
||||
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 fromPart: (part: PromptInput["parts"][number]) => Effect.Effect<Draft<MessageV2.Part>[]> = Effect.fn(
|
||||
"PromptParts.fromPart",
|
||||
)(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.prompt.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.prompt.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.prompt.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: `[Binary content: ${mime}]`,
|
||||
})
|
||||
}
|
||||
}
|
||||
pieces.push({ ...part, messageID: input.info.id, sessionID: input.prompt.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.prompt.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.prompt.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.prompt.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: decodeDataUrl(part.url),
|
||||
},
|
||||
{ ...part, messageID: input.info.id, sessionID: input.prompt.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.prompt.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.prompt.sessionID }]
|
||||
: []),
|
||||
{
|
||||
messageID: input.info.id,
|
||||
sessionID: input.prompt.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.prompt.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.prompt.sessionID,
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
pieces.push({ ...part, mime, messageID: input.info.id, sessionID: input.prompt.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.prompt.sessionID,
|
||||
error: new NamedError.Unknown({ message }).toObject(),
|
||||
})
|
||||
pieces.push({
|
||||
messageID: input.info.id,
|
||||
sessionID: input.prompt.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.prompt.sessionID,
|
||||
error: new NamedError.Unknown({ message }).toObject(),
|
||||
})
|
||||
return [
|
||||
...(referenceContext
|
||||
? [{ ...referenceContext, messageID: input.info.id, sessionID: input.prompt.sessionID }]
|
||||
: []),
|
||||
{
|
||||
messageID: input.info.id,
|
||||
sessionID: input.prompt.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.prompt.sessionID }]
|
||||
: []),
|
||||
{
|
||||
messageID: input.info.id,
|
||||
sessionID: input.prompt.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||
},
|
||||
{
|
||||
messageID: input.info.id,
|
||||
sessionID: input.prompt.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: exit.value.output,
|
||||
},
|
||||
{ ...part, mime, messageID: input.info.id, sessionID: input.prompt.sessionID },
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
...(referenceContext
|
||||
? [{ ...referenceContext, messageID: input.info.id, sessionID: input.prompt.sessionID }]
|
||||
: []),
|
||||
{
|
||||
messageID: input.info.id,
|
||||
sessionID: input.prompt.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.prompt.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.prompt.sessionID },
|
||||
{
|
||||
messageID: input.info.id,
|
||||
sessionID: input.prompt.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.prompt.sessionID }]
|
||||
})
|
||||
|
||||
const resolvedParts = yield* Effect.forEach(input.prompt.parts, fromPart, { concurrency: "unbounded" }).pipe(
|
||||
Effect.map((x) => x.flat().map(assign)),
|
||||
)
|
||||
|
||||
yield* services.plugin.trigger(
|
||||
"chat.message",
|
||||
{
|
||||
sessionID: input.prompt.sessionID,
|
||||
agent: input.prompt.agent,
|
||||
model: input.prompt.model,
|
||||
messageID: input.prompt.messageID,
|
||||
variant: input.prompt.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, nextPrompt: nextPrompt(parts) }
|
||||
})
|
||||
|
||||
function resolveTemplateParts(template: string, services: Services) {
|
||||
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 nextPrompt(parts: MessageV2.Part[]) {
|
||||
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 PromptParts from "./parts"
|
||||
Reference in New Issue
Block a user