fix(core): materialize file and directory attachments before provider lowering

This commit is contained in:
Kit Langton 2026-07-02 00:18:22 -04:00
commit 28de367444
4 changed files with 309 additions and 1 deletions

View file

@ -0,0 +1,101 @@
export * as SessionRunnerAttachment from "./attachment"
import { fileURLToPath } from "url"
import { Effect } from "effect"
import { AbsolutePath } from "../../schema"
import { ReadToolFileSystem } from "../../tool/read-filesystem"
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
/**
* Materialize local `file:` attachments during per-turn request assembly.
*
* Providers accept media content only for a narrow set of mimes, so lowering an
* unresolved `file:` URI (or an `application/x-directory` attachment) as a media
* part fails the provider turn. Directories become an inline listing, text files
* become inline content, and images are re-encoded as data URLs. Other URI
* schemes (data URLs, MCP resources) pass through unchanged, and unreadable
* attachments degrade to a model-visible note instead of failing the turn. The
* durable projected message is never modified.
*/
export const materialize = Effect.fn("SessionRunnerAttachment.materialize")(function* (
reader: ReadToolFileSystem.Interface,
messages: readonly SessionMessage.Message[],
) {
return yield* Effect.forEach(messages, (message) => {
if (message.type !== "user" || !message.files?.some(local)) return Effect.succeed(message)
return Effect.forEach(message.files, (file) => materializeFile(reader, file)).pipe(
Effect.map((results) =>
SessionMessage.User.make({
...message,
text: [
message.text,
...results.flatMap((result) => (result.expansion === undefined ? [] : [result.expansion])),
].join("\n\n"),
files: results.flatMap((result) => (result.file === undefined ? [] : [result.file])),
}),
),
)
})
})
const local = (file: FileAttachment) => file.uri.startsWith("file:")
interface Materialized {
readonly file?: FileAttachment
readonly expansion?: string
}
const wrap = (tag: string, path: string, body: string) => `<${tag} path=${JSON.stringify(path)}>\n${body}\n</${tag}>`
const unavailable = (path: string, reason: string): Materialized => ({
expansion: wrap("attachment-unavailable", path, reason),
})
// Mirror V1's `?start`/`?end` line-range attachment parameters.
const pageFromRange = (url: URL) => {
const start = url.searchParams.get("start")
if (start === null) return undefined
const offset = Math.max(parseInt(start, 10) || 1, 1)
const end = url.searchParams.get("end")
const parsedEnd = end === null ? Number.NaN : parseInt(end, 10)
return { offset, ...(parsedEnd >= offset ? { limit: parsedEnd - offset + 1 } : {}) }
}
const materializeFile = (reader: ReadToolFileSystem.Interface, file: FileAttachment) => {
if (!local(file)) return Effect.succeed<Materialized>({ file })
const resolved = Effect.try({
try: () => {
const url = new URL(file.uri)
const page = pageFromRange(url)
url.search = ""
url.hash = ""
return { target: AbsolutePath.make(fileURLToPath(url)), page }
},
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
return Effect.gen(function* () {
const { target, page } = yield* resolved
const display = file.name ?? target
const kind = yield* reader.inspect(target)
if (kind === "directory") {
const listing = yield* reader.list(target)
const lines = [
...listing.entries.map((entry) => entry.path),
...(listing.truncated ? ["(listing truncated)"] : []),
]
return { expansion: wrap("attached-directory", display, lines.join("\n")) } satisfies Materialized
}
const content = yield* reader.read(target, display, page)
if ("encoding" in content && content.encoding === "base64")
return {
file: { ...file, uri: `data:${content.mime};base64,${content.content}`, mime: content.mime },
} satisfies Materialized
const truncated = "truncated" in content && content.truncated ? "\n(content truncated)" : ""
return { expansion: wrap("attached-file", display, content.content + truncated) } satisfies Materialized
}).pipe(
Effect.catch((error) =>
Effect.succeed(unavailable(file.name ?? file.uri, error instanceof Error ? error.message : String(error))),
),
)
}

View file

@ -23,6 +23,7 @@ import { SkillGuidance } from "../../skill/guidance"
import { ReferenceGuidance } from "../../reference/guidance"
import { McpGuidance } from "../../mcp/guidance"
import { ToolRegistry } from "../../tool/registry"
import { ReadToolFileSystem } from "../../tool/read-filesystem"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextEpoch } from "../context-epoch"
import { SessionCompaction } from "../compaction"
@ -33,6 +34,7 @@ import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { type RunError, Service } from "./index"
import { SessionRunnerAttachment } from "./attachment"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
@ -99,6 +101,7 @@ const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service
const reader = yield* ReadToolFileSystem.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
@ -203,6 +206,8 @@ const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
// Expand local file/directory attachments for this request only; durable history keeps the URIs.
const materialized = yield* SessionRunnerAttachment.materialize(reader, context)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep
? undefined
@ -214,7 +219,7 @@ const layer = Layer.effect(
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
messages: [...toLLMMessages(materialized, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
})
@ -432,6 +437,7 @@ export const node = makeLocationNode({
llmClient,
AgentV2.node,
ToolRegistry.node,
ReadToolFileSystem.node,
SessionRunnerModel.node,
SessionStore.node,
Location.node,