From c5a4e5f90674f3c0967bc91a73e5f56562fd0440 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 21:24:35 +0000 Subject: [PATCH 1/3] Reduce search discovery round trips for entity and list results Batch entity detail (up to 10 refs), inline top capability call shapes, surface related synthesized-provider operations, and add a package Maintain pointer so agents need fewer search round trips. --- .../worker/src/mcp/tools/search-format.ts | 76 +++++- packages/worker/src/mcp/tools/search.ts | 254 +++++++++++++++--- 2 files changed, 297 insertions(+), 33 deletions(-) diff --git a/packages/worker/src/mcp/tools/search-format.ts b/packages/worker/src/mcp/tools/search-format.ts index fef683ab87..ea119ae96c 100644 --- a/packages/worker/src/mcp/tools/search-format.ts +++ b/packages/worker/src/mcp/tools/search-format.ts @@ -102,6 +102,14 @@ export type SearchResultStructuredContent = { }> } +export type RelatedCapabilityOperation = { + name: string + entityRef: string + description: string + method?: string + path?: string +} + export type SlimSearchMatch = | { type: 'capability' @@ -114,6 +122,7 @@ export type SlimSearchMatch = remoteConnector?: CapabilitySpec['remoteConnector'] mcpServer?: CapabilitySpec['mcpServer'] openApi?: CapabilitySpec['openApi'] + inputTypeDefinition?: string } | { type: 'package' @@ -223,6 +232,7 @@ export type SearchEntityDetailStructured = openApi?: CapabilitySpec['openApi'] inputTypeDefinition: string outputTypeDefinition?: string + relatedOperations?: Array } | { kind: 'entity' @@ -240,6 +250,10 @@ export type SearchEntityDetailStructured = hidden: boolean hostedUrl: string | null appEntry: string | null + maintain: { + gitLane: string + publish: string + } exports: Array<{ subpath: string importSpecifier: string @@ -341,6 +355,7 @@ export type SearchEntityDetail = title: string description: string spec: CapabilitySpec + relatedOperations?: Array } | { type: 'package' @@ -386,6 +401,8 @@ export type SearchMatch = remoteConnector?: CapabilitySpec['remoteConnector'] mcpServer?: CapabilitySpec['mcpServer'] openApi?: CapabilitySpec['openApi'] + inputTypeDefinition?: string + inputTypeDefinitionTruncated?: boolean } | { type: 'package' @@ -470,7 +487,7 @@ function buildNamespacedKodyAccessor(input: { return `${entryAccessor}[${JSON.stringify(input.toolName)}]` } -function buildKodyCapabilityAccessor(spec: { +export function buildKodyCapabilityAccessor(spec: { name: string source?: CapabilitySpec['source'] remoteConnector?: CapabilitySpec['remoteConnector'] @@ -505,6 +522,29 @@ function buildKodyCapabilityAccessor(spec: { return `kody[${JSON.stringify(name)}]` } +export const inlineCapabilityInputTypeMaxLength = 500 + +export function compactCapabilityInputTypeDefinition( + inputTypeDefinition: string, + maxLength = inlineCapabilityInputTypeMaxLength, +): { definition: string; truncated: boolean } { + const collapsed = formatInlineTypeDefinition(inputTypeDefinition) + if (collapsed.length <= maxLength) { + return { definition: collapsed, truncated: false } + } + return { + definition: `${collapsed.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`, + truncated: true, + } +} + +export function buildPackageMaintainSnippets(kodyId: string) { + return { + gitLane: `package_get_git_remote({ kody_id: ${JSON.stringify(kodyId)} })`, + publish: `package_publish_external_push({ kody_id: ${JSON.stringify(kodyId)} })`, + } +} + function buildCapabilityExecuteExample(spec: CapabilitySpec) { return `import { kody } from 'kody:runtime' @@ -656,7 +696,15 @@ export function formatSearchMarkdown(input: { function formatMatchListItem(match: SearchMatch, index: number) { if (match.type === 'capability') { const entityRef = buildEntityRef(match.name, 'capability') - return `${String(index + 1)}. **capability** ${formatMarkdownInlineCode(match.name)} — ${escapeMarkdownText(formatOneLineSentence(match.description))} Entity: ${formatMarkdownInlineCode(entityRef)}` + const mainLine = `${String(index + 1)}. **capability** ${formatMarkdownInlineCode(match.name)} — ${escapeMarkdownText(formatOneLineSentence(match.description))} Entity: ${formatMarkdownInlineCode(entityRef)}` + if (!match.inputTypeDefinition) { + return mainLine + } + const accessor = buildKodyCapabilityAccessor(match) + const truncatedNote = match.inputTypeDefinitionTruncated + ? '; use entity detail for the full definition' + : '' + return `${mainLine}\n ${formatMarkdownInlineCode(`${accessor}(args)`)} — ${formatMarkdownInlineCode(match.inputTypeDefinition)}${truncatedNote}` } if (match.type === 'package') { const entityRef = buildEntityRef(match.kodyId, 'package') @@ -709,6 +757,9 @@ export function toSlimStructuredMatches(input: { : {}), ...(match.mcpServer ? { mcpServer: match.mcpServer } : {}), ...(match.openApi ? { openApi: match.openApi } : {}), + ...(match.inputTypeDefinition + ? { inputTypeDefinition: match.inputTypeDefinition } + : {}), } } if (match.type === 'package') { @@ -838,6 +889,7 @@ export function toSlimStructuredMatches(input: { export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { if (detail.type === 'capability') { + const relatedOperations = detail.relatedOperations ?? [] const lines = [ `# Capability — \`${detail.spec.name}\``, '', @@ -872,6 +924,18 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { : []), '```', ] + if (relatedOperations.length > 0) { + lines.push('', '## Related operations (same provider)', '') + for (const related of relatedOperations) { + const openApiSuffix = + related.method && related.path + ? ` — \`${related.method.toUpperCase()} ${related.path}\`` + : '' + lines.push( + `- \`${related.name}\`${openApiSuffix} — ${escapeMarkdownText(formatOneLineSentence(related.description))} Entity: \`${related.entityRef}\``, + ) + } + } return { markdown: lines.join('\n'), structured: { @@ -897,6 +961,7 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { ...(detail.spec.outputTypeDefinition ? { outputTypeDefinition: detail.spec.outputTypeDefinition } : {}), + ...(relatedOperations.length > 0 ? { relatedOperations } : {}), } satisfies SearchEntityDetailStructured, } } @@ -945,6 +1010,7 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { const readme = buildPackageReadmeDetail({ files: detail.files, }) + const maintain = buildPackageMaintainSnippets(detail.record.kodyId) const lines = [ `# Package — \`${detail.record.kodyId}\``, '', @@ -960,6 +1026,11 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { `- Has app: ${detail.record.hasApp ? 'yes' : 'no'}`, `- Hidden: ${detail.record.hidden ? 'yes' : 'no'}`, ...(detail.hostedUrl ? [`- Hosted URL: \`${detail.hostedUrl}\``] : []), + '', + '## Maintain', + '', + `- Git lane: \`${maintain.gitLane}\` → clone → edit → push → \`${maintain.publish}\``, + '- Tool-only: `package_save` / repo sessions; full guide: `coding_guide_get({ guide: "package_authoring" })`', ] if (appEntry) { lines.push( @@ -1055,6 +1126,7 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { hidden: detail.record.hidden, hostedUrl: detail.hostedUrl, appEntry, + maintain, exports: exportDetails, jobs, retrievers, diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index 4cd32b8db6..aa4c062ba4 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -69,9 +69,13 @@ import { } from './tool-call-context.ts' import { type PackageActionMatch, + type RelatedCapabilityOperation, + type SearchEntityDetailStructured, type SearchMatch, type SearchResultStructuredContent, + buildKodyCapabilityAccessor, buildPackageActionImportUsage, + compactCapabilityInputTypeDefinition, formatEntityDetailMarkdown, formatSearchMarkdown, getPrimaryPackageActionFunction, @@ -95,6 +99,9 @@ const maxTokens = 6_000 const maxChars = maxTokens * charsPerToken const defaultSearchLimit = 15 const defaultMaxResponseSize = 4_000 +const topCapabilityInlineCallShapeCount = 3 +const maxRelatedCapabilityOperations = 20 +const maxBatchEntityRefs = 10 /** Upper bound on package candidates kept after lexical/vector rank fusion. */ const maxFusedPackageCandidates = 100 @@ -399,31 +406,11 @@ function buildRecommendedNextStep( return `Inspect integration detail with \`search({ entity: "${topMatch.integrationName}:integration" })\` and then run a minimal authenticated \`execute\` smoke test before building or calling integration-backed code.` } if (topMatch?.type === 'capability') { - if (topMatch.source === 'remote-connector' && topMatch.remoteConnector) { - const connectorName = topMatch.remoteConnector.connectorName - const toolName = topMatch.remoteConnector.toolName - const accessor = /^[A-Za-z_$][\w$]*$/.test(toolName) - ? `kody.remote[${JSON.stringify(connectorName)}].${toolName}` - : `kody.remote[${JSON.stringify(connectorName)}][${JSON.stringify(toolName)}]` - return `Inspect capability detail with \`search({ entity: "${topMatch.name}:capability" })\` to confirm the TypeScript call shape, then call it from \`execute\` via \`${accessor}(args)\`.` + const accessor = buildKodyCapabilityAccessor(topMatch) + if (topMatch.inputTypeDefinition) { + return `Call \`${accessor}(args)\` from \`execute\` using the inlined call shape above. Use \`search({ entity: "${topMatch.name}:capability" })\` only if you need the full type definitions.` } - if (topMatch.source === 'mcp-server' && topMatch.mcpServer) { - const serverName = topMatch.mcpServer.kodyName - const toolName = topMatch.mcpServer.toolName - const accessor = /^[A-Za-z_$][\w$]*$/.test(toolName) - ? `kody.mcp[${JSON.stringify(serverName)}].${toolName}` - : `kody.mcp[${JSON.stringify(serverName)}][${JSON.stringify(toolName)}]` - return `Inspect capability detail with \`search({ entity: "${topMatch.name}:capability" })\` to confirm the TypeScript call shape, then call it from \`execute\` via \`${accessor}(args)\`.` - } - if (topMatch.source === 'openapi' && topMatch.openApi) { - const providerName = topMatch.openApi.kodyName - const operationSlug = topMatch.openApi.operationSlug - const accessor = /^[A-Za-z_$][\w$]*$/.test(operationSlug) - ? `kody.openapi[${JSON.stringify(providerName)}].${operationSlug}` - : `kody.openapi[${JSON.stringify(providerName)}][${JSON.stringify(operationSlug)}]` - return `Inspect capability detail with \`search({ entity: "${topMatch.name}:capability" })\` to confirm the TypeScript call shape, then call it from \`execute\` via \`${accessor}(args)\`.` - } - return `Inspect capability detail with \`search({ entity: "${topMatch.name}:capability" })\` to confirm the TypeScript call shape, then call it from \`execute\` via \`kody.${topMatch.name}(args)\`.` + return `Inspect capability detail with \`search({ entity: "${topMatch.name}:capability" })\` to confirm the TypeScript call shape, then call it from \`execute\` via \`${accessor}(args)\`.` } return undefined } @@ -1410,6 +1397,92 @@ function capabilityMatchToCandidate( } } +/** + * Attaches a compact call shape (accessor + collapsed input type) to the top + * capability matches only, so query responses stay lean while still letting + * agents call without an immediate entity round trip. + */ +function attachTopCapabilityCallShapes(input: { + matches: Array + registry: Awaited> + limit?: number +}): void { + const limit = input.limit ?? topCapabilityInlineCallShapeCount + let attached = 0 + for (const match of input.matches) { + if (attached >= limit) break + if (match.type !== 'capability') continue + const spec = input.registry.capabilitySpecs[match.name] + if (!spec?.inputTypeDefinition) continue + const compact = compactCapabilityInputTypeDefinition( + spec.inputTypeDefinition, + ) + match.inputTypeDefinition = compact.definition + if (compact.truncated) { + match.inputTypeDefinitionTruncated = true + } + attached += 1 + } +} + +function sameSynthesizedProvider( + left: Awaited< + ReturnType + >['capabilitySpecs'][string], + right: Awaited< + ReturnType + >['capabilitySpecs'][string], +): boolean { + if (left.source !== right.source) return false + if (left.source === 'openapi' && left.openApi && right.openApi) { + return left.openApi.kodyName === right.openApi.kodyName + } + if (left.source === 'mcp-server' && left.mcpServer && right.mcpServer) { + return left.mcpServer.kodyName === right.mcpServer.kodyName + } + if ( + left.source === 'remote-connector' && + left.remoteConnector && + right.remoteConnector + ) { + return ( + left.remoteConnector.connectorName === right.remoteConnector.connectorName + ) + } + return false +} + +function collectRelatedCapabilityOperations(input: { + spec: Awaited< + ReturnType + >['capabilitySpecs'][string] + registry: Awaited> +}): Array { + const { spec, registry } = input + if ( + spec.source !== 'openapi' && + spec.source !== 'mcp-server' && + spec.source !== 'remote-connector' + ) { + return [] + } + return Object.values(registry.capabilitySpecs) + .filter( + (other) => + other.name !== spec.name && sameSynthesizedProvider(spec, other), + ) + .sort((left, right) => left.name.localeCompare(right.name)) + .slice(0, maxRelatedCapabilityOperations) + .map((other) => ({ + name: other.name, + entityRef: `${other.name}:capability`, + description: other.description, + ...(other.openApi + ? { method: other.openApi.method, path: other.openApi.path } + : {}), + })) +} + /** * Loads README snippets and export metadata for the bounded set of package * matches actually being returned (at most `limit` rows), instead of loading @@ -1552,6 +1625,10 @@ export async function searchUnified(input: { limit, }) const matches = reranked.map((candidate) => candidate.match) + attachTopCapabilityCallShapes({ + matches, + registry: input.registry, + }) await hydrateTopPackageMatches({ query: intent.normalizedQuery, matches, @@ -1683,9 +1760,11 @@ without competing semantic matches. Hidden exact queries require \`includeHiddenPackages: true\`. **entity: "{id}:{type}"** — detail for one hit (\`capability\` | \`value\` -| \`integration\` | \`package\` | \`secret\`). Capability detail includes an -exact \`execute\` module snippet plus TypeScript call-shape definitions by -default. +| \`integration\` | \`package\` | \`secret\`), or an array of 1–10 refs to batch +related lookups in one call. Capability detail includes an exact \`execute\` +module snippet plus TypeScript call-shape definitions by default. Synthesized +provider capabilities (OpenAPI, MCP server, remote connector) also list related +operations from the same provider. Packages: \`package_list\` and \`package_get\` to inspect. Coding agents with local filesystem/git access author via \`package_get_git_remote\` @@ -1716,6 +1795,7 @@ Example arguments: - \`{ "query": "preferred org value or saved integration", "limit": 10 }\` - \`{ "query": "package with worker app ui", "limit": 10 }\` - \`{ "entity": "coding_guide_get:capability" }\` +- \`{ "entity": ["openapi:canva:createdesignexportjob:capability", "openapi:canva:getdesignexportjob:capability"] }\` - \`{ "entity": "user:preferred_org:value" }\` - \`{ "entity": "github:integration" }\` @@ -1882,12 +1962,17 @@ async function resolveEntityDetail(input: { if (!spec) { throw new Error('Capability not found.') } + const relatedOperations = collectRelatedCapabilityOperations({ + spec, + registry: input.searchRows.registry, + }) return { type: 'capability' as const, id: ref.id, title: spec.name, description: spec.description, spec, + ...(relatedOperations.length > 0 ? { relatedOperations } : {}), } } @@ -2017,11 +2102,13 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { 'Natural language description, or an exact saved-package UUID, kody id, current-origin account package URL, or owner-matching hosted package URL.', ), entity: z - .string() - .min(1) + .union([ + z.string().min(1), + z.array(z.string().min(1)).min(1).max(maxBatchEntityRefs), + ]) .optional() .describe( - 'Optional exact entity reference in the format "{id}:{type}" where type is capability, package, secret, value, or integration.', + 'Optional exact entity reference "{id}:{type}" (capability, package, secret, value, or integration), or an array of 1–10 refs to batch related detail lookups.', ), limit: z .number() @@ -2051,7 +2138,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { }, async (args: { query?: string - entity?: string + entity?: string | Array limit?: number maxResponseSize?: number conversationId?: string @@ -2172,6 +2259,39 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { warnings = searchRows.warnings if (args.entity) { + if (Array.isArray(args.entity)) { + const batchResults = await Promise.all( + args.entity.map(async (entityRef) => { + try { + const detail = await resolveEntityDetail({ + agent, + callerContext, + userId, + username, + entity: entityRef, + searchRows, + }) + return { + ok: true as const, + entityRef, + detail, + } + } catch (cause) { + const error = + cause instanceof Error ? cause : new Error(String(cause)) + return { + ok: false as const, + entityRef, + error: error.message, + } + } + }), + ) + return { + mode: 'entity-batch' as const, + results: batchResults, + } + } return { mode: 'entity' as const, detail: await resolveEntityDetail({ @@ -2211,6 +2331,21 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { | { mode: 'entity' detail: Awaited> + } + | { + mode: 'entity-batch' + results: Array< + | { + ok: true + entityRef: string + detail: Awaited> + } + | { + ok: false + entityRef: string + error: string + } + > } = await Sentry.startSpan( { name: 'mcp.tool.search', @@ -2249,6 +2384,63 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { } } + if (outcome.mode === 'entity-batch') { + const timing = finishToolTiming(timingStart) + const structuredResults: Array< + SearchEntityDetailStructured | { entityRef: string; error: string } + > = [] + const markdownParts: Array = [] + let successCount = 0 + for (const entry of outcome.results) { + if (!entry.ok) { + structuredResults.push({ + entityRef: entry.entityRef, + error: entry.error, + }) + markdownParts.push( + `Error resolving \`${entry.entityRef}\`: ${entry.error}`, + ) + continue + } + successCount += 1 + const entityResult = formatEntityDetailMarkdown(entry.detail) + structuredResults.push(entityResult.structured) + markdownParts.push(entityResult.markdown) + } + const allFailed = successCount === 0 + logMcpEvent({ + category: 'mcp', + tool: 'search', + toolName: 'search', + outcome: allFailed ? 'failure' : 'success', + durationMs: timing.durationMs, + baseUrl, + hasUser, + ...(allFailed + ? { + sandboxError: false, + errorName: 'EntityBatchError', + errorMessage: 'All entity lookups failed.', + } + : {}), + }) + return { + content: prependToolMetadataContent(conversationId, [ + { + type: 'text', + text: truncateSearchText(markdownParts.join('\n\n---\n\n')), + }, + ]), + structuredContent: { + conversationId, + timing, + result: structuredResults, + ...(allFailed ? { error: 'All entity lookups failed.' } : {}), + }, + ...(allFailed ? { isError: true } : {}), + } + } + const normalizedRemoteConnectorStatuses = remoteConnectorDownStatuses.length > 0 ? remoteConnectorDownStatuses.map(serializeRemoteConnectorStatus) From a74ed71344ef16d461e647bffb07044f9210f8b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 21:24:43 +0000 Subject: [PATCH 2/3] Cover search discovery friction fixes in docs and tests Document batch entity lookup and inlined call shapes, and add colocated coverage for batch success/partial/all-fail, top-3 inline shapes, related operations, and package Maintain pointers. --- docs/use/search.md | 28 +- .../src/mcp/tools/search-format.node.test.ts | 256 ++++++++++++++++++ .../src/mcp/tools/search-handler.node.test.ts | 174 +++++++++++- .../worker/src/mcp/tools/search.node.test.ts | 144 ++++++++++ 4 files changed, 597 insertions(+), 5 deletions(-) diff --git a/docs/use/search.md b/docs/use/search.md index 0b3f9795fe..79d8df56f5 100644 --- a/docs/use/search.md +++ b/docs/use/search.md @@ -20,7 +20,10 @@ it). See [Packages](./packages.md#hidden-packages). Pass a **`query`** string that describes what you want to do. Results are ranked; order in the response matters. Query responses are intentionally compact: the markdown response is a short list of matches with the result type, -title, one-line summary, and entity reference when applicable. +title, one-line summary, and entity reference when applicable. The top few +capability hits also include a compact inlined call shape (runtime accessor plus +a whitespace-collapsed input type, truncated when long) so you can often call +from **execute** without an immediate entity round trip. An entire saved-package UUID or `kody.id` is treated as an exact package identity when it resolves for the signed-in user. Kody also recognizes @@ -42,7 +45,7 @@ Optional **`limit`** caps how many ranked hits return. Optional **`maxResponseSize`** trims low-ranked matches against the compact list when the response must stay small. -## Single-entity detail +## Entity detail To get **full markdown and call shapes for one hit** (for example a capability’s ready-to-run **execute** snippet plus `inputTypeDefinition` / @@ -50,9 +53,15 @@ ready-to-run **execute** snippet plus `inputTypeDefinition` / `"{id}:{type}"` where **`type`** is `capability`, `value`, `integration`, `package`, or `secret`. +Pass an **array of 1–10 entity refs** when you need several related details at +once (for example a create/poll OpenAPI pair). Each ref resolves independently: +failures become per-entity error lines without aborting the whole batch. If +every ref fails, the tool returns an error result. + Examples: - `coding_guide_get:capability` +- `["openapi:canva:createdesignexportjob:capability", "openapi:canva:getdesignexportjob:capability"]` - `user:preferred_org:value` - `github:integration` - `my-package:package` @@ -60,13 +69,24 @@ Examples: - `spotify:integration` - `spotify-access-token:secret` -There is **no separate `detail` flag** on search. Deeper inspection of one -entity uses **`entity`**, not a different mode of the same ranked query. +There is **no separate `detail` flag** on search. Deeper inspection uses +**`entity`**, not a different mode of the same ranked query. Top-level ranked result cards include an explicit entity ref for each hit when applicable, using that same `"{id}:{type}"` format, so you can immediately copy the ref into a follow-up `entity` lookup when needed. +For synthesized provider capabilities (OpenAPI bindings, connected MCP servers, +and remote connectors), capability detail also lists **related operations from +the same provider** so create/poll pairs and sibling tools are visible without a +second lookup. Built-in capabilities skip that section to keep common lookups +lean. + +Package detail includes a short **Maintain** pointer: the git lane +(`package_get_git_remote` → clone/edit/push → `package_publish_external_push`) +and the tool-only alternative (`package_save` / repo sessions, with +`coding_guide_get({ guide: "package_authoring" })` for the full guide). + Capability detail shows the exact runtime pattern for **execute**: ```ts diff --git a/packages/worker/src/mcp/tools/search-format.node.test.ts b/packages/worker/src/mcp/tools/search-format.node.test.ts index c1b7271bf7..32baa9f7c2 100644 --- a/packages/worker/src/mcp/tools/search-format.node.test.ts +++ b/packages/worker/src/mcp/tools/search-format.node.test.ts @@ -1,8 +1,10 @@ import { Script, createContext } from 'node:vm' import { expect, test } from 'vitest' import { + compactCapabilityInputTypeDefinition, formatEntityDetailMarkdown, formatSearchMarkdown, + inlineCapabilityInputTypeMaxLength, parseEntityRef, toSlimStructuredMatches, } from './search-format.ts' @@ -841,3 +843,257 @@ test('search markdown summarizes broad results safely and only suggests entity d }), ]) }) + +test('search formatting inlines top capability call shapes, related ops, and package maintain pointers', () => { + const longInputType = `type LongInput = {\n\t${'field: string\n\t'.repeat(40)}}` + const compact = compactCapabilityInputTypeDefinition(longInputType) + expect(compact.truncated).toBe(true) + expect(compact.definition.length).toBeLessThanOrEqual( + inlineCapabilityInputTypeMaxLength, + ) + expect(compact.definition.endsWith('...')).toBe(true) + + const listMarkdown = formatSearchMarkdown({ + matches: [ + { + type: 'capability', + name: 'openapi:widgets:createwidget', + description: 'Create a widget.', + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'createwidget', + method: 'post', + path: '/widgets', + }, + inputTypeDefinition: 'type CreateWidgetInput = { name: string }', + }, + { + type: 'capability', + name: 'openapi:widgets:listwidgets', + description: 'List widgets.', + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'listwidgets', + method: 'get', + path: '/widgets', + }, + inputTypeDefinition: compact.definition, + inputTypeDefinitionTruncated: true, + }, + { + type: 'capability', + name: 'fourth_capability', + description: 'Beyond the top inline set.', + source: 'builtin', + }, + ], + includePreamble: false, + }) + expect(listMarkdown).toContain('kody.openapi["widgets"].createwidget(args)') + expect(listMarkdown).toContain('type CreateWidgetInput = { name: string }') + expect(listMarkdown).toContain('use entity detail for the full definition') + expect(listMarkdown).not.toMatch( + /fourth_capability[\s\S]*kody\.fourth_capability\(args\)/, + ) + + const [slimWithShape, slimTruncated, slimWithoutShape] = + toSlimStructuredMatches({ + baseUrl: 'http://localhost', + matches: [ + { + type: 'capability', + name: 'openapi:widgets:createwidget', + description: 'Create a widget.', + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'createwidget', + method: 'post', + path: '/widgets', + }, + inputTypeDefinition: 'type CreateWidgetInput = { name: string }', + }, + { + type: 'capability', + name: 'openapi:widgets:listwidgets', + description: 'List widgets.', + source: 'openapi', + inputTypeDefinition: compact.definition, + inputTypeDefinitionTruncated: true, + }, + { + type: 'capability', + name: 'fourth_capability', + description: 'Beyond the top inline set.', + source: 'builtin', + }, + ], + }) + expect(slimWithShape).toMatchObject({ + type: 'capability', + inputTypeDefinition: 'type CreateWidgetInput = { name: string }', + }) + expect(slimTruncated).toMatchObject({ + type: 'capability', + inputTypeDefinition: compact.definition, + }) + expect(slimWithoutShape).toMatchObject({ type: 'capability' }) + expect(slimWithoutShape).not.toHaveProperty('inputTypeDefinition') + + const openApiDetail = formatEntityDetailMarkdown({ + type: 'capability', + id: 'openapi:widgets:createwidget', + title: 'openapi:widgets:createwidget', + description: 'Create a widget.', + spec: { + name: 'openapi:widgets:createwidget', + domain: 'openapi:widgets', + description: 'Create a widget.', + keywords: [], + readOnly: false, + idempotent: false, + destructive: false, + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'createwidget', + method: 'post', + path: '/widgets', + }, + inputFields: ['name'], + requiredInputFields: ['name'], + outputFields: [], + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + inputTypeDefinition: 'type CreateWidgetInput = { name: string }', + }, + relatedOperations: [ + { + name: 'openapi:widgets:listwidgets', + entityRef: 'openapi:widgets:listwidgets:capability', + description: 'List widgets', + method: 'get', + path: '/widgets', + }, + { + name: 'openapi:widgets:getwidget', + entityRef: 'openapi:widgets:getwidget:capability', + description: 'Get one widget.', + method: 'get', + path: '/widgets/{id}', + }, + ], + }) + expect(openApiDetail.markdown).toContain( + '## Related operations (same provider)', + ) + expect(openApiDetail.markdown).toContain('GET /widgets') + expect(openApiDetail.markdown).toContain( + 'openapi:widgets:listwidgets:capability', + ) + expect(openApiDetail.structured).toMatchObject({ + type: 'capability', + relatedOperations: [ + expect.objectContaining({ + name: 'openapi:widgets:listwidgets', + method: 'get', + path: '/widgets', + }), + expect.objectContaining({ + name: 'openapi:widgets:getwidget', + }), + ], + }) + + const builtinDetail = formatEntityDetailMarkdown({ + type: 'capability', + id: 'coding_guide_get', + title: 'coding_guide_get', + description: 'Load an official guide.', + spec: { + name: 'coding_guide_get', + domain: 'coding', + description: 'Load an official guide.', + keywords: [], + readOnly: true, + idempotent: true, + destructive: false, + source: 'builtin', + inputFields: ['guide'], + requiredInputFields: ['guide'], + outputFields: [], + inputSchema: { + type: 'object', + properties: { guide: { type: 'string' } }, + required: ['guide'], + }, + inputTypeDefinition: 'type CodingGuideGetInput = { guide: string }', + }, + }) + expect(builtinDetail.markdown).not.toContain( + '## Related operations (same provider)', + ) + expect(builtinDetail.structured).not.toHaveProperty('relatedOperations') + + const packageDetail = formatEntityDetailMarkdown({ + type: 'package', + id: 'notes-helper', + title: '@user/notes-helper', + description: 'Notes helper package.', + baseUrl: 'http://localhost', + ownerUsername: 'user', + hostedUrl: null, + record: { + id: 'package-notes', + userId: 'user-1', + name: '@user/notes-helper', + kodyId: 'notes-helper', + description: 'Notes helper package.', + tags: [], + searchText: null, + sourceId: 'source-notes', + hasApp: false, + hidden: false, + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T00:00:00.000Z', + }, + manifest: { + name: '@user/notes-helper', + exports: { '.': './index.ts' }, + kody: { + id: 'notes-helper', + description: 'Notes helper package.', + }, + }, + files: { + 'package.json': '{}', + 'index.ts': 'export default function main() {}', + }, + }) + expect(packageDetail.markdown).toContain('## Maintain') + expect(packageDetail.markdown).toContain( + 'package_get_git_remote({ kody_id: "notes-helper" })', + ) + expect(packageDetail.markdown).toContain( + 'package_publish_external_push({ kody_id: "notes-helper" })', + ) + expect(packageDetail.markdown).toContain( + 'coding_guide_get({ guide: "package_authoring" })', + ) + expect(packageDetail.structured).toMatchObject({ + type: 'package', + maintain: { + gitLane: 'package_get_git_remote({ kody_id: "notes-helper" })', + publish: 'package_publish_external_push({ kody_id: "notes-helper" })', + }, + }) +}) diff --git a/packages/worker/src/mcp/tools/search-handler.node.test.ts b/packages/worker/src/mcp/tools/search-handler.node.test.ts index 99496e5045..d011aab4dd 100644 --- a/packages/worker/src/mcp/tools/search-handler.node.test.ts +++ b/packages/worker/src/mcp/tools/search-handler.node.test.ts @@ -95,7 +95,7 @@ const mockPerformanceNow = vi.spyOn(performance, 'now') type SearchHandler = (input: { query?: string - entity?: string + entity?: string | Array limit?: number maxResponseSize?: number conversationId?: string @@ -450,3 +450,175 @@ test('search tool treats exact package identity as authoritative and still resol ) expect(mockModule.getSavedPackageByKodyId).not.toHaveBeenCalled() }) + +test('search tool batches entity detail with per-ref isolation and preserves single-entity shape', async () => { + vi.clearAllMocks() + mockModule.getCapabilityRegistryForContext.mockResolvedValue({ + capabilitySpecs: { + search_docs: { + name: 'search_docs', + description: 'Search docs capability', + domain: 'meta', + keywords: [], + inputFields: [], + requiredInputFields: [], + outputFields: [], + readOnly: true, + idempotent: true, + destructive: false, + source: 'builtin', + inputSchema: { type: 'object', properties: {} }, + inputTypeDefinition: 'type SearchDocsInput = Record', + }, + 'openapi:widgets:createwidget': { + name: 'openapi:widgets:createwidget', + description: 'Create a widget.', + domain: 'openapi:widgets', + keywords: [], + inputFields: ['name'], + requiredInputFields: ['name'], + outputFields: [], + readOnly: false, + idempotent: false, + destructive: false, + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'createwidget', + method: 'post', + path: '/widgets', + }, + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + inputTypeDefinition: 'type CreateWidgetInput = { name: string }', + }, + 'openapi:widgets:getwidget': { + name: 'openapi:widgets:getwidget', + description: 'Get a widget.', + domain: 'openapi:widgets', + keywords: [], + inputFields: ['id'], + requiredInputFields: ['id'], + outputFields: [], + readOnly: true, + idempotent: true, + destructive: false, + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'getwidget', + method: 'get', + path: '/widgets/{id}', + }, + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + inputTypeDefinition: 'type GetWidgetInput = { id: string }', + }, + }, + }) + + const handler = await getSearchHandler() + + mockPerformanceNow.mockReturnValueOnce(100).mockReturnValueOnce(110) + const singleResponse = await handler({ + entity: 'search_docs:capability', + conversationId: 'conv-single-entity', + }) + expect(singleResponse.isError).toBeUndefined() + expect(singleResponse.structuredContent.result).toMatchObject({ + kind: 'entity', + type: 'capability', + id: 'search_docs', + entityRef: 'search_docs:capability', + }) + expect(singleResponse.structuredContent.result).not.toHaveProperty( + 'relatedOperations', + ) + expect(Array.isArray(singleResponse.structuredContent.result)).toBe(false) + + mockPerformanceNow.mockReturnValueOnce(200).mockReturnValueOnce(210) + const batchSuccess = await handler({ + entity: [ + 'openapi:widgets:createwidget:capability', + 'openapi:widgets:getwidget:capability', + ], + conversationId: 'conv-batch-success', + }) + expect(batchSuccess.isError).toBeUndefined() + expect(batchSuccess.structuredContent.result).toEqual([ + expect.objectContaining({ + kind: 'entity', + type: 'capability', + id: 'openapi:widgets:createwidget', + relatedOperations: [ + expect.objectContaining({ + name: 'openapi:widgets:getwidget', + method: 'get', + path: '/widgets/{id}', + }), + ], + }), + expect.objectContaining({ + kind: 'entity', + type: 'capability', + id: 'openapi:widgets:getwidget', + relatedOperations: [ + expect.objectContaining({ + name: 'openapi:widgets:createwidget', + }), + ], + }), + ]) + const batchText = batchSuccess.content.map((item) => item.text).join('\n') + expect(batchText).toContain('---') + expect(batchText).toContain('## Related operations (same provider)') + + mockPerformanceNow.mockReturnValueOnce(300).mockReturnValueOnce(310) + const partialFailure = await handler({ + entity: [ + 'openapi:widgets:createwidget:capability', + 'missing_thing:capability', + ], + conversationId: 'conv-batch-partial', + }) + expect(partialFailure.isError).toBeUndefined() + expect(partialFailure.structuredContent.result).toEqual([ + expect.objectContaining({ + kind: 'entity', + type: 'capability', + id: 'openapi:widgets:createwidget', + }), + expect.objectContaining({ + entityRef: 'missing_thing:capability', + error: expect.stringMatching(/not found/i), + }), + ]) + + mockPerformanceNow.mockReturnValueOnce(400).mockReturnValueOnce(410) + const allFailed = await handler({ + entity: ['missing_a:capability', 'missing_b:capability'], + conversationId: 'conv-batch-all-failed', + }) + expect(allFailed.isError).toBe(true) + expect(allFailed.structuredContent.error).toMatch( + /all entity lookups failed/i, + ) + expect(allFailed.structuredContent.result).toEqual([ + expect.objectContaining({ + entityRef: 'missing_a:capability', + error: expect.any(String), + }), + expect.objectContaining({ + entityRef: 'missing_b:capability', + error: expect.any(String), + }), + ]) +}) diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts index ff31c7a30c..9b7fb4734a 100644 --- a/packages/worker/src/mcp/tools/search.node.test.ts +++ b/packages/worker/src/mcp/tools/search.node.test.ts @@ -1060,3 +1060,147 @@ test('down remote connector statuses surface only disconnected connectors for si }) expect(anonymous).toEqual([]) }) + +test('searchUnified inlines call shapes for the top three capability matches only', async () => { + const longTypeBody = Array.from( + { length: 40 }, + (_, index) => `field${String(index)}: string`, + ).join('; ') + const registry = buildCapabilityRegistry([ + { + name: 'openapi:widgets', + description: 'Widget OpenAPI ops', + capabilities: [ + { + name: 'openapi:widgets:createwidget', + domain: 'openapi:widgets', + description: 'Create a widget export job.', + keywords: ['widget', 'create', 'export'], + readOnly: false, + idempotent: false, + destructive: false, + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'createwidget', + method: 'post', + path: '/widgets', + }, + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + inputTypeDefinition: `type CreateWidgetInput = { ${longTypeBody} }`, + handler: async () => null, + }, + { + name: 'openapi:widgets:getwidget', + domain: 'openapi:widgets', + description: 'Get a widget export job.', + keywords: ['widget', 'get', 'export'], + readOnly: true, + idempotent: true, + destructive: false, + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'getwidget', + method: 'get', + path: '/widgets/{id}', + }, + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + inputTypeDefinition: 'type GetWidgetInput = { id: string }', + handler: async () => null, + }, + { + name: 'openapi:widgets:listwidgets', + domain: 'openapi:widgets', + description: 'List widget export jobs.', + keywords: ['widget', 'list', 'export'], + readOnly: true, + idempotent: true, + destructive: false, + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'listwidgets', + method: 'get', + path: '/widgets', + }, + inputSchema: { type: 'object', properties: {} }, + inputTypeDefinition: 'type ListWidgetsInput = Record', + handler: async () => null, + }, + { + name: 'openapi:widgets:deletewidget', + domain: 'openapi:widgets', + description: 'Delete a widget export job.', + keywords: ['widget', 'delete', 'export'], + readOnly: false, + idempotent: true, + destructive: true, + source: 'openapi', + openApi: { + bindingName: 'widgets', + kodyName: 'widgets', + operationSlug: 'deletewidget', + method: 'delete', + path: '/widgets/{id}', + }, + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + inputTypeDefinition: 'type DeleteWidgetInput = { id: string }', + handler: async () => null, + }, + ], + }, + ]) + + const result = await searchUnified({ + env: {} as Env, + query: 'widget export job', + limit: 10, + registry, + optionalRows: emptyOptionalSearchRows, + }) + + const capabilityMatches = result.matches.filter( + (match) => match.type === 'capability', + ) + expect(capabilityMatches.length).toBeGreaterThanOrEqual(4) + + const withShapes = capabilityMatches.filter( + (match) => match.type === 'capability' && match.inputTypeDefinition, + ) + expect(withShapes).toHaveLength(3) + expect( + capabilityMatches + .slice(0, 3) + .every( + (match) => + match.type === 'capability' && + typeof match.inputTypeDefinition === 'string', + ), + ).toBe(true) + expect(capabilityMatches[3]).toMatchObject({ type: 'capability' }) + expect(capabilityMatches[3]).not.toHaveProperty('inputTypeDefinition') + + const truncated = withShapes.find( + (match) => + match.type === 'capability' && match.inputTypeDefinitionTruncated, + ) + expect(truncated).toBeDefined() + expect(truncated?.inputTypeDefinition?.endsWith('...')).toBe(true) + expect(result.guidance).toMatch(/inlined call shape/i) +}) From 4b6eff1b9306414e9fe9edfd8126942035c5c706 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Jul 2026 21:37:55 +0000 Subject: [PATCH 3/3] Address CodeRabbit search feedback before merge Propagate truncation flags and avoid recommending truncated call shapes, escape batch/related-ops markdown safely, bound batch structured payload size, and clarify capability-only call-shape docs. --- docs/use/search.md | 10 +++--- .../src/mcp/tools/search-format.node.test.ts | 2 ++ .../worker/src/mcp/tools/search-format.ts | 8 +++-- .../worker/src/mcp/tools/search.node.test.ts | 33 ++++++++++++----- packages/worker/src/mcp/tools/search.ts | 35 +++++++++++++++++-- 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/docs/use/search.md b/docs/use/search.md index 79d8df56f5..60dfb2b4dd 100644 --- a/docs/use/search.md +++ b/docs/use/search.md @@ -47,11 +47,11 @@ response must stay small. ## Entity detail -To get **full markdown and call shapes for one hit** (for example a capability’s -ready-to-run **execute** snippet plus `inputTypeDefinition` / -`outputTypeDefinition`), call **search** again with **`entity`** set to -`"{id}:{type}"` where **`type`** is `capability`, `value`, `integration`, -`package`, or `secret`. +To get **full markdown detail for one hit**, call **search** again with +**`entity`** set to `"{id}:{type}"` where **`type`** is `capability`, `value`, +`integration`, `package`, or `secret`. Capability entities additionally include +a ready-to-run **execute** snippet plus `inputTypeDefinition` / +`outputTypeDefinition`. Pass an **array of 1–10 entity refs** when you need several related details at once (for example a create/poll OpenAPI pair). Each ref resolves independently: diff --git a/packages/worker/src/mcp/tools/search-format.node.test.ts b/packages/worker/src/mcp/tools/search-format.node.test.ts index 32baa9f7c2..046b4d254a 100644 --- a/packages/worker/src/mcp/tools/search-format.node.test.ts +++ b/packages/worker/src/mcp/tools/search-format.node.test.ts @@ -941,9 +941,11 @@ test('search formatting inlines top capability call shapes, related ops, and pac expect(slimTruncated).toMatchObject({ type: 'capability', inputTypeDefinition: compact.definition, + inputTypeDefinitionTruncated: true, }) expect(slimWithoutShape).toMatchObject({ type: 'capability' }) expect(slimWithoutShape).not.toHaveProperty('inputTypeDefinition') + expect(slimWithoutShape).not.toHaveProperty('inputTypeDefinitionTruncated') const openApiDetail = formatEntityDetailMarkdown({ type: 'capability', diff --git a/packages/worker/src/mcp/tools/search-format.ts b/packages/worker/src/mcp/tools/search-format.ts index ea119ae96c..f45f4d17af 100644 --- a/packages/worker/src/mcp/tools/search-format.ts +++ b/packages/worker/src/mcp/tools/search-format.ts @@ -123,6 +123,7 @@ export type SlimSearchMatch = mcpServer?: CapabilitySpec['mcpServer'] openApi?: CapabilitySpec['openApi'] inputTypeDefinition?: string + inputTypeDefinitionTruncated?: boolean } | { type: 'package' @@ -760,6 +761,9 @@ export function toSlimStructuredMatches(input: { ...(match.inputTypeDefinition ? { inputTypeDefinition: match.inputTypeDefinition } : {}), + ...(match.inputTypeDefinitionTruncated + ? { inputTypeDefinitionTruncated: true } + : {}), } } if (match.type === 'package') { @@ -929,10 +933,10 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { for (const related of relatedOperations) { const openApiSuffix = related.method && related.path - ? ` — \`${related.method.toUpperCase()} ${related.path}\`` + ? ` — ${formatMarkdownInlineCode(`${related.method.toUpperCase()} ${related.path}`)}` : '' lines.push( - `- \`${related.name}\`${openApiSuffix} — ${escapeMarkdownText(formatOneLineSentence(related.description))} Entity: \`${related.entityRef}\``, + `- ${formatMarkdownInlineCode(related.name)}${openApiSuffix} — ${escapeMarkdownText(formatOneLineSentence(related.description))} Entity: ${formatMarkdownInlineCode(related.entityRef)}`, ) } } diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts index 9b7fb4734a..c1dde8867c 100644 --- a/packages/worker/src/mcp/tools/search.node.test.ts +++ b/packages/worker/src/mcp/tools/search.node.test.ts @@ -1169,7 +1169,7 @@ test('searchUnified inlines call shapes for the top three capability matches onl const result = await searchUnified({ env: {} as Env, - query: 'widget export job', + query: 'create widget export job', limit: 10, registry, optionalRows: emptyOptionalSearchRows, @@ -1196,11 +1196,28 @@ test('searchUnified inlines call shapes for the top three capability matches onl expect(capabilityMatches[3]).toMatchObject({ type: 'capability' }) expect(capabilityMatches[3]).not.toHaveProperty('inputTypeDefinition') - const truncated = withShapes.find( - (match) => - match.type === 'capability' && match.inputTypeDefinitionTruncated, - ) - expect(truncated).toBeDefined() - expect(truncated?.inputTypeDefinition?.endsWith('...')).toBe(true) - expect(result.guidance).toMatch(/inlined call shape/i) + const [topMatch] = capabilityMatches + expect(topMatch).toMatchObject({ + type: 'capability', + name: 'openapi:widgets:createwidget', + inputTypeDefinitionTruncated: true, + }) + expect(topMatch?.inputTypeDefinition?.endsWith('...')).toBe(true) + expect(result.guidance).toMatch(/Inspect capability detail/i) + expect(result.guidance).not.toMatch(/inlined call shape/i) + + const nonTruncatedTop = await searchUnified({ + env: {} as Env, + query: 'list widget export jobs', + limit: 10, + registry, + optionalRows: emptyOptionalSearchRows, + }) + const [listTop] = nonTruncatedTop.matches + expect(listTop).toMatchObject({ + type: 'capability', + name: 'openapi:widgets:listwidgets', + }) + expect(listTop).not.toHaveProperty('inputTypeDefinitionTruncated') + expect(nonTruncatedTop.guidance).toMatch(/inlined call shape/i) }) diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index aa4c062ba4..09c84dc034 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -67,6 +67,10 @@ import { memoryContextInputField, resolveConversationId, } from './tool-call-context.ts' +import { + escapeMarkdownText, + formatMarkdownInlineCode, +} from './markdown-safety.ts' import { type PackageActionMatch, type RelatedCapabilityOperation, @@ -407,7 +411,10 @@ function buildRecommendedNextStep( } if (topMatch?.type === 'capability') { const accessor = buildKodyCapabilityAccessor(topMatch) - if (topMatch.inputTypeDefinition) { + if ( + topMatch.inputTypeDefinition && + !topMatch.inputTypeDefinitionTruncated + ) { return `Call \`${accessor}(args)\` from \`execute\` using the inlined call shape above. Use \`search({ entity: "${topMatch.name}:capability" })\` only if you need the full type definitions.` } return `Inspect capability detail with \`search({ entity: "${topMatch.name}:capability" })\` to confirm the TypeScript call shape, then call it from \`execute\` via \`${accessor}(args)\`.` @@ -2398,12 +2405,34 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { error: entry.error, }) markdownParts.push( - `Error resolving \`${entry.entityRef}\`: ${entry.error}`, + `Error resolving ${formatMarkdownInlineCode(entry.entityRef)}: ${escapeMarkdownText(entry.error)}`, ) continue } - successCount += 1 const entityResult = formatEntityDetailMarkdown(entry.detail) + const candidateStructured = [ + ...structuredResults, + entityResult.structured, + ] + const hasFullDetail = structuredResults.some( + (result) => 'kind' in result && result.kind === 'entity', + ) + if ( + hasFullDetail && + JSON.stringify(candidateStructured).length > maxChars + ) { + const overflowError = + 'Omitted from batch response (exceeds size budget). Look up individually with search({ entity }).' + structuredResults.push({ + entityRef: entry.entityRef, + error: overflowError, + }) + markdownParts.push( + `Error resolving ${formatMarkdownInlineCode(entry.entityRef)}: ${escapeMarkdownText(overflowError)}`, + ) + continue + } + successCount += 1 structuredResults.push(entityResult.structured) markdownParts.push(entityResult.markdown) }