fix(codemode): improve prompting (#35509)

This commit is contained in:
Aiden Cline 2026-07-06 09:19:04 -05:00 committed by GitHub
commit 7f008df79d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 407 additions and 362 deletions

View file

@ -38,12 +38,12 @@ export type ExecutionLimits = {
/** Controls how much of the tool catalog is inlined in agent instructions. */
export type DiscoveryOptions = {
/**
* Estimated-token budget (chars/4, default 2000) for inlined full tool signatures in agent
* instructions. Signatures that fit are inlined round-robin across namespaces; every
* namespace is always listed with its tool count regardless of budget, and
* Approximate budget, in estimated tokens (chars/4, default 2000), for full tool entries in
* the instruction catalog. Tool entries are selected round-robin across namespaces. Fixed
* instructions and namespace summaries do not count toward the budget, and
* `tools.$codemode.search` is always registered.
*/
readonly maxInlineCatalogTokens?: number
readonly catalogBudget?: number
}
type ToolTree<R = never> = {
@ -3921,8 +3921,8 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
const tools = ToolRuntime.make(
(options.tools ?? {}) as HostTools<Services<Tools>>,
limits.maxToolCalls,
hooks,
searchIndex,
hooks,
)
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
@ -4061,10 +4061,10 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
const limits = resolveExecutionLimits(options.limits)
const discovery = ToolRuntime.discoveryPlan(tools, options.discovery?.maxInlineCatalogTokens)
const executeProgram = (code: string) => executeWithLimits<Tools>({ ...options, code }, limits, discovery.searchIndex)
const catalog = discovery.catalog
const instructions = discovery.instructions
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
const executeProgram = (code: string) => executeWithLimits<Tools>({ ...options, code }, limits, prepared.searchIndex)
const catalog = prepared.catalog
const instructions = prepared.instructions
return {
catalog: () => catalog,

View file

@ -1,4 +1,4 @@
import { Cause, Effect } from "effect"
import { Cause, Effect, Schema } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
@ -75,10 +75,26 @@ export type ToolDescription = {
export type SafeObject = Record<string, unknown>
const reservedNamespace = "$codemode"
const defaultMaxInlineCatalogTokens = 2_000
const defaultCatalogBudget = 2_000
const defaultSearchLimit = 10
const searchSignature =
"tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
const SearchInput = Schema.Struct({
query: Schema.optionalKey(Schema.String),
namespace: Schema.optionalKey(Schema.String),
limit: Schema.optionalKey(PositiveInt),
offset: Schema.optionalKey(NonNegativeInt),
})
const SearchItem = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
})
const SearchOutput = Schema.Struct({
items: Schema.Array(SearchItem),
remaining: NonNegativeInt,
next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })),
})
const toolExpression = (path: string) =>
"tools" +
path
@ -294,15 +310,17 @@ const definitions = <R>(
return entries
}
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
})
const visibleDefinitions = <R>(tools: HostTools<R>) =>
definitions(tools).map(({ path, definition }) => ({
path,
definition,
description: {
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
},
description: describeDefinition(path, definition),
}))
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
@ -316,11 +334,6 @@ export type DiscoveryPlan = {
export type SearchEntry = {
readonly description: ToolDescription
/**
* JSDoc-annotated multiline signature shown on search-result items; the compact
* single-line form (inline catalog lines) stays in `description.signature`.
*/
readonly signature: string
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
readonly namespace: string
/** Lowercased path + description + input property names/descriptions, for substring matching. */
@ -354,8 +367,75 @@ const termForms = (term: string): Array<string> => {
return forms
}
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
_tag: "CodeModeTool",
description: "Search available Code Mode tools",
input: SearchInput,
output: SearchOutput,
run: (input) =>
Effect.sync(() => {
const request = input as typeof SearchInput.Type
const query = request.query ?? ""
const offset = request.offset ?? 0
const scoped =
request.namespace === undefined
? searchIndex
: searchIndex.filter((entry) => entry.namespace === request.namespace)
// A query that names one tool path exactly (canonical path or rendered JavaScript
// expression) is a lookup, not a search: return that tool alone.
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact =
pathQuery === ""
? undefined
: scoped.find(
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const terms = tokenize(query).map(termForms)
// Additive field-weighted scoring, summed across terms: exact path or path segment
// (20) > path substring (8) > description substring (4) > any searchable text,
// including input parameter names and descriptions (2).
const ranked =
exact !== undefined
? [exact]
: scoped
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
)
.map(({ entry }) => entry)
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
...description,
path: toolExpression(description.path),
}))
const remaining = Math.max(0, ranked.length - offset - items.length)
return {
items,
remaining,
next: remaining > 0 ? { offset: offset + items.length } : null,
}
}),
})
const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([]))
const catalogLine = (tool: ToolDescription) => {
// Inline catalog lines use only a compact first line; full text stays in search results.
// Keep the tool description concise; the full schema documentation remains in the signature.
const line = tool.description.split("\n", 1)[0]!.trim()
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
@ -363,7 +443,6 @@ const catalogLine = (tool: ToolDescription) => {
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
namespace: path.split(".", 1)[0]!,
searchText: [
path,
@ -388,7 +467,7 @@ export const assertValidTools = <R>(tools: HostTools<R>): void => {
/**
* Budgeted catalog: every namespace is always listed with its tool count; full call
* signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens,
* signatures are inlined against the `catalogBudget` (estimated tokens,
* chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
* a namespace whose next line does not fit is done while the others keep going - so every
@ -397,12 +476,9 @@ export const assertValidTools = <R>(tools: HostTools<R>): void => {
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
* tool count even at budget 0.
*/
export const discoveryPlan = <R>(
tools: HostTools<R>,
maxInlineCatalogTokens = defaultMaxInlineCatalogTokens,
): DiscoveryPlan => {
if (!Number.isSafeInteger(maxInlineCatalogTokens) || maxInlineCatalogTokens < 0) {
throw new RangeError("discovery.maxInlineCatalogTokens must be a non-negative safe integer")
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
}
const visible = visibleDefinitions(tools)
const described = visible.map(({ description }) => description)
@ -437,7 +513,7 @@ export const discoveryPlan = <R>(
for (const selection of active) {
const tool = selection.queue[0]!
const cost = estimateTokens(catalogLine(tool))
if (used + cost > maxInlineCatalogTokens) continue
if (used + cost > catalogBudget) continue
selection.queue.shift()
selection.picked.add(tool)
used += cost
@ -458,13 +534,14 @@ export const discoveryPlan = <R>(
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
const intro = [
"Write a CodeMode program to answer the request. Return code only.",
empty
? "Execute JavaScript in a confined runtime."
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
: complete
? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here."
: "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.",
...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.",
...(empty
? []
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
]
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
@ -478,16 +555,12 @@ export const discoveryPlan = <R>(
...(complete
? [
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
'2. Call it using the exact signature shown; bracket notation and quotes are part of the path.',
'3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"4. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
"2. Call it using the exact signature shown: `const result = await tools.<namespace>.<tool>(input)`; bracket notation and quotes are part of the path.",
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
]
: [
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
"2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.",
"3. Call the result's `path` as-is; bracket notation and quotes are part of the path.",
'4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"5. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
]),
]
@ -498,24 +571,26 @@ export const discoveryPlan = <R>(
"## Rules",
"",
complete
? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed."
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.",
? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed."
: "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []
: ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
: [
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
]),
]
const syntax = [
const language = [
"",
"## Syntax",
"## Language",
"",
"Standard modern JavaScript works: functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date, RegExp, Map, Set, and Promise.all/allSettled/race/resolve/reject.",
"TypeScript type annotations are allowed and stripped before execution (decorators are not supported).",
"Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch).",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, arbitrary methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
]
@ -544,11 +619,11 @@ export const discoveryPlan = <R>(
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
}
if (!complete) {
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`)
}
}
const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection]
const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection]
return {
catalog: described,
instructions: lines.join("\n"),
@ -557,20 +632,13 @@ export const discoveryPlan = <R>(
}
/**
* The enumerable names at one node of the host tool tree - namespace names at the root,
* The enumerable names at one node of the callable tool tree - namespace names at the root,
* tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
* discovery idioms, mirroring how calling an unknown tool fails.
*/
const namespaceKeys = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): ReadonlyArray<string> => {
// The reserved discovery namespace is virtual (never present in the host tree); enumerate
// it explicitly so `Object.keys(tools.$codemode)` matches the callable surface.
if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"]
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
if (
@ -579,15 +647,9 @@ const namespaceKeys = <R>(
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool namespace '${path.join(".")}'.`,
searchEnabled
? [
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
]
: ["Object.keys(tools) lists the available namespaces."],
)
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
@ -595,11 +657,7 @@ const namespaceKeys = <R>(
return Object.keys(value)
}
const resolve = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): HostTool<R> | Definition<R> => {
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<R> | Definition<R> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
@ -609,11 +667,9 @@ const resolve = <R>(
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool '${path.join(".")}'.`,
searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [],
)
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
"Use tools.$codemode.search({ query }) to find available described tools.",
])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
@ -629,7 +685,7 @@ export type ToolRuntime<R = never> = {
readonly root: ToolReference
readonly calls: Array<ToolCall>
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/** Enumerable namespace/tool names at one node of the host tool tree; see `namespaceKeys`. */
/** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
@ -637,11 +693,14 @@ export const make = <R>(
tools: HostTools<R>,
/** Undefined means unlimited tool calls. */
maxToolCalls: number | undefined,
searchIndex: ReadonlyArray<SearchEntry>,
hooks?: ToolCallHooks<R>,
searchIndex?: ReadonlyArray<SearchEntry>,
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const searchEnabled = searchIndex !== undefined
const callableTools = {
...tools,
[reservedNamespace]: { search: makeSearchTool(searchIndex) },
}
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
@ -680,7 +739,7 @@ export const make = <R>(
return {
root: new ToolReference([]),
calls,
keys: (path) => namespaceKeys(tools, path, searchEnabled),
keys: (path) => namespaceKeys(callableTools, path),
invoke: (path, args) =>
Effect.gen(function* () {
const name = path.join(".")
@ -691,107 +750,7 @@ export const make = <R>(
recordCall(call)
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
if (name === "$codemode.search") {
if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`)
const input = externalArgs[0]
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.",
)
}
const request = input as { query?: unknown; namespace?: unknown; limit?: unknown }
if (request.query !== undefined && typeof request.query !== "string") {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search query must be a string when provided.",
)
}
if (request.namespace !== undefined && typeof request.namespace !== "string") {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search namespace must be a string when provided.",
)
}
if (
request.limit !== undefined &&
(typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)
) {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search limit must be a positive safe integer when provided.",
)
}
const query = typeof request.query === "string" ? request.query : ""
const namespace = typeof request.namespace === "string" ? request.namespace : undefined
const index = yield* recordAndObserve(request)
return yield* observeEnd(
Effect.try({
try: () => {
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
const scoped =
namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
// A query that names one tool path exactly (canonical path or rendered
// JavaScript expression) is a lookup, not a search: return that tool alone.
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact =
pathQuery === ""
? undefined
: scoped.find(
(entry) =>
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const terms = tokenize(query).map(termForms)
// Additive field-weighted scoring, summed across terms: exact path or path
// segment (20) > path substring (8) > description substring (4) > any
// searchable text, incl. input parameter names/descriptions (2). Each term
// matches a field when any of its forms (the term or a singular variant)
// does. An empty query browses everything, alphabetical by path.
const ranked =
exact !== undefined
? [exact]
: scoped
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score ||
left.entry.description.path.localeCompare(right.entry.description.path),
)
.map(({ entry }) => entry)
// Result paths are rendered as JavaScript expressions so each `path` is
// directly usable as the call site (`await tools.github.list({ ... })` or
// `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty,
// JSDoc-annotated form (schema descriptions and constraints ride along as
// field comments).
const items = ranked.slice(0, limit).map(({ description, signature }) => ({
...description,
path: toolExpression(description.path),
signature,
}))
return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'")
},
catch: (cause) => cause,
}),
{ index, name, input: request },
)
}
const tool = resolve(tools, path, searchEnabled)
const tool = resolve(callableTools, path)
let describedInput: unknown
if (isDefinition(tool)) {
if (externalArgs.length !== 1)

View file

@ -187,9 +187,9 @@ const renderSchema = (
if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1)
const lines = properties.map(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
return "unknown"