From 334613c18fecf7d2eb42115009327d30aa9aafab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 00:36:40 -0400 Subject: [PATCH] feat(core): render skill, reference, and MCP context updates as deltas Pure additions announce only the new entries and pure removals name what is gone, instead of restating the full list on every change. Description or instruction edits still restate the superseding list. --- packages/core/src/mcp/guidance.ts | 51 ++++++++++---- packages/core/src/reference/guidance.ts | 50 ++++++++++---- packages/core/src/skill/guidance.ts | 48 +++++++++---- packages/core/test/reference-guidance.test.ts | 37 ++++++++++ packages/core/test/skill/guidance.test.ts | 68 ++++++++++++++++++- 5 files changed, 214 insertions(+), 40 deletions(-) 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))) })