diff --git a/docs b/docs index 63dc1699..08572486 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 63dc1699e1ba88069bbd6f5537344072e87ed2b9 +Subproject commit 085724868216f964b297c9950116302bfccbd44a diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/diag-session.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/diag-session.test.ts index 251a01ea..391806b1 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/diag-session.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/diag-session.test.ts @@ -87,7 +87,7 @@ describe('get_session_diagnostics tool', () => { expect(apiFetchMock).toHaveBeenCalledTimes(1); // fails before minting a ticket }); - it('mints a ticket and returns an absolute downloadUrl + zipMap', async () => { + it('mints a ticket and returns an absolute downloadUrl + a pointer to the layout resource', async () => { apiFetchMock .mockResolvedValueOnce(sessionEnvelope('AgentDiagnostics-x.zip')) .mockResolvedValueOnce({ @@ -106,8 +106,9 @@ describe('get_session_diagnostics tool', () => { expect(payload.downloadUrl).toBe(`${API_BASE_URL}/api/diagnostics/download?t=ABC`); expect(payload.sizeBytes).toBe(12345); expect(payload.destination).toBe('CustomerSas'); - expect(payload.zipMap).toBeDefined(); - expect(payload.zipMap.files.length).toBeGreaterThan(0); + // The ~5k-character layout is a static resource, read once per conversation — not repeated per ticket. + expect(payload.zipMap).toBeUndefined(); + expect(payload.zipLayoutResource).toContain('diag_zip_layout'); expect(payload.instructions).toMatch(/no auth header/i); }); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/get-resource-section.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/get-resource-section.test.ts new file mode 100644 index 00000000..122b37e2 --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/get-resource-section.test.ts @@ -0,0 +1,66 @@ +/** + * get_resource sections: the rule-authoring trio is tens of KB together, so a caller can read one + * top-level part at a time. The section list in the tool description is derived from the live + * catalogs, and an unknown section is a clear error that names the available ones. + */ +import { describe, it, expect } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/server'; +import { registerTools } from '../tools.js'; +import { runWithCaller } from '../client.js'; +import { getResourceContent } from '../resource-catalog.js'; + +type ToolHandler = (args: Record, extra: unknown) => Promise<{ + content?: Array<{ type: string; text?: string }>; + isError?: boolean; +}>; + +const GA = { token: 'ga', isGlobalAdmin: true }; +const extra = { signal: new AbortController().signal }; + +function getResourceTool(): { handler: ToolHandler; description: string } { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + registerTools(server, undefined, undefined, undefined, true, true, false); + const registry = (server as unknown as { _registeredTools: Record })._registeredTools; + return registry.get_resource; +} + +function text(r: { content?: Array<{ text?: string }> }): string { + return (r.content ?? []).map((c) => c.text ?? '').join(''); +} + +describe('get_resource section', () => { + it('returns one top-level part of a large resource', async () => { + const { handler } = getResourceTool(); + const res = await runWithCaller(GA, () => handler({ name: 'rule_schemas', section: 'analyzeRuleSchema' }, extra)); + const body = JSON.parse(text(res)) as { resource: string; section: string; content: Record }; + + expect(res.isError).toBeFalsy(); + expect(body.resource).toBe('rule_schemas'); + expect(body.section).toBe('analyzeRuleSchema'); + expect(body.content).toEqual((getResourceContent('rule_schemas') as Record).analyzeRuleSchema); + // Strictly smaller than the whole resource — the point of the feature. + expect(text(res).length).toBeLessThan(JSON.stringify(getResourceContent('rule_schemas')).length); + }); + + it('without a section the whole resource is returned unchanged', async () => { + const { handler } = getResourceTool(); + const res = await runWithCaller(GA, () => handler({ name: 'rule_guardrails' }, extra)); + expect(JSON.parse(text(res))).toEqual(getResourceContent('rule_guardrails')); + }); + + it('names the available sections on an unknown section', async () => { + const { handler } = getResourceTool(); + const res = await runWithCaller(GA, () => handler({ name: 'rule_authoring_guide', section: 'nope' }, extra)); + expect(res.isError).toBe(true); + expect(text(res)).toContain('analyzeRules'); + expect(text(res)).toContain('gatherRules'); + }); + + it('lists every object-shaped resource with its sections in the description', () => { + const { description } = getResourceTool(); + for (const name of ['rule_authoring_guide', 'rule_schemas', 'rule_guardrails', 'diag_zip_layout'] as const) { + const keys = Object.keys(getResourceContent(name) as Record); + expect(description).toContain(`${name} → ${keys.join(', ')}`); + } + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-search.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-search.test.ts index d904d90d..60078d2e 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-search.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-search.test.ts @@ -815,3 +815,36 @@ describe('scanLexical (error-code fallback scan)', () => { expect(scanLexical(docs, [])).toEqual([]); }); }); + +describe('fetchEventsViaIndex concurrency', () => { + it('walks up to 3 candidate types at once and still returns events in candidate order', async () => { + let inFlight = 0; + let peak = 0; + // The first candidate is the slowest so a completion-order merge would misorder the result. + const delays: Record = { a: 30, b: 10, c: 5, d: 1 }; + const fetcher = async (path: string): Promise => { + const type = new URL(`http://x${path}`).searchParams.get('eventType') ?? ''; + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, delays[type] ?? 1)); + inFlight--; + return { events: [{ sessionId: `s-${type}`, eventType: type }] }; + }; + + const res = await fetchEventsViaIndex(['a', 'b', 'c', 'd'], BASE, undefined, { maxPagesPerType: 10, wallClockMs: 60_000 }, fetcher); + + expect(peak).toBeGreaterThanOrEqual(2); + expect(peak).toBeLessThanOrEqual(3); + expect(res.truncated).toBe(false); + expect(res.events.map((e) => e._sessionId)).toEqual(['s-a', 's-b', 's-c', 's-d']); + }); + + it('a single type walk is unaffected by the fan-out (concurrency capped at the candidate count)', async () => { + let calls = 0; + const fetcher = async (): Promise => { calls++; return { events: [{ sessionId: 's1' }] }; }; + const res = await fetchEventsViaIndex(['only'], BASE, undefined, { maxPagesPerType: 10, wallClockMs: 60_000 }, fetcher, Date.now, 3); + expect(calls).toBe(1); + expect(res.events).toHaveLength(1); + }); +}); + diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts index c93e48fd..f785310f 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts @@ -48,8 +48,6 @@ const ALLOWED_INLINE_ENUMS: Record = { "'low', 'medium', 'high', 'critical'": 'CVE overall-risk filter. Risk ranking lives inline in TableStorageService.AgentApi; ' + 'no C# constants class.', - "'event_types', 'ops_event_types', 'device_properties', 'diag_zip_layout', 'rule_authoring_guide', 'rule_schemas', 'rule_guardrails'": - 'get_resource names — the MCP resource catalog itself, pinned by ResourceName in resource-catalog.ts.', }; /** Every inline z.enum([...]) literal across the tool sources, with its file. */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts index 6fce3234..9903404b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts @@ -81,7 +81,7 @@ export function registerPrompts(server: McpServer, ga: boolean): void { 'first — status, noise-filtered timeline, stats, rule analysis in one shot.\n' + `2. Call get_session_diagnostics(sessionId="${sessionId}"${tenantId ? `, tenantId="${tenantId}"` : ''}). ` + 'If available=true, DOWNLOAD the ZIP from downloadUrl using your local tools (no auth header — ' + - 'it is a short-lived signed ticket), unzip it locally, and read files per the returned zipMap: ' + + 'it is a short-lived signed ticket), unzip it locally, and read files in the priority order of the diag_zip_layout resource (get_resource(name="diag_zip_layout")): ' + 'start with AgentState/final-status.json and the agent log (grep [ERROR]/[WARN] first), then ' + 'journal/signal logs. AppWorkload*.log can be hundreds of MB → grep only, never read whole. ' + 'If available=false, note it and continue with backend data only.\n' + diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index cb9c76a6..87bbcb26 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -421,14 +421,21 @@ export const OPS_EVENTS_CATALOG = { eventTypes: OPS_EVENT_TYPES, } as const; -export type ResourceName = - | 'event_types' - | 'ops_event_types' - | 'device_properties' - | 'diag_zip_layout' - | 'rule_authoring_guide' - | 'rule_schemas' - | 'rule_guardrails'; +/** + * The MCP resource catalog — single source for the get_resource `name` enum and the + * ResourceName type (an MCP-local vocabulary, not a backend one). + */ +export const RESOURCE_NAMES = [ + 'event_types', + 'ops_event_types', + 'device_properties', + 'diag_zip_layout', + 'rule_authoring_guide', + 'rule_schemas', + 'rule_guardrails', +] as const; + +export type ResourceName = (typeof RESOURCE_NAMES)[number]; export function getResourceContent(name: ResourceName): unknown { switch (name) { diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index d51e7aef..06d353d4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server'; import { z } from 'zod'; import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback } from '../client.js'; import { withToolTelemetry } from '../telemetry.js'; -import { getResourceContent, assertKnownEventType } from '../resource-catalog.js'; +import { getResourceContent, assertKnownEventType, RESOURCE_NAMES } from '../resource-catalog.js'; import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription } from './shared.js'; import { toolError } from './error-handler.js'; import { shapeVerdictCalibration } from '../verdict-calibration-shape.js'; @@ -1853,6 +1853,16 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo // Tool: get_resource — returns the same content as the MCP `resources` would, // but via a regular tool call. Workaround for clients (e.g. Claude Code's HTTP-MCP // bridge in stateless mode) that don't expose `resources/list` reliably. + /** Top-level keys of an object-shaped resource — the units a caller can read one at a time. */ + const resourceSections = (content: unknown): string[] => + content !== null && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content as Record) : []; + /** Derived from the live catalogs at registration so the description can never drift from the content. */ + const describeResourceSections = (): string => + RESOURCE_NAMES + .map((name) => ({ name, sections: resourceSections(getResourceContent(name)) })) + .filter((r) => r.sections.length > 0) + .map((r) => `${r.name} → ${r.sections.join(', ')}`) + .join('; '); server.registerTool( 'get_resource', { @@ -1866,18 +1876,36 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo ' - "diag_zip_layout": expected file layout of an agent diagnostics ZIP (what get_session_diagnostics returns you for local analysis)\n' + ' - "rule_authoring_guide": complete guide for authoring gather/analyze rules (read FIRST when asked to create a rule; then validate_rule / test_analyze_rule)\n' + ' - "rule_schemas": the JSON Schemas for gather and analyze rules (the exact contract)\n' + - ' - "rule_guardrails": on-device collection allowlists a gather rule target must satisfy', + ' - "rule_guardrails": on-device collection allowlists a gather rule target must satisfy\n' + + 'The rule trio is large (tens of KB together). Pass "section" to read ONE top-level part — ' + + 'e.g. section="analyzeRules" of rule_authoring_guide when authoring an analyze rule, or ' + + 'section="gatherRuleSchema" of rule_schemas. Sections per resource: ' + describeResourceSections() + '.', inputSchema: { name: z - .enum(['event_types', 'ops_event_types', 'device_properties', 'diag_zip_layout', 'rule_authoring_guide', 'rule_schemas', 'rule_guardrails']) + .enum(RESOURCE_NAMES) .describe('Resource name'), + section: z.string().trim().min(1).max(64).optional() + .describe('Optional top-level key of the resource to return on its own (see the section list in the description). Omit for the whole resource.'), }, annotations: READ_ONLY_OPEN, }, async (args) => withToolTelemetry('get_resource', args, async () => { try { const data = getResourceContent(args.name); - return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); + if (!args.section) return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); + + const sections = resourceSections(data); + if (!sections.includes(args.section)) { + return toolError('get_resource', args, new Error( + sections.length === 0 + ? `Resource "${args.name}" has no sections — call it without "section".` + : `Resource "${args.name}" has no section "${args.section}". Available sections: ${sections.join(', ')}.`, + )); + } + return toolResultText( + { resource: args.name, section: args.section, content: (data as Record)[args.section] }, + MAX_RESULT_SIZE_CHARS.small, + ); } catch (error: unknown) { return toolError('get_resource', args, error); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts index c519a7a0..7d8ac034 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts @@ -239,14 +239,24 @@ type PageFetcher = (path: string) => Promise; const defaultPageFetcher: PageFetcher = async (path) => (await apiFetch(path)) as RawEventsPage; +/** + * Candidate event types walked at the same time. One type's pages are a sequential nextLink + * chain, but the types are independent index scans on the backend, so a small fan-out cuts the + * wall-clock of a multi-type query by up to this factor while the backend load per call stays + * bounded (each walk is one budgeted scan at a time). + */ +export const TYPE_WALK_CONCURRENCY = 3; + /** * Walks /api/raw/events once per candidate event type, following nextLink up to - * the given budget. Returns the merged events (each tagged with `_sessionId`), - * whether any walk was cut short (`truncated`), and whether at least one page - * was fetched (`anySucceeded` — drives the legacy-fallback decision). + * the given budget. Returns the merged events (each tagged with `_sessionId`) in + * candidate order regardless of which walk finished first, whether any walk was + * cut short (`truncated`), and whether at least one page was fetched + * (`anySucceeded` — drives the legacy-fallback decision). * - * A per-type page error preserves everything accumulated so far, flags - * `truncated`, and moves to the next type — it must NOT collapse the whole + * Up to `concurrency` types are walked at once; within a type the pages stay + * sequential. A per-type page error preserves everything accumulated so far, + * flags `truncated`, and moves to the next type — it must NOT collapse the whole * search back to the narrow legacy path once real pages have been returned. */ export async function fetchEventsViaIndex( @@ -256,15 +266,17 @@ export async function fetchEventsViaIndex( budget: FetchBudget, fetchPage: PageFetcher = defaultPageFetcher, now: () => number = Date.now, + concurrency: number = TYPE_WALK_CONCURRENCY, ): Promise<{ events: EventEntry[]; truncated: boolean; anySucceeded: boolean }> { - const events: EventEntry[] = []; + const perType: EventEntry[][] = candidates.map(() => []); let truncated = false; let anySucceeded = false; const deadline = now() + budget.wallClockMs; + let nextCandidate = 0; - for (const eventType of candidates) { - if (now() > deadline) { truncated = true; break; } - + const walkType = async (index: number): Promise => { + const eventType = candidates[index]; + const events = perType[index]; // First page from params; subsequent pages follow the backend's nextLink // verbatim (followNextLink enforces path-equality against basePath). let path = followNextLink(basePath, { eventType, tenantId, pageSize: RAW_EVENTS_PAGE_SIZE }, undefined); @@ -277,7 +289,7 @@ export async function fetchEventsViaIndex( } catch { // Preserve accumulated events, flag the gap, try the next type. truncated = true; - break; + return; } anySucceeded = true; for (const e of page.events ?? []) { @@ -287,16 +299,30 @@ export async function fetchEventsViaIndex( pages++; const link = page.nextLink; - if (!link) break; // this event type fully drained — not a truncation + if (!link) return; // this event type fully drained — not a truncation if (pages >= budget.maxPagesPerType || now() > deadline) { truncated = true; // stopped early — a real recall gap - break; + return; } path = followNextLink(basePath, {}, link); } - } + }; - return { events, truncated, anySucceeded }; + // Each worker takes the next untouched candidate; the deadline is checked before every walk + // starts, so once it passes the remaining candidates are skipped and flagged as a recall gap. + const worker = async (): Promise => { + for (;;) { + const index = nextCandidate++; + if (index >= candidates.length) return; + if (now() > deadline) { truncated = true; return; } + await walkType(index); + } + }; + + const workers = Math.max(1, Math.min(concurrency, candidates.length)); + await Promise.all(Array.from({ length: workers }, worker)); + + return { events: perType.flat(), truncated, anySucceeded }; } /** @@ -1201,16 +1227,21 @@ export function registerSearchTools( results = results.slice(0, topK); - const formatted = results.map((r) => ({ - id: r.id, - score: Math.round(r.score * 1000) / 1000, - type: r.metadata.type, - title: r.metadata.title ?? r.metadata.description ?? r.id, - content: r.text, - metadata: r.metadata, - // Surfaced by literal error-code match rather than (or in addition to) semantic similarity. - matchType: errorCodeHitIds.has(r.id) ? ('error-code' as const) : undefined, - })); + const formatted = results.map((r) => { + // `type` and `title` are lifted to the top level — the remaining metadata (severity, + // category, tags, action) is what a caller cannot get from the lifted fields or `content`. + const { type: _type, title: _title, ...metadata } = r.metadata; + return { + id: r.id, + score: Math.round(r.score * 1000) / 1000, + type: r.metadata.type, + title: r.metadata.title ?? r.metadata.description ?? r.id, + content: r.text, + metadata, + // Surfaced by literal error-code match rather than (or in addition to) semantic similarity. + matchType: errorCodeHitIds.has(r.id) ? ('error-code' as const) : undefined, + }; + }); if (formatted.length === 0) logSearchZeroHit('search_knowledge', query); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index d1fb36df..65f858c3 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -7,7 +7,6 @@ import { toolError } from './error-handler.js'; import { assertKnownEventType, assertKnownDevicePropertyKeys } from '../resource-catalog.js'; import { interpolateAnalysisResults } from '../interpolate-rule-template.js'; import { API_BASE_URL } from '../config.js'; -import { DIAG_ZIP_MAP } from '../diag-zip-map.js'; import type { DiagnosticsDownloadTicketResponse, EnrollmentEvent, @@ -311,9 +310,11 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: title: 'Get Session Diagnostics (Agent Log ZIP)', description: 'Returns a short-lived, ready-to-use download URL for the agent DIAGNOSTICS ZIP of a session ' + - '(agent logs, DecisionCore journal/signals, IME logs, final-status.json), plus a "zipMap" ' + - 'describing the archive layout. This is the highest-value source for root-causing why an ' + - 'enrollment went wrong: correlate the on-device agent log against the backend Events table.\n\n' + + '(agent logs, DecisionCore journal/signals, IME logs, final-status.json). The archive layout and ' + + 'the file priority order are the static "diag_zip_layout" resource — read it ONCE via ' + + 'get_resource(name="diag_zip_layout"); it is not repeated in this response. This is the ' + + 'highest-value source for root-causing why an enrollment went wrong: correlate the on-device ' + + 'agent log against the backend Events table.\n\n' + 'CLIENT REQUIREMENT: this needs a client that can download files and run local file/shell tools ' + '(e.g. Claude Code or another agentic client). A pure chat client (Claude Desktop, claude.ai web) ' + 'has no local filesystem and CANNOT unzip the binary archive — there this tool only yields a ' + @@ -321,8 +322,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: 'HOW TO USE: download the ZIP from "downloadUrl" — NO auth header needed, it is a short-lived ' + 'signed ticket (~10 min) — then unzip and analyze it LOCALLY. The backend never unzips or parses ' + 'it; you process it on your side and enrich with get_session_events / query_raw_events / ' + - 'search_knowledge. Read files in zipMap priority order; AppWorkload*.log can be hundreds of MB → ' + - 'grep, never read whole.\n\n' + + 'search_knowledge. Read files in the diag_zip_layout priority order; AppWorkload*.log can be ' + + 'hundreds of MB → grep, never read whole.\n\n' + 'If "available" is false there is no uploaded diagnostics package (upload mode may be Off or ' + 'OnFailure on a successful session) — proceed with backend telemetry only. ' + 'Tenant admins get their own tenant\'s diagnostics; ' + @@ -384,11 +385,14 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: instructions: 'Download the ZIP from downloadUrl with NO auth header (it carries a short-lived signed ' + 'ticket). Unzip it locally and analyze on your side — the backend does not parse it. ' + - 'Read files per zipMap.files priority; AppWorkload*.log can be huge → grep only. Then ' + - 'correlate the agent log timeline against backend events (get_session_events / ' + - 'query_raw_events) and look up rules/patterns via search_knowledge. The download URL ' + - 'expires at expiresAt — re-call this tool for a fresh one if needed.', - zipMap: DIAG_ZIP_MAP, + 'Read files in the priority order of the diag_zip_layout resource (zipLayoutResource); ' + + 'AppWorkload*.log can be huge → grep only. Then correlate the agent log timeline against ' + + 'backend events (get_session_events / query_raw_events) and look up rules/patterns via ' + + 'search_knowledge. The download URL expires at expiresAt — re-call this tool for a fresh ' + + 'one if needed.', + // The layout is static per deployment: one resource read per conversation instead of + // ~5k characters repeated on every ticket. + zipLayoutResource: 'get_resource(name="diag_zip_layout")', }, MAX_RESULT_SIZE_CHARS.small); } catch (error: unknown) { return toolError('get_session_diagnostics', args, error);