feat(core): support pinned Code Mode tools (#39550)

This commit is contained in:
Aiden Cline 2026-07-29 13:16:49 -05:00 committed by GitHub
commit 5cb633a48e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 77 additions and 16 deletions

View file

@ -6,6 +6,7 @@ export const Entry = Schema.Struct({
path: Schema.String, path: Schema.String,
description: Schema.String, description: Schema.String,
signature: Schema.String, signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
}) })
export type Entry = typeof Entry.Type export type Entry = typeof Entry.Type
@ -56,26 +57,39 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
if (left.path > right.path) return 1 if (left.path > right.path) return 1
return 0 return 0
}) })
const ranked = rankListings(listings)
const pinned = new Set(
namespaceEntries
.filter((entry) => entry.pinned)
.map((entry) => listings.find((listing) => listing.path === entry.path))
.filter((listing) => listing !== undefined),
)
return { return {
name, name,
listings, listings,
selectionOrder: rankListings(listings), selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: new Set<typeof Listing.Type>(), selectedListings: pinned,
selectionIndex: 0,
} }
}) })
const active = new Set(namespaces) const active = new Set(namespaces)
let remaining = 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)
while (active.size > 0) { while (active.size > 0) {
for (const namespace of active) { for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectedListings.size] const candidate = namespace.selectionOrder[namespace.selectionIndex]
if (!candidate || candidate.cost > remaining) { if (!candidate || candidate.cost > remaining) {
active.delete(namespace) active.delete(namespace)
continue continue
} }
namespace.selectedListings.add(candidate.listing) namespace.selectedListings.add(candidate.listing)
namespace.selectionIndex += 1
remaining -= candidate.cost remaining -= candidate.cost
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace) if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
} }
} }

View file

@ -138,7 +138,14 @@ export const create = (
} }
export const catalog = (registrations: ReadonlyMap<string, Info>) => { export const catalog = (registrations: ReadonlyMap<string, Info>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog() const pinned = new Set(
Array.from(registrations.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
} }
function runtime( function runtime(
@ -149,11 +156,7 @@ function runtime(
const tools: Record<string, Tool.Tool<never>> = {} const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) { for (const [name, registration] of registrations) {
const child = definition(registration) const child = definition(registration)
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_") const path = qualifiedName(registration)
const path =
registration.options?.namespace === undefined
? normalized
: `${registration.options.namespace}.${normalized}`
tools[path] = Tool.make({ tools[path] = Tool.make({
description: child.description, description: child.description,
input: child.inputSchema, input: child.inputSchema,
@ -164,6 +167,12 @@ function runtime(
return CodeMode.make<typeof tools>({ tools, ...hooks }) return CodeMode.make<typeof tools>({ tools, ...hooks })
} }
function qualifiedName(registration: Info) {
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
if (registration.options?.namespace === undefined) return normalized
return `${registration.options.namespace}.${normalized}`
}
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact. // Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined { function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return if (input === null || input === undefined) return

View file

@ -16,6 +16,7 @@ describe("CodeMode", () => {
description: "Echo text", description: "Echo text",
input: Schema.Struct({ text: Schema.String }), input: Schema.Struct({ text: Schema.String }),
output: Schema.String, output: Schema.String,
options: { pinned: true },
execute: ({ text }) => Effect.succeed({ output: text }), execute: ({ text }) => Effect.succeed({ output: text }),
}), }),
) )
@ -27,6 +28,7 @@ describe("CodeMode", () => {
path: "echo", path: "echo",
description: "Echo text", description: "Echo text",
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>", signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
pinned: true,
}, },
]) ])
}).pipe( }).pipe(

View file

@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog" import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions" import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({ const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
path, path,
description, description,
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`, signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
pinned,
}) })
const lookup = entry( const lookup = entry(
@ -46,6 +47,30 @@ describe("CodeModeCatalog.summarize", () => {
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true) expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
}) })
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)
expect(catalog.shown).toBe(2)
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
"alpha.first",
"beta.second",
])
})
test("spends the budget remaining after pinned tools on unpinned tools", () => {
const pinned = entry("alpha.pinned", "Pinned", undefined, true)
const unpinned = entry("beta.unpinned", "Unpinned")
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
})
test("retains only the rendered portion of inline descriptions", () => { test("retains only the rendered portion of inline descriptions", () => {
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)]) const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary") expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")

View file

@ -67,7 +67,7 @@ const transform = (
) => ) =>
service.transform((draft) => service.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) => Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: { ...tool.options, ...options } }), draft.add({ ...tool, name, options: options ?? tool.options }),
), ),
) )

View file

@ -231,7 +231,7 @@ const permission = Layer.succeed(
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) => const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
registry.transform((draft) => registry.transform((draft) =>
Object.entries(tools).forEach(([name, tool]) => Object.entries(tools).forEach(([name, tool]) =>
draft.add({ ...tool, name, options: { ...tool.options, ...options } }), draft.add({ ...tool, name, options: options ?? tool.options }),
), ),
) )
const echo = Layer.effectDiscard( const echo = Layer.effectDiscard(

View file

@ -19,12 +19,23 @@ export interface Context {
readonly progress: (update: Metadata) => Effect.Effect<void> readonly progress: (update: Metadata) => Effect.Effect<void>
} }
export interface Options { interface BaseOptions {
readonly namespace?: string readonly namespace?: string
readonly codemode?: boolean
readonly permission?: string readonly permission?: string
} }
export type Options = BaseOptions &
(
| {
readonly codemode?: true
readonly pinned?: boolean
}
| {
readonly codemode: boolean
readonly pinned?: never
}
)
export type ValueSchema<A = unknown> = export type ValueSchema<A = unknown> =
| Schema.Codec<A, any> | Schema.Codec<A, any>
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>) | (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)