Compare commits

...
11 changed files with 340 additions and 8 deletions
+19 -2
View File
@@ -7,6 +7,7 @@ export const Entry = Schema.Struct({
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
namespaceInstructions: Schema.optionalKey(Schema.String),
})
export type Entry = typeof Entry.Type
@@ -19,6 +20,7 @@ const Namespace = Schema.Struct({
name: Schema.String,
count: Schema.Number,
entries: Schema.Array(Listing),
instructions: Schema.optionalKey(Schema.String),
})
export const Summary = Schema.Struct({
@@ -42,6 +44,9 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
return 0
})
.map(([name, namespaceEntries]) => {
const instructions = namespaceEntries.find(
(entry) => entry.namespaceInstructions !== undefined,
)?.namespaceInstructions
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
@@ -64,6 +69,7 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
)
return {
name,
...(instructions === undefined ? {} : { instructions }),
listings,
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
@@ -75,8 +81,18 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
let remaining =
budget -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
.flatMap((namespace) => {
const listings = namespace.listings
.filter((listing) => namespace.selectedListings.has(listing))
.map((listing) => listing.line)
if (namespace.instructions === undefined) return listings
const instructions = namespace.instructions
.split("\n")
.map((line) => ` ${line}`)
.join("\n")
return [instructions, ...listings]
})
.reduce((total, text) => total + Math.round(text.length / CHARACTERS_PER_TOKEN), 0)
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectionIndex]
@@ -95,6 +111,7 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
name: namespace.name,
count: namespace.listings.length,
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
...(namespace.instructions === undefined ? {} : { instructions: namespace.instructions }),
}))
return {
total: entries.length,
+18 -1
View File
@@ -30,7 +30,11 @@ export function render(catalog: CodeModeCatalog.Summary) {
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
return [
`- ${namespace.name} (${label})`,
...(namespace.instructions?.split("\n").map((line) => ` ${line}`) ?? []),
...namespace.entries.map((entry) => entry.line),
]
})
return `${prompt(catalog.shown < catalog.total)}
@@ -47,6 +51,19 @@ ${render(current)}`
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return replacement
const guidance = Instructions.diffByKey(
previous.namespaces,
current.namespaces,
(namespace) => namespace.name,
(before, after) => before.instructions !== after.instructions,
)
if (
guidance.changed.length > 0 ||
guidance.added.some((namespace) => namespace.instructions !== undefined) ||
guidance.removed.some((namespace) => namespace.instructions !== undefined)
)
return replacement
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
current.namespaces.flatMap((namespace) => namespace.entries),
+9 -2
View File
@@ -134,7 +134,7 @@ export const create = (
} satisfies Info
}
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
export const catalog = (registrations: ReadonlyMap<string, Info>, instructions: ReadonlyMap<string, string>) => {
const pinned = new Set(
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
@@ -142,7 +142,14 @@ export const catalog = (registrations: ReadonlyMap<string, Info>) => {
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
.map((entry) => {
const guidance = instructions.get(entry.path.split(".", 1)[0] ?? entry.path)
return {
...entry,
pinned: pinned.has(entry.path),
...(guidance === undefined ? {} : { namespaceInstructions: guidance }),
}
})
}
function runtime(
+39 -1
View File
@@ -145,6 +145,20 @@ const layer = Layer.effect(
validateNamespace,
{ discard: true },
)
const invalid = tools.find((tool) => {
const direct = tool.options?.codemode === false
return tool.options?.namespaceInstructions !== undefined && (tool.options.namespace === undefined || direct)
})
if (invalid)
return yield* Effect.fail(
new RegistrationError({
name: invalid.name,
message:
invalid.options?.namespace === undefined
? `Namespace instructions require a tool namespace: "${invalid.name}"`
: `Namespace instructions require a Code Mode tool: "${invalid.name}"`,
}),
)
const entries = normalizedEntries(tools)
yield* Effect.forEach(entries, (entry) => validateName(normalizedName(entry.tool)), { discard: true })
const collision = entries.find(
@@ -182,6 +196,24 @@ const layer = Layer.effect(
yield* Effect.uninterruptible(
lock.withPermit(
Effect.gen(function* () {
const conflict = [
...Map.groupBy(
[
...Array.from(local.values()).flatMap((registrations) => registrations.map((item) => item.tool)),
...tools,
].filter((tool) => tool.options?.namespaceInstructions !== undefined),
(tool) => tool.options?.namespace?.split(".", 1)[0] ?? "",
),
].find(
([, registrations]) => new Set(registrations.map((tool) => tool.options?.namespaceInstructions)).size > 1,
)
if (conflict)
return yield* Effect.fail(
new RegistrationError({
name: conflict[0],
message: `Conflicting instructions for tool namespace: "${conflict[0]}"`,
}),
)
const token = {}
for (const entry of entries)
local.set(entry.key, [...(local.get(entry.key) ?? []), { token, tool: entry.tool }])
@@ -207,10 +239,16 @@ const layer = Layer.effect(
lock.withPermit(
Effect.gen(function* () {
const active = new Map<string, Tool.Info>()
const guidance = new Map<string, string>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const tool = entries.at(-1)?.tool
if (!tool) continue
if (tool.options?.namespace !== undefined && tool.options.namespaceInstructions !== undefined)
guidance.set(
tool.options.namespace.split(".", 1)[0] ?? tool.options.namespace,
tool.options.namespaceInstructions,
)
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
@@ -221,7 +259,7 @@ const layer = Layer.effect(
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode, guidance) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
+31
View File
@@ -40,4 +40,35 @@ describe("CodeMode", () => {
),
),
)
it.effect("includes namespace instructions in the materialized catalog", () =>
Effect.gen(function* () {
const tools = yield* Tool.Service
yield* tools.transform((draft) =>
draft.add({
name: "create",
description: "Create a session",
input: Schema.Struct({}),
output: Schema.String,
options: {
namespace: "sessions",
namespaceInstructions: "Create independent sessions only when explicitly requested.",
},
execute: () => Effect.succeed({ output: "created" }),
}),
)
expect((yield* tools.snapshot()).codeModeCatalog?.[0]).toMatchObject({
path: "sessions.create",
namespaceInstructions: "Create independent sessions only when explicitly requested.",
})
}).pipe(
Effect.scoped,
Effect.provide(
AppNodeBuilder.build(Tool.node, [
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
]),
),
),
)
})
@@ -47,6 +47,32 @@ describe("CodeModeCatalog.summarize", () => {
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
})
test("retains namespace instructions when no tool listing fits", () => {
const catalog = CodeModeCatalog.summarize(
[{ ...lookup, namespaceInstructions: "Use order tools only for verified customers." }],
0,
)
expect(catalog.namespaces).toEqual([
{
name: "orders",
count: 1,
entries: [],
instructions: "Use order tools only for verified customers.",
},
])
})
test("charges namespace instructions against the tool listing budget", () => {
const guidance = "Ask before using these tools."
const guided = { ...lookup, namespaceInstructions: guidance }
const line = ` - ${lookup.signature} // Look up an order by ID`
const budget = Math.round(line.length / 4) + Math.round(` ${guidance}`.length / 4)
expect(CodeModeCatalog.summarize([guided], budget).shown).toBe(1)
expect(CodeModeCatalog.summarize([guided], budget - 1).shown).toBe(0)
})
test("always retains pinned tools beyond the inline budget", () => {
const pinned = [entry("alpha.first", "First", undefined, true), entry("beta.second", "Second", undefined, true)]
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
@@ -108,6 +134,26 @@ describe("CodeModeInstructions.render", () => {
expect(partial).not.toContain("tools.orders.lookup(input:")
})
test("renders multiline namespace instructions once before tool listings", () => {
const guidance = "Use order tools only for verified customers.\nNever create orders without approval."
const instructions = render([
{ ...lookup, namespaceInstructions: guidance },
{ ...entry("orders.cancel", "Cancel an order"), namespaceInstructions: guidance },
])
expect(instructions).toContain(
"- orders (2 tools)\n Use order tools only for verified customers.\n Never create orders without approval.",
)
expect(instructions.split("Use order tools only for verified customers.")).toHaveLength(2)
})
test("renders namespace instructions when no tool listing fits", () => {
const instructions = render([{ ...lookup, namespaceInstructions: "Verify the customer before searching." }], 0)
expect(instructions).toContain("- orders (1 tool, none shown)\n Verify the customer before searching.")
expect(instructions).not.toContain("tools.orders.lookup(input:")
})
test("budgets signatures round-robin so every namespace remains visible", () => {
const cheapAlpha = entry("alpha.cheap", "Cheap")
const cheapBeta = entry("beta.cheap", "Cheap")
@@ -197,4 +243,31 @@ describe("CodeModeInstructions.update", () => {
expect(text).not.toContain("tools.alpha.tool10(input:")
expect(text).not.toContain("## Available tools")
})
test("replaces complete catalogs when namespace instructions change", () => {
const previous = { ...lookup, namespaceInstructions: "Ask before looking up orders." }
const current = { ...lookup, namespaceInstructions: "Only look up approved orders." }
const text = update([previous], [current])
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
expect(text).toContain("- orders (1 tool)\n Only look up approved orders.")
expect(text).not.toContain("Ask before looking up orders.")
})
test("replaces partial catalogs when namespace instructions change", () => {
const previous = { ...lookup, namespaceInstructions: "Ask before looking up orders." }
const current = { ...lookup, namespaceInstructions: "Only look up approved orders." }
const text = update([previous], [current], 0)
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
expect(text).toContain("- orders (1 tool, none shown)\n Only look up approved orders.")
expect(text).not.toContain("tools.orders.lookup(input:")
})
test("replaces catalogs when an instructed namespace is added or removed", () => {
const guided = { ...lookup, namespaceInstructions: "Ask before looking up orders." }
expect(update([echo], [echo, guided])).toContain("- orders (1 tool)\n Ask before looking up orders.")
expect(update([echo, guided], [echo])).toContain("This catalog supersedes the previous Code Mode tool catalog.")
})
})
@@ -69,6 +69,23 @@ describe("CodeModeInstructions", () => {
}),
)
it.effect("persists namespace instruction changes as durable catalog replacements", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(
CodeModeInstructions.make([{ ...echo, namespaceInstructions: "Ask before using note tools." }]),
)
expect(initialized.text).toContain("- notes (1 tool)\n Ask before using note tools.")
const changed = yield* readUpdate(
CodeModeInstructions.make([{ ...echo, namespaceInstructions: "Use note tools only when requested." }]),
initialized,
)
expect(changed.changed).toBe(true)
expect(changed.text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
expect(changed.text).toContain("- notes (1 tool)\n Use note tools only when requested.")
}),
)
it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => {
const alpha = {
name: "alpha",
@@ -82,6 +82,120 @@ describe("Tool", () => {
}),
)
it.effect("rejects namespace instructions without a namespace", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const error = yield* transform(service, { echo: make() }, { namespaceInstructions: "Ask first." }).pipe(
Effect.flip,
)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Namespace instructions require a tool namespace: "echo"')
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
}),
)
it.effect("rejects namespace instructions on direct-only tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const options = { namespace: "sessions", codemode: false as const }
Reflect.set(options, "namespaceInstructions", "Ask first.")
const error = yield* transform(service, { echo: make() }, options).pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Namespace instructions require a Code Mode tool: "echo"')
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
}),
)
it.effect("rejects conflicting namespace instructions before installing a registration batch", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const error = yield* service
.transform((draft) => {
draft.add({
...make(),
name: "first",
options: { namespace: "sessions", namespaceInstructions: "Ask first." },
})
draft.add({
...make(),
name: "second",
options: { namespace: "sessions", namespaceInstructions: "Never ask first." },
})
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Conflicting instructions for tool namespace: "sessions"')
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
}),
)
it.effect("rejects conflicting namespace instructions across registrations", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { first: make() }, { namespace: "sessions", namespaceInstructions: "Ask first." })
const error = yield* transform(
service,
{ second: make() },
{ namespace: "sessions.admin", namespaceInstructions: "Never ask first." },
).pipe(Effect.flip)
expect(error.message).toBe('Conflicting instructions for tool namespace: "sessions"')
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual(["sessions.first"])
}),
)
it.effect("accepts matching namespace instructions across registrations", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const options = { namespace: "sessions", namespaceInstructions: "Ask first." }
yield* transform(service, { first: make() }, options)
yield* transform(service, { second: make() }, options)
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual([
"sessions.first",
"sessions.second",
])
}),
)
it.effect("rejects instructions conflicting with a shadowed registration", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { first: make() }, { namespace: "sessions", namespaceInstructions: "Ask first." })
yield* transform(service, { first: make() }, { namespace: "sessions" })
const error = yield* transform(
service,
{ second: make() },
{ namespace: "sessions", namespaceInstructions: "Never ask first." },
).pipe(Effect.flip)
expect(error.message).toBe('Conflicting instructions for tool namespace: "sessions"')
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual(["sessions.first"])
}),
)
it.effect("keeps namespace instructions when their declaring tool is denied", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({
...make(),
name: "create",
options: { namespace: "sessions", namespaceInstructions: "Ask before using session tools." },
})
draft.add({ ...make(), name: "get", options: { namespace: "sessions" } })
})
const catalog = (yield* service.snapshot([{ action: "sessions_create", resource: "*", effect: "deny" }]))
.codeModeCatalog
expect(catalog?.map((tool) => tool.path)).toEqual(["sessions.get"])
expect(catalog?.[0]?.namespaceInstructions).toBe("Ask before using session tools.")
}),
)
it.effect("rejects invalid and colliding normalized names", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+2
View File
@@ -29,10 +29,12 @@ export type Options = BaseOptions &
| {
readonly codemode?: true
readonly pinned?: boolean
readonly namespaceInstructions?: string
}
| {
readonly codemode: boolean
readonly pinned?: never
readonly namespaceInstructions?: never
}
)
@@ -828,7 +828,11 @@ effect: (ctx) =>
description: "Create a greeting",
input: Schema.Struct({ name: Schema.String }),
output: Schema.Struct({ greeting: Schema.String }),
options: { namespace: "acme", codemode: true },
options: {
namespace: "acme",
codemode: true,
namespaceInstructions: "Use Acme tools only when the user requests an Acme operation.",
},
execute: ({ name }, context) =>
Effect.gen(function* () {
yield* context.progress({ status: "greeting" })
@@ -839,6 +843,10 @@ effect: (ctx) =>
}),
```
Set `namespaceInstructions` on a namespaced Code Mode tool to add guidance once under its top-level namespace in the tool
catalog. The instructions remain visible when individual tools are hidden, participate in durable instruction updates,
and must match any instructions registered by other tools in the same top-level namespace.
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),
[`Tool.FileContent`](/api#schema-Tool.FileContent).
@@ -796,7 +796,11 @@ await ctx.tool.transform((draft) => {
required: ["name"],
additionalProperties: false,
},
options: { namespace: "acme", codemode: true },
options: {
namespace: "acme",
codemode: true,
namespaceInstructions: "Use Acme tools only when the user requests an Acme operation.",
},
execute: async (input, tool) => {
await tool.progress({ status: "greeting" })
return { content: `Hello ${(input as { name: string }).name}!` }
@@ -805,6 +809,10 @@ await ctx.tool.transform((draft) => {
})
```
Set `namespaceInstructions` on a namespaced Code Mode tool to add guidance once under its top-level namespace in the tool
catalog. The instructions remain visible when individual tools are hidden, participate in durable instruction updates,
and must match any instructions registered by other tools in the same top-level namespace.
#### Reference
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),