Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions packages/core/src/codemode/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const Entry = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Literal(true)),
})
export type Entry = typeof Entry.Type

Expand All @@ -31,8 +32,8 @@ const DESCRIPTION_LIMIT = 120
const CHARACTERS_PER_TOKEN = 4
const INLINE_BUDGET = 2_000

// Keep every namespace searchable, then select full listings one per namespace per round,
// considering shorter listings first until the inline budget is exhausted.
// Keep every namespace searchable and every pinned listing visible, then select additional
// listings one per namespace per round until the remaining inline budget is exhausted.
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
.sort(([left], [right]) => {
Expand All @@ -45,44 +46,46 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
const description =
firstLine.length > DESCRIPTION_LIMIT
? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..."
: firstLine
firstLine.length > DESCRIPTION_LIMIT ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..." : firstLine
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) => {
if (left.path < right.path) return -1
if (left.path > right.path) return 1
return 0
})
const pinned = listings.filter((listing) => listing.pinned)
return {
name,
listings,
selectionOrder: rankListings(listings),
selectedListings: new Set<typeof Listing.Type>(),
selectionOrder: rankListings(listings.filter((listing) => !listing.pinned)),
selectedListings: new Set<typeof Listing.Type>(pinned),
pinnedCost: pinned.reduce((total, listing) => total + listingCost(listing), 0),
}
})

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) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
const candidate = namespace.selectionOrder.shift()
if (!candidate || candidate.cost > remaining) {
active.delete(namespace)
continue
}
namespace.selectedListings.add(candidate.listing)
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) => ({
name: namespace.name,
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 {
total: entries.length,
Expand All @@ -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>) {
return listings
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
.map((listing) => ({ listing, cost: listingCost(listing) }))
.toSorted((left, right) => {
if (left.cost !== right.cost) return left.cost - right.cost
if (left.listing.path < right.listing.path) return -1
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/tool/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,14 @@ export const create = (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(
Expand All @@ -140,8 +147,7 @@ function runtime(
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = toLLMDefinition(name, registration.tool)
const path =
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
const path = registrationPath(registration)
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
Expand All @@ -152,6 +158,10 @@ function runtime(
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.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ const registryLayer = Layer.effect(
}),
)
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")
if (reserved)
return yield* Effect.fail(
Expand Down
59 changes: 51 additions & 8 deletions packages/core/test/codemode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ describe("CodeMode", () => {
Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
yield* codeMode.register(
Tool.registrationEntries({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed({ output: text }),
}),
}),
Tool.registrationEntries(
{
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed({ output: text }),
}),
},
{ pinned: true },
),
)

const materialized = yield* codeMode.materialize()
Expand All @@ -27,6 +30,46 @@ describe("CodeMode", () => {
path: "echo",
description: "Echo text",
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))),
Expand Down
17 changes: 17 additions & 0 deletions packages/core/test/codemode/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ describe("CodeModeCatalog.summarize", () => {
expect(description).toHaveLength(120)
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", () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
Expand Down
5 changes: 4 additions & 1 deletion packages/docs/build/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
underscores, or hyphens, and are joined with dots. Pass the optional third
argument to `tools.add` to configure the registration with
`{ namespace, codemode }`:
`{ namespace, codemode, pinned }`:

- `namespace` prefixes and groups the exposed tool name.
- `codemode` defaults to `true` and makes the tool available through the
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
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`,
`agent`, `messageID`, `callID`, and `progress`. A tool with `output`
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin/src/v2/effect/internal/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export interface RegisterOptions {
readonly namespace?: string
/** Defaults to true. False exposes the tool directly to the provider. */
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. */
readonly permission?: string
}
Expand All @@ -138,6 +140,7 @@ export interface Registration {
readonly name: string
readonly namespace?: string
readonly permission: string
readonly pinned?: boolean
}

export const validateName = (name: string) =>
Expand All @@ -159,6 +162,7 @@ export const registrationEntries = (
namespace: options?.namespace,
tool,
permission: options?.permission ?? key,
...(options?.pinned === true ? { pinned: true } : {}),
}
})

Expand Down
13 changes: 13 additions & 0 deletions packages/plugin/test/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})

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 }])
})
Loading