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.
This commit is contained in:
Kit Langton 2026-07-02 00:36:40 -04:00
commit 334613c18f
5 changed files with 214 additions and 40 deletions

View file

@ -14,16 +14,42 @@ const Summary = Schema.Struct({
})
type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
const render = (servers: ReadonlyArray<Summary>) =>
[
"<mcp_instructions>",
...servers.flatMap((server) => [
` <server name="${server.server}">`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
]),
"</mcp_instructions>",
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
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<SystemContext.SystemContext>
@ -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.",
})
}),

View file

@ -11,20 +11,50 @@ const Summary = Schema.Struct({
description: Schema.String.pipe(Schema.optional),
})
const entries = (references: ReadonlyArray<typeof Summary.Type>) =>
references.flatMap((reference) => [
" <reference>",
` <name>${reference.name}</name>`,
` <path>${reference.path}</path>`,
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
" </reference>",
])
const render = (references: ReadonlyArray<typeof Summary.Type>) =>
[
"Project references provide additional directories that can be accessed when relevant.",
"<available_references>",
...references.flatMap((reference) => [
" <reference>",
` <name>${reference.name}</name>`,
` <path>${reference.path}</path>`,
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
" </reference>",
]),
...entries(references),
"</available_references>",
].join("\n")
const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => {
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<SystemContext.SystemContext>
}
@ -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.",
})
}),

View file

@ -13,23 +13,49 @@ const Summary = Schema.Struct({
})
type Summary = typeof Summary.Type
const entries = (skills: ReadonlyArray<Summary>) =>
skills.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
" </skill>",
])
const render = (skills: ReadonlyArray<Summary>) =>
[
"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."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
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
? []
: [
"<available_skills>",
...skills.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
" </skill>",
]),
"</available_skills>",
`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<SystemContext.SystemContext>
@ -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.",
})
}),

View file

@ -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:",
" <reference>",
" <name>examples</name>",
" <path>/examples</path>",
" <description>Use for examples</description>",
" </reference>",
].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) }))))
})
})

View file

@ -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:",
" <skill>",
" <name>debugging</name>",
" <description>Diagnose hard bugs</description>",
" </skill>",
].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)))
})