diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 090387ddda7..c94418f94c1 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -85,22 +85,29 @@ const layer = Layer.effectDiscard( agent: context.agent, source, }) - if (type === "directory") - return yield* reader.list(absolute, { offset: input.offset, limit: input.limit }) - const content = yield* reader.read(absolute, resource, { - offset: input.offset, - limit: input.limit, - }) - // After a successful file content read (not directory listings), discover - // nearby AGENTS.md walking up to the Location root exclusive and inject them - // as durable synthetic instructions. Discovery failures never fail the read. + const content = + type === "directory" + ? yield* reader.list(absolute, { offset: input.offset, limit: input.limit }) + : yield* reader.read(absolute, resource, { + offset: input.offset, + limit: input.limit, + }) + // After a successful read, discover nearby AGENTS.md walking up to the Location + // root exclusive and inject them as durable synthetic instructions. For a + // directory listing the walk starts at the directory itself (so its own AGENTS.md + // is discovered); for a file it starts at the file's dirname. External reads are + // skipped, and discovery failures never fail the read. yield* Effect.gen(function* () { if (target.externalDirectory !== undefined) return const resolved = FSUtil.resolve(target.canonical) const root = FSUtil.resolve(location.directory) // up() searches its stop directory, so the Location-root AGENTS.md (already // supplied by the core/instructions baseline) is dropped by the dirname filter. - const discovered = yield* fs.up({ targets: [FILENAME], start: dirname(resolved), stop: root }) + const discovered = yield* fs.up({ + targets: [FILENAME], + start: type === "directory" ? resolved : dirname(resolved), + stop: root, + }) const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root) if (candidates.length === 0) return yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 1ef12271fef..b9e401be4bc 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -201,6 +201,62 @@ describe("SessionInstructions", () => { }), ) + it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () => + Effect.gen(function* () { + const location = yield* Location.Service + const dir = location.directory + const rootPath = path.resolve(dir, "AGENTS.md") + const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md") + yield* mkdir(path.resolve(dir, "packages", "foo")) + yield* writeAgents(rootPath, "root-instructions") + yield* writeAgents(pkgPath, "pkg-instructions") + yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content")) + + const session = yield* SessionV2.Service + const registry = yield* ToolRegistry.Service + const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id + + // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding + // the Location root (already supplied by the core/instructions baseline). + yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo")) + + const firstInjected = yield* synthetics(sessionID) + expect(firstInjected).toHaveLength(1) + expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`) + expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`) + expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } }) + expect(firstInjected[0]!.text).not.toContain("root-instructions") + + // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is + // already injected for this session, so nothing new is emitted. + yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/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 + const dir = location.directory + const rootPath = path.resolve(dir, "AGENTS.md") + const subPath = path.resolve(dir, "sub", "AGENTS.md") + yield* mkdir(path.resolve(dir, "sub")) + yield* writeAgents(rootPath, "root-instructions") + yield* writeAgents(subPath, "sub-instructions") + + const session = yield* SessionV2.Service + const registry = yield* ToolRegistry.Service + const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id + + // The walk starts and stops at the Location root: the root AGENTS.md is searched but + // dropped by the dirname filter, and up() only walks upward so nested dirs are unseen. + yield* settleTool(registry, readCall(sessionID, "call-root-list", ".")) + + expect((yield* synthetics(sessionID))).toHaveLength(0) + }), + ) + it.effect("loads instructions directly without a read", () => Effect.gen(function* () { const location = yield* Location.Service