From 256498b96071fee8aed2705446b607935f8f6734 Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Thu, 23 Jul 2026 14:19:08 +0800 Subject: [PATCH 1/9] fix: make sample-catalog fetches resilient to transient failures Replaces the silent catch-all in the raw.githubusercontent fetch layer (which randomly dropped templates on rate-limit/5xx) with a retrying fetch: distinguishes 404 (genuinely missing) from transient 429/5xx/network errors, retries the latter with jittered exponential backoff honoring server Retry-After, and caps per-wait and total backoff. A transient failure that survives retries now aborts the run loudly instead of producing an incomplete catalog. --- .github/scripts/generate_sample_catalog.mjs | 179 ++++++++++++++++++-- 1 file changed, 168 insertions(+), 11 deletions(-) diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index 4a888be..60f0931 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -141,6 +141,155 @@ function warn(message) { console.warn(`WARN: ${message}`); } +/** + * HTTP error carrying the response status so callers can distinguish a + * genuine 404 (resource does not exist) from a transient failure + * (429 rate-limit / 5xx) that is worth retrying and must NOT be silently + * treated as "missing". + */ +class HttpError extends Error { + /** + * @param {number} status + * @param {string} url + */ + constructor(status, url) { + super(`HTTP ${status} for ${url}`); + this.name = 'HttpError'; + this.status = status; + } +} + +// Retry knobs for the raw.githubusercontent.com / GitHub API fetches. These +// endpoints rate-limit (429) and occasionally 5xx under the ~90-request +// serial scan, which previously surfaced as random "missing" templates when +// the error was swallowed. Retry transient failures with exponential backoff +// (plus jitter, and honoring a server `Retry-After`) before giving up. +// Overridable via env for local debugging. +const FETCH_MAX_ATTEMPTS = Number(process.env.FETCH_MAX_ATTEMPTS) || 4; +const FETCH_BASE_DELAY_MS = Number(process.env.FETCH_BASE_DELAY_MS) || 500; +// Cap a single wait so a large server `Retry-After` (GitHub can return tens of +// seconds) cannot stall one request indefinitely. +const FETCH_MAX_DELAY_MS = Number(process.env.FETCH_MAX_DELAY_MS) || 20_000; +// Cap the cumulative time spent sleeping across ALL requests. Without this, +// many requests each hitting the rate limit could sum to more than the job +// timeout; once exceeded, retries stop and the run fails fast (and loudly) +// rather than hanging. +const FETCH_TOTAL_BACKOFF_BUDGET_MS = Number(process.env.FETCH_TOTAL_BACKOFF_BUDGET_MS) || 120_000; + +// Running total of time spent in backoff sleeps, enforced against +// FETCH_TOTAL_BACKOFF_BUDGET_MS. +let totalBackoffMs = 0; + +/** + * @param {number} ms + * @returns {Promise} + */ +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * A failure is transient (worth retrying) when it is a network-level error + * (no HttpError status, e.g. ECONNRESET / socket timeout) or an HTTP 429 / + * 5xx. A 4xx other than 429 (notably 404) is permanent and not retried. + * @param {unknown} error + * @returns {boolean} + */ +function isTransientError(error) { + if (error instanceof HttpError) { + return error.status === 429 || error.status >= 500; + } + return true; +} + +/** + * Parse a `Retry-After` header (RFC 7231): either delay-seconds or an HTTP + * date. Returns the delay in milliseconds, or `undefined` when absent or + * unparseable. When a server tells us how long to wait, we honor it instead + * of our own exponential backoff — but still clamp it to FETCH_MAX_DELAY_MS. + * @param {Response} response + * @returns {number | undefined} + */ +function parseRetryAfterMs(response) { + const value = response.headers.get('retry-after'); + if (!value) { + return undefined; + } + const seconds = Number(value); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + const dateMs = Date.parse(value); + if (!Number.isNaN(dateMs)) { + return Math.max(0, dateMs - Date.now()); + } + return undefined; +} + +/** + * Compute the backoff for a given attempt: prefer the server's `Retry-After` + * when present, otherwise exponential backoff with full jitter + * (`random * base * 2^(n-1)`), which AWS recommends to avoid synchronized + * retry storms. Either way the result is clamped to FETCH_MAX_DELAY_MS. + * @param {number} attempt 1-based attempt number that just failed. + * @param {number | undefined} retryAfterMs + * @returns {number} + */ +function computeBackoffMs(attempt, retryAfterMs) { + if (retryAfterMs !== undefined) { + return Math.min(retryAfterMs, FETCH_MAX_DELAY_MS); + } + const ceiling = FETCH_BASE_DELAY_MS * 2 ** (attempt - 1); + const jittered = Math.random() * ceiling; + return Math.min(jittered, FETCH_MAX_DELAY_MS); +} + +/** + * Fetch a URL, retrying transient failures with jittered exponential backoff + * (honoring a server `Retry-After`). Throws the last error once attempts are + * exhausted OR the global backoff budget is spent, so the caller can decide + * whether a 404 is acceptable while a transient error becomes fatal. + * @param {string} url + * @param {Record} headers + * @returns {Promise} + */ +async function fetchWithRetry(url, headers) { + /** @type {unknown} */ + let lastError; + for (let attempt = 1; attempt <= FETCH_MAX_ATTEMPTS; attempt++) { + /** @type {number | undefined} */ + let retryAfterMs; + try { + const response = await fetch(url, { headers }); + if (!response.ok) { + if (response.status === 429 || response.status >= 500) { + retryAfterMs = parseRetryAfterMs(response); + } + throw new HttpError(response.status, url); + } + return response; + } catch (error) { + lastError = error; + if (!isTransientError(error) || attempt === FETCH_MAX_ATTEMPTS) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + const backoff = computeBackoffMs(attempt, retryAfterMs); + if (totalBackoffMs + backoff > FETCH_TOTAL_BACKOFF_BUDGET_MS) { + throw new Error( + `Exhausted total backoff budget (${FETCH_TOTAL_BACKOFF_BUDGET_MS}ms) while retrying ${url}; last error: ${message}` + ); + } + totalBackoffMs += backoff; + const source = retryAfterMs !== undefined ? 'server Retry-After' : 'jittered backoff'; + console.warn(`Transient fetch failure (attempt ${attempt}/${FETCH_MAX_ATTEMPTS}) for ${url}: ${message}; retrying in ${Math.round(backoff)}ms (${source}).`); + await delay(backoff); + } + } + // Unreachable — the loop either returns or throws — but satisfies the type checker. + throw lastError; +} + /** * @param {string} url * @returns {Promise} @@ -154,10 +303,7 @@ async function fetchJson(url) { if (GITHUB_TOKEN) { headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; } - const response = await fetch(url, { headers }); - if (!response.ok) { - throw new Error(`HTTP ${response.status} for ${url}`); - } + const response = await fetchWithRetry(url, headers); return response.json(); } @@ -171,10 +317,7 @@ async function fetchText(url) { if (GITHUB_TOKEN) { headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; } - const response = await fetch(url, { headers }); - if (!response.ok) { - throw new Error(`HTTP ${response.status} for ${url}`); - } + const response = await fetchWithRetry(url, headers); return response.text(); } @@ -403,6 +546,13 @@ function parseAzureYaml(content) { /** * Fetch and parse a sample directory's azure.yaml. + * + * Returns `null` ONLY when the file genuinely does not exist (HTTP 404) — a + * legitimate signal that the directory is not a template. A transient failure + * (429 / 5xx / network) that survives retries is re-thrown so the run fails + * loudly instead of silently dropping a real template (which is how the same + * upstream commit could yield 88 vs 89 templates across runs). + * * @param {string} samplePath * @param {string} ref * @returns {Promise<{ protocols: string[], requiresModel: boolean } | null>} @@ -412,8 +562,11 @@ async function fetchAzureYaml(samplePath, ref) { try { const content = await fetchText(rawUrl); return parseAzureYaml(content); - } catch { - return null; + } catch (error) { + if (error instanceof HttpError && error.status === 404) { + return null; + } + throw error; } } @@ -646,7 +799,11 @@ async function scanTemplates(commitSha) { for (const templatePath of templateDirs) { const azureInfo = await fetchAzureYaml(templatePath, commitSha); if (!azureInfo) { - warn(`Could not fetch or parse azure.yaml for "${templatePath}"; skipping this template.`); + // null means a genuine 404 — the directory matched the scan + // prefix but has no azure.yaml, so it is not a template. + // (Transient fetch failures are re-thrown by fetchAzureYaml + // and abort the run instead of silently dropping a sample.) + warn(`No azure.yaml found for "${templatePath}" (HTTP 404); skipping this template.`); continue; } From 8adc4918c965d5ece53931b063b1679e207bf2c7 Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Thu, 23 Jul 2026 14:58:00 +0800 Subject: [PATCH 2/9] feat: add opt-in AI refinement of sample displayName and description Adds a 'refine_with_ai' workflow_dispatch input (default off). When off, behavior is unchanged: empty displayName is derived from the folder name and empty description is filled by the LLM, existing values untouched. When on, the LLM reviews both displayName and description of every template against its README and rewrites them only when they no longer fit, keeping values that already match. displayName now follows the Foundry Sample Finder convention (short human-friendly Title Case names). Shared LLM plumbing is extracted into callLLMForJson to avoid duplicating the request/parse logic. --- .github/scripts/generate_sample_catalog.mjs | 285 +++++++++++++++----- .github/workflows/sync-sample-catalog.yml | 6 + 2 files changed, 219 insertions(+), 72 deletions(-) diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index 4a888be..f710fae 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -439,6 +439,12 @@ const AZURE_OPENAI_MAX_COMPLETION_TOKENS = Number(process.env.AZURE_OPENAI_MAX_C // o-series use `low`. const AZURE_OPENAI_REASONING_EFFORT = process.env.AZURE_OPENAI_REASONING_EFFORT || ''; +// When true (workflow `refine_with_ai` input), the LLM reviews EVERY template's +// existing displayName/description and rewrites them only when they no longer +// fit the sample's README. When false (default), the LLM only fills BLANK +// fields and never touches values that are already present. +const AI_REFINE = /^(true|1|yes)$/i.test(process.env.AI_REFINE || ''); + /** * Fetch README.md content for a sample directory. * @param {string} samplePath @@ -455,46 +461,24 @@ async function fetchReadme(samplePath, ref) { } /** - * Call Azure OpenAI to generate a description from README content. We - * intentionally do NOT ask the LLM for displayName — the folder name (with - * numeric-prefix stripped, dashes turned into spaces, and Title Case) - * produces more consistent results across the catalog and is easier for PMs - * to predict at review time. + * Low-level Azure OpenAI chat call that expects a single JSON object back. + * Centralizes the request shape, error handling, and JSON extraction so the + * description-only and displayName+description prompts share one code path. + * Returns the parsed object, or `null` when the LLM is not configured or the + * call/parse fails (each failure mode is surfaced via `warn`). * - * @param {string} readmeContent - * @param {string} samplePath - * @returns {Promise<{ description: string } | null>} + * @param {string} systemPrompt + * @param {string} userPrompt + * @param {string} samplePath Used only for diagnostic messages. + * @returns {Promise | null>} */ -async function generateWithLLM(readmeContent, samplePath) { +async function callLLMForJson(systemPrompt, userPrompt, samplePath) { if (!AZURE_OPENAI_ENDPOINT || !AZURE_OPENAI_API_KEY) { return null; } const apiUrl = `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`; - const systemPrompt = `You generate one-sentence descriptions for a VS Code template picker. -The user has already selected language, framework, and protocol before seeing these items, so the description must NOT repeat those choices. - -Rules: -- One sentence, max 100 characters -- Plain text, no markdown -- Describe what the sample does, not how it is implemented -- Do NOT include language names, protocol names, framework names, or words like "Sample" / "Demo" - -Examples: - {"description": "Minimal agent that echoes a response from a Foundry model."} - {"description": "Conversational agent with multi-turn session history."} - {"description": "Agent with local function tools for hotel search."} - {"description": "Agent that discovers and invokes tools from a remote MCP server."} - {"description": "Agent that saves and retrieves notes using function calling."} - -Respond ONLY with a JSON object: {"description": "..."}`; - - const userPrompt = `Path: ${samplePath} - -README.md: -${readmeContent.substring(0, 2000)}`; - /** @type {{ messages: Array<{role: string, content: string}>, max_completion_tokens: number, reasoning_effort?: string }} */ const body = { messages: [ @@ -503,7 +487,7 @@ ${readmeContent.substring(0, 2000)}`; ], // `max_completion_tokens` (not the legacy `max_tokens`) so newer models // accept the request. Kept generous because reasoning models spend part - // of the budget on hidden reasoning tokens before emitting the sentence. + // of the budget on hidden reasoning tokens before emitting the answer. // `temperature` is intentionally omitted: several newer models only // support the default value and 400 on anything else. max_completion_tokens: AZURE_OPENAI_MAX_COMPLETION_TOKENS, @@ -532,7 +516,7 @@ ${readmeContent.substring(0, 2000)}`; } catch { // ignore body read failures } - warn(`LLM API returned ${response.status} for ${samplePath}; description will be left empty.${detail ? ` Response: ${detail}` : ''}`); + warn(`LLM API returned ${response.status} for ${samplePath}.${detail ? ` Response: ${detail}` : ''}`); return null; } @@ -543,31 +527,131 @@ ${readmeContent.substring(0, 2000)}`; // A successful (200) call with empty content is almost always a // reasoning model exhausting `max_completion_tokens` on hidden // reasoning (finish_reason: "length"). Surface it instead of - // silently leaving the description empty, and hint at the knobs. + // silently returning nothing, and hint at the knobs. const finishReason = choice?.finish_reason ?? 'unknown'; const reasoningTokens = data.usage?.completion_tokens_details?.reasoning_tokens; const usageHint = reasoningTokens !== undefined ? ` (reasoning_tokens=${reasoningTokens})` : ''; - warn(`LLM returned empty content for ${samplePath} (finish_reason=${finishReason}${usageHint}); description left empty. If finish_reason is "length", raise AZURE_OPENAI_MAX_COMPLETION_TOKENS or set AZURE_OPENAI_REASONING_EFFORT.`); + warn(`LLM returned empty content for ${samplePath} (finish_reason=${finishReason}${usageHint}). If finish_reason is "length", raise AZURE_OPENAI_MAX_COMPLETION_TOKENS or set AZURE_OPENAI_REASONING_EFFORT.`); return null; } - // Parse JSON response — strip markdown code fences if present + // Parse JSON response — strip markdown code fences if present. const jsonStr = content.replace(/^```json\s*/, '').replace(/\s*```$/, ''); const parsed = JSON.parse(jsonStr); - - const description = typeof parsed.description === 'string' ? parsed.description.trim() : ''; - if (!description) { - warn(`LLM response for ${samplePath} had no usable "description" field; description left empty.`); + if (!parsed || typeof parsed !== 'object') { + warn(`LLM response for ${samplePath} was not a JSON object.`); return null; } - - return { description }; + return /** @type {Record} */ (parsed); } catch (/** @type {any} */ err) { warn(`LLM call failed for ${samplePath}: ${err.message}`); return null; } } +// Shared guidance describing what a good picker description looks like, reused +// by both the description-only prompt and the combined refine prompt so the +// two paths stay consistent. +const DESCRIPTION_GUIDANCE = `A good description: +- Is one sentence, max 100 characters, plain text (no markdown) +- Describes what the sample does, not how it is implemented +- Does NOT include language, protocol, or framework names, or words like "Sample" / "Demo" +Examples: + "Minimal agent that echoes a response from a Foundry model." + "Conversational agent with multi-turn session history." + "Agent with local function tools for hotel search." + "Agent that discovers and invokes tools from a remote MCP server."`; + +/** + * Generate a one-sentence description from README content (used when only + * BLANK descriptions are being filled — the default, non-refine path). + * displayName is intentionally NOT requested here; when not refining it is + * derived deterministically from the folder name. + * + * @param {string} readmeContent + * @param {string} samplePath + * @returns {Promise<{ description: string } | null>} + */ +async function generateWithLLM(readmeContent, samplePath) { + const systemPrompt = `You generate one-sentence descriptions for a VS Code template picker. +The user has already selected language, framework, and protocol before seeing these items, so the description must NOT repeat those choices. + +${DESCRIPTION_GUIDANCE} + +Respond ONLY with a JSON object: {"description": "..."}`; + + const userPrompt = `Path: ${samplePath} + +README.md: +${readmeContent.substring(0, 2000)}`; + + const parsed = await callLLMForJson(systemPrompt, userPrompt, samplePath); + if (!parsed) { + return null; + } + const description = typeof parsed.description === 'string' ? parsed.description.trim() : ''; + if (!description) { + warn(`LLM response for ${samplePath} had no usable "description" field; description left empty.`); + return null; + } + return { description }; +} + +/** + * Review and (only when needed) rewrite a template's displayName AND + * description against its README (used by the opt-in `refine_with_ai` path). + * + * The current displayName and description are passed to the model so it can + * KEEP them verbatim when they already fit the sample's scenario — refinement + * is not a forced rewrite. displayName follows the Foundry Sample Finder + * convention: a short, human-friendly Title-Case name for the scenario + * (e.g. "Basic Agent", "Foundry Toolbox", "Azure Search RAG") rather than the + * raw folder name. + * + * @param {string} readmeContent + * @param {string} samplePath + * @param {string} currentDisplayName + * @param {string} currentDescription + * @returns {Promise<{ displayName: string, description: string } | null>} + */ +async function refineDisplayFieldsWithLLM(readmeContent, samplePath, currentDisplayName, currentDescription) { + const systemPrompt = `You curate the displayName and description of a hosted-agent sample shown in a VS Code template picker. +The user has already selected language, framework, and protocol before seeing these items, so neither field should repeat those choices. + +You are given the CURRENT displayName and description. If they already fit the sample's README scenario, KEEP them exactly as-is. Only rewrite a field when it is empty, inaccurate, or unclear. + +displayName rules: +- A short, human-friendly Title Case name for the scenario (2-4 words) +- Name what the sample demonstrates, not the folder (e.g. "Basic Agent", "Foundry Toolbox", "Azure Search RAG", "Human-in-the-Loop") +- No leading numbers, no raw folder tokens, no words like "Sample" / "Demo" +- Keep known acronyms uppercased (MCP, RAG, SDK, API, UI) + +description rules: +${DESCRIPTION_GUIDANCE} + +Respond ONLY with a JSON object: {"displayName": "...", "description": "..."}`; + + const userPrompt = `Path: ${samplePath} +Current displayName: ${currentDisplayName || '(empty)'} +Current description: ${currentDescription || '(empty)'} + +README.md: +${readmeContent.substring(0, 2000)}`; + + const parsed = await callLLMForJson(systemPrompt, userPrompt, samplePath); + if (!parsed) { + return null; + } + const displayName = typeof parsed.displayName === 'string' ? parsed.displayName.trim() : ''; + const description = typeof parsed.description === 'string' ? parsed.description.trim() : ''; + if (!displayName && !description) { + warn(`LLM refine response for ${samplePath} had no usable "displayName"/"description" fields; leaving values unchanged.`); + return null; + } + return { displayName, description }; +} + + /** * Brand and acronym casing overrides applied during displayName derivation. * Keys are lower-case tokens; values are the canonical user-facing rendering. @@ -814,22 +898,50 @@ function applyOverrides(templates, overrides) { } /** - * Auto-fill empty displayName and description fields. + * Fill and (optionally) refine displayName and description fields. * - * displayName is ALWAYS derived from the template's directory name when empty - * — the LLM is not consulted, because folder-name derivation is deterministic - * and PM-predictable. Existing PM-curated displayName values are preserved by - * the prior `mergeExistingDisplayFields` step, so this only affects newly - * scanned templates. + * Default path (AI_REFINE off): empty displayName is derived deterministically + * from the folder name; empty description is filled by the LLM when configured. + * Values that already exist (PM-curated or preserved from the prior catalog) + * are left untouched. * - * description is filled by the LLM (when configured) using the sample's - * README as context. Without LLM credentials the description stays empty and - * gets surfaced as an anomaly in the step summary. + * Refine path (AI_REFINE on, opt-in via the `refine_with_ai` workflow input): + * the LLM reviews BOTH fields of every template against its README and rewrites + * them only when they no longer fit — existing values that already match the + * scenario are kept verbatim. Without LLM credentials this path degrades to the + * default behavior. * * @param {Array<{displayName: string, description: string, path: string}>} templates * @param {string} commitSha */ async function autoFillDisplayFields(templates, commitSha) { + const hasLLM = Boolean(AZURE_OPENAI_ENDPOINT && AZURE_OPENAI_API_KEY); + + if (AI_REFINE && hasLLM) { + await refineAllWithLLM(templates, commitSha); + } else { + if (AI_REFINE && !hasLLM) { + warn('refine_with_ai was requested but no Azure OpenAI credentials are configured; falling back to filling blanks only.'); + } + await fillBlankDisplayFields(templates, commitSha, hasLLM); + } + + for (const template of templates) { + if (!template.description) { + warn(`Template "${template.path}" is missing: description. PM should fill it before merge.`); + } + } +} + +/** + * Default path: derive empty displayName from the folder name and fill empty + * descriptions via the LLM. Never touches values that already exist. + * + * @param {Array<{displayName: string, description: string, path: string}>} templates + * @param {string} commitSha + * @param {boolean} hasLLM + */ +async function fillBlankDisplayFields(templates, commitSha, hasLLM) { // Fill displayName first — deterministic, no API calls. for (const template of templates) { if (!template.displayName) { @@ -840,34 +952,63 @@ async function autoFillDisplayFields(templates, commitSha) { const needsDescription = templates.filter((t) => !t.description); if (needsDescription.length === 0) { console.log('All templates already have a description.'); - } else { - const hasLLM = Boolean(AZURE_OPENAI_ENDPOINT && AZURE_OPENAI_API_KEY); - if (hasLLM) { - console.log(`Generating descriptions for ${needsDescription.length} templates using LLM...`); - for (const template of needsDescription) { - const readme = await fetchReadme(template.path, commitSha); - if (!readme) { - continue; - } - const result = await generateWithLLM(readme, template.path); - if (result?.description) { - template.description = result.description; - } - } - } else { - console.log(`${needsDescription.length} templates need a description but no LLM is configured; leaving empty (will be flagged as anomalies).`); + return; + } + if (!hasLLM) { + console.log(`${needsDescription.length} templates need a description but no LLM is configured; leaving empty (will be flagged as anomalies).`); + return; + } + + console.log(`Generating descriptions for ${needsDescription.length} templates using LLM...`); + for (const template of needsDescription) { + const readme = await fetchReadme(template.path, commitSha); + if (!readme) { + continue; + } + const result = await generateWithLLM(readme, template.path); + if (result?.description) { + template.description = result.description; } } +} - // displayName always gets filled by the folder-name fallback above, so - // anomaly detection here is description-only. +/** + * Refine path: ask the LLM to review displayName + description for every + * template against its README, keeping values that already fit and rewriting + * only those that do not. Templates whose README cannot be fetched fall back + * to folder-name derivation for an empty displayName so nothing is left blank. + * + * @param {Array<{displayName: string, description: string, path: string}>} templates + * @param {string} commitSha + */ +async function refineAllWithLLM(templates, commitSha) { + console.log(`Refining displayName/description for ${templates.length} templates using LLM...`); for (const template of templates) { - if (!template.description) { - warn(`Template "${template.path}" is missing: description. PM should fill it before merge.`); + const readme = await fetchReadme(template.path, commitSha); + if (!readme) { + if (!template.displayName) { + template.displayName = displayNameFromPath(template.path); + } + continue; + } + const result = await refineDisplayFieldsWithLLM( + readme, + template.path, + template.displayName, + template.description + ); + if (result?.displayName) { + template.displayName = result.displayName; + } else if (!template.displayName) { + template.displayName = displayNameFromPath(template.path); + } + if (result?.description) { + template.description = result.description; } } } + /** * Reorder templates so the curated pins come first, in the declared * PINNED_TEMPLATE_PATHS order, followed by every other template in its existing diff --git a/.github/workflows/sync-sample-catalog.yml b/.github/workflows/sync-sample-catalog.yml index 0055a41..99c29b2 100644 --- a/.github/workflows/sync-sample-catalog.yml +++ b/.github/workflows/sync-sample-catalog.yml @@ -8,6 +8,11 @@ on: required: false type: string default: "" + refine_with_ai: + description: "Let AI review and refine existing displayName/description (not just fill empty ones). Leave off to only fill blanks." + required: false + type: boolean + default: false # Least-privilege for the default GITHUB_TOKEN: only what `actions/checkout` # needs. All writes (push branch + create PR) are performed via the GitHub @@ -80,6 +85,7 @@ jobs: AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} AZURE_OPENAI_DEPLOYMENT: ${{ secrets.AZURE_OPENAI_DEPLOYMENT }} + AI_REFINE: ${{ inputs.refine_with_ai }} run: node .github/scripts/generate_sample_catalog.mjs "${{ steps.resolve-sha.outputs.sha }}" - name: Detect catalog changes From 76f6623520da55c5b3c990d01fd74a329aa62747 Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Thu, 23 Jul 2026 14:59:54 +0800 Subject: [PATCH 3/9] style: drop stray blank line between refine helpers --- .github/scripts/generate_sample_catalog.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index f710fae..9353018 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -651,7 +651,6 @@ ${readmeContent.substring(0, 2000)}`; return { displayName, description }; } - /** * Brand and acronym casing overrides applied during displayName derivation. * Keys are lower-case tokens; values are the canonical user-facing rendering. From 4d0ee24e98015dc6dbedd23f70396464323eecb9 Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Thu, 23 Jul 2026 15:13:19 +0800 Subject: [PATCH 4/9] fix: retry Azure OpenAI 429/5xx with backoff in refine path The refine path fires one LLM request per template (~90 in a full run); a burst can trip the rate limiter at the sliding-window edge even with ample quota, which previously surfaced as many 429s and dropped fields. Wrap callLLMForJson's request in a retry loop that retries 429/5xx/network errors with jittered exponential backoff, honoring a server Retry-After (capped). Non-retryable 4xx and empty/invalid responses behave as before. --- .github/scripts/generate_sample_catalog.mjs | 151 ++++++++++++++------ 1 file changed, 106 insertions(+), 45 deletions(-) diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index 9353018..78bd5b1 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -445,6 +445,44 @@ const AZURE_OPENAI_REASONING_EFFORT = process.env.AZURE_OPENAI_REASONING_EFFORT // fields and never touches values that are already present. const AI_REFINE = /^(true|1|yes)$/i.test(process.env.AI_REFINE || ''); +// Retry knobs for the Azure OpenAI calls. The refine path fires one request +// per template (~90 in a full run); even with ample TPM/RPM quota a burst can +// momentarily trip the rate limiter (HTTP 429) at the sliding-window edge. +// Retry those (and transient 5xx / network errors) with jittered exponential +// backoff, honoring a server `Retry-After`, so an occasional throttle self-heals +// instead of dropping the field. Overridable via env for local debugging. +const LLM_MAX_ATTEMPTS = Number(process.env.LLM_MAX_ATTEMPTS) || 5; +const LLM_BASE_DELAY_MS = Number(process.env.LLM_BASE_DELAY_MS) || 1000; +const LLM_MAX_DELAY_MS = Number(process.env.LLM_MAX_DELAY_MS) || 30_000; + +/** + * @param {number} ms + * @returns {Promise} + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Parse a `Retry-After` header (RFC 7231): delay-seconds or an HTTP date. + * Returns milliseconds, or `undefined` when absent/unparseable. + * @param {Response} response + * @returns {number | undefined} + */ +function retryAfterMsFromResponse(response) { + const value = response.headers.get('retry-after'); + if (!value) { + return undefined; + } + const seconds = Number(value); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + const dateMs = Date.parse(value); + return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - Date.now()); +} + + /** * Fetch README.md content for a sample directory. * @param {string} samplePath @@ -496,57 +534,80 @@ async function callLLMForJson(systemPrompt, userPrompt, samplePath) { body.reasoning_effort = AZURE_OPENAI_REASONING_EFFORT; } - try { - const response = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-key': AZURE_OPENAI_API_KEY, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - // Include a truncated response body so the exact reason (e.g. an - // unsupported param, a missing deployment, or a wrong endpoint) is - // visible in the CI log instead of a bare status code. - let detail = ''; - try { - detail = (await response.text()).replace(/\s+/g, ' ').trim().slice(0, 400); - } catch { - // ignore body read failures + for (let attempt = 1; attempt <= LLM_MAX_ATTEMPTS; attempt++) { + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-key': AZURE_OPENAI_API_KEY, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + // Retry rate-limit (429) and transient 5xx, honoring a server + // `Retry-After`; give up on other 4xx (bad request, auth, etc.). + const retryable = response.status === 429 || response.status >= 500; + if (retryable && attempt < LLM_MAX_ATTEMPTS) { + const retryAfter = retryAfterMsFromResponse(response); + const backoff = retryAfter !== undefined + ? Math.min(retryAfter, LLM_MAX_DELAY_MS) + : Math.min(Math.random() * LLM_BASE_DELAY_MS * 2 ** (attempt - 1), LLM_MAX_DELAY_MS); + const source = retryAfter !== undefined ? 'server Retry-After' : 'jittered backoff'; + console.warn(`LLM API returned ${response.status} for ${samplePath} (attempt ${attempt}/${LLM_MAX_ATTEMPTS}); retrying in ${Math.round(backoff)}ms (${source}).`); + await sleep(backoff); + continue; + } + // Include a truncated response body so the exact reason (e.g. an + // unsupported param, a missing deployment, or a wrong endpoint) is + // visible in the CI log instead of a bare status code. + let detail = ''; + try { + detail = (await response.text()).replace(/\s+/g, ' ').trim().slice(0, 400); + } catch { + // ignore body read failures + } + warn(`LLM API returned ${response.status} for ${samplePath}.${detail ? ` Response: ${detail}` : ''}`); + return null; } - warn(`LLM API returned ${response.status} for ${samplePath}.${detail ? ` Response: ${detail}` : ''}`); - return null; - } - const data = await response.json(); - const choice = data.choices?.[0]; - const content = choice?.message?.content?.trim(); - if (!content) { - // A successful (200) call with empty content is almost always a - // reasoning model exhausting `max_completion_tokens` on hidden - // reasoning (finish_reason: "length"). Surface it instead of - // silently returning nothing, and hint at the knobs. - const finishReason = choice?.finish_reason ?? 'unknown'; - const reasoningTokens = data.usage?.completion_tokens_details?.reasoning_tokens; - const usageHint = reasoningTokens !== undefined ? ` (reasoning_tokens=${reasoningTokens})` : ''; - warn(`LLM returned empty content for ${samplePath} (finish_reason=${finishReason}${usageHint}). If finish_reason is "length", raise AZURE_OPENAI_MAX_COMPLETION_TOKENS or set AZURE_OPENAI_REASONING_EFFORT.`); - return null; - } + const data = await response.json(); + const choice = data.choices?.[0]; + const content = choice?.message?.content?.trim(); + if (!content) { + // A successful (200) call with empty content is almost always a + // reasoning model exhausting `max_completion_tokens` on hidden + // reasoning (finish_reason: "length"). Surface it instead of + // silently returning nothing, and hint at the knobs. + const finishReason = choice?.finish_reason ?? 'unknown'; + const reasoningTokens = data.usage?.completion_tokens_details?.reasoning_tokens; + const usageHint = reasoningTokens !== undefined ? ` (reasoning_tokens=${reasoningTokens})` : ''; + warn(`LLM returned empty content for ${samplePath} (finish_reason=${finishReason}${usageHint}). If finish_reason is "length", raise AZURE_OPENAI_MAX_COMPLETION_TOKENS or set AZURE_OPENAI_REASONING_EFFORT.`); + return null; + } - // Parse JSON response — strip markdown code fences if present. - const jsonStr = content.replace(/^```json\s*/, '').replace(/\s*```$/, ''); - const parsed = JSON.parse(jsonStr); - if (!parsed || typeof parsed !== 'object') { - warn(`LLM response for ${samplePath} was not a JSON object.`); + // Parse JSON response — strip markdown code fences if present. + const jsonStr = content.replace(/^```json\s*/, '').replace(/\s*```$/, ''); + const parsed = JSON.parse(jsonStr); + if (!parsed || typeof parsed !== 'object') { + warn(`LLM response for ${samplePath} was not a JSON object.`); + return null; + } + return /** @type {Record} */ (parsed); + } catch (/** @type {any} */ err) { + // Network-level errors (ECONNRESET, socket timeout) are transient. + if (attempt < LLM_MAX_ATTEMPTS) { + const backoff = Math.min(Math.random() * LLM_BASE_DELAY_MS * 2 ** (attempt - 1), LLM_MAX_DELAY_MS); + console.warn(`LLM call failed for ${samplePath} (attempt ${attempt}/${LLM_MAX_ATTEMPTS}): ${err.message}; retrying in ${Math.round(backoff)}ms.`); + await sleep(backoff); + continue; + } + warn(`LLM call failed for ${samplePath}: ${err.message}`); return null; } - return /** @type {Record} */ (parsed); - } catch (/** @type {any} */ err) { - warn(`LLM call failed for ${samplePath}: ${err.message}`); - return null; } + return null; } // Shared guidance describing what a good picker description looks like, reused From c6be5152b7a3e43bf35ac2f567cc674b34e62b77 Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Thu, 23 Jul 2026 15:43:43 +0800 Subject: [PATCH 5/9] feat: add ignore_existing_catalog input for a from-scratch refine The first AI refine should regenerate every displayName/description without being anchored by the previous catalog's values (the keep-if-fits logic would otherwise preserve them). Adds an opt-in ignore_existing_catalog workflow input (default false) that skips mergeExistingDisplayFields via the IGNORE_EXISTING env, so all fields start empty and are fully regenerated. Normal runs are unchanged and still preserve PM-curated values. --- .github/scripts/generate_sample_catalog.mjs | 18 ++++++++++++++++-- .github/workflows/sync-sample-catalog.yml | 6 ++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index 78bd5b1..3e65301 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -445,6 +445,14 @@ const AZURE_OPENAI_REASONING_EFFORT = process.env.AZURE_OPENAI_REASONING_EFFORT // fields and never touches values that are already present. const AI_REFINE = /^(true|1|yes)$/i.test(process.env.AI_REFINE || ''); +// When true (workflow `ignore_existing_catalog` input), the previous catalog's +// displayName/description values are NOT carried over. Every field starts empty, +// so a fresh run — typically the first AI refine — regenerates all values instead +// of being anchored by the "keep it if it already fits" logic. Off by default, +// so normal runs keep preserving PM-curated values. +const IGNORE_EXISTING = /^(true|1|yes)$/i.test(process.env.IGNORE_EXISTING || ''); + + // Retry knobs for the Azure OpenAI calls. The refine path fires one request // per template (~90 in a full run); even with ample TPM/RPM quota a burst can // momentarily trip the rate limiter (HTTP 429) at the sliding-window edge. @@ -1108,8 +1116,14 @@ async function main() { const templates = await scanTemplates(commitSha); console.log(`Found ${templates.length} templates`); - // Step 1: Preserve existing PM-curated displayName/description values. - mergeExistingDisplayFields(templates); + // Step 1: Preserve existing PM-curated displayName/description values, + // unless a fresh run was requested (ignore_existing_catalog) — then every + // field starts empty so nothing anchors the regeneration. + if (IGNORE_EXISTING) { + console.log('IGNORE_EXISTING is set; not carrying over displayName/description from the previous catalog.'); + } else { + mergeExistingDisplayFields(templates); + } // Step 2: Apply source-controlled per-path overrides (structural fields like // `framework` that the upstream tree layout cannot express on its own). diff --git a/.github/workflows/sync-sample-catalog.yml b/.github/workflows/sync-sample-catalog.yml index 99c29b2..687a53a 100644 --- a/.github/workflows/sync-sample-catalog.yml +++ b/.github/workflows/sync-sample-catalog.yml @@ -13,6 +13,11 @@ on: required: false type: boolean default: false + ignore_existing_catalog: + description: "Ignore the previous catalog's displayName/description (regenerate from scratch). Use for the first AI refine so existing values don't anchor the result." + required: false + type: boolean + default: false # Least-privilege for the default GITHUB_TOKEN: only what `actions/checkout` # needs. All writes (push branch + create PR) are performed via the GitHub @@ -86,6 +91,7 @@ jobs: AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} AZURE_OPENAI_DEPLOYMENT: ${{ secrets.AZURE_OPENAI_DEPLOYMENT }} AI_REFINE: ${{ inputs.refine_with_ai }} + IGNORE_EXISTING: ${{ inputs.ignore_existing_catalog }} run: node .github/scripts/generate_sample_catalog.mjs "${{ steps.resolve-sha.outputs.sha }}" - name: Detect catalog changes From 92d28ec46785f048399246bff009d6fb93f813c9 Mon Sep 17 00:00:00 2001 From: Yimin-Jin <139844715+Yimin-Jin@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:56:05 +0000 Subject: [PATCH 6/9] ci: sync sample catalog from foundry-samples (template/dev) --- samples/hosted-agent/sample-catalog.json | 204 +++++++++++------------ 1 file changed, 93 insertions(+), 111 deletions(-) diff --git a/samples/hosted-agent/sample-catalog.json b/samples/hosted-agent/sample-catalog.json index aa31af1..5e5da1d 100644 --- a/samples/hosted-agent/sample-catalog.json +++ b/samples/hosted-agent/sample-catalog.json @@ -1,7 +1,7 @@ { - "commitSha": "9c4e42a1eedcec32c1582bebeb83a381a23efbe1", + "commitSha": "84cb8c9a15d7a8bd40e1d80556e00557ea917049", "repo": "https://github.com/microsoft-foundry/foundry-samples/", - "generatedAt": "2026-07-07T06:19:12Z", + "generatedAt": "2026-07-23T07:56:05Z", "dimensions": { "language": { "title": "Select a Language", @@ -63,6 +63,96 @@ "placeholder": "Choose a template for your agent" }, "templates": [ + { + "language": "python", + "framework": "agent-framework", + "protocol": "responses", + "displayName": "Basic", + "description": "Agent that supports multi-turn conversations with response tracking.", + "path": "samples/python/hosted-agents/agent-framework/responses/01-basic", + "requiresModel": true + }, + { + "language": "python", + "framework": "copilot-sdk", + "protocol": "invocations", + "displayName": "GitHub Copilot", + "description": "Agent streams Copilot session events with support for multi-turn conversations.", + "path": "samples/python/hosted-agents/bring-your-own/invocations/github-copilot", + "requiresModel": false + }, + { + "language": "python", + "framework": "langgraph", + "protocol": "responses", + "displayName": "Langgraph Chat", + "description": "Chat agent with tools for current time and math calculations.", + "path": "samples/python/hosted-agents/langgraph/responses/01-langgraph-chat", + "requiresModel": true + }, + { + "language": "python", + "framework": "langgraph", + "protocol": "responses", + "displayName": "Langgraph Toolbox", + "description": "Agent that uses a toolbox for web search and GitHub Copilot MCP tools with managed credentials.", + "path": "samples/python/hosted-agents/langgraph/responses/02-langgraph-toolbox", + "requiresModel": true + }, + { + "language": "python", + "framework": "agent-framework", + "protocol": "responses", + "displayName": "Foundry Toolbox", + "description": "Agent that discovers and invokes centrally managed tools from a Foundry Toolbox.", + "path": "samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox", + "requiresModel": true + }, + { + "language": "python", + "framework": "agent-framework", + "protocol": "responses", + "displayName": "Workflows", + "description": "Processes requests through slogan writing, legal review, and formatting agents in sequence.", + "path": "samples/python/hosted-agents/agent-framework/responses/05-workflows", + "requiresModel": true + }, + { + "language": "python", + "framework": "langgraph", + "protocol": "responses", + "displayName": "Workflows", + "description": "Processes a request through slogan writing, legal review, and formatting steps.", + "path": "samples/python/hosted-agents/langgraph/responses/05-workflows", + "requiresModel": true + }, + { + "language": "python", + "framework": "agent-framework", + "protocol": "responses", + "displayName": "Azure Search RAG", + "description": "Answers questions using product documentation and cites sources from a search index.", + "path": "samples/python/hosted-agents/agent-framework/responses/11-azure-search-rag", + "requiresModel": true + }, + { + "language": "python", + "framework": "bring-your-own", + "protocol": "invocations", + "displayName": "Claude Agent SDK", + "description": "Agent that streams responses from a Claude model for plain text input.", + "path": "samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk", + "requiresModel": false + }, + { + "language": "python", + "framework": "bring-your-own", + "protocol": "responses", + "displayName": "OpenAI Agents SDK", + "description": "Minimal agent that streams text responses from a Foundry model.", + "path": "samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk", + "requiresModel": true + }, { "language": "csharp", "framework": "agent-framework", @@ -293,7 +383,7 @@ "framework": "bring-your-own", "protocol": "responses", "displayName": "Browser Automation", - "description": "", + "description": "Provisions and controls a remote browser session, runs browsing tasks, and streams a live view.", "path": "samples/csharp/hosted-agents/bring-your-own/responses/browser-automation", "requiresModel": true }, @@ -342,15 +432,6 @@ "path": "samples/python/hosted-agents/agent-framework/invocations/01-basic", "requiresModel": true }, - { - "language": "python", - "framework": "agent-framework", - "protocol": "responses", - "displayName": "Basic", - "description": "Agent that supports multi-turn conversations with response tracking.", - "path": "samples/python/hosted-agents/agent-framework/responses/01-basic", - "requiresModel": true - }, { "language": "python", "framework": "agent-framework", @@ -360,33 +441,6 @@ "path": "samples/python/hosted-agents/agent-framework/responses/02-tools", "requiresModel": true }, - { - "language": "python", - "framework": "agent-framework", - "protocol": "responses", - "displayName": "MCP", - "description": "Agent that dynamically discovers and invokes tools from a remote GitHub Copilot MCP server.", - "path": "samples/python/hosted-agents/agent-framework/responses/03-mcp", - "requiresModel": true - }, - { - "language": "python", - "framework": "agent-framework", - "protocol": "responses", - "displayName": "Foundry Toolbox", - "description": "Agent that discovers and invokes centrally managed tools from a Foundry Toolbox.", - "path": "samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox", - "requiresModel": true - }, - { - "language": "python", - "framework": "agent-framework", - "protocol": "responses", - "displayName": "Workflows", - "description": "Processes requests through slogan writing, legal review, and formatting agents in sequence.", - "path": "samples/python/hosted-agents/agent-framework/responses/05-workflows", - "requiresModel": true - }, { "language": "python", "framework": "agent-framework", @@ -441,15 +495,6 @@ "path": "samples/python/hosted-agents/agent-framework/responses/10-downstream-azure", "requiresModel": true }, - { - "language": "python", - "framework": "agent-framework", - "protocol": "responses", - "displayName": "Azure Search RAG", - "description": "Answers questions using product documentation and cites sources from a search index.", - "path": "samples/python/hosted-agents/agent-framework/responses/11-azure-search-rag", - "requiresModel": true - }, { "language": "python", "framework": "agent-framework", @@ -513,15 +558,6 @@ "path": "samples/python/hosted-agents/bring-your-own/invocations/ag-ui", "requiresModel": true }, - { - "language": "python", - "framework": "bring-your-own", - "protocol": "invocations", - "displayName": "Claude Agent SDK", - "description": "Agent that streams responses from a Claude model for plain text input.", - "path": "samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk", - "requiresModel": false - }, { "language": "python", "framework": "bring-your-own", @@ -540,15 +576,6 @@ "path": "samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger", "requiresModel": true }, - { - "language": "python", - "framework": "copilot-sdk", - "protocol": "invocations", - "displayName": "GitHub Copilot", - "description": "Agent streams Copilot session events with support for multi-turn conversations.", - "path": "samples/python/hosted-agents/bring-your-own/invocations/github-copilot", - "requiresModel": false - }, { "language": "python", "framework": "bring-your-own", @@ -720,15 +747,6 @@ "path": "samples/python/hosted-agents/bring-your-own/responses/notetaking-agent", "requiresModel": true }, - { - "language": "python", - "framework": "bring-your-own", - "protocol": "responses", - "displayName": "OpenAI Agents SDK", - "description": "Minimal agent that streams text responses from a Foundry model.", - "path": "samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk", - "requiresModel": true - }, { "language": "python", "framework": "bring-your-own", @@ -819,42 +837,6 @@ "path": "samples/python/hosted-agents/langgraph/invocations/01-langgraph-chat", "requiresModel": true }, - { - "language": "python", - "framework": "langgraph", - "protocol": "responses", - "displayName": "Langgraph Chat", - "description": "Chat agent with tools for current time and math calculations.", - "path": "samples/python/hosted-agents/langgraph/responses/01-langgraph-chat", - "requiresModel": true - }, - { - "language": "python", - "framework": "langgraph", - "protocol": "responses", - "displayName": "Langgraph Toolbox", - "description": "Agent that uses a toolbox for web search and GitHub Copilot MCP tools with managed credentials.", - "path": "samples/python/hosted-agents/langgraph/responses/02-langgraph-toolbox", - "requiresModel": true - }, - { - "language": "python", - "framework": "langgraph", - "protocol": "responses", - "displayName": "MCP", - "description": "Agent that discovers and invokes tools from a remote server for enhanced capabilities.", - "path": "samples/python/hosted-agents/langgraph/responses/04-mcp", - "requiresModel": true - }, - { - "language": "python", - "framework": "langgraph", - "protocol": "responses", - "displayName": "Workflows", - "description": "Processes a request through slogan writing, legal review, and formatting steps.", - "path": "samples/python/hosted-agents/langgraph/responses/05-workflows", - "requiresModel": true - }, { "language": "python", "framework": "langgraph", From a74f8cd97068eb840f0a44f96595b3a95759799e Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Thu, 23 Jul 2026 16:11:35 +0800 Subject: [PATCH 7/9] refactor: reuse shared delay/parseRetryAfterMs helpers in LLM retry After merging template/dev (PR #580 added fetch-layer retry with delay/parseRetryAfterMs/computeBackoffMs), the LLM retry path's local sleep/retryAfterMsFromResponse duplicated those helpers. Drop the duplicates and reuse the shared delay/parseRetryAfterMs. --- .github/scripts/generate_sample_catalog.mjs | 38 ++++----------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index a986ba4..6d8df42 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -610,39 +610,13 @@ const IGNORE_EXISTING = /^(true|1|yes)$/i.test(process.env.IGNORE_EXISTING || '' // per template (~90 in a full run); even with ample TPM/RPM quota a burst can // momentarily trip the rate limiter (HTTP 429) at the sliding-window edge. // Retry those (and transient 5xx / network errors) with jittered exponential -// backoff, honoring a server `Retry-After`, so an occasional throttle self-heals -// instead of dropping the field. Overridable via env for local debugging. +// backoff, honoring a server `Retry-After` (reusing the shared `delay` and +// `parseRetryAfterMs` helpers), so an occasional throttle self-heals instead of +// dropping the field. Overridable via env for local debugging. const LLM_MAX_ATTEMPTS = Number(process.env.LLM_MAX_ATTEMPTS) || 5; const LLM_BASE_DELAY_MS = Number(process.env.LLM_BASE_DELAY_MS) || 1000; const LLM_MAX_DELAY_MS = Number(process.env.LLM_MAX_DELAY_MS) || 30_000; -/** - * @param {number} ms - * @returns {Promise} - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Parse a `Retry-After` header (RFC 7231): delay-seconds or an HTTP date. - * Returns milliseconds, or `undefined` when absent/unparseable. - * @param {Response} response - * @returns {number | undefined} - */ -function retryAfterMsFromResponse(response) { - const value = response.headers.get('retry-after'); - if (!value) { - return undefined; - } - const seconds = Number(value); - if (Number.isFinite(seconds)) { - return Math.max(0, seconds * 1000); - } - const dateMs = Date.parse(value); - return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - Date.now()); -} - /** * Fetch README.md content for a sample directory. @@ -711,13 +685,13 @@ async function callLLMForJson(systemPrompt, userPrompt, samplePath) { // `Retry-After`; give up on other 4xx (bad request, auth, etc.). const retryable = response.status === 429 || response.status >= 500; if (retryable && attempt < LLM_MAX_ATTEMPTS) { - const retryAfter = retryAfterMsFromResponse(response); + const retryAfter = parseRetryAfterMs(response); const backoff = retryAfter !== undefined ? Math.min(retryAfter, LLM_MAX_DELAY_MS) : Math.min(Math.random() * LLM_BASE_DELAY_MS * 2 ** (attempt - 1), LLM_MAX_DELAY_MS); const source = retryAfter !== undefined ? 'server Retry-After' : 'jittered backoff'; console.warn(`LLM API returned ${response.status} for ${samplePath} (attempt ${attempt}/${LLM_MAX_ATTEMPTS}); retrying in ${Math.round(backoff)}ms (${source}).`); - await sleep(backoff); + await delay(backoff); continue; } // Include a truncated response body so the exact reason (e.g. an @@ -761,7 +735,7 @@ async function callLLMForJson(systemPrompt, userPrompt, samplePath) { if (attempt < LLM_MAX_ATTEMPTS) { const backoff = Math.min(Math.random() * LLM_BASE_DELAY_MS * 2 ** (attempt - 1), LLM_MAX_DELAY_MS); console.warn(`LLM call failed for ${samplePath} (attempt ${attempt}/${LLM_MAX_ATTEMPTS}): ${err.message}; retrying in ${Math.round(backoff)}ms.`); - await sleep(backoff); + await delay(backoff); continue; } warn(`LLM call failed for ${samplePath}: ${err.message}`); From 7dc6442752f659f949cf25732ee51682a414e656 Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Thu, 23 Jul 2026 16:20:28 +0800 Subject: [PATCH 8/9] feat: warn on duplicate displayName within a picker group Two samples in the same language+framework+protocol group getting the same displayName (e.g. both '05-workflows' -> 'Multi-Agent Workflow') is confusing since the user sees them together. Add detection-only warnDuplicateDisplayNames that flags such same-group collisions in the CI step summary so a PM can disambiguate before merge. Cross-group duplicates are allowed (never seen side by side). The catalog is left unchanged. --- .github/scripts/generate_sample_catalog.mjs | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs index 6d8df42..300ceca 100644 --- a/.github/scripts/generate_sample_catalog.mjs +++ b/.github/scripts/generate_sample_catalog.mjs @@ -1239,6 +1239,44 @@ function reorderPinnedFirst(templates) { return [...pinned, ...rest]; } +/** + * Warn about templates that share the same displayName WITHIN a single + * language+framework+protocol group — i.e. the set a user actually sees at once + * in the picker after choosing those dimensions. Duplicate names across + * DIFFERENT groups are fine (the user never sees them side by side) and are not + * reported. This is detection-only: the catalog is left unchanged so a PM can + * disambiguate the flagged names when reviewing the generated PR. + * + * @param {Array<{displayName: string, language: string, framework: string, protocol: string, path: string}>} templates + */ +function warnDuplicateDisplayNames(templates) { + /** @type {Map>} */ + const groups = new Map(); + for (const t of templates) { + const groupKey = `${t.language} / ${t.framework} / ${t.protocol}`; + const nameKey = t.displayName.trim().toLowerCase(); + if (!nameKey) { + continue; + } + let byName = groups.get(groupKey); + if (!byName) { + byName = new Map(); + groups.set(groupKey, byName); + } + const paths = byName.get(nameKey) ?? []; + paths.push(t.path); + byName.set(nameKey, paths); + } + + for (const [groupKey, byName] of groups) { + for (const [, paths] of byName) { + if (paths.length > 1) { + warn(`Duplicate displayName within "${groupKey}" (users see these together): ${paths.join(', ')}. A PM should disambiguate these names before merge.`); + } + } + } +} + async function main() { const commitSha = parseCommitShaArg(); console.log(`Using commit: ${commitSha}`); @@ -1264,6 +1302,9 @@ async function main() { // description from the LLM when configured. await autoFillDisplayFields(templates, commitSha); + // Flag same-name collisions a user would see together (detection only). + warnDuplicateDisplayNames(templates); + const dimensions = buildDimensions(templates); const orderedTemplates = reorderPinnedFirst(templates); From 69a2f5df3ea0970485b746831f8f0eb975ae1557 Mon Sep 17 00:00:00 2001 From: Yimin Jin Date: Fri, 24 Jul 2026 14:21:17 +0800 Subject: [PATCH 9/9] chore: apply PM-validated displayName edits to sample catalog Replaces the catalog with the PM's validated final version (gist 353c256): 15 displayName changes verified against each sample's source + deps (MCP/Voice Live capability surfacing), plus the two 'Multi-Agent Workflow' entries disambiguated by framework. Normal (non-refine) sync runs preserve these via mergeExistingDisplayFields, so they persist unless someone explicitly opts into refine_with_ai/ignore_existing_catalog. --- samples/hosted-agent/sample-catalog.json | 328 +++++++++++------------ 1 file changed, 164 insertions(+), 164 deletions(-) diff --git a/samples/hosted-agent/sample-catalog.json b/samples/hosted-agent/sample-catalog.json index 5e5da1d..239a57e 100644 --- a/samples/hosted-agent/sample-catalog.json +++ b/samples/hosted-agent/sample-catalog.json @@ -1,7 +1,7 @@ { - "commitSha": "84cb8c9a15d7a8bd40e1d80556e00557ea917049", + "commitSha": "3b98218db7e9a92367dc3e4b6638fd97ac7c9502", "repo": "https://github.com/microsoft-foundry/foundry-samples/", - "generatedAt": "2026-07-23T07:56:05Z", + "generatedAt": "2026-07-23T07:59:46Z", "dimensions": { "language": { "title": "Select a Language", @@ -67,8 +67,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Basic", - "description": "Agent that supports multi-turn conversations with response tracking.", + "displayName": "Basic Hosted Agent", + "description": "Minimal hosted agent that supports basic request/response and multi-turn conversations.", "path": "samples/python/hosted-agents/agent-framework/responses/01-basic", "requiresModel": true }, @@ -76,8 +76,8 @@ "language": "python", "framework": "copilot-sdk", "protocol": "invocations", - "displayName": "GitHub Copilot", - "description": "Agent streams Copilot session events with support for multi-turn conversations.", + "displayName": "Event Stream Agent", + "description": "Streams raw session events and supports multi-turn session resume across requests.", "path": "samples/python/hosted-agents/bring-your-own/invocations/github-copilot", "requiresModel": false }, @@ -85,8 +85,8 @@ "language": "python", "framework": "langgraph", "protocol": "responses", - "displayName": "Langgraph Chat", - "description": "Chat agent with tools for current time and math calculations.", + "displayName": "Tool-Enabled Chat Agent", + "description": "Multi-turn chat agent with local time and calculator tools.", "path": "samples/python/hosted-agents/langgraph/responses/01-langgraph-chat", "requiresModel": true }, @@ -94,8 +94,8 @@ "language": "python", "framework": "langgraph", "protocol": "responses", - "displayName": "Langgraph Toolbox", - "description": "Agent that uses a toolbox for web search and GitHub Copilot MCP tools with managed credentials.", + "displayName": "MCP Toolbox ReAct Agent", + "description": "Agent that uses a toolbox to call web_search and the Microsoft Learn MCP.", "path": "samples/python/hosted-agents/langgraph/responses/02-langgraph-toolbox", "requiresModel": true }, @@ -103,8 +103,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Foundry Toolbox", - "description": "Agent that discovers and invokes centrally managed tools from a Foundry Toolbox.", + "displayName": "MCP Toolbox Integration", + "description": "Agent that discovers and invokes tools from a Foundry Toolbox via MCP.", "path": "samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox", "requiresModel": true }, @@ -112,8 +112,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Workflows", - "description": "Processes requests through slogan writing, legal review, and formatting agents in sequence.", + "displayName": "Multi-Agent Workflow (Agent Framework)", + "description": "Chained agents that write a slogan, perform legal review, and format the final output.", "path": "samples/python/hosted-agents/agent-framework/responses/05-workflows", "requiresModel": true }, @@ -121,8 +121,8 @@ "language": "python", "framework": "langgraph", "protocol": "responses", - "displayName": "Workflows", - "description": "Processes a request through slogan writing, legal review, and formatting steps.", + "displayName": "Multi-Agent Workflow (LangGraph)", + "description": "Sequential agents that write, legally review, and format a final message.", "path": "samples/python/hosted-agents/langgraph/responses/05-workflows", "requiresModel": true }, @@ -130,8 +130,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Azure Search RAG", - "description": "Answers questions using product documentation and cites sources from a search index.", + "displayName": "Azure AI Search RAG", + "description": "Agent that searches Azure AI Search and cites source documents in responses.", "path": "samples/python/hosted-agents/agent-framework/responses/11-azure-search-rag", "requiresModel": true }, @@ -139,8 +139,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Claude Agent SDK", - "description": "Agent that streams responses from a Claude model for plain text input.", + "displayName": "Claude Streaming Agent", + "description": "Agent that streams assistant text responses in real time.", "path": "samples/python/hosted-agents/bring-your-own/invocations/claude-agent-sdk", "requiresModel": false }, @@ -148,8 +148,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "OpenAI Agents SDK", - "description": "Minimal agent that streams text responses from a Foundry model.", + "displayName": "Foundry Streaming Agent", + "description": "Minimal hosted agent that streams model text deltas from a Foundry deployment.", "path": "samples/python/hosted-agents/bring-your-own/responses/openai-agents-sdk", "requiresModel": true }, @@ -157,8 +157,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Caller", - "description": "Concierge agent that delegates questions to a remote specialist and summarizes answers.", + "displayName": "Delegation Caller", + "description": "Caller agent that delegates questions to a remote specialist and summarizes the reply.", "path": "samples/csharp/hosted-agents/agent-framework/a2a/01-delegation/caller", "requiresModel": true }, @@ -166,8 +166,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Executor", - "description": "Answers arithmetic and math questions as an expert agent.", + "displayName": "Math Executor Agent", + "description": "Agent that answers arithmetic and math questions.", "path": "samples/csharp/hosted-agents/agent-framework/a2a/01-delegation/executor", "requiresModel": true }, @@ -175,8 +175,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Agent Skills", - "description": "Agent that loads behavioral guidelines from Foundry Skills at startup for dynamic updates.", + "displayName": "Foundry Skills Agent", + "description": "Agent that loads behavioral guidelines from Foundry Skills at startup.", "path": "samples/csharp/hosted-agents/agent-framework/agent-skills", "requiresModel": true }, @@ -185,7 +185,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Azure Search RAG", - "description": "Support agent answers questions using a keyword-indexed knowledge base for grounding.", + "description": "Support agent that grounds answers using Azure AI Search retrieval.", "path": "samples/csharp/hosted-agents/agent-framework/azure-search-rag", "requiresModel": true }, @@ -194,7 +194,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Browser Automation", - "description": "Automates web browsing, scraping, and form filling in a remote Chromium browser.", + "description": "Automates browsing, web scraping, and form filling using a remote Chromium session with live view.", "path": "samples/csharp/hosted-agents/agent-framework/browser-automation", "requiresModel": true }, @@ -202,8 +202,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "File Tools", - "description": "Agent answers questions using bundled and user-uploaded files as knowledge sources.", + "displayName": "Agent with File Tools", + "description": "Agent that answers questions over bundled and session file sources using scoped file tools.", "path": "samples/csharp/hosted-agents/agent-framework/file-tools", "requiresModel": true }, @@ -212,7 +212,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Foundry Memory RAG", - "description": "Personal coach agent that remembers user preferences and goals across sessions.", + "description": "Personal coach agent with persistent per-user memory and RAG-grounded responses.", "path": "samples/csharp/hosted-agents/agent-framework/foundry-memory-rag", "requiresModel": true }, @@ -220,8 +220,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Foundry Toolbox MCP Skills", - "description": "Agent that dynamically discovers and uses skills from a remote toolbox endpoint.", + "displayName": "MCP Toolbox Skills", + "description": "Agent that discovers and invokes skills from a Foundry Toolbox via MCP.", "path": "samples/csharp/hosted-agents/agent-framework/foundry-toolbox-mcp-skills", "requiresModel": true }, @@ -229,8 +229,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Foundry Toolbox Server Side", - "description": "Agent that uses tools from a Foundry Toolbox, invoked server-side by the platform.", + "displayName": "Foundry MCP Toolbox (Platform-Managed)", + "description": "Agent that uses a Foundry Toolbox's server-side tools through the MCP proxy.", "path": "samples/csharp/hosted-agents/agent-framework/foundry-toolbox-server-side", "requiresModel": true }, @@ -238,8 +238,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Hello World", - "description": "Minimal hosted agent that responds to user questions and supports multi-turn conversation.", + "displayName": "Hello World Agent", + "description": "Minimal hosted agent that responds to simple text queries.", "path": "samples/csharp/hosted-agents/agent-framework/hello-world", "requiresModel": true }, @@ -247,8 +247,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "invocations", - "displayName": "Invocations Echo Agent", - "description": "Echoes back input messages with a simple prefix for testing agent hosting.", + "displayName": "Echo Agent", + "description": "Agent that echoes the request message back prefixed with Echo:", "path": "samples/csharp/hosted-agents/agent-framework/invocations-echo-agent", "requiresModel": false }, @@ -256,8 +256,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Local Tools", - "description": "Hotel search assistant that uses local function tools for queries.", + "displayName": "Agent with Local Tools", + "description": "Agent with local function tools for hotel search.", "path": "samples/csharp/hosted-agents/agent-framework/local-tools", "requiresModel": true }, @@ -265,8 +265,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "MCP Tools", - "description": "Agent answers questions by searching documentation using integrated tool providers.", + "displayName": "MCP Documentation Tools", + "description": "Agent that integrates client- and server-side tools for documentation search.", "path": "samples/csharp/hosted-agents/agent-framework/mcp-tools", "requiresModel": true }, @@ -274,8 +274,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Observability", - "description": "Agent with built-in observability for traces, metrics, and logs via Azure Monitor.", + "displayName": "Observability Agent", + "description": "Agent with built-in tracing, metrics, and logs for end-to-end observability.", "path": "samples/csharp/hosted-agents/agent-framework/observability", "requiresModel": true }, @@ -284,7 +284,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Simple Agent", - "description": "General-purpose AI assistant that replies to user input with model-generated responses.", + "description": "Minimal hosted agent that routes input to a configured model and returns its reply.", "path": "samples/csharp/hosted-agents/agent-framework/simple-agent", "requiresModel": true }, @@ -292,8 +292,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Teams Activity", - "description": "Agent that answers questions about Teams and calendar, and handles file attachments.", + "displayName": "Teams Activity Agent", + "description": "Agent that answers Teams and calendar questions and accepts file attachments.", "path": "samples/csharp/hosted-agents/agent-framework/teams-activity", "requiresModel": true }, @@ -302,7 +302,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Text Search RAG", - "description": "Agent answers support questions using retrieved passages from external documents.", + "description": "Support agent that grounds answers using text-search retrieval from a knowledge base.", "path": "samples/csharp/hosted-agents/agent-framework/text-search-rag", "requiresModel": true }, @@ -311,7 +311,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Toolbox Auth Paths", - "description": "Agent toolbox demonstrates different authentication methods for accessing upstream tools.", + "description": "Demonstrates how toolbox tools authenticate to upstream servers using different paths.", "path": "samples/csharp/hosted-agents/agent-framework/toolbox-auth-paths", "requiresModel": true }, @@ -319,8 +319,8 @@ "language": "csharp", "framework": "agent-framework", "protocol": "responses", - "displayName": "Workflows", - "description": "Workflow chains three translation agents to show intermediate language outputs.", + "displayName": "Translation Workflow", + "description": "Chains three translation agents English→French→Spanish→English and returns each translation.", "path": "samples/csharp/hosted-agents/agent-framework/workflows", "requiresModel": true }, @@ -328,8 +328,8 @@ "language": "csharp", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "HelloWorld", - "description": "Hosted agent that returns a streaming hello world response from a Foundry model.", + "displayName": "Hello World Agent", + "description": "Minimal agent that calls a model and returns the reply as an SSE event stream.", "path": "samples/csharp/hosted-agents/bring-your-own/invocations/HelloWorld", "requiresModel": true }, @@ -337,8 +337,8 @@ "language": "csharp", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Human In The Loop", - "description": "Agent that generates proposals and waits for human approval before proceeding.", + "displayName": "Human Approval Gate", + "description": "Agent that generates proposals and pauses for human approval, revision, or rejection.", "path": "samples/csharp/hosted-agents/bring-your-own/invocations/human-in-the-loop", "requiresModel": true }, @@ -346,8 +346,8 @@ "language": "csharp", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Notetaking Agent", - "description": "Agent that saves and retrieves session-persistent notes with real-time streaming responses.", + "displayName": "Note-Taking Agent", + "description": "Agent that creates, retrieves, and stores session-persistent notes with real-time responses.", "path": "samples/csharp/hosted-agents/bring-your-own/invocations/notetaking-agent", "requiresModel": true }, @@ -355,8 +355,8 @@ "language": "csharp", "framework": "bring-your-own", "protocol": "invocations_ws", - "displayName": "HelloWorld", - "description": "Routes real-time audio between a browser and a managed voice service session.", + "displayName": "Voice Live Hello World", + "description": "Minimal real-time voice agent that bridges browser audio to a managed Voice Live session.", "path": "samples/csharp/hosted-agents/bring-your-own/invocations_ws/HelloWorld", "requiresModel": false }, @@ -364,8 +364,8 @@ "language": "csharp", "framework": "bring-your-own", "protocol": "responses", - "displayName": "HelloWorld", - "description": "Minimal hosted agent that returns a greeting from a Foundry model using the Responses API.", + "displayName": "Hello World Agent", + "description": "Minimal hosted agent that calls a model and returns the reply.", "path": "samples/csharp/hosted-agents/bring-your-own/responses/HelloWorld", "requiresModel": true }, @@ -374,7 +374,7 @@ "framework": "bring-your-own", "protocol": "responses", "displayName": "Background Agent", - "description": "Handles research analysis requests asynchronously with background processing and streaming updates.", + "description": "Background agent that streams multi-section research results and supports asynchronous processing.", "path": "samples/csharp/hosted-agents/bring-your-own/responses/background-agent", "requiresModel": true }, @@ -382,8 +382,8 @@ "language": "csharp", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Browser Automation", - "description": "Provisions and controls a remote browser session, runs browsing tasks, and streams a live view.", + "displayName": "Browser Automation Agent", + "description": "Agent that runs Playwright CLI commands on a remote Chromium and streams a live view.", "path": "samples/csharp/hosted-agents/bring-your-own/responses/browser-automation", "requiresModel": true }, @@ -392,7 +392,7 @@ "framework": "bring-your-own", "protocol": "responses", "displayName": "Env Vars Agent", - "description": "Agent retrieves environment variable values with safety policies based on their type.", + "description": "Agent that exposes env vars injected from connections and returns kind-aware responses.", "path": "samples/csharp/hosted-agents/bring-your-own/responses/env-vars-agent", "requiresModel": true }, @@ -400,8 +400,8 @@ "language": "csharp", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Notetaking Agent", - "description": "Agent that saves and retrieves session-persistent notes for each user.", + "displayName": "Note-Taking Agent", + "description": "Agent that captures and retrieves session-persistent notes stored locally.", "path": "samples/csharp/hosted-agents/bring-your-own/responses/notetaking-agent", "requiresModel": true }, @@ -409,8 +409,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Caller", - "description": "Concierge agent that delegates questions to a remote specialist and summarizes the answers.", + "displayName": "Delegating Caller", + "description": "Agent that delegates user questions to a remote specialist and summarizes the reply.", "path": "samples/python/hosted-agents/agent-framework/a2a/01-delegation/caller", "requiresModel": true }, @@ -418,8 +418,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Executor", - "description": "Answers arithmetic and math questions as an expert agent.", + "displayName": "Math Expert", + "description": "Agent that answers arithmetic and math questions for a caller.", "path": "samples/python/hosted-agents/agent-framework/a2a/01-delegation/executor", "requiresModel": true }, @@ -427,8 +427,8 @@ "language": "python", "framework": "agent-framework", "protocol": "invocations", - "displayName": "Basic", - "description": "Agent with in-memory session management for multi-turn conversations.", + "displayName": "Session Management Agent", + "description": "Agent that maintains per-session conversation history in memory.", "path": "samples/python/hosted-agents/agent-framework/invocations/01-basic", "requiresModel": true }, @@ -436,8 +436,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Tools", - "description": "Agent with custom tools like weather lookup callable during conversation.", + "displayName": "Agent with Local Tools", + "description": "Agent that provides local tools the model can invoke during conversations.", "path": "samples/python/hosted-agents/agent-framework/responses/02-tools", "requiresModel": true }, @@ -445,8 +445,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Files", - "description": "Agent that lists, reads, and interprets files using shell and code tools.", + "displayName": "File Tools Agent", + "description": "Agent with local file tools and a code interpreter for inspecting and manipulating files.", "path": "samples/python/hosted-agents/agent-framework/responses/06-files", "requiresModel": true }, @@ -454,8 +454,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Skills", - "description": "Agent that discovers and runs local file-based skills like generating PDF travel guides.", + "displayName": "Local Skills Provider", + "description": "Agent that discovers and runs local file-based skills and generates a PDF travel guide.", "path": "samples/python/hosted-agents/agent-framework/responses/07-skills", "requiresModel": true }, @@ -463,8 +463,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Teams Activity", - "description": "Agent that answers questions about Teams and calendar, including handling file attachments.", + "displayName": "Teams Activity Agent", + "description": "Agent that interacts via Teams, handles file attachments, and answers calendar queries.", "path": "samples/python/hosted-agents/agent-framework/responses/07-teams-activity", "requiresModel": true }, @@ -472,8 +472,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Observability", - "description": "Agent with built-in observability and telemetry for diagnostics and monitoring.", + "displayName": "Agent Observability", + "description": "Instrumented agent that captures telemetry, traces, metrics, and logs via env vars.", "path": "samples/python/hosted-agents/agent-framework/responses/08-observability", "requiresModel": true }, @@ -482,7 +482,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Declarative Customer Support", - "description": "Customer support triage agent that routes requests to specialized agents based on conversation history.", + "description": "Declarative multi-turn customer support workflow that triages and routes requests to agents.", "path": "samples/python/hosted-agents/agent-framework/responses/09-declarative-customer-support", "requiresModel": true }, @@ -490,8 +490,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Downstream Azure", - "description": "Agent performs data operations on Blob Storage and Service Bus using its own identity.", + "displayName": "Per-Agent Azure Identity", + "description": "Agent that accesses Blob Storage and Service Bus using a per-agent Microsoft Entra identity.", "path": "samples/python/hosted-agents/agent-framework/responses/10-downstream-azure", "requiresModel": true }, @@ -499,8 +499,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Foundry Skills", - "description": "Agent that loads behavioral guidelines from Foundry Skills at startup for dynamic updates.", + "displayName": "Foundry Skills Agent", + "description": "Agent that loads and applies behavioral guidelines from Foundry Skills.", "path": "samples/python/hosted-agents/agent-framework/responses/12-foundry-skills", "requiresModel": true }, @@ -508,8 +508,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Foundry Memory", - "description": "Agent that remembers user facts across sessions using persistent semantic memory.", + "displayName": "Foundry Semantic Memory", + "description": "Agent with persistent semantic memory backed by an Azure Foundry memory store.", "path": "samples/python/hosted-agents/agent-framework/responses/13-foundry-memory", "requiresModel": true }, @@ -518,7 +518,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Browser Automation Agent", - "description": "Automates web browsing tasks such as navigation, scraping, and form filling in a remote browser.", + "description": "Agent that controls a remote browser for browsing, scraping, and form filling.", "path": "samples/python/hosted-agents/agent-framework/responses/14-browser-automation-agent", "requiresModel": true }, @@ -526,8 +526,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Optimization Travel Approver", - "description": "Agent that reviews and approves travel requests for employees.", + "displayName": "Travel Request Approver", + "description": "Agent that approves travel requests by checking policy, cost, and manager authorization.", "path": "samples/python/hosted-agents/agent-framework/responses/15-optimization-travel-approver", "requiresModel": true }, @@ -536,7 +536,7 @@ "framework": "agent-framework", "protocol": "responses", "displayName": "Content Safety Guardrail", - "description": "Agent screens prompts and responses for harmful content based on a safety policy.", + "description": "Agent with a Responsible AI content safety guardrail to filter harmful prompts and responses.", "path": "samples/python/hosted-agents/agent-framework/responses/16-content-safety-guardrail", "requiresModel": true }, @@ -544,8 +544,8 @@ "language": "python", "framework": "agent-framework", "protocol": "responses", - "displayName": "Foundry Iq Toolbox", - "description": "Answers user questions using a knowledge base accessed through a remote toolbox connection.", + "displayName": "Foundry IQ Knowledge Base", + "description": "Agent that answers questions from a Foundry IQ knowledge base via a Foundry Toolbox.", "path": "samples/python/hosted-agents/agent-framework/responses/17-foundry-iq-toolbox", "requiresModel": true }, @@ -553,8 +553,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "AG UI", - "description": "Agent that streams AG-UI lifecycle events in response to user input.", + "displayName": "Automatic UI Events", + "description": "Agent that streams UI events and handles the full run lifecycle.", "path": "samples/python/hosted-agents/bring-your-own/invocations/ag-ui", "requiresModel": true }, @@ -563,7 +563,7 @@ "framework": "bring-your-own", "protocol": "invocations", "displayName": "Diagnostic Agent", - "description": "Runs network probes on hostnames and returns a JSON report of connectivity results.", + "description": "Agent that runs network probes and returns a structured reachability report.", "path": "samples/python/hosted-agents/bring-your-own/invocations/diagnostic-agent", "requiresModel": false }, @@ -572,7 +572,7 @@ "framework": "bring-your-own", "protocol": "invocations", "displayName": "Event Grid Trigger", - "description": "Processes new blobs in Azure Storage by summarizing content and saving results to a summary container.", + "description": "Agent triggered by Event Grid that summarizes uploaded blobs and writes JSON summaries.", "path": "samples/python/hosted-agents/bring-your-own/invocations/event-grid-trigger", "requiresModel": true }, @@ -580,8 +580,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Hello World", - "description": "Hosted agent that streams a model's reply as a server-sent event.", + "displayName": "Hello World Agent", + "description": "Minimal agent that calls a Foundry model and streams the reply.", "path": "samples/python/hosted-agents/bring-your-own/invocations/hello-world", "requiresModel": true }, @@ -589,8 +589,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Human In The Loop", - "description": "Agent that generates proposals and waits for human approval before proceeding.", + "displayName": "Human in the Loop", + "description": "Agent that generates proposals and pauses for human approval, revision, or rejection.", "path": "samples/python/hosted-agents/bring-your-own/invocations/human-in-the-loop", "requiresModel": true }, @@ -598,8 +598,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Langgraph Chat", - "description": "Conversational agent with session history and built-in time and calculator tools.", + "displayName": "Tool-Enabled Multi-Turn Chat", + "description": "Multi-turn conversational agent with conditional tool calling and session state.", "path": "samples/python/hosted-agents/bring-your-own/invocations/langgraph-chat", "requiresModel": true }, @@ -607,8 +607,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Notetaking Agent", - "description": "Agent that saves and retrieves notes with per-session isolation and streaming responses.", + "displayName": "Note-Taking Agent", + "description": "Note-taking agent that saves and retrieves per-session notes via session files.", "path": "samples/python/hosted-agents/bring-your-own/invocations/notetaking-agent", "requiresModel": true }, @@ -616,8 +616,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Toolbox", - "description": "Agent connects to a toolbox server and enables tool discovery and invocation during chat.", + "displayName": "MCP Tool Calling Agent", + "description": "Agent that discovers toolbox tools via MCP and lets the model call them.", "path": "samples/python/hosted-agents/bring-your-own/invocations/toolbox", "requiresModel": true }, @@ -625,8 +625,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations_ws", - "displayName": "Duplex Live Agent", - "description": "Real-time voice agent that handles conversation and background tasks in parallel.", + "displayName": "Voice Live Duplex Agent", + "description": "Real-time voice agent with low-latency conversation and parallel background workers.", "path": "samples/python/hosted-agents/bring-your-own/invocations_ws/duplex-live-agent", "requiresModel": false }, @@ -634,8 +634,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations_ws", - "displayName": "Hello World", - "description": "Real-time voice agent that relays audio and events between browser and a managed service.", + "displayName": "Voice Live Hello World", + "description": "Real-time agent that forwards browser audio to Voice Live for STT and TTS.", "path": "samples/python/hosted-agents/bring-your-own/invocations_ws/hello-world", "requiresModel": false }, @@ -643,8 +643,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations_ws", - "displayName": "Livekit Server", - "description": "Voice agent connects to a LiveKit server for real-time audio conversations.", + "displayName": "LiveKit Voice Agent", + "description": "Voice agent that uses LiveKit to receive audio, transcribe, generate responses, and synthesize speech.", "path": "samples/python/hosted-agents/bring-your-own/invocations_ws/livekit-server", "requiresModel": false }, @@ -652,8 +652,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations_ws", - "displayName": "Pipecat Webrtc", - "description": "Real-time voice agent with multi-agent pipeline and WebRTC audio media support.", + "displayName": "Pipecat Real-Time Voice Agent (WebRTC)", + "description": "Real-time voice agent hosted as a bring-your-own container with multi-agent pipeline.", "path": "samples/python/hosted-agents/bring-your-own/invocations_ws/pipecat-webrtc", "requiresModel": false }, @@ -661,8 +661,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations_ws", - "displayName": "Pipecat Ws Server", - "description": "Real-time voice agent with greeting and order-checking capabilities.", + "displayName": "Pipecat Real-Time Voice Agent (WebSocket)", + "description": "Real-time voice agent that transcribes, routes to LLM sub-agents, and streams speech output.", "path": "samples/python/hosted-agents/bring-your-own/invocations_ws/pipecat-ws-server", "requiresModel": false }, @@ -671,7 +671,7 @@ "framework": "bring-your-own", "protocol": "responses", "displayName": "Background Agent", - "description": "Handles long-running research analysis requests with background processing and streaming updates.", + "description": "Background agent that processes requests asynchronously and streams partial results.", "path": "samples/python/hosted-agents/bring-your-own/responses/background-agent", "requiresModel": true }, @@ -679,8 +679,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Bring Your Own Toolbox", - "description": "Agent connects to a remote toolbox and enables tool invocation during conversations.", + "displayName": "MCP Toolbox Integration (BYO)", + "description": "Agent that connects to a Foundry toolbox and discovers and invokes tools via MCP.", "path": "samples/python/hosted-agents/bring-your-own/responses/bring-your-own-toolbox", "requiresModel": true }, @@ -689,7 +689,7 @@ "framework": "bring-your-own", "protocol": "responses", "displayName": "Browser Automation", - "description": "Automates web browsers for tasks like form filling and web scraping with session control.", + "description": "Agent that automates browsers with lazy sessions, multi-session control, and on-demand skills.", "path": "samples/python/hosted-agents/bring-your-own/responses/browser-automation", "requiresModel": true }, @@ -697,8 +697,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Env Vars Agent", - "description": "Agent that reveals environment variable values with safety policies based on their type.", + "displayName": "Environment Variable Injection", + "description": "Agent exposing a tool to return connection-backed env vars with kind-aware safety.", "path": "samples/python/hosted-agents/bring-your-own/responses/env-vars-agent", "requiresModel": true }, @@ -706,8 +706,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Hello World", - "description": "Hosted agent that returns a hello world message from a Foundry model using a custom backend.", + "displayName": "Hello World Agent", + "description": "Minimal hosted agent that calls a Foundry model and returns the reply.", "path": "samples/python/hosted-agents/bring-your-own/responses/hello-world", "requiresModel": true }, @@ -715,8 +715,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Langgraph Chat", - "description": "Conversational agent with built-in calculator and time tools, streaming responses.", + "displayName": "Multi-Turn Chat", + "description": "Conversational agent with server-side history and conditional tool calling.", "path": "samples/python/hosted-agents/bring-your-own/responses/langgraph-chat", "requiresModel": true }, @@ -724,8 +724,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Langgraph Toolbox", - "description": "Agent that dynamically loads tools from a remote toolbox and handles incoming requests.", + "displayName": "MCP Toolbox Agent (LangGraph)", + "description": "Agent that loads tools from a remote Foundry toolbox and responds to incoming requests.", "path": "samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox", "requiresModel": true }, @@ -733,8 +733,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Langgraph Toolbox User Identity", - "description": "Agent that accesses mail, calendar, and GitHub tools for user-identity scenarios.", + "displayName": "MCP User-Identity OAuth Agent", + "description": "Agent that uses a Foundry toolbox via MCP to access user-identity OAuth tools.", "path": "samples/python/hosted-agents/bring-your-own/responses/langgraph-toolbox-user-identity", "requiresModel": true }, @@ -742,8 +742,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Notetaking Agent", - "description": "Agent that saves and retrieves notes with per-session isolation and streaming responses.", + "displayName": "Note-Taking Agent", + "description": "Agent that saves and retrieves per-session notes with persistent session storage.", "path": "samples/python/hosted-agents/bring-your-own/responses/notetaking-agent", "requiresModel": true }, @@ -751,8 +751,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Optimization Customer Support", - "description": "Customer support agent for troubleshooting and product assistance.", + "displayName": "Customer Support Agent", + "description": "Customer support agent for consumer electronics handling inquiries and troubleshooting.", "path": "samples/python/hosted-agents/bring-your-own/responses/optimization-customer-support", "requiresModel": true }, @@ -760,8 +760,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Optimization Hello World", - "description": "Minimal hosted agent that returns a static greeting message.", + "displayName": "Agent Optimizer Hello World", + "description": "Minimal agent demonstrating Agent Optimizer for response optimization.", "path": "samples/python/hosted-agents/bring-your-own/responses/optimization-hello-world", "requiresModel": true }, @@ -770,7 +770,7 @@ "framework": "bring-your-own", "protocol": "responses", "displayName": "Session Multiplexing", - "description": "Supports multiple users sharing a session while keeping response chains user-specific.", + "description": "Agent showing multiple users sharing an agent session while isolating response chains.", "path": "samples/python/hosted-agents/bring-your-own/responses/session-multiplexing", "requiresModel": true }, @@ -778,8 +778,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Foreground Background Agents Responses Voicelive", - "description": "Voice agent that routes quick replies to users and delegates long tasks to a background worker.", + "displayName": "Foreground Background Agents", + "description": "Router agent delegates long tasks to a background worker and manages task status.", "path": "samples/python/hosted-agents/bring-your-own/voicelive/foreground-background-agents-responses-voicelive", "requiresModel": true }, @@ -787,8 +787,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "responses", - "displayName": "Handoff Langgraph Responses Voicelive", - "description": "Customer support agent with triage and handoff between refund and order specialists.", + "displayName": "Customer Support Handoff", + "description": "Multi-agent customer support handoff optimized for voice-first interactions.", "path": "samples/python/hosted-agents/bring-your-own/voicelive/handoff-langgraph-responses-voicelive", "requiresModel": true }, @@ -796,8 +796,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Hello World Invocations Voicelive", - "description": "Minimal hosted agent for real-time voice interactions with audio transcription streaming.", + "displayName": "Voice Live Invocations Agent", + "description": "Agent that streams model audio transcriptions for real-time VoiceLive interactions.", "path": "samples/python/hosted-agents/bring-your-own/voicelive/hello-world-invocations-voicelive", "requiresModel": true }, @@ -805,8 +805,8 @@ "language": "python", "framework": "bring-your-own", "protocol": "invocations", - "displayName": "Hotel Booking Invocations Voicelive", - "description": "Hotel booking agent that handles speech and UI actions with session state and streaming.", + "displayName": "Hotel Booking Agent", + "description": "Voice-enabled hotel booking agent with multi-turn state and UI actions.", "path": "samples/python/hosted-agents/bring-your-own/voicelive/hotel-booking-invocations-voicelive", "requiresModel": true }, @@ -814,8 +814,8 @@ "language": "python", "framework": "langgraph", "protocol": "responses", - "displayName": "A2a Caller", - "description": "Concierge agent that delegates specialist questions to a remote expert agent.", + "displayName": "A2A Caller", + "description": "Concierge agent that delegates specialist questions to a remote A2A executor.", "path": "samples/python/hosted-agents/langgraph/a2a/a2a-caller", "requiresModel": true }, @@ -823,8 +823,8 @@ "language": "python", "framework": "langgraph", "protocol": "responses", - "displayName": "A2a Executor", - "description": "Math expert agent that answers questions and explains steps, callable by other agents.", + "displayName": "Math Expert Executor", + "description": "Executor agent that answers arithmetic questions and exposes an incoming agent endpoint.", "path": "samples/python/hosted-agents/langgraph/a2a/a2a-executor", "requiresModel": true }, @@ -832,8 +832,8 @@ "language": "python", "framework": "langgraph", "protocol": "invocations", - "displayName": "Langgraph Chat", - "description": "Multi-turn chat agent with tools for current time and math calculations.", + "displayName": "Chat With Local Tools", + "description": "Multi-turn chat agent with two local tools and per-session memory.", "path": "samples/python/hosted-agents/langgraph/invocations/01-langgraph-chat", "requiresModel": true }, @@ -841,8 +841,8 @@ "language": "python", "framework": "langgraph", "protocol": "responses", - "displayName": "Files", - "description": "Agent that analyzes and manipulates files using local and sandboxed tools.", + "displayName": "File Manipulation Agent", + "description": "Agent that manipulates uploaded files using filesystem tools and a code interpreter.", "path": "samples/python/hosted-agents/langgraph/responses/06-files", "requiresModel": true }, @@ -850,8 +850,8 @@ "language": "python", "framework": "langgraph", "protocol": "responses", - "displayName": "Human In The Loop", - "description": "Agent drafts proposals and pauses for human review with approve, revise, or reject options.", + "displayName": "Human in the Loop", + "description": "Agent that pauses for human review and supports approve, revise, or reject decisions.", "path": "samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop", "requiresModel": true }, @@ -859,8 +859,8 @@ "language": "python", "framework": "langgraph", "protocol": "responses", - "displayName": "Observability", - "description": "Agent with automatic tracing for observability of all actions and tool invocations.", + "displayName": "Automatic Tracing", + "description": "Agent with automatic OpenTelemetry tracing across LLM calls, nodes, and tools.", "path": "samples/python/hosted-agents/langgraph/responses/08-observability", "requiresModel": true }