Compare commits

..
1 Commits
7 changed files with 91 additions and 154 deletions
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
@@ -8,7 +8,6 @@ import { Protocol } from "../route/protocol.js"
import {
AIError,
LLMEvent,
Message,
mergeJsonRecords,
Usage,
type CacheHint,
@@ -360,8 +359,6 @@ const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): strin
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
}
const hasText = (part: { readonly text: string }) => part.text.trim().length > 0
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
name: tool.name,
description: tool.description,
@@ -449,10 +446,7 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Tool.Content> = part.result.value
return yield* Effect.forEach(
content.filter((item) => item.type !== "text" || hasText(item)),
lowerToolResultContentItem,
)
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
// Mid-conversation system messages became available with Opus 4.8 and version
@@ -469,8 +463,7 @@ const supportsNativeSystemUpdates = (request: LLMRequest) => {
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
}
const endsInServerToolUse = (message: LLMRequest["messages"][number] | undefined) => {
if (!message) return false
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
const last = message.content.at(-1)
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
}
@@ -522,26 +515,13 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
const content = (yield* ProviderShared.systemUpdateText("Anthropic Messages", message)).filter(hasText)
if (content.length === 0) continue
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
const normalized = Message.make({
id: message.id,
role: "system",
content,
metadata: message.metadata,
native: message.native,
})
if (
supportsNativeSystemUpdates(request) &&
canUseNativeSystemUpdate(request.messages, index) &&
(messages.at(-1)?.role === "user" || endsInServerToolUse(request.messages[index - 1]))
) {
messages.push(yield* lowerNativeSystemUpdate(normalized, breakpoints))
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}
const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", normalized)
const part = yield* ProviderShared.wrappedSystemUpdate("Anthropic Messages", message)
const block = { type: "text" as const, text: part.text, cache_control: cacheControl(breakpoints, part.cache) }
const previous = messages.at(-1)
if (previous?.role === "user")
@@ -554,7 +534,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicUserBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (!hasText(part)) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -564,7 +543,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
}
if (content.length > 0) messages.push({ role: "user", content })
messages.push({ role: "user", content })
continue
}
@@ -572,7 +551,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (!hasText(part)) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -601,7 +579,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
)
}
if (content.length > 0) messages.push({ role: "assistant", content })
messages.push({ role: "assistant", content })
continue
}
@@ -618,7 +596,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
})
}
const previous = messages.at(-1)
if (content.length === 0) continue
if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result"))
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
@@ -678,11 +655,10 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const systemParts = request.system.filter(hasText)
const system =
systemParts.length === 0
request.system.length === 0
? undefined
: systemParts.map((part) => ({
: request.system.map((part) => ({
type: "text" as const,
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
@@ -58,93 +58,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("filters empty text blocks while preserving assistant replay state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
system: [
{ type: "text", text: " \n\t" },
{ type: "text", text: " Keep this system prompt. " },
],
messages: [
Message.user([
{ type: "text", text: "" },
{ type: "text", text: " Use the tool. " },
{ type: "text", text: " \n\t" },
]),
Message.assistant([
{ type: "text", text: "", providerMetadata: { anthropic: { itemId: "ignored" } } },
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({
id: "call_1",
name: "lookup",
resultType: "content",
result: [
{ type: "text", text: " \n\t" },
{ type: "text", text: " Tool result. " },
],
}),
Message.assistant(" \n\t"),
Message.user("Continue."),
],
cache: "none",
}),
)
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: " Keep this system prompt. " }],
messages: [
{ role: "user", content: [{ type: "text", text: " Use the tool. " }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "", signature: "sig_1" },
{ type: "tool_use", id: "call_1", name: "lookup", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: [{ type: "text", text: " Tool result. " }],
},
],
},
{ role: "user", content: [{ type: "text", text: "Continue." }] },
],
})
}),
)
it.effect("filters empty native system update blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
Message.user("Before."),
Message.system([
{ type: "text", text: " \n\t" },
{ type: "text", text: "Operator update." },
]),
Message.assistant("After."),
],
cache: "none",
}),
)
expect(prepared.body.messages[1]).toEqual({
role: "system",
content: [{ type: "text", text: "Operator update." }],
})
}),
)
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+42 -27
View File
@@ -37,15 +37,17 @@ const layer = Layer.effect(
// root so opening a subdirectory still describes paths from the project root.
const root = yield* fs.resolve(location.project.directory)
// Same-step parallel reads settle concurrently, so an in-memory claim guards each
// Session/path pair before any filesystem work. The durable history check below covers
// paths injected in earlier steps after this Location layer was reopened.
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
// Session/path pair while a load is in flight. The claim is released once the load
// settles: the synthetic message metadata scanned below is the only lasting ledger,
// so paths whose synthetics drop out of model-visible history (compaction, revert)
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const claimed = yield* Ref.modify(injected, (map) => {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
if (newlyClaimed.length === 0) return [newlyClaimed, map]
@@ -54,30 +56,43 @@ const layer = Layer.effect(
return [newlyClaimed, next]
})
if (claimed.length === 0) return
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
if (toInject.length === 0) return
const files = yield* Effect.forEach(
toInject,
(path) =>
fs
.readFileStringSafe(path)
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
{ concurrency: "unbounded" },
yield* Effect.gen(function* () {
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
if (toInject.length === 0) return
const files = yield* Effect.forEach(
toInject,
(path) =>
fs
.readFileStringSafe(path)
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
{ concurrency: "unbounded" },
)
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
if (readable.length === 0) return
// Publish directly rather than through Session.synthetic: a Location-scoped layer
// cannot depend on Session (it routes through LocationServiceMap, forming a type
// cycle with this node). The durable publish commits the synthetic and its metadata
// ledger atomically, so releasing the claim afterwards cannot readmit the paths.
yield* bus.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } },
})
}).pipe(
Effect.ensuring(
Ref.update(inFlight, (map) => {
const existing = map.get(input.sessionID)
if (!existing) return map
const remaining = new Set([...existing].filter((path) => !claimed.includes(path)))
const next = new Map(map)
if (remaining.size === 0) next.delete(input.sessionID)
else next.set(input.sessionID, remaining)
return next
}),
),
)
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
if (readable.length === 0) return
// Publish directly rather than through Session.synthetic: a Location-scoped layer
// cannot depend on Session (it routes through LocationServiceMap, forming a type
// cycle with this node). The durable publish is what makes the synthetic visible on
// the next projected history reload. The dedup ledger lives on the synthetic message
// metadata so it survives across Location layer restarts.
yield* bus.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } },
})
})
return Service.of({ load })
@@ -7,9 +7,6 @@ import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
const hasProviderMetadata = (metadata: ProviderMetadata | undefined) =>
metadata !== undefined && Object.keys(metadata).length > 0
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
@@ -191,9 +188,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
return result ? [call, result] : [call]
})
const meaningful = content.filter((part) => {
if (part.type === "text") return part.text !== "" || hasProviderMetadata(part.providerMetadata)
if (part.type === "text") return part.text !== ""
if (part.type !== "reasoning") return true
return part.text !== "" || hasProviderMetadata(part.providerMetadata)
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
@@ -233,6 +233,37 @@ describe("SessionInstructions", () => {
}),
)
it.effect("re-injects nested instructions dropped from history by compaction", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const dir = location.directory
const subPath = path.resolve(dir, "sub", "AGENTS.md")
yield* mkdir(path.resolve(dir, "sub"))
yield* writeAgents(path.resolve(dir, "AGENTS.md"), "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
const session = yield* Session.Service
const registry = yield* Tool.Service
const bus = yield* Bus.Service
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
yield* executeTool(registry, readCall(sessionID, "call-before", "sub/file.txt"))
expect(yield* synthetics(sessionID)).toHaveLength(1)
// A completed compaction truncates model-visible history at its boundary, dropping
// the synthetic that carried sub's instructions.
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" })
expect(yield* synthetics(sessionID)).toHaveLength(0)
// The model no longer has the rules, so the next read under the subtree must
// re-inject them rather than trusting a stale in-memory claim.
yield* executeTool(registry, readCall(sessionID, "call-after", "sub/file.txt"))
expect(yield* synthetics(sessionID)).toHaveLength(1)
}),
)
it.effect("listing the Location root directory injects no instructions", () =>
Effect.gen(function* () {
const location = yield* Location.Service
@@ -1031,7 +1031,7 @@ Recent work
content: [
SessionMessage.AssistantText.make({
type: "text",
text: "",
text: "Checking.",
state: { phase: "commentary" },
}),
],
@@ -1045,7 +1045,7 @@ Recent work
expect(messages[0]?.content).toEqual([
{
type: "text",
text: "",
text: "Checking.",
providerMetadata: { provider: { phase: "commentary" } },
},
])