mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 14:41:48 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2342683aa5 |
@@ -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
|
||||
|
||||
@@ -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]) => {
|
||||
@@ -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,
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
@@ -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,
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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))),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+4
-1
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -138,6 +140,7 @@ export interface Registration {
|
||||
readonly name: string
|
||||
readonly namespace?: string
|
||||
readonly permission: string
|
||||
readonly pinned?: boolean
|
||||
}
|
||||
|
||||
export const validateName = (name: string) =>
|
||||
@@ -159,6 +162,7 @@ export const registrationEntries = (
|
||||
namespace: options?.namespace,
|
||||
tool,
|
||||
permission: options?.permission ?? key,
|
||||
...(options?.pinned === true ? { pinned: true } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user