Compare commits

...
1 Commits
Author SHA1 Message Date
rekram1-node 2fa63c521d 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.
2026-09-19 20:30:50 -05:00
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"),
)
}),
)
})