Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nested-instruction-reinjection.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 42 additions & 27 deletions packages/core/src/session/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 })
Expand Down
31 changes: 31 additions & 0 deletions packages/core/test/session-instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading