Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 2342683aa5 feat(core): add pinned Code Mode tools 2026-07-24 16:14:15 -05:00
9 changed files with 140 additions and 25 deletions
+20 -13
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.Literal(true)),
}) })
export type Entry = typeof Entry.Type export type Entry = typeof Entry.Type
@@ -31,8 +32,8 @@ const DESCRIPTION_LIMIT = 120
const CHARACTERS_PER_TOKEN = 4 const CHARACTERS_PER_TOKEN = 4
const INLINE_BUDGET = 2_000 const INLINE_BUDGET = 2_000
// Keep every namespace searchable, then select full listings one per namespace per round, // Keep every namespace searchable and every pinned listing visible, then select additional
// considering shorter listings first until the inline budget is exhausted. // listings one per namespace per round until the remaining inline budget is exhausted.
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary { export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)] const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
.sort(([left], [right]) => { .sort(([left], [right]) => {
@@ -45,44 +46,46 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
.map((entry) => { .map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? "" const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
const description = const description =
firstLine.length > DESCRIPTION_LIMIT firstLine.length > DESCRIPTION_LIMIT ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..." : firstLine
? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..."
: firstLine
const suffix = description.length === 0 ? "" : ` // ${description}` const suffix = description.length === 0 ? "" : ` // ${description}`
return { path: entry.path, line: ` - ${entry.signature}${suffix}` } return { path: entry.path, line: ` - ${entry.signature}${suffix}`, pinned: entry.pinned === true }
}) })
.toSorted((left, right) => { .toSorted((left, right) => {
if (left.path < right.path) return -1 if (left.path < right.path) return -1
if (left.path > right.path) return 1 if (left.path > right.path) return 1
return 0 return 0
}) })
const pinned = listings.filter((listing) => listing.pinned)
return { return {
name, name,
listings, listings,
selectionOrder: rankListings(listings), selectionOrder: rankListings(listings.filter((listing) => !listing.pinned)),
selectedListings: new Set<typeof Listing.Type>(), selectedListings: new Set<typeof Listing.Type>(pinned),
pinnedCost: pinned.reduce((total, listing) => total + listingCost(listing), 0),
} }
}) })
const active = new Set(namespaces) const active = new Set(namespaces)
let remaining = budget let remaining = Math.max(0, budget - namespaces.reduce((total, namespace) => total + namespace.pinnedCost, 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.shift()
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)
remaining -= candidate.cost remaining -= candidate.cost
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace) if (namespace.selectionOrder.length === 0) active.delete(namespace)
} }
} }
const namespaceSummaries = namespaces.map((namespace) => ({ const namespaceSummaries = namespaces.map((namespace) => ({
name: namespace.name, name: namespace.name,
count: namespace.listings.length, count: namespace.listings.length,
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)), entries: namespace.listings
.filter((listing) => namespace.selectedListings.has(listing))
.map((listing) => ({ path: listing.path, line: listing.line })),
})) }))
return { return {
total: entries.length, total: entries.length,
@@ -91,9 +94,13 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
} }
} }
function listingCost(listing: typeof Listing.Type) {
return Math.round(listing.line.length / CHARACTERS_PER_TOKEN)
}
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) { function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return listings return listings
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) })) .map((listing) => ({ listing, cost: listingCost(listing) }))
.toSorted((left, right) => { .toSorted((left, right) => {
if (left.cost !== right.cost) return left.cost - right.cost if (left.cost !== right.cost) return left.cost - right.cost
if (left.listing.path < right.listing.path) return -1 if (left.listing.path < right.listing.path) return -1
+13 -3
View File
@@ -129,7 +129,14 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
} }
export const catalog = (registrations: ReadonlyMap<string, Registration>) => { export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog() const pinned = new Set(
Array.from(registrations.values()).flatMap((registration) =>
registration.pinned === true ? [registrationPath(registration)] : [],
),
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => (pinned.has(entry.path) ? { ...entry, pinned: true as const } : entry))
} }
function runtime( function runtime(
@@ -140,8 +147,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 = toLLMDefinition(name, registration.tool) const child = toLLMDefinition(name, registration.tool)
const path = const path = registrationPath(registration)
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
tools[path] = Tool.make({ tools[path] = Tool.make({
description: child.description, description: child.description,
input: child.inputSchema, input: child.inputSchema,
@@ -152,6 +158,10 @@ function runtime(
return CodeMode.make<typeof tools>({ tools, ...hooks }) return CodeMode.make<typeof tools>({ tools, ...hooks })
} }
function registrationPath(registration: Registration) {
return registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
}
// 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
+7
View File
@@ -245,6 +245,13 @@ const registryLayer = Layer.effect(
}), }),
) )
const codemode = options?.codemode ?? true const codemode = options?.codemode ?? true
if (!codemode && options?.pinned === true)
return yield* Effect.fail(
new Tool.RegistrationError({
name: entries[0]?.key ?? "",
message: "Pinned tools must use Code Mode",
}),
)
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute") const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
if (reserved) if (reserved)
return yield* Effect.fail( return yield* Effect.fail(
+51 -8
View File
@@ -10,14 +10,17 @@ describe("CodeMode", () => {
Effect.gen(function* () { Effect.gen(function* () {
const codeMode = yield* CodeMode.Service const codeMode = yield* CodeMode.Service
yield* codeMode.register( yield* codeMode.register(
Tool.registrationEntries({ Tool.registrationEntries(
echo: Tool.make({ {
description: "Echo text", echo: Tool.make({
input: Schema.Struct({ text: Schema.String }), description: "Echo text",
output: Schema.String, input: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ output: text }), output: Schema.String,
}), execute: ({ text }) => Effect.succeed({ output: text }),
}), }),
},
{ pinned: true },
),
) )
const materialized = yield* codeMode.materialize() const materialized = yield* codeMode.materialize()
@@ -27,6 +30,46 @@ 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(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
)
it.effect("omits denied pinned registrations from the catalog", () =>
Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
yield* codeMode.register(
Tool.registrationEntries({
visible: Tool.make({
description: "Visible tool",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ output: "visible" }),
}),
}),
)
yield* codeMode.register(
Tool.registrationEntries(
{
hidden: Tool.make({
description: "Hidden tool",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ output: "hidden" }),
}),
},
{ permission: "hidden", pinned: true },
),
)
const materialized = yield* codeMode.materialize([{ action: "hidden", resource: "*", effect: "deny" }])
expect(materialized.tool).toBeDefined()
expect(materialized.catalog).toEqual([
{
path: "visible",
description: "Visible tool",
signature: "tools.visible(input: {}): Promise<string>",
}, },
]) ])
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))), }).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
@@ -57,6 +57,23 @@ describe("CodeModeCatalog.summarize", () => {
expect(description).toHaveLength(120) expect(description).toHaveLength(120)
expect(description).toEndWith("...") expect(description).toEndWith("...")
}) })
test("keeps pinned signatures when no unpinned listing fits", () => {
const pinned = { ...entry("files.read", "Read a file"), pinned: true as const }
const catalog = CodeModeCatalog.summarize([entry("files.write", "Write a file"), pinned], 0)
expect(catalog).toEqual({
total: 2,
shown: 1,
namespaces: [
{
name: "files",
count: 2,
entries: [{ path: pinned.path, line: ` - ${pinned.signature} // Read a file` }],
},
],
})
})
}) })
describe("CodeModeInstructions.render", () => { describe("CodeModeInstructions.render", () => {
@@ -121,6 +121,17 @@ describe("ToolRegistry", () => {
}), }),
) )
it.effect("rejects pinned tools outside Code Mode", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const error = yield* service.register({ echo: make() }, { codemode: false, pinned: true }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe("Pinned tools must use Code Mode")
expect((yield* service.snapshot()).definitions).toEqual([])
}),
)
it.effect("canonicalizes effective definitions and keeps Code Mode last", () => it.effect("canonicalizes effective definitions and keeps Code Mode last", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
+4 -1
View File
@@ -326,12 +326,15 @@ Unsupported characters in tool names are normalized to underscores. Namespace
segments must begin with a letter, contain at most 64 letters, digits, segments must begin with a letter, contain at most 64 letters, digits,
underscores, or hyphens, and are joined with dots. Pass the optional third underscores, or hyphens, and are joined with dots. Pass the optional third
argument to `tools.add` to configure the registration with argument to `tools.add` to configure the registration with
`{ namespace, codemode }`: `{ namespace, codemode, pinned }`:
- `namespace` prefixes and groups the exposed tool name. - `namespace` prefixes and groups the exposed tool name.
- `codemode` defaults to `true` and makes the tool available through the - `codemode` defaults to `true` and makes the tool available through the
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the `execute` CodeMode tool. Set `codemode: false` to expose it directly to the
provider. provider.
- `pinned: true` keeps the tool's complete signature visible when the Code Mode
catalog is compact. Pinned tools must use Code Mode and cannot set
`codemode: false`.
The executor receives a second context argument containing `sessionID`, The executor receives a second context argument containing `sessionID`,
`agent`, `messageID`, `callID`, and `progress`. A tool with `output` `agent`, `messageID`, `callID`, and `progress`. A tool with `output`
@@ -129,6 +129,8 @@ export interface RegisterOptions {
readonly namespace?: string readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */ /** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean readonly codemode?: boolean
/** Keeps the complete signature visible when the Code Mode catalog is compact. */
readonly pinned?: boolean
/** Permission action used for whole-tool visibility filtering. */ /** Permission action used for whole-tool visibility filtering. */
readonly permission?: string readonly permission?: string
} }
@@ -138,6 +140,7 @@ export interface Registration {
readonly name: string readonly name: string
readonly namespace?: string readonly namespace?: string
readonly permission: string readonly permission: string
readonly pinned?: boolean
} }
export const validateName = (name: string) => export const validateName = (name: string) =>
@@ -159,6 +162,7 @@ export const registrationEntries = (
namespace: options?.namespace, namespace: options?.namespace,
tool, tool,
permission: options?.permission ?? key, permission: options?.permission ?? key,
...(options?.pinned === true ? { pinned: true } : {}),
} }
}) })
+13
View File
@@ -125,3 +125,16 @@ test("raw JSON schemas are render-only and omitted output means model-only", asy
}) })
expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: 1 }))).toEqual({ value: 1 }) expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: 1 }))).toEqual({ value: 1 })
}) })
test("registration metadata omits disabled pinning", () => {
const tool = Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed({ output: text }),
})
const registration = Tool.registrationEntries({ echo: tool })
expect(Tool.registrationEntries({ echo: tool }, { pinned: false })).toEqual(registration)
expect(Tool.registrationEntries({ echo: tool }, { pinned: true })).toEqual([{ ...registration[0], pinned: true }])
})