feat(core): announce top-level tool availability changes

Track the direct tool names shown to the model as an instruction source so
later requests announce only what was added or removed, phrased like the
Code Mode catalog updates. The request's own tool list is the baseline, so
instruction sources may now omit an initial render and nothing is said until
the set changes.
This commit is contained in:
rekram1-node
2026-09-19 20:30:50 -05:00
parent eb0e26b974
commit 2fa63c521d
4 changed files with 100 additions and 2 deletions
+4 -2
View File
@@ -45,7 +45,8 @@ export declare namespace Source {
readonly codec: Schema.Codec<A, Schema.Json>
readonly read: Effect.Effect<A | Unavailable | Removed>
readonly render: {
readonly initial: (current: A) => string
/** Omit when the baseline is already visible to the model and only changes carry information. */
readonly initial?: (current: A) => string
readonly changed: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
}
@@ -88,7 +89,8 @@ export const empty: List = []
export function make<A>(source: Source.Definition<A>): List {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
const initial = (value: A) =>
source.render.initial === undefined ? undefined : requireText(source.key, "initial", source.render.initial(value))
const decodeValue = (value: Schema.Json) => Option.getOrUndefined(decode(value))
return [
{
+2
View File
@@ -16,6 +16,7 @@ import { McpTool } from "../tool/mcp.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
import { ToolInstructions } from "../tool/instructions.js"
import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
@@ -146,6 +147,7 @@ const layer = Layer.effect(
agent: { ...agent, info: agent.info },
instructions: Instructions.combine([
loaded.builtins,
ToolInstructions.make(loaded.tools.definitions.map((definition) => definition.name)),
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
loaded.discovery,
loaded.skills,
+36
View File
@@ -0,0 +1,36 @@
export * as ToolInstructions from "./instructions.js"
import { Effect, Schema } from "effect"
import { Instructions } from "../instructions/index.js"
const Names = Schema.Array(Schema.String)
type Names = typeof Names.Type
const list = (names: ReadonlyArray<string>) => names.map((name) => `\`${name}\``).join(", ")
export function update(previous: Names, current: Names) {
const added = current.filter((name) => !previous.includes(name))
const removed = previous.filter((name) => !current.includes(name))
return [
"The available tools have changed.",
...(added.length > 0 ? [`New tools are available in addition to those previously provided: ${list(added)}.`] : []),
...(removed.length > 0
? [`The following tools are no longer available and must not be called: ${list(removed)}.`]
: []),
].join("\n\n")
}
const key = Instructions.Key.make("core/tools")
const codec = Schema.toCodecJson(Names)
/**
* Tracks the top-level tool names the model has been shown. The request's own
* tool list is the baseline, so nothing renders until that set changes.
*/
export const make = (names: ReadonlyArray<string>): Instructions.List =>
Instructions.make({
key,
codec,
read: Effect.succeed(Array.from(new Set(names)).sort()),
render: { changed: update },
})
@@ -0,0 +1,58 @@
import { describe, expect } from "bun:test"
import { ToolInstructions } from "@opencode/core/tool/instructions"
import { Effect } from "effect"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
describe("ToolInstructions", () => {
it.effect("renders nothing for the baseline and announces only the delta afterwards", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(ToolInstructions.make(["shell", "read", "edit"]))
expect(initialized.text).toBe("")
expect(initialized.values["core/tools"]).toEqual(["edit", "read", "shell"])
const unchanged = yield* readUpdate(ToolInstructions.make(["edit", "shell", "read", "read"]), initialized)
expect(unchanged.text).toBe("")
const changed = yield* readUpdate(ToolInstructions.make(["edit", "read", "write", "glob"]), initialized)
expect(changed.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `glob`, `write`.",
"The following tools are no longer available and must not be called: `shell`.",
].join("\n\n"),
)
const restored = yield* readUpdate(ToolInstructions.make(["edit", "read", "shell", "write", "glob"]), changed)
expect(restored.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `shell`.",
].join("\n\n"),
)
}),
)
it.effect("announces transitions to and from an empty tool set", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(ToolInstructions.make([]))
expect(initialized.text).toBe("")
const added = yield* readUpdate(ToolInstructions.make(["read"]), initialized)
expect(added.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `read`.",
].join("\n\n"),
)
const emptied = yield* readUpdate(ToolInstructions.make([]), added)
expect(emptied.text).toBe(
[
"The available tools have changed.",
"The following tools are no longer available and must not be called: `read`.",
].join("\n\n"),
)
}),
)
})