diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index 8334e7b063..89882ea19b 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -14,16 +14,42 @@ const Summary = Schema.Struct({ }) type Summary = typeof Summary.Type +const entries = (servers: ReadonlyArray) => + servers.flatMap((server) => [ + ` `, + ...server.instructions.split("\n").map((line) => ` ${line}`), + " ", + ]) + const render = (servers: ReadonlyArray) => - [ - "", - ...servers.flatMap((server) => [ - ` `, - ...server.instructions.split("\n").map((line) => ` ${line}`), - " ", - ]), - "", + ["", ...entries(servers), ""].join("\n") + +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const names = new Set(current.map((server) => server.server)) + const previousByName = new Map(previous.map((server) => [server.server, server])) + const added = current.filter((server) => !previousByName.has(server.server)) + const removed = previous.filter((server) => !names.has(server.server)) + const changed = current.filter((server) => { + const before = previousByName.get(server.server) + return before !== undefined && before.instructions !== server.instructions + }) + // Additions and removals render as small deltas; anything else restates the full list. + if (changed.length > 0 || (added.length === 0 && removed.length === 0)) + return [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + render(current), + ].join("\n") + return [ + ...(added.length === 0 + ? [] + : ["New MCP server instructions are available in addition to those previously listed:", ...entries(added)]), + ...(removed.length === 0 + ? [] + : [ + `Instructions for the following MCP servers are no longer available: ${removed.map((server) => server.server).join(", ")}.`, + ]), ].join("\n") +} export interface Interface { readonly load: (agent: AgentV2.Selection) => Effect.Effect @@ -50,7 +76,8 @@ export const layer = Layer.effect( return ( owned.length === 0 || owned.some( - (tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", + (tool) => + PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", ) ) }) @@ -61,11 +88,7 @@ export const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(visible), baseline: render, - update: (_previous, current) => - [ - "The available MCP server instructions have changed. This list supersedes the previous one.", - render(current), - ].join("\n"), + update, removed: () => "MCP server instructions are no longer available.", }) }), diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index 11fcb9ecea..6544852df2 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -11,20 +11,50 @@ const Summary = Schema.Struct({ description: Schema.String.pipe(Schema.optional), }) +const entries = (references: ReadonlyArray) => + references.flatMap((reference) => [ + " ", + ` ${reference.name}`, + ` ${reference.path}`, + ...(reference.description === undefined ? [] : [` ${reference.description}`]), + " ", + ]) + const render = (references: ReadonlyArray) => [ "Project references provide additional directories that can be accessed when relevant.", "", - ...references.flatMap((reference) => [ - " ", - ` ${reference.name}`, - ` ${reference.path}`, - ...(reference.description === undefined ? [] : [` ${reference.description}`]), - " ", - ]), + ...entries(references), "", ].join("\n") +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const names = new Set(current.map((reference) => reference.name)) + const previousByName = new Map(previous.map((reference) => [reference.name, reference])) + const added = current.filter((reference) => !previousByName.has(reference.name)) + const removed = previous.filter((reference) => !names.has(reference.name)) + const changed = current.filter((reference) => { + const before = previousByName.get(reference.name) + return before !== undefined && (before.path !== reference.path || before.description !== reference.description) + }) + // Additions and removals render as small deltas; anything else restates the full list. + if (changed.length > 0 || (added.length === 0 && removed.length === 0)) + return [ + "The available project references have changed. This list supersedes the previous reference list.", + render(current), + ].join("\n") + return [ + ...(added.length === 0 + ? [] + : ["New project references are available in addition to those previously listed:", ...entries(added)]), + ...(removed.length === 0 + ? [] + : [ + `The following project references are no longer available and must not be used: ${removed.map((reference) => reference.name).join(", ")}.`, + ]), + ].join("\n") +} + export interface Interface { readonly load: () => Effect.Effect } @@ -52,11 +82,7 @@ const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(available), baseline: render, - update: (_previous, current) => - [ - "The available project references have changed. This list supersedes the previous reference list.", - render(current), - ].join("\n"), + update, removed: () => "Project reference guidance is no longer available. Do not use previously listed references.", }) }), diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index f2ec815744..abca17b6b0 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -13,23 +13,49 @@ const Summary = Schema.Struct({ }) type Summary = typeof Summary.Type +const entries = (skills: ReadonlyArray) => + skills.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + " ", + ]) + const render = (skills: ReadonlyArray) => [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", ...(skills.length === 0 ? ["No skills are currently available."] + : ["", ...entries(skills), ""]), + ].join("\n") + +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const names = new Set(current.map((skill) => skill.name)) + const previousByName = new Map(previous.map((skill) => [skill.name, skill])) + const added = current.filter((skill) => !previousByName.has(skill.name)) + const removed = previous.filter((skill) => !names.has(skill.name)) + const changed = current.filter((skill) => { + const before = previousByName.get(skill.name) + return before !== undefined && before.description !== skill.description + }) + // Additions and removals render as small deltas; anything else restates the full list. + if (changed.length > 0 || (added.length === 0 && removed.length === 0)) + return [ + "The available skills have changed. This list supersedes the previous available skills list.", + render(current), + ].join("\n") + return [ + ...(added.length === 0 + ? [] + : ["New skills are available in addition to those previously listed:", ...entries(added)]), + ...(removed.length === 0 + ? [] : [ - "", - ...skills.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - " ", - ]), - "", + `The following skills are no longer available and must not be used: ${removed.map((skill) => skill.name).join(", ")}.`, ]), ].join("\n") +} export interface Interface { readonly load: (agent: AgentV2.Selection) => Effect.Effect @@ -61,11 +87,7 @@ const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(available), baseline: render, - update: (_previous, current) => - [ - "The available skills have changed. This list supersedes the previous available skills list.", - render(current), - ].join("\n"), + update, removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.", }) }), diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 4ff4a2ace4..3db3212c3c 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -73,4 +73,41 @@ describe("ReferenceGuidance", () => { ), ), ) + + it.effect("announces added and removed references as deltas", () => { + const reference = (name: string, description: string) => + new Reference.Info({ + name, + path: AbsolutePath.make(`/${name}`), + description, + source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make(`/${name}`), description }), + }) + let references = [reference("docs", "Use for product documentation")] + return Effect.gen(function* () { + const guidance = yield* ReferenceGuidance.Service + const initialized = yield* SystemContext.initialize(yield* guidance.load()) + + references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")] + const added = yield* SystemContext.reconcile(yield* guidance.load(), initialized.applied) + expect(added).toMatchObject({ + _tag: "Updated", + text: [ + "New project references are available in addition to those previously listed:", + " ", + " examples", + " /examples", + " Use for examples", + " ", + ].join("\n"), + }) + + references = [reference("examples", "Use for examples")] + expect( + yield* SystemContext.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}), + ).toMatchObject({ + _tag: "Updated", + text: "The following project references are no longer available and must not be used: docs.", + }) + }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) })))) + }) }) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index e7ef9c83ca..b26b75fe71 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -74,7 +74,73 @@ describe("SkillGuidance", () => { .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))), ).toMatchObject({ _tag: "Updated", - text: expect.stringContaining("No skills are currently available."), + text: "The following skills are no longer available and must not be used: effect.", + }) + }).pipe(Effect.provide(layer(() => skills))) + }) + + it.effect("announces added and removed skills as deltas without restating the list", () => { + const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) + const debugging = SkillV2.Info.make({ + name: "debugging", + description: "Diagnose hard bugs", + location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")), + content: "Debugging guidance", + }) + let skills = [effect] + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + const initialized = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap(SystemContext.initialize)) + + skills = [effect, debugging] + const added = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))) + expect(added).toMatchObject({ + _tag: "Updated", + text: [ + "New skills are available in addition to those previously listed:", + " ", + " debugging", + " Diagnose hard bugs", + " ", + ].join("\n"), + }) + + skills = [debugging] + const removed = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe( + Effect.flatMap((context) => SystemContext.reconcile(context, added._tag === "Updated" ? added.applied : {})), + ) + expect(removed).toMatchObject({ + _tag: "Updated", + text: "The following skills are no longer available and must not be used: effect.", + }) + }).pipe(Effect.provide(layer(() => skills))) + }) + + it.effect("restates the full skill list when a description changes", () => { + const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) + let skills = [effect] + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + const initialized = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap(SystemContext.initialize)) + + skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })] + expect( + yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))), + ).toMatchObject({ + _tag: "Updated", + text: expect.stringContaining( + "The available skills have changed. This list supersedes the previous available skills list.", + ), }) }).pipe(Effect.provide(layer(() => skills))) })