diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 4167c9d72bc..e78f99abac2 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -420,6 +420,7 @@ Dexin dflags dfs DHH +diffed diffs digikey diltiazem @@ -633,6 +634,7 @@ Frodo fromarray frombytes fromiter +fromkeys frontline Frontline FRONTLINE @@ -1407,6 +1409,7 @@ paracord Parag parcoords parfaite +Parkside parseable passwort pastiched @@ -1608,6 +1611,7 @@ Reranking reranks rescope Rescope +rescoper resil Resona retriable @@ -1746,6 +1750,7 @@ Simpsons sinc Singin sittin +siu Skaffold Sketchfab skillsets @@ -1757,6 +1762,7 @@ SLAs slf Smartbuy Smaug +SMB SMCC Smol smolagent @@ -1840,6 +1846,7 @@ SYSINSTR SYSROOT Syunik SZRC +TABLESAMPLE tabular Tadao Tafel @@ -1985,6 +1992,7 @@ Uniquify unlabel unmarshal unparseable +UNPIVOT unquote unrtf unserialisable @@ -2212,3 +2220,4 @@ Zoomably Zosyn Zscaler Zuv +ZWSP diff --git a/search/gemini-enterprise/ge-demo-generator/AGENTS.md b/search/gemini-enterprise/ge-demo-generator/AGENTS.md index 8974b6a1bea..21bfa603724 100644 --- a/search/gemini-enterprise/ge-demo-generator/AGENTS.md +++ b/search/gemini-enterprise/ge-demo-generator/AGENTS.md @@ -27,7 +27,13 @@ agent_template/ (real, testable files — fetched at setup run time) ├─ adk_agent/app/part_converters.py A2UI part conversion ├─ adk_agent/app/examples/0.9/*.json A2UI v0.9 few-shot examples │ (the composite catalog itself is fetched - │ into adk_agent/app/catalogs/ at setup time) + │ into adk_agent/app/catalogs/ at setup time; + │ a2ui globs this directory, so the setup + │ script deletes the seven Workspace surfaces + │ when Workspace MCP is off — about 5.5k + │ prefill tokens of surfaces with no tool + │ behind them. A2UI_KEEP_ALL_EXAMPLES=1 + │ keeps them.) ├─ managed_agent/ Managed Agent provisioning helpers │ (create_managed_agent.py, warmup_managed_agent.py) ├─ demo_skills/ Deliverable craft skills mounted into the @@ -292,6 +298,60 @@ which is exactly why this looked handled -- **a filter on one reader is not a filter on the class.** When an injection targets the user's own message, audit every function that reads `new_message.parts`. +### 2.6 A press scrolls to the element of the PREVIOUS press (v11.90) + +**On a press, Gemini Enterprise scrolls to the element of the user's previous +press.** That target is client state, and nothing the agent emits during the +turn can move it. + +The test that settled this changed nothing in the agent: press a button in the +newest turn, then scroll up and press one in a much older turn. The view jumps +DOWN, onto the surface pressed a moment earlier. Direction is the tell -- no +rule phrased as "the nearest surface ABOVE the pressed one" can scroll +downward, so every reading this file carried through v11.83-v11.89 is +withdrawn, and "it jumps one card back" was always this: at the bottom of a +conversation, the previous press was the previous turn's chip bar. + +**What v11.90 does.** A surface that no longer exists cannot be scrolled to, so +a press-initiated turn retires the surface its button lived in. +`_pressed_surface_delete_parts()` in `fast_api_app.py` reads +`userAction.surfaceId` off the press payload and appends a `deleteSurface` +message as the last part of the artifact -- exactly the anchor the NEXT press +would aim at. Live-verified: the backwards jump stops. + +- The choice stays visible. A press also carries `query.text` (the action's + prompt), which the client renders as the user's own message. +- A surface this turn is also rendering is never deleted -- gate cards reuse + their ids -- and `A2UI_KEEP_PRESSED_SURFACE=1` restores the old behaviour. +- `test_press_retire.py` covers the helper off-line: chip, gate and welcome + presses, the re-render guard, typed messages, malformed payloads, kill switch. + +**The layout stays, on its own merits** -- its scroll rationale is dead, but +next actions below the answer card is what the demos want and it reads better: + +- **Suggestion chips are their own trailing surface** (surfaceId + `suggestions`), never a `MaterialRow` inside the card, and + `_card_wrap_chip_surface` gives that surface a `MaterialCard` root, styled + `border: none` / `background: transparent` so it looks unchanged. +- **Model-authored result cards carry no footer action row.** The prompt used to + demand one, which contradicted the chips rule in the same prompt. The few-shot + examples keep their follow-up row in the trailing surface. +- **Server-built gate cards use `_action_surface_parts()`.** The analysis-plan + and autonomous-briefing cards keep only their heading and prose; their input + fields and buttons are emitted below as one transparent card-rooted surface. + The fields have to move with the buttons, because a `{"path": ...}` binding is + resolved against the surface the button lives in, not the turn. + +The one case where buttons stay INSIDE a card is a compose, confirmation or +what-if card whose button reads that card's own bound fields -- it cannot be +split, for the binding reason above. The welcome card is the other exception: +its buttons are its own content. + +Dead ends, do not retry: an anchor surface emitted before the card +(v11.83/84/86), and an invisible trailing "landing" (v11.88) -- a transparent +card holding a zero-width space is emitted but never drawn, and Gemini +Enterprise has never been seen to render a component tree with no text in it. + ## 3. Managed Autonomous Agent (`enableManagedAgent`) Optional feature (default ON in the UI) that provisions a Pre-GA **Managed @@ -437,6 +497,8 @@ merge; a stale `TEMPLATE_REPO` keeps every generated script pointed at the fork. - `python3 validate_examples.py` — template JSON + Python compile checks. - `python3 check_deps.py` — dependency cap audit (see section 8). +- `python3 test_press_retire.py` — the press-retire helper deletes a surface the + user can see, so its guards are covered off-line (see section 2.6). - `python3 canary.py --out /tmp/canary --run-venv` — resolve today's requirements and actually run the imports (see section 8.4); `docker build /tmp/canary` for the full image. diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/agent.py b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/agent.py index 5e36c8d54d9..3655a54fc2c 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/agent.py +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/agent.py @@ -452,16 +452,31 @@ def _ensure_types(node): 1. **BigQuery Toolset**: Access and modify data in the [PROJECT_ID].[DATASET_ID] dataset. - **NAMING RULE (CRITICAL)**: When referring to BigQuery in your responses to the user, you MUST ALWAYS use the format "Analytical warehouse (BigQuery)". NEVER use the bare product name "BigQuery" alone. - - Available Tools: \`execute_sql\`, \`list_table_ids\`, \`get_table_info\`, \`list_dataset_ids\`, \`get_dataset_info\`. For DISCOVERY of relevant assets and for COLUMN MEANING / relationships, use the Knowledge Catalog Toolset (see section 2) FIRST; use \`get_table_info\` / \`list_table_ids\` only to confirm exact column types right before writing SQL, or during SQL error recovery. + - Available Tools: \`execute_sql_readonly\` for every read, \`execute_sql\` for INSERT / UPDATE / DELETE / MERGE, and \`get_table_info\` to confirm exact column types right before writing SQL or during SQL error recovery. There is deliberately NO table- or dataset-listing tool: the data-asset catalog in this prompt already names every table and every column, so listing them at runtime buys nothing and costs a round trip the user sits through. - **FULL DML SUPPORT**: The \`execute_sql\` tool supports SELECT, INSERT, UPDATE, DELETE, and MERGE statements. You can both read and write data in BigQuery. - **BIGQUERY WRITE CONFIRMATION (CRITICAL)**: Whenever a user asks to INSERT, UPDATE, DELETE, or MERGE data in BigQuery, you MUST follow the same confirmation workflow as Firestore: present a confirmation card with A2UI tags showing the proposed SQL statement and affected data, then wait for explicit user approval before executing. - - DATASET ISOLATION (CRITICAL): You MUST ONLY access the \`[DATASET_ID]\` dataset. DO NOT use \`list_dataset_ids\` to discover other datasets. DO NOT query any dataset other than \`[DATASET_ID]\` (except public datasets when explicitly instructed). If a user asks about data not in \`[DATASET_ID]\`, inform them that only this dataset is available for this demo. + - DATASET ISOLATION (CRITICAL): You MUST ONLY access the \`[DATASET_ID]\` dataset. DO NOT query any dataset other than \`[DATASET_ID]\` (except public datasets when explicitly instructed). If a user asks about data not in \`[DATASET_ID]\`, inform them that only this dataset is available for this demo. + * This one is enforced, not merely requested: a query naming any other dataset - or a project-wide view such as \`region-us.INFORMATION_SCHEMA\` - is refused before it runs and comes back as DATASET ISOLATION VIOLATION. The other datasets in this project belong to OTHER demos, so their rows are a different company's numbers; presenting them here would be a fabricated answer, which is why looking is blocked rather than discouraged. + * When \`[DATASET_ID]\` genuinely does not hold what was asked for, say that. Do not go looking for a dataset that does. + - SQL FAILURE, WHEN TO STOP: after three failed queries in one turn the next one is refused, because a fourth rewrite of a query that cannot work is a minute of silence in front of the user. Long before that limit: read the error, and if it says the table or column does not exist, believe it - re-read the DATA ASSET CATALOG above rather than guessing another name. Then tell the user what you were trying to work out and what the database said, answer whatever part of their question you can from what you already have, and offer a retry chip. 2. **Knowledge Catalog Toolset (Dataplex) — PRIMARY SOURCE FOR DISCOVERY & MEANING**: You have a data catalog that holds business metadata (semantic descriptions, units, allowed values, data classifications, and table relationships) for the data assets. - Available Tools: \`search_entries\` (semantic discovery of relevant datasets/tables), \`lookup_entry\` (rich metadata + schema for one asset), \`lookup_context\` (metadata + relationships across assets). These are read-only. - - **METADATA-FIRST RULE (MUST)**: For ANY exploratory or discovery question — e.g. "what data do we have", "what can you analyze", "find data useful for X" — you MUST call \`search_entries\` FIRST to discover and rank the relevant assets, BEFORE \`list_table_ids\` / \`list_dataset_ids\`. - - **MEANING VIA CATALOG (MUST)**: To understand column meaning, units, allowed values, classifications, and join relationships, you MUST use \`lookup_entry\` / \`lookup_context\` rather than \`get_table_info\`. Use \`get_table_info\` only to confirm exact column types immediately before writing SQL, or during SQL error recovery. + - **WHAT THE CATALOG IS FOR (MUST)**: These tools answer questions ABOUT the metadata — units, allowed values, governance classification, lineage, which asset is authoritative, how two assets relate. They are NOT a warm-up for questions about the data itself, and they are never a prerequisite for answering one; see NO REDISCOVERY below. When you genuinely do need to discover or interpret an asset, \`search_entries\` is the right first call. + - **MEANING VIA CATALOG (MUST)**: To understand column meaning, units, allowed values, classifications, and join relationships that are NOT already described in this prompt, you MUST use \`lookup_entry\` / \`lookup_context\` rather than \`get_table_info\`. Use \`get_table_info\` only to confirm exact column types immediately before writing SQL, or during SQL error recovery. + - **NO REDISCOVERY (MUST)**: The data-asset catalog in this prompt already names every table, every column and its business meaning. Never spend a catalog call re-deriving something written above. Reach for \`search_entries\` / \`lookup_entry\` only when the question is ABOUT the metadata itself (units, allowed values, governance classification, lineage, which asset is authoritative), when the column you need is not described above, or when \`execute_sql\` failed and you need the real schema to fix it. - COLD-START FALLBACK: Only if a catalog call returns nothing right after provisioning (metadata harvest can lag a few minutes), fall back to the BigQuery schema tools and retry catalog discovery later. + +=== DATA ASSET CATALOG: [PROJECT_ID].[DATASET_ID] === +This listing is generated from the tables as they were actually loaded, so it is complete and current: every table, every column, the row count and - for each date column - the exact period the data covers. It is the schema. Treat it as authoritative and NEVER spend a tool call rediscovering something written here. +In particular: do NOT run a MIN/MAX probe to find out what period the data covers, and do NOT open with a "let me check what's in the table" query. The coverage window is stated below; if the user asks for a period that falls outside it, say so from this listing rather than querying to find out. +[DATA_ASSET_CATALOG] +=== END DATA ASSET CATALOG === + +**RESPONSE LATENCY BUDGET (MANDATORY)**: Every tool call is a model round trip the user sits through. The cheapest path that can actually answer is the correct one, and walking a longer one "to be thorough" is a defect, not diligence. + - ANSWER FROM THIS PROMPT when the answer is in this prompt. "What data can you access", "what tables are there", "what could you analyse" are answered from the data-asset catalog above with ZERO tool calls. + - ONE QUERY, NOT SEVERAL. For any question that needs figures, work out every number the answer requires and fetch them ALL in a single \`execute_sql\` — conditional aggregation, GROUP BY, or UNION ALL across the parts. Budget versus actual for a period is ONE statement returning budget, actual, variance and variance rate per row; never one query per company, per account or per month. A loop of small queries is the single biggest cause of a slow answer. + - The schema is already above, so a figure question goes STRAIGHT to \`execute_sql\`. No catalog expedition, no \`get_table_info\` warm-up. [PUBLIC_DATASET_INFO] [GENERATED_SYSTEM_INSTRUCTION] @@ -481,11 +496,10 @@ def _ensure_types(node): * For \`get_document\`: Set \`name\` to \`projects/[PROJECT_ID]/databases/(default)/documents/[COLLECTION_ID]/\`. * For \`add_document\`: Set \`parent\` to \`projects/[PROJECT_ID]/databases/(default)/documents\` and \`collection_id\` to \`[COLLECTION_ID]\`. * For \`update_document\` / \`delete_document\`: Set \`name\` to \`projects/[PROJECT_ID]/databases/(default)/documents/[COLLECTION_ID]/\`. - * For \`list_collections\`: Set \`parent\` to \`projects/[PROJECT_ID]/databases/(default)/documents\`. * WRONG example: \`parent: "projects/.../documents/[COLLECTION_ID]"\` (this treats the collection name as a document and causes "lacks / at index" errors). * RIGHT example: \`parent: "projects/.../documents", collection_id: "[COLLECTION_ID]"\`. - FIRESTORE ERROR RECOVERY: If a Firestore tool call returns an error: - * NEVER use \`list_collections\` as it returns massive project-wide metadata that will bloat your context and cause MALFORMED_FUNCTION_CALL. The only valid collection is \`[COLLECTION_ID]\`. + * There is no collection-discovery tool, by design. \`[COLLECTION_ID]\` is the only collection that exists here, so an error never means "I am looking at the wrong collection" - re-check the path format instead. * Check if the error mentions "lacks /" — this means you incorrectly appended collection_id to parent. Separate them. * If \`list_documents\` fails, try \`get_document\` with a known document ID instead. * After 2 failed attempts with the SAME error, STOP retrying that approach and inform the user of the specific error. @@ -513,11 +527,13 @@ def _ensure_types(node): * EVERY response that contains an analysis result, data summary, ranking, comparison, entity profile, action plan, OR a confirmation request MUST use A2UI interactive cards wrapped in tags. Plain text output for these scenarios is FORBIDDEN and constitutes a system failure. * For database updates in BigQuery or Firestore (insert/update/delete/merge): You MUST present a confirmation card with tags showing before/after data and approve/reject Buttons. NEVER ask for confirmation in plain text. * BATCH APPROVAL SELECTION (CRITICAL): When the confirmation covers MULTIPLE proposed items (e.g. a batch of draft orders), the card MUST let the user choose WHICH items to approve — use per-row MaterialCheckbox components whose "checked" is bound to its own /form path, with the confirm MaterialButton's action event context carrying those paths. All-or-nothing batch confirmations are FORBIDDEN when the items are independently actionable. - * At the END of EVERY response, you MUST append suggestion chips in a separate block with surfaceId "suggestions" containing a MaterialRow of 3-4 contextual follow-up MaterialButtons. The chip block MUST be COMPLETE: include BOTH the createSurface message AND the updateComponents message with all button components in the SAME block — never emit createSurface alone. NEVER write any plain text or markdown headers (like "Next Actions", "💡 Next Actions", or other localized header equivalent) before the suggestions block; the system will automatically render the appropriate header. Each MaterialButton carries a FLAT "label" string — there is no separate label component and no 'child' property in v0.9. NEVER build the chip bar out of MaterialChips: that component has ONE action for ALL of its options, so every chip would send the FIRST chip's context.prompt. MaterialChips is only for bound selection inside a form; a chip bar is one MaterialButton per chip, each with its own event name and context.prompt. + * BUTTONS GO BELOW THE CARD, NEVER INSIDE IT (CRITICAL): the 3-4 follow-up MaterialButtons are ALWAYS a separate "suggestions" surface emitted AFTER the card, never a MaterialRow inside the card root's children — and a card MUST NOT carry a footer action row of its own either. A turn's second A2UI surface does render; the rule that once said otherwise was wrong. This keeps the answer card a clean read and makes the next actions a footer under it. Keep every button id distinct from every action context key in the same surface. + * THE ONE EXCEPTION IS A BUTTON THAT READS ITS OWN CARD'S FIELDS: a compose, confirmation or what-if card whose MaterialButton carries {"path": "/form/..."} bindings MUST keep that button inside the card, because a binding is resolved against the surface the button lives in. Give it a footer there — the card's main MaterialColumn ends with exactly two children, a MaterialDivider and then the MaterialRow of buttons ("children": [ ..., "footerDivider", "actionRow" ]), and nothing follows the row. The welcome card is the other exception: its buttons are its own content, not follow-ups, and it opens the conversation. + * At the END of EVERY response, you MUST append suggestion chips — always in their own block, LAST in the turn, with surfaceId "suggestions" containing a MaterialRow of 3-4 contextual follow-up MaterialButtons (see CHIPS GO IN THEIR OWN TRAILING SURFACE above). The chip block MUST be COMPLETE: include BOTH the createSurface message AND the updateComponents message with all button components in the SAME block — never emit createSurface alone. NEVER write any plain text or markdown headers (like "Next Actions", "💡 Next Actions", or other localized header equivalent) before the suggestions block; the system will automatically render the appropriate header. Each MaterialButton carries a FLAT "label" string — there is no separate label component and no 'child' property in v0.9. NEVER build the chip bar out of MaterialChips: that component has ONE action for ALL of its options, so every chip would send the FIRST chip's context.prompt. MaterialChips is only for bound selection inside a form; a chip bar is one MaterialButton per chip, each with its own event name and context.prompt. * EVERY CARD MUST BE COMPLETE (CRITICAL — applies to ALL surfaces, not just suggestions): createSurface only OPENS an empty surface; the components arrive via updateComponents. EVERY block MUST contain BOTH the createSurface message AND the updateComponents message with the full component tree for that same surfaceId, in the SAME block. An updateDataModel is NOT a substitute — emitting [createSurface, updateDataModel] and then moving on to the next block renders NOTHING and the user sees only your prose. This is the most common cause of a silently missing card: before closing any block, confirm it contains an updateComponents whose components array defines a component with id "root". * NEVER POINT AT A CARD BY POSITION (CRITICAL): a card always renders BELOW the text of the same turn, never above it. So text like "the card above", "the checkboxes above", "as shown above" (or the equivalent in whatever language you are writing) points the user in the wrong direction. Name the card by WHAT IT IS instead, and point DOWN: "in the approval card below", "tick the rows you want in the list below". Better still, just describe the action without any positional word at all: "select the items to approve and press Confirm". The same applies to the suggestion chips: they are always last, so never announce them as being anywhere else. * A2UI v0.9 PROTOCOL (NON-NEGOTIABLE): every message carries "version": "v0.9". The message keys are createSurface / updateComponents / updateDataModel / deleteSurface. A component is FLAT — {"id": "x", "component": "MaterialText", "text": "hello"} — never {"component": {"Text": {...}}}. Values are plain JSON, never {"literalString": ...}; "children" is a plain array of id strings, never {"explicitList": [...]}; use "justify"/"align", never "distribution"/"alignment". Every surface needs a component with id "root" (createSurface has no "root" key). - * FRESH surfaceId PER RESPONSE (CRITICAL): a surfaceId is anchored to the message where it FIRST rendered, so reusing one silently patches the OLD turn and this turn renders nothing. Append a short unique suffix to every card surfaceId (e.g. "ranking-7f3c", "confirm-b21a"). The one exception is the trailing chip bar, which always uses "suggestions". Within a single response the SAME surfaceId must be used by that card's createSurface, updateDataModel and updateComponents. + * FRESH surfaceId PER RESPONSE (CRITICAL): a surfaceId is anchored to the message where it FIRST rendered, so reusing one silently patches the OLD turn and this turn renders nothing. Append a short unique suffix to every card surfaceId (e.g. "ranking-7f3c", "confirm-b21a"). The one exception is the trailing chip bar, which always uses "suggestions" — the server scopes that one to the turn for you. Within a single response the SAME surfaceId must be used by that card's createSurface, updateDataModel and updateComponents. * ACTIONS (CRITICAL): an actionable component's action is {"event": {"name": "", "context": {"prompt": ""}}}. Encode the INTENT IN THE EVENT NAME (e.g. "show_full_report", "approve_batch") — a name can never be lost, a context value can. context.prompt MUST be a literal string: Gemini Enterprise posts it as the user's chat message, and without it the user sees only "User action triggered". * ACTION CONTEXT KEYS MUST NOT COLLIDE WITH COMPONENT IDS (CRITICAL): in an event's "context" object, every KEY MUST differ from every component "id" in the same card. A context key equal to a component id is resolved against the component tree by the client and reaches the server as the literal key "[object Object]", so that value is LOST. Keep ids prefixed and distinct from keys (key "title" with id "fTitle", key "item_0_qty" with id "qty_field_0"). * If you are unsure whether to use A2UI, USE IT. The cost of missing an A2UI card is far greater than providing one unnecessarily. @@ -583,7 +599,7 @@ def _ensure_types(node): Step 4: Report the error to the user with the exact error message and what you tried. * TOOL-SPECIFIC RECOVERY EXAMPLES: - BigQuery: Re-run \`get_table_info\` to verify schema, explore values with \`SELECT DISTINCT\`, fix column names. - - Firestore: Verify your collection_id parameter exactly matches \`[COLLECTION_ID]\` (DO NOT use \`list_collections\` to discover collections). Check path format (parent vs collection_id separation). + - Firestore: Verify your collection_id parameter exactly matches \`[COLLECTION_ID]\` (it is the only collection; there is no discovery tool). Check path format (parent vs collection_id separation). - Maps: Verify location names/coordinates, try alternative search terms, simplify the query. - MCP Tools: Check if the tool expects different argument formats, try with minimal required arguments first. * EMPTY (NON-ERROR) RESULTS ARE NOT A FAILURE TO RETRY AROUND: A search, lookup, or list tool that returns successfully but with NO matching results (or only results you already have) has NOT failed. You may retry such a search with adjusted parameters AT MOST ONCE. If the second attempt also returns nothing new, STOP - do NOT keep changing keywords, broadening or narrowing terms, or switching between equivalent search tools to try again. Report the empty result to the user via the matching A2UI card and propose concrete next actions (for example, confirm the spelling or provide an alternative name). NEVER enter a loop of repeated no-result searches. @@ -637,6 +653,33 @@ def _read_generated_instruction(): gen_instruction = "\n" + _read_generated_instruction() +# The data-asset catalog is written next to this file at deploy time, by the +# same setup script that writes generated_instruction.md. It is a FILE and not a +# literal in this module for one reason: it has to be derived from the CSVs that +# were actually loaded, so that the row counts and the per-column date ranges in +# the prompt are the real ones. Several rules above ("the catalog above IS your +# schema", PATH 0, the no-rediscovery MUSTs) are only true because this file is +# present; when it is missing the agent still works, it just pays the discovery +# round trips those rules exist to remove. +def _load_data_asset_catalog(): + _path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data_assets.md") + try: + with open(_path, "r", encoding="utf-8") as _fh: + _txt = _fh.read().strip() + if _txt: + print(" [CATALOG] Data-asset catalog loaded (%d chars)." % len(_txt)) + return _txt + except FileNotFoundError: + pass + except Exception as _exc: # noqa: BLE001 - never block startup on this + print(" [CATALOG] Could not read data_assets.md: %s" % _exc) + print(" [CATALOG] No data_assets.md; the agent will discover the schema at runtime.") + return ("(Not available in this deployment - discover the schema with " + "`search_entries` / `get_table_info` before writing SQL.)") + + +data_asset_catalog = _load_data_asset_catalog() + # --- Instruction sections for optional toolsets (replaced below) --- _custom_mcp_sections = "" for _mcp_i, _mcp in enumerate(tools.get_mcp_config()): @@ -697,6 +740,7 @@ def _read_generated_instruction(): .replace("[COLLECTION_ID]", _fs_collection) .replace("[REFERENCE_DATE]", _reference_date) .replace("[CURRENT_REAL_DATE]", _ge_real_today) + .replace("[DATA_ASSET_CATALOG]", data_asset_catalog) .replace("[PUBLIC_DATASET_INFO]", public_info.replace("[PUBLIC_DATASET_ID]", _public_dataset_id)) .replace("[CUSTOM_MCP_SECTIONS]\n", _custom_mcp_sections) .replace("[WORKSPACE_MCP_SECTION]\n", _workspace_mcp_section) @@ -704,6 +748,70 @@ def _read_generated_instruction(): .replace("[GENERATED_SYSTEM_INSTRUCTION]", gen_instruction) ) +# --- Data exploration routing --- +# The expensive habit this replaces is not SQL, it is the metadata expedition in +# front of it: the model opens a figure question with search_entries -> +# lookup_entry -> get_table_info before the first useful call. The catalog is +# already in this prompt, so that is three round trips buying nothing, and saying +# so is what makes the difference. Two contradictory rules in a 100k-token prompt +# do not resolve in favour of the later one, they resolve at random, so there is +# deliberately no competing METADATA-FIRST rule anywhere above this block. +_ONE_QUERY_RULE = ( + "ONE QUERY, NOT SEVERAL (MANDATORY). Before calling `execute_sql`, work out every " + "figure the answer needs and fetch them ALL in a single statement - conditional " + "aggregation, GROUP BY, or UNION ALL across the parts. Budget versus actual for a " + "period is ONE query returning budget, actual, variance and variance rate per row, " + "never one query per company, per account or per month.\n\n" +) +# ADK executes every function call emitted in ONE model turn concurrently +# (asyncio.gather in google/adk/flows/llm_flows/functions.py), so N independent +# calls issued together cost one round trip instead of N. Measured on 2026-08-23: +# across 24 runs of the generated agent, turns == calls in every single one - the +# model never once batched on its own. This rule exists to make it. +_PARALLEL_CALLS_RULE = ( + "SEVERAL CALLS? MAKE THEM IN THE SAME TURN (MANDATORY). One statement cannot always " + "cover it - a SQL aggregate plus a Firestore read, a query plus a place lookup, two " + "systems that hold different halves of the answer. When the calls do NOT depend on each " + "other's results, emit them ALL as function calls in ONE response: calls issued together " + "run concurrently, so three of them cost one round trip instead of three. Issuing one, " + "waiting for it, then issuing the next is the slowest possible way to ask the same " + "questions, and the user sits through every extra wait.\n" + "The exception is a real dependency: when call B needs a value only call A's result can " + "supply, B waits for the next turn. Never invent A's output to fake a batch.\n\n" +) +_NO_NARRATION_RULE = ( + "Never narrate which path you took and never name the tool to the user - they asked " + "a business question, not for a query plan.\n" +) + +instruction += ( + "\n\n--- DATA EXPLORATION: ANSWER IN ONE CALL (MANDATORY) ---\n" + "Three paths, listed in ascending cost. Pick the cheapest one that can actually " + "answer the question asked, and never walk further down the list for practice.\n\n" + "PATH 0 - ANSWER FROM THIS PROMPT. Zero calls, instant. The data-asset catalog above " + "already names every table, every column and its business meaning. Any question about " + "WHAT DATA EXISTS or what you are able to analyse is answered from it directly.\n" + "PATH 1 - SQL: `execute_sql`. One call. The catalog above IS your schema, so a figure " + "question needs no metadata expedition first - write the query and run it.\n" + "PATH 2 - CATALOG: `search_entries` / `lookup_entry` / `lookup_context`, then SQL. Two " + "extra round trips before the user sees anything.\n\n" + "ROUTING:\n" + "- \"What data do you have\", \"what can you analyse\" -> PATH 0. Zero tool calls.\n" + "- EVERYTHING that touches actual records - a lookup by name or id, a profile, an " + "aggregate, a comparison, a ranking, a trend, a filter over a date or numeric range, " + "a join, or any write -> PATH 1, directly. Do not stop at PATH 2 on the way.\n" + "- PATH 2 ONLY when the question is about the metadata itself, when a column you need " + "is not described above, or when `execute_sql` failed and you need the real schema to " + "fix it.\n\n" + + _ONE_QUERY_RULE + _PARALLEL_CALLS_RULE + + "ANSWER THE QUESTION THAT WAS ASKED. \"Tell me about X\" asks who or what X is - ONE " + "query for that record's own row, NOT a full analytical work-up. Offer the sales " + "trend, the customer mix and the ranking as follow-up buttons; run them when the user " + "presses one.\n\n" + + _NO_NARRATION_RULE + + "--- END DATA EXPLORATION ---\n" +) + # --- Conditional Data Viewer integration --- _viewer_url = os.environ.get("DATA_VIEWER_URL", "") if _viewer_url: @@ -1497,6 +1605,272 @@ def _dedup_workspace_writes(tool, args, tool_context): } return None +# ============================================================================= +# BigQuery scope + SQL error-recovery gate +# ============================================================================= +# Two failures observed in one diagnostic run on 2026-08-23, both of which the +# instruction already forbade in words and neither of which the words prevented: +# +# 1. DATASET ISOLATION was ignored. With its own dataset returning "Not found", +# the agent went looking around the project and answered the user from +# THREE other demos' datasets - demo_shelf_inventory_*, demo_smart_shelf_*, +# demo_coffee_retail_* - presenting another demo's numbers as this demo's. +# It also read `region-us.INFORMATION_SCHEMA`, which is project-wide. +# 2. There is no cap on SQL error recovery. The same run burned more than ten +# model round trips re-attempting variations of a query that could not +# succeed, roughly a minute and a half of silence in front of the user, +# and never told them anything was wrong. +# +# A rule in a 100k-token prompt is a suggestion. These are the enforcement. +import re as _ge_re + +_BQ_SQL_TOOLS = frozenset(('execute_sql', 'execute_sql_readonly', 'query', + 'run_query', 'execute_query')) +# Only identifiers in table position are inspected. Matching every dotted token +# in the statement would trip over struct field access (`t.address.city`) and +# string literals, and a false positive here does not slow a demo down, it +# breaks it. +_BQ_REF_TOKEN = r'(?:`[^`]+`|[A-Za-z_][\w\-]*)' +_BQ_REF_AT_RE = _ge_re.compile( + r'\s*(' + _BQ_REF_TOKEN + r'(?:\s*\.\s*' + _BQ_REF_TOKEN + r')*)') +_BQ_KEYWORD_RE = _ge_re.compile(r'\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b', + _ge_re.IGNORECASE) +_BQ_ALIAS_RE = _ge_re.compile(r'\s*(?:AS\s+)?([A-Za-z_]\w*)', _ge_re.IGNORECASE) +_BQ_COMMA_RE = _ge_re.compile(r'\s*,') +# Words that can follow a table reference but are never its alias. Consuming one +# as an alias would let the scan walk past the end of the FROM clause. +_BQ_NOT_AN_ALIAS = frozenset(( + 'ON', 'USING', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', + 'WINDOW', 'QUALIFY', 'UNION', 'INTERSECT', 'EXCEPT', 'JOIN', 'INNER', + 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'SELECT', 'SET', 'VALUES', 'WITH', + 'TABLESAMPLE', 'PIVOT', 'UNPIVOT', 'FOR', 'WHEN', 'USE')) +# Comments and string literals are blanked before the scan. A note column +# reading 'escalated from siu.queue' is not a table reference, and neither is a +# -- comment saying where the numbers came from; both used to be blocked. +# Backtick-quoted identifiers are deliberately NOT stripped: they are the table +# names this is here to read. +_BQ_SQL_NOISE_RE = _ge_re.compile( + r"--[^\n]*" + r"|/\*.*?\*/" + r"|'''.*?'''" + r'|""".*?"""' + r"|'(?:\\.|[^'\\\n])*'" + r'|"(?:\\.|[^"\\\n])*"', + _ge_re.DOTALL) +# Anchored, because "error" appears in plenty of legitimate result rows - a +# status column, an incident description, an audit note. +_BQ_ERROR_PREFIXES = ( + 'not found', 'syntax error', 'unrecognized name', 'invalid ', + 'access denied', 'permission denied', 'table not found', + 'no matching signature', 'query error', 'bad request', '400 ', '403 ', +) +_BQ_MAX_CONSECUTIVE_ERRORS = 3 + + +def _bq_allowed_datasets(): + """Datasets this agent may read, lower-cased. Its own, plus any opt-in.""" + _allowed = {(os.environ.get("BIGQUERY_DATASET", "") or "").strip().lower()} + # Escape hatch for a demo that legitimately joins against another dataset + # (a shared reference dataset, a verified public table outside the + # bigquery-public-data project). Accepts `dataset` or `project.dataset`; + # only the dataset part is compared. Semicolons as well as commas, because + # this arrives through `gcloud run deploy --set-env-vars`, whose own + # separator is the comma - a comma-separated value there would be read as + # the start of the next variable. + for _extra in _ge_re.split(r'[,;]', os.environ.get("BQ_ALLOWED_DATASETS", "") or ""): + _extra = _extra.strip().lower() + if _extra: + _allowed.add(_extra.split(".")[-1]) + _allowed.discard("") + return _allowed + + +def _bq_skip_parens(sql, i): + """Index just past the parenthesised group that starts at sql[i] == '('.""" + _depth = 0 + while i < len(sql): + if sql[i] == '(': + _depth += 1 + elif sql[i] == ')': + _depth -= 1 + if _depth == 0: + return i + 1 + i += 1 + return len(sql) + + +def _bq_refs_after_keyword(sql, pos): + """The table references one FROM/JOIN keyword introduces, commas included. + + `FROM a.x, b.y` is a join written with a comma, and every item after the + first is as much a table reference as the first - reading only the first one + let a second dataset in through the side door. The walk stays anchored to + references it has already accepted, so a comma in a SELECT list, where the + dotted tokens are aliases and struct fields, can never reach it. + """ + _refs = [] + while True: + _m = _BQ_REF_AT_RE.match(sql, pos) + if not _m: + break + pos = _m.end() + if pos < len(sql) and sql[pos] == '(': + # UNNEST(...), EXTERNAL_QUERY(...): a call, not a table. + pos = _bq_skip_parens(sql, pos) + else: + _refs.append(_m.group(1)) + _alias = _BQ_ALIAS_RE.match(sql, pos) + if _alias and _alias.group(1).upper() not in _BQ_NOT_AN_ALIAS: + pos = _alias.end() + _comma = _BQ_COMMA_RE.match(sql, pos) + if not _comma: + break + pos = _comma.end() + return _refs + + +def _bq_out_of_scope_refs(sql): + """Returns the table references in `sql` that fall outside this demo. + + Each entry is the offending text as it appeared, so the message handed back + to the model names the thing it actually wrote. + """ + _allowed = _bq_allowed_datasets() + if not _allowed: + return [] + _clean = _BQ_SQL_NOISE_RE.sub(' ', str(sql or "")) + _bad = [] + _raws = [] + for _kw in _BQ_KEYWORD_RE.finditer(_clean): + _raws.extend(_bq_refs_after_keyword(_clean, _kw.end())) + for _raw in _raws: + _parts = [_p.strip().strip('`').strip() + for _p in _ge_re.split(r'\s*\.\s*', _raw.strip().strip('`'))] + _parts = [_p for _p in _parts if _p] + if len(_parts) < 2: + # A CTE name, an alias, UNNEST(...), or an unqualified table. None + # of them can reach another dataset. + continue + # region-us.INFORMATION_SCHEMA.JOBS and friends are project-wide by + # definition: there is no dataset to scope them to. + if _parts[0].lower().startswith('region-'): + _bad.append(_raw.strip()) + continue + _upper = [_p.upper() for _p in _parts] + if 'INFORMATION_SCHEMA' in _upper: + # Its position, not the part count, says which token is the dataset: + # `ds.INFORMATION_SCHEMA.COLUMNS` and + # `proj.ds.INFORMATION_SCHEMA.COLUMNS` are both scoped to the part + # immediately before the keyword. Reading parts[1] as the dataset + # here would reject the agent's own dataset. + _idx = _upper.index('INFORMATION_SCHEMA') + _project = _parts[0].lower() if _idx >= 2 else "" + _dataset = _parts[_idx - 1].lower() if _idx >= 1 else "" + else: + _project = _parts[0].lower() if len(_parts) >= 3 else "" + _dataset = _parts[1].lower() if len(_parts) >= 3 else _parts[0].lower() + if _project == 'bigquery-public-data': + continue + if _dataset in _allowed: + continue + _bad.append(_raw.strip()) + # The same table named twice reads as two violations otherwise, and the + # message quotes only the first five. + return list(dict.fromkeys(_bad)) + + +def _looks_like_sql_error(tool_response): + """True when a BigQuery MCP response is a failure rather than a result set.""" + if isinstance(tool_response, dict): + if tool_response.get('error') or tool_response.get('isError'): + return True + if str(tool_response.get('status', '')).lower() in ('error', 'failed'): + return True + _txt = str(tool_response or "").strip().lower()[:400] + if not _txt: + return False + return any(_marker in _txt[:200] for _marker in _BQ_ERROR_PREFIXES) + + +def _bq_error_state(tool_context): + """Per-invocation consecutive-failure counter. + + Scoped to the invocation on purpose: a turn that ends on three failures must + not leave the next turn pre-blocked, and the user's follow-up question is + frequently the thing that fixes the query. + """ + _inv = str(getattr(tool_context, 'invocation_id', '') or '') + _state = tool_context.state.get('_bq_err') or {} + if _state.get('inv') != _inv: + _state = {'inv': _inv, 'n': 0} + return _state + + +def _bigquery_scope_gate(tool, args, tool_context): + """Block cross-dataset SQL, and stop the agent flailing after N failures.""" + _name = getattr(tool, 'name', '') or '' + if _name not in _BQ_SQL_TOOLS: + return None + if os.environ.get("BQ_SCOPE_GATE_OFF", "").lower() in ("1", "true", "yes"): + return None + + _state = _bq_error_state(tool_context) + if _state.get('n', 0) >= _BQ_MAX_CONSECUTIVE_ERRORS: + return { + "status": "blocked", + "message": ( + "SQL RECOVERY BUDGET EXHAUSTED: " + str(_state['n']) + " queries in a " + "row have failed, so this one was not run. Stop rewriting the query. " + "Tell the user plainly, in one or two sentences, WHAT you were trying " + "to work out and WHAT the database said, answer whatever part of their " + "question the data you already have can answer, and offer a suggestion " + "chip to retry. Never present another demo's data, an estimate, or an " + "invented figure as the answer." + ), + } + + _a = args or {} + _sql = _a.get('query') or _a.get('sql') or _a.get('statement') or '' + _bad = _bq_out_of_scope_refs(str(_sql)) + if not _bad: + return None + _dataset = os.environ.get("BIGQUERY_DATASET", "") + print(" [SCOPE GATE] Blocked out-of-scope SQL reference(s): " + ", ".join(_bad[:5])) + return { + "status": "blocked", + "message": ( + "DATASET ISOLATION VIOLATION - this query was NOT run. It references " + + ", ".join("`" + _b + "`" for _b in _bad[:5]) + ", which is outside this " + "demo. The only dataset you may read is `" + _dataset + "` (plus " + "`bigquery-public-data` when the instruction names a public table). Other " + "datasets in this project belong to OTHER demos and their numbers are not " + "this business's numbers - quoting them would be a fabricated answer. " + "Project-wide views such as `region-us.INFORMATION_SCHEMA` are off limits " + "for the same reason. Rewrite the query against `" + _dataset + "`, and if " + "the data genuinely is not there, say so instead of looking elsewhere." + ), + } + + +def _bq_track_sql_errors(tool, args, tool_context, tool_response): + """Count consecutive BigQuery failures so _bigquery_scope_gate can cap them.""" + _name = getattr(tool, 'name', '') or '' + if _name not in _BQ_SQL_TOOLS: + return None + try: + _state = _bq_error_state(tool_context) + if _looks_like_sql_error(tool_response): + _state['n'] = _state.get('n', 0) + 1 + print(" [SQL RECOVERY] Failure %d/%d this turn." + % (_state['n'], _BQ_MAX_CONSECUTIVE_ERRORS)) + else: + _state['n'] = 0 + tool_context.state['_bq_err'] = _state + except Exception: # noqa: BLE001 - a broken counter must not break the tool + pass + return None + + def _record_workspace_write(tool, args, tool_context, tool_response): """After a Workspace write succeeds, record it for dedup.""" _name = getattr(tool, 'name', '') @@ -2342,8 +2716,8 @@ def _log_bq_activity(tool, args, tool_context, tool_response): generate_content_config=_validated_generate_config, before_model_callback=_strip_part_metadata, after_model_callback=[inject_image_callback, a2ui_metadata_callback, _enforce_task_result_text], - before_tool_callback=[_inline_tool_budget_gate, _dedup_workspace_writes], - after_tool_callback=[_record_workspace_write, _log_bq_activity], + before_tool_callback=[_inline_tool_budget_gate, _dedup_workspace_writes, _bigquery_scope_gate], + after_tool_callback=[_record_workspace_write, _log_bq_activity, _bq_track_sql_errors], disallow_transfer_to_parent=False, disallow_transfer_to_peers=False, ) @@ -2936,8 +3310,8 @@ def _root_instruction(_ctx): before_agent_callback=_inject_completed_tasks, before_model_callback=_strip_part_metadata, after_model_callback=[inject_image_callback, a2ui_metadata_callback, _enforce_task_result_text], - before_tool_callback=[_inline_tool_budget_gate, _dedup_workspace_writes], - after_tool_callback=[_record_workspace_write, _log_bq_activity], + before_tool_callback=[_inline_tool_budget_gate, _dedup_workspace_writes, _bigquery_scope_gate], + after_tool_callback=[_record_workspace_write, _log_bq_activity, _bq_track_sql_errors], ) # --- Background execution agent (Pro) --- @@ -3171,8 +3545,8 @@ def _root_instruction(_ctx): generate_content_config=_validated_generate_config, before_model_callback=_strip_part_metadata, after_model_callback=[_enforce_task_result_text], - before_tool_callback=_dedup_workspace_writes, - after_tool_callback=[_record_workspace_write, _log_bq_activity], + before_tool_callback=[_dedup_workspace_writes, _bigquery_scope_gate], + after_tool_callback=[_record_workspace_write, _log_bq_activity, _bq_track_sql_errors], ) app = App( diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/action_plan.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/action_plan.json index 00c0e66052e..e4eccfa335b 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/action_plan.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/action_plan.json @@ -28,8 +28,7 @@ "step1", "step2", "step3", - "step4", - "actionRow" + "step4" ], "align": "stretch" }, @@ -42,7 +41,7 @@ { "id": "subtitle", "component": "MaterialText", - "text": "Economics Engagement Improvement — 4-Step Strategy", + "text": "Mid-Market Engagement Improvement — 4-Step Strategy", "usageHint": "subtitle1" }, { @@ -52,39 +51,53 @@ { "id": "step1", "component": "MaterialText", - "text": "1️⃣ [Immediate] Personal outreach email from the Dean to Takahashi (CFO) — Expected: Engagement Score +15pt", + "text": "1️⃣ [Immediate] Personal outreach email from the account executive to Takahashi (CFO) — Expected: Engagement Score +15pt", "usageHint": "body" }, { "id": "step2", "component": "MaterialText", - "text": "2️⃣ [Within 1 month] Plan & invite to VIP dinner event — Target: 5 mid-tier alumni (Score 40-60)", + "text": "2️⃣ [Within 1 month] Plan & invite to an executive briefing — Target: 5 mid-tier accounts (Score 40-60)", "usageHint": "body" }, { "id": "step3", "component": "MaterialText", - "text": "3️⃣ [Within 3 months] Launch Economics-exclusive mentoring program — Goal: Faculty avg Score 67→75", + "text": "3️⃣ [Within 3 months] Launch a Mid-Market enablement program — Goal: Segment avg Score 67→75", "usageHint": "body" }, { "id": "step4", "component": "MaterialText", - "text": "4️⃣ [At 6 months] Impact assessment & next strategy — KPI: Donations +20% YoY, Avg Score ≥75", + "text": "4️⃣ [At 6 months] Impact assessment & next strategy — KPI: Value +20% YoY, Avg Score ≥75", "usageHint": "body" - }, + } + ] + } + }, + { + "version": "v0.9", + "createSurface": { + "surfaceId": "suggestions", + "catalogId": "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "suggestions", + "components": [ { - "id": "actionRow", + "id": "root", "component": "MaterialRow", "children": [ "btnStep1", "btnSchedule" ], - "justify": "end", + "justify": "start", "align": "center", "style": { - "gap": "8px", - "marginTop": "12px" + "gap": "8px" } }, { diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/analysis_summary_card.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/analysis_summary_card.json index b09267a7d78..2dab6ae94fb 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/analysis_summary_card.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/analysis_summary_card.json @@ -26,8 +26,7 @@ "divider1", "kpiRow", "divider2", - "summaryText", - "actionRow" + "summaryText" ], "align": "stretch" }, @@ -79,19 +78,33 @@ "component": "MaterialText", "text": "Key findings: Revenue growth driven by APAC region (+15.3%). Three critical anomalies in billing reconciliation require immediate attention. Recommended action: escalate invoice IDs INV-4521, INV-4589 to finance team.", "usageHint": "body" - }, + } + ] + } + }, + { + "version": "v0.9", + "createSurface": { + "surfaceId": "suggestions", + "catalogId": "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "suggestions", + "components": [ { - "id": "actionRow", + "id": "root", "component": "MaterialRow", "children": [ "btnDrillDown", "btnExport" ], - "justify": "end", + "justify": "start", "align": "center", "style": { - "gap": "8px", - "marginTop": "12px" + "gap": "8px" } }, { diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/batch_editor.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/batch_editor.json index 7e67758cbe9..bbda886cd36 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/batch_editor.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/batch_editor.json @@ -41,6 +41,7 @@ "row_container_0", "dividerRow1", "row_container_1", + "footerDivider", "actionRow" ], "align": "stretch" @@ -58,7 +59,7 @@ { "id": "companyHeader1", "component": "MaterialText", - "text": "🏢 Kansai Air Conditioning Services Co., Ltd.", + "text": "🏢 Example Corporation", "usageHint": "h3" }, { @@ -97,7 +98,7 @@ { "id": "orig_name_0", "component": "MaterialText", - "text": "エアコン5馬力 (SZRC140BC)", + "text": "Line item A (LEGACY-1001)", "usageHint": "body" }, { @@ -181,7 +182,7 @@ { "id": "orig_name_1", "component": "MaterialText", - "text": "エアコン3馬力 (PROD012)", + "text": "Line item B (LEGACY-1002)", "usageHint": "body" }, { @@ -225,6 +226,10 @@ "text": "💡 Matches master catalog with 95% confidence", "usageHint": "caption" }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "actionRow", "component": "MaterialRow", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/calendar_event_compose.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/calendar_event_compose.json index fbb6f69c905..b8edeee7134 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/calendar_event_compose.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/calendar_event_compose.json @@ -45,6 +45,7 @@ "tEnd", "fLoc", "fAtt", + "footerDivider", "actions" ], "align": "stretch", @@ -110,6 +111,10 @@ "path": "/form/attendees" } }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "actions", "component": "MaterialRow", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/canvas_report.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/canvas_report.json index 897f52b9e76..110cbc3450e 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/canvas_report.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/canvas_report.json @@ -34,6 +34,7 @@ "repBody2", "repSec3", "repBody3", + "footerDivider", "repActions" ], "align": "stretch", @@ -87,6 +88,10 @@ "text": "Prioritise the APAC expansion budget, freeze discretionary discounting in the Americas until the reconciliation gaps close, and add an automated variance check to the nightly billing job.", "usageHint": "body" }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "repActions", "component": "MaterialRow", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/chat_compose.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/chat_compose.json index d92af5df0c7..0b60b8852d0 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/chat_compose.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/chat_compose.json @@ -37,6 +37,7 @@ "divider1", "selectSpace", "fieldMsg", + "footerDivider", "actions" ], "align": "stretch", @@ -84,6 +85,10 @@ "path": "/form/message" } }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "actions", "component": "MaterialRow", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/comparison_matrix.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/comparison_matrix.json index 6822d03f0ef..d79ea5f7910 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/comparison_matrix.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/comparison_matrix.json @@ -10,23 +10,23 @@ "version": "v0.9", "updateDataModel": { "surfaceId": "comparison-matrix", - "path": "/faculties", + "path": "/segments", "value": [ { - "faculty": "🏗️ Engineering", - "donations": "[CURRENCY]1.97M", + "segment": "🏢 Enterprise", + "value": "[CURRENCY]1.97M", "score": "79.0", "members": "6" }, { - "faculty": "⚖️ Law", - "donations": "[CURRENCY]1.77M", + "segment": "🏬 Mid-Market", + "value": "[CURRENCY]1.77M", "score": "75.5", "members": "8" }, { - "faculty": "💰 Economics", - "donations": "[CURRENCY]1.20M", + "segment": "🏪 SMB", + "value": "[CURRENCY]1.20M", "score": "67.0", "members": "8" } @@ -40,19 +40,19 @@ "path": "/chartSpec", "value": { "$schema": "https://vega.github.io/schema/vega-lite/v5.json", - "description": "Average engagement score by faculty", + "description": "Average engagement score by segment", "data": { "values": [ { - "faculty": "Engineering", + "segment": "Enterprise", "score": 79.0 }, { - "faculty": "Law", + "segment": "Mid-Market", "score": 75.5 }, { - "faculty": "Economics", + "segment": "SMB", "score": 67.0 } ] @@ -60,9 +60,9 @@ "mark": "bar", "encoding": { "x": { - "field": "faculty", + "field": "segment", "type": "nominal", - "title": "Faculty" + "title": "Segment" }, "y": { "field": "score", @@ -92,15 +92,14 @@ "title", "table", "chart", - "summaryText", - "actionRow" + "summaryText" ], "align": "stretch" }, { "id": "title", "component": "MaterialText", - "text": "📊 Faculty Performance Comparison", + "text": "📊 Segment Performance Comparison", "usageHint": "h2" }, { @@ -108,12 +107,12 @@ "component": "MaterialTable", "columns": [ { - "header": "Faculty", - "field": "faculty" + "header": "Segment", + "field": "segment" }, { - "header": "Donations", - "field": "donations" + "header": "Value", + "field": "value" }, { "header": "Avg Score", @@ -125,7 +124,7 @@ } ], "rows": { - "path": "/faculties" + "path": "/segments" }, "style": { "marginTop": "8px" @@ -135,9 +134,6 @@ "id": "chart", "component": "VegaChart", "height": 220, - "style": { - "marginTop": "12px" - }, "spec": { "path": "/chartSpec" } @@ -145,35 +141,49 @@ { "id": "summaryText", "component": "MaterialText", - "text": "💡 Engineering leads in both donations and score. Economics has more members but lower scores — engagement strategy reinforcement recommended.", + "text": "💡 Enterprise leads in both value and score. SMB has more accounts but lower scores — engagement strategy reinforcement recommended.", "usageHint": "body", "style": { "marginTop": "12px" } - }, + } + ] + } + }, + { + "version": "v0.9", + "createSurface": { + "surfaceId": "suggestions", + "catalogId": "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "suggestions", + "components": [ { - "id": "actionRow", + "id": "root", "component": "MaterialRow", "children": [ "btnEcon", "btnReport" ], - "justify": "end", + "justify": "start", "align": "center", "style": { - "gap": "8px", - "marginTop": "12px" + "gap": "8px" } }, { "id": "btnEcon", "component": "MaterialButton", - "label": "📉 Deep-Dive Economics", + "label": "📉 Deep-Dive SMB", "action": { "event": { - "name": "deep_dive_faculty", + "name": "deep_dive_segment", "context": { - "prompt": "Analyze the root cause of low engagement in Economics" + "prompt": "Analyze the root cause of low engagement in SMB" } } }, @@ -182,12 +192,12 @@ { "id": "btnReport", "component": "MaterialButton", - "label": "📋 All Faculties Report", + "label": "📋 All Segments Report", "action": { "event": { - "name": "generate_faculty_report", + "name": "generate_segment_report", "context": { - "prompt": "Generate a detailed report for all faculties" + "prompt": "Generate a detailed report for all segments" } } } diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/complex_confirmation_card.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/complex_confirmation_card.json index 396797208ba..ecd04c73168 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/complex_confirmation_card.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/complex_confirmation_card.json @@ -25,6 +25,7 @@ "titleText", "beforeText", "afterText", + "footerDivider", "actionRow" ], "align": "stretch" @@ -47,6 +48,10 @@ "text": "After: [New Data Summary]", "usageHint": "body" }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "actionRow", "component": "MaterialRow", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/detail_modal.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/detail_modal.json index fe5f9142f77..f90d17fa47f 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/detail_modal.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/detail_modal.json @@ -27,8 +27,7 @@ "kpiRow", "divider2", "openDialogBtn", - "profileDialog", - "actionRow" + "profileDialog" ], "align": "stretch" }, @@ -92,7 +91,7 @@ { "id": "kpi2Lbl", "component": "MaterialText", - "text": "Lifetime Donations", + "text": "Lifetime Value", "usageHint": "caption" }, { @@ -113,7 +112,7 @@ { "id": "kpi3Lbl", "component": "MaterialText", - "text": "Attendance", + "text": "Engagements", "usageHint": "caption" }, { @@ -147,33 +146,47 @@ { "id": "detailInfo", "component": "MaterialText", - "text": "🏢 Mitsubishi UFJ Bank CFO — 🎓 Class of 2000, Economics — 📧 k.takahashi@example.com", + "text": "🏢 Example Corporation CFO — 🏷️ Mid-Market segment, customer since 2000 — 📧 k.takahashi@example.com", "usageHint": "body" }, { "id": "detailHistory", "component": "MaterialText", - "text": "💰 Donation History: 2021: [CURRENCY]10,000 — 2022: [CURRENCY]15,000 — 2023: [CURRENCY]25,000 — Total: [CURRENCY]50,000", + "text": "💰 Value History: 2021: [CURRENCY]10,000 — 2022: [CURRENCY]15,000 — 2023: [CURRENCY]25,000 — Total: [CURRENCY]50,000", "usageHint": "body" }, { "id": "detailEvents", "component": "MaterialText", - "text": "📅 Event Attendance: 75% (3/4) — ✅ Career Seminar, Alumni Meetup, Lecture — ❌ Spring Gala 2024", + "text": "📅 Engagement: 75% (3/4) — ✅ Quarterly Review, User Group Meetup, Product Webinar — ❌ Q2 Executive Briefing", "usageHint": "body" - }, + } + ] + } + }, + { + "version": "v0.9", + "createSurface": { + "surfaceId": "suggestions", + "catalogId": "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "suggestions", + "components": [ { - "id": "actionRow", + "id": "root", "component": "MaterialRow", "children": [ "btnApproach", "btnEdit" ], - "justify": "end", + "justify": "start", "align": "center", "style": { - "gap": "8px", - "marginTop": "12px" + "gap": "8px" } }, { @@ -196,7 +209,7 @@ "label": "✏️ Edit Record", "action": { "event": { - "name": "edit_alumni_record", + "name": "edit_contact_record", "context": { "prompt": "I want to edit Takahashi's record" } diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/drive_file_compose.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/drive_file_compose.json index 0d514eee7b0..908951b03ae 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/drive_file_compose.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/drive_file_compose.json @@ -39,6 +39,7 @@ "fName", "selectType", "fContent", + "footerDivider", "actions" ], "align": "stretch", @@ -94,6 +95,10 @@ "path": "/form/content" } }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "actions", "component": "MaterialRow", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/email_compose.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/email_compose.json index a0b99bf58c5..99517df024c 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/email_compose.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/email_compose.json @@ -39,6 +39,7 @@ "fTo", "fSubject", "fBody", + "footerDivider", "actions" ], "align": "stretch", @@ -81,6 +82,10 @@ "path": "/form/body" } }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "actions", "component": "MaterialRow", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/event_list.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/event_list.json index 563d5e476ee..c9eb5aac905 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/event_list.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/event_list.json @@ -25,21 +25,20 @@ "title", "subtitle", "divider1", - "eventList", - "actionRow" + "eventList" ], "align": "stretch" }, { "id": "title", "component": "MaterialText", - "text": "📅 Event Attendance History", + "text": "📅 Engagement History", "usageHint": "h2" }, { "id": "subtitle", "component": "MaterialText", - "text": "Kenta Takahashi (ALM-005) — Past 12 Months", + "text": "Kenta Takahashi (ACC-005) — Past 12 Months", "usageHint": "caption" }, { @@ -82,7 +81,7 @@ { "id": "ev1Text", "component": "MaterialText", - "text": "2024/03/05 Global Career Seminar — Attended", + "text": "2024/03/05 Quarterly Business Review — Attended", "usageHint": "body" }, { @@ -107,7 +106,7 @@ { "id": "ev2Text", "component": "MaterialText", - "text": "2024/04/10 Spring Gala 2024 — No-Show", + "text": "2024/04/10 Product Webinar — No-Show", "usageHint": "body" }, { @@ -132,7 +131,7 @@ { "id": "ev3Text", "component": "MaterialText", - "text": "2024/06/15 Alumni Summer Meetup — Attended", + "text": "2024/06/15 User Group Meetup — Attended", "usageHint": "body" }, { @@ -157,21 +156,35 @@ { "id": "ev4Text", "component": "MaterialText", - "text": "2024/09/20 Autumn Gala 2024 — Invited (Pending)", + "text": "2024/09/20 Q3 Executive Briefing — Invited (Pending)", "usageHint": "body" - }, + } + ] + } + }, + { + "version": "v0.9", + "createSurface": { + "surfaceId": "suggestions", + "catalogId": "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "suggestions", + "components": [ { - "id": "actionRow", + "id": "root", "component": "MaterialRow", "children": [ "btnAll", "btnInvite" ], - "justify": "end", + "justify": "start", "align": "center", "style": { - "gap": "8px", - "marginTop": "12px" + "gap": "8px" } }, { @@ -182,7 +195,7 @@ "event": { "name": "show_full_event_history", "context": { - "prompt": "Show the full event attendance history for Takahashi" + "prompt": "Show the full engagement history for Takahashi" } } } @@ -195,7 +208,7 @@ "event": { "name": "draft_rsvp_email", "context": { - "prompt": "Draft an RSVP confirmation email for Autumn Gala 2024" + "prompt": "Draft an RSVP confirmation email for the Q3 Executive Briefing" } } }, diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/iframe_dashboard.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/iframe_dashboard.json index bff18374b28..9d77cc7248b 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/iframe_dashboard.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/iframe_dashboard.json @@ -38,10 +38,7 @@ "id": "frame", "component": "IFrameSrcdoc", "height": 260, - "htmlContent": "

Regional Performance

$12.4MTotal revenue
+8.2%YoY growth
23Open anomalies
", - "style": { - "marginTop": "8px" - } + "htmlContent": "

Regional Performance

$12.4MTotal revenue
+8.2%YoY growth
23Open anomalies
" }, { "id": "note", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/image_report.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/image_report.json index c82c81225aa..01f30c30e8d 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/image_report.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/image_report.json @@ -25,15 +25,14 @@ "title", "divider1", "chartImage", - "insight", - "actionRow" + "insight" ], "align": "stretch" }, { "id": "title", "component": "MaterialText", - "text": "📊 Donation Trend Analysis Report", + "text": "📊 Value Trend Analysis Report", "usageHint": "h2" }, { @@ -44,31 +43,45 @@ "id": "chartImage", "component": "MaterialImage", "url": "https://example.com/chart.png", - "alt": "2020-2024 Donation Trends by Faculty", + "alt": "2020-2024 Value Trends by Segment", "fit": "contain", "roundedCorners": true }, { "id": "insight", "component": "MaterialText", - "text": "💡 Engineering donations up +23% YoY. Economics down -8%. Engagement strategy review recommended.", + "text": "💡 Enterprise value up +23% YoY. SMB down -8%. Engagement strategy review recommended.", "usageHint": "body", "style": { "marginTop": "12px" } - }, + } + ] + } + }, + { + "version": "v0.9", + "createSurface": { + "surfaceId": "suggestions", + "catalogId": "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "suggestions", + "components": [ { - "id": "actionRow", + "id": "root", "component": "MaterialRow", "children": [ "btnDetail", "btnExport" ], - "justify": "end", + "justify": "start", "align": "center", "style": { - "gap": "8px", - "marginTop": "12px" + "gap": "8px" } }, { @@ -77,9 +90,9 @@ "label": "📉 Root Cause", "action": { "event": { - "name": "deep_dive_faculty", + "name": "deep_dive_segment", "context": { - "prompt": "Analyze the root cause of declining donations in the Economics faculty" + "prompt": "Analyze the root cause of declining value in the SMB segment" } } }, diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/interactive_form.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/interactive_form.json index e4fff024d3d..83d24ccdb4b 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/interactive_form.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/interactive_form.json @@ -14,11 +14,11 @@ "value": { "name": "Kenta Takahashi", "dept": "Corporate Planning", - "faculty": "Economics", + "segment": "Mid-Market", "score": 45, "contactDate": "2024-03-05", "vip": false, - "notes": "Key contact for CFO network.\nSchedule follow-up after Autumn Gala." + "notes": "Key contact for the CFO network.\nSchedule follow-up after the Q3 review." } } }, @@ -42,12 +42,13 @@ "divider1", "fieldName", "fieldDept", - "selectFaculty", + "selectSegment", "scoreLabel", "sliderScore", "dateContact", "toggleVip", "fieldNotes", + "footerDivider", "actionRow" ], "align": "stretch", @@ -58,7 +59,7 @@ { "id": "title", "component": "MaterialText", - "text": "📝 Edit Alumni Record", + "text": "📝 Edit Contact Record", "usageHint": "h2" }, { @@ -82,32 +83,32 @@ } }, { - "id": "selectFaculty", + "id": "selectSegment", "component": "MaterialSelect", - "label": "Faculty", + "label": "Segment", "value": { - "path": "/form/faculty" + "path": "/form/segment" }, "options": [ { - "label": "Economics", - "value": "Economics" + "label": "Mid-Market", + "value": "Mid-Market" }, { - "label": "Engineering", - "value": "Engineering" + "label": "Enterprise", + "value": "Enterprise" }, { - "label": "Law", - "value": "Law" + "label": "SMB", + "value": "SMB" }, { - "label": "Medicine", - "value": "Medicine" + "label": "Strategic", + "value": "Strategic" }, { - "label": "Literature", - "value": "Literature" + "label": "Other", + "value": "Other" } ] }, @@ -152,6 +153,10 @@ "path": "/form/notes" } }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "actionRow", "component": "MaterialRow", @@ -172,7 +177,7 @@ "label": "💾 Save", "action": { "event": { - "name": "submit_alumni_record", + "name": "submit_contact_record", "context": { "prompt": "Update the record with the following values", "aName": { @@ -181,8 +186,8 @@ "aDept": { "path": "/form/dept" }, - "aFaculty": { - "path": "/form/faculty" + "aSegment": { + "path": "/form/segment" }, "aScore": { "path": "/form/score" diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/maps_place_card.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/maps_place_card.json index d60cbcb132d..04f6701b149 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/maps_place_card.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/maps_place_card.json @@ -31,8 +31,7 @@ "place2Detail", "divider3", "place3", - "place3Detail", - "actionRow" + "place3Detail" ], "align": "stretch" }, @@ -49,13 +48,13 @@ { "id": "place1", "component": "MaterialText", - "text": "🏢 Palace Hotel Tokyo ⭐ 4.6", + "text": "🏢 Riverside Conference Hotel ⭐ 4.6", "usageHint": "h3" }, { "id": "place1Detail", "component": "MaterialText", - "text": "📌 Marunouchi 1-1-1, Chiyoda | ☎ 03-3211-5211 — 💰 Budget: [CURRENCY]30,000+/person | Capacity: up to 200", + "text": "📌 1-1-1 Central District | ☎ +1-555-0101 — 💰 Budget: [CURRENCY]30,000+/person | Capacity: up to 200", "usageHint": "body" }, { @@ -65,13 +64,13 @@ { "id": "place2", "component": "MaterialText", - "text": "🏢 Andaz Tokyo ⭐ 4.5", + "text": "🏢 Garden Terrace Hotel ⭐ 4.5", "usageHint": "h3" }, { "id": "place2Detail", "component": "MaterialText", - "text": "📌 Toranomon 1-23-4, Minato | ☎ 03-6830-1234 — 💰 Budget: [CURRENCY]25,000+/person | Capacity: up to 150", + "text": "📌 1-23-4 Harbour District | ☎ +1-555-0102 — 💰 Budget: [CURRENCY]25,000+/person | Capacity: up to 150", "usageHint": "body" }, { @@ -81,27 +80,41 @@ { "id": "place3", "component": "MaterialText", - "text": "🏢 Imperial Hotel ⭐ 4.4", + "text": "🏢 Grand Park Hotel ⭐ 4.4", "usageHint": "h3" }, { "id": "place3Detail", "component": "MaterialText", - "text": "📌 Uchisaiwaicho 1-1-1, Chiyoda | ☎ 03-3504-1111 — 💰 Budget: [CURRENCY]35,000+/person | Capacity: up to 300", + "text": "📌 1-1-1 Parkside District | ☎ +1-555-0103 — 💰 Budget: [CURRENCY]35,000+/person | Capacity: up to 300", "usageHint": "body" - }, + } + ] + } + }, + { + "version": "v0.9", + "createSurface": { + "surfaceId": "suggestions", + "catalogId": "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "suggestions", + "components": [ { - "id": "actionRow", + "id": "root", "component": "MaterialRow", "children": [ "btnBook", "btnCompare" ], - "justify": "end", + "justify": "start", "align": "center", "style": { - "gap": "8px", - "marginTop": "12px" + "gap": "8px" } }, { @@ -112,7 +125,7 @@ "event": { "name": "plan_at_venue", "context": { - "prompt": "Create a detailed event plan at Palace Hotel Tokyo" + "prompt": "Create a detailed event plan at Riverside Conference Hotel" } } }, diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/profile_analysis_dashboard.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/profile_analysis_dashboard.json index 278c60d987d..1618030386b 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/profile_analysis_dashboard.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/profile_analysis_dashboard.json @@ -41,13 +41,13 @@ { "id": "headerTitle", "component": "MaterialText", - "text": "📊 Kenta Takahashi (ALM-005) Profile Analysis", + "text": "📊 Kenta Takahashi (ACC-005) Profile Analysis", "usageHint": "h2" }, { "id": "profileSubtitle", "component": "MaterialText", - "text": "Class of 2000, Economics | Mitsubishi UFJ Bank, Head of Corporate Planning", + "text": "Mid-Market segment, customer since 2000 | Example Corporation, Head of Corporate Planning", "usageHint": "subtitle1" }, { @@ -59,7 +59,7 @@ "component": "MaterialRow", "children": [ "kpiScore", - "kpiDonation", + "kpiValue", "kpiRank" ], "justify": "spaceEvenly", @@ -87,24 +87,24 @@ "usageHint": "caption" }, { - "id": "kpiDonation", + "id": "kpiValue", "component": "MaterialColumn", "children": [ - "kpiDonationValue", - "kpiDonationLabel" + "kpiValueAmount", + "kpiValueLabel" ], "align": "center" }, { - "id": "kpiDonationValue", + "id": "kpiValueAmount", "component": "MaterialText", "text": "[CURRENCY]50,000", "usageHint": "h2" }, { - "id": "kpiDonationLabel", + "id": "kpiValueLabel", "component": "MaterialText", - "text": "Lifetime Donations", + "text": "Lifetime Value", "usageHint": "caption" }, { @@ -135,19 +135,19 @@ { "id": "timelineTitle", "component": "MaterialText", - "text": "📅 Event Attendance History", + "text": "📅 Engagement History", "usageHint": "h3" }, { "id": "timelineItem1", "component": "MaterialText", - "text": "✅ 2024/03/05 Global Career Seminar — Attended", + "text": "✅ 2024/03/05 Quarterly Business Review — Attended", "usageHint": "body" }, { "id": "timelineItem2", "component": "MaterialText", - "text": "❌ 2024/04/10 Spring Gala 2024 — Absent (coincided with CFO appointment)", + "text": "❌ 2024/04/10 Product Webinar — Absent (coincided with CFO appointment)", "usageHint": "body" }, { @@ -163,7 +163,7 @@ { "id": "insightBody", "component": "MaterialText", - "text": "Post-CFO appointment workload likely caused the absence. As things stabilize, now is the ideal time for a 1-on-1 outreach from the Dean or a VIP dinner invitation.", + "text": "Post-CFO appointment workload likely caused the absence. As things stabilize, now is the ideal time for a 1-on-1 outreach from the account executive or an executive briefing invitation.", "usageHint": "body" }, { @@ -191,9 +191,9 @@ "label": "🔍 Deep-Dive", "action": { "event": { - "name": "deep_dive_donations", + "name": "deep_dive_value", "context": { - "prompt": "Analyze Takahashi's donation history in detail" + "prompt": "Analyze Takahashi's value history in detail" } } }, diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/ranking_table.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/ranking_table.json index d527fc99bae..5e7e3b365e2 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/ranking_table.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/ranking_table.json @@ -15,35 +15,35 @@ { "rank": "🥇 1", "name": "Taro Tanaka", - "faculty": "Engineering", + "segment": "Enterprise", "amount": "[CURRENCY]1,200,000", "score": "92" }, { "rank": "🥈 2", "name": "Hanako Sato", - "faculty": "Law", + "segment": "SMB", "amount": "[CURRENCY]980,000", "score": "88" }, { "rank": "🥉 3", "name": "Ichiro Suzuki", - "faculty": "Medicine", + "segment": "Strategic", "amount": "[CURRENCY]750,000", "score": "85" }, { "rank": "4", "name": "Misaki Yamada", - "faculty": "Economics", + "segment": "Mid-Market", "amount": "[CURRENCY]520,000", "score": "76" }, { "rank": "5", "name": "Kenta Takahashi", - "faculty": "Economics", + "segment": "Mid-Market", "amount": "[CURRENCY]50,000", "score": "45" } @@ -68,27 +68,26 @@ "children": [ "title", "subtitle", - "table", - "actionRow" + "table" ], "align": "stretch" }, { "id": "title", "component": "MaterialText", - "text": "🏆 Donation Ranking TOP 5", + "text": "🏆 Top 5 Accounts by Value", "usageHint": "h2" }, { "id": "subtitle", "component": "MaterialText", - "text": "FY2024 — Cumulative Donations", + "text": "FY2024 — Cumulative Value", "usageHint": "caption" }, { "id": "table", "component": "MaterialTable", - "caption": "Top 5 donors by cumulative amount", + "caption": "Top 5 accounts by cumulative value", "columns": [ { "header": "#", @@ -99,11 +98,11 @@ "field": "name" }, { - "header": "Faculty", - "field": "faculty" + "header": "Segment", + "field": "segment" }, { - "header": "Donations", + "header": "Value", "field": "amount" }, { @@ -117,19 +116,33 @@ "style": { "marginTop": "8px" } - }, + } + ] + } + }, + { + "version": "v0.9", + "createSurface": { + "surfaceId": "suggestions", + "catalogId": "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "suggestions", + "components": [ { - "id": "actionRow", + "id": "root", "component": "MaterialRow", "children": [ "btnDetail", "btnExport" ], - "justify": "end", + "justify": "start", "align": "center", "style": { - "gap": "8px", - "marginTop": "12px" + "gap": "8px" } }, { @@ -138,7 +151,7 @@ "label": "🔍 Deep-Dive #1", "action": { "event": { - "name": "deep_dive_top_donor", + "name": "deep_dive_top_account", "context": { "prompt": "Analyze #1 Taro Tanaka in detail" } @@ -154,7 +167,7 @@ "event": { "name": "show_full_ranking", "context": { - "prompt": "Show the full alumni ranking" + "prompt": "Show the full account ranking" } } } diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/suggestion_chips.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/suggestion_chips.json index d87d815f4d1..77da7a5f637 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/suggestion_chips.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/suggestion_chips.json @@ -14,7 +14,6 @@ { "id": "root", "component": "MaterialRow", - "ariaLabel": "Suggested follow-up questions", "justify": "spaceEvenly", "align": "center", "style": { @@ -26,13 +25,13 @@ { "id": "chip_ranking", "component": "MaterialButton", - "label": "📊 Donation Ranking", + "label": "📊 Value Ranking", "variant": "stroked", "action": { "event": { "name": "suggestion_ranking", "context": { - "prompt": "Show the donation ranking" + "prompt": "Show the account value ranking" } } } @@ -46,7 +45,7 @@ "event": { "name": "suggestion_low_score", "context": { - "prompt": "Analyze alumni with low engagement scores" + "prompt": "Analyze accounts with low engagement scores" } } } diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/tabbed_comparison.json b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/tabbed_comparison.json index 36bb9ea911f..ae59b2ff23e 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/tabbed_comparison.json +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/examples/0.9/tabbed_comparison.json @@ -25,6 +25,7 @@ "title", "divider1", "tabs", + "footerDivider", "actionRow" ], "align": "stretch" @@ -115,6 +116,10 @@ "text": "Score: 60 ✏️", "usageHint": "body" }, + { + "id": "footerDivider", + "component": "MaterialDivider" + }, { "id": "actionRow", "component": "MaterialRow", diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/fast_api_app.py b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/fast_api_app.py index 932b7b95910..bcd08fc7b23 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/fast_api_app.py +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/fast_api_app.py @@ -681,13 +681,20 @@ def _a2ui_component_props(): for _cn, _csch in (_cs.get('components') or {}).items(): if not isinstance(_csch, dict): continue - # remove_strict_validation deletes additionalProperties:false, so - # unevaluatedProperties:false is the remaining strictness signal - - # and it marks exactly the 18 basic v0.9 primitives. Every Material* - # component is open, so nothing is ever pruned from one. - if (_csch.get('unevaluatedProperties') is not False - and _csch.get('additionalProperties') is not False): - continue + # Index EVERY component, not only the schema-closed ones. The + # catalog JSON leaves Material* components open (no + # unevaluatedProperties:false at the component level), but the GE + # renderer is strict anyway: one property outside the schema and + # the ENTIRE surface is silently dropped - the turn renders as its + # lead text with no card and no error. Proven live 2026-08-22: + # "color": "primary" on a MaterialButton (legal-looking, absent + # from the catalog, waved through by the draft-07 validator that + # ignores unevaluatedProperties) killed the analysis-plan and + # briefing cards on every attempt, while byte-identical cards + # without it rendered. So closed-only pruning protected exactly + # the components that never needed it. Pruning a legal prop is + # impossible as long as _a2ui_collect_props sees the full allOf / + # $defs / common_types composition, which it walks. _names = _a2ui_collect_props(_csch, _defs, _common) if _names: _A2UI_COMPONENT_PROPS[_cn] = _names | {'id', 'component'} @@ -973,7 +980,10 @@ def _a2ui_msg_schema_ok(msg): def _prep_a2ui_msg(msg): _shaped = _normalize_a2ui_shapes(msg) _healed = _heal_buttons_in_a2ui(_shaped) - return _scope_suggestions_surface(_healed) + # Wrap BEFORE scoping: the wrapper matches on the 'suggestions' prefix, which + # _scope_suggestions_surface keeps, but matching the bare id is one less + # thing to keep in step. + return _scope_suggestions_surface(_card_wrap_chip_surface(_healed)) def _build_a2ui_part(msg): # Pass version=VERSION_0_9 ("0.9"). The SDK's create_a2ui_part() maps every @@ -1067,8 +1077,172 @@ def create_a2ui_parts(msg): return [] return [_build_a2ui_part(_m) for _m in _rescope_one(_prepped, _allow_promote=True)] +# ============================================================================= +# PRESS-SCROLL (v11.83-v11.90) - WHAT IS ACTUALLY WRONG +# At the instant a button is pressed, Gemini Enterprise scrolls the conversation +# backwards. Six rounds of emission-side fixes moved the symptom around without +# curing it. The test that finally pinned it (2026-08-24) changed nothing in the +# agent: press a button in the NEWEST turn, then scroll up and press a button in +# a MUCH OLDER one. The view jumped DOWN - onto the surface pressed a moment +# earlier. Repeating it from an even higher card jumped down again. +# +# on a press, GE scrolls to the element of the user's PREVIOUS press. +# +# The direction is the tell: no rule phrased as "the nearest/last surface ABOVE +# the pressed one" can ever scroll downward. Every earlier report collapses into +# this one - pressing at the bottom of the conversation lands on the previous +# turn's chip bar because that is where the previous press was, which is what +# "it jumps one card back" has meant all along. +# +# The readings this file carried before (v11.87's "last card of the PREVIOUS +# turn", v11.89's "nearest card-rooted surface above") are both withdrawn. The +# second one rested on a live test that never happened; the only real reports +# are v11.83, v11.86, v11.87 and v11.89, and all four fit the rule above. +# +# The anchor is client state, so no arrangement of surfaces can move it. The one +# lever left is that a surface which no longer exists cannot be scrolled to - +# see _pressed_surface_delete_parts() below. +# +# What the earlier rounds bought, and why the code stays: +# v11.87 chips in their own trailing surface, card-wrapped by the helper below. +# The landing rationale is dead; the wrap is kept because the chip bar +# renders identically with it and removing it is pure churn. +# v11.89 gate cards emit their fields and buttons in a trailing surface +# (_action_surface_parts). Also shipped as a scroll fix, and also not +# one - but next actions outside the answer card is the layout that was +# asked for, and it reads better, so it stays. +# Dead ends, do not retry: an anchor surface before the card (v11.83/84/86) and +# an invisible zero-width-space landing (v11.88 - GE does not draw a component +# tree with no visible text, v10.68). +# ============================================================================= +_CHIP_CARD_ROOT_ID = 'chipBarRoot' +# Only these are in the catalog's style allowlist (additionalProperties: false); +# note boxShadow is NOT, which is why appearance is left off rather than set to +# 'raised' and undone here. +_CHIP_CARD_STYLE = {"border": "none", "background": "transparent", + "padding": "0px", "margin": "0px"} + +def _card_wrap_chip_surface(msg): + """Give the trailing chip bar a MaterialCard root, so a press lands on it.""" + try: + if _a2ui_kind(msg) != 'updateComponents': + return msg + _su = msg.get('updateComponents') or {} + if not (_su.get('surfaceId') or '').startswith('suggestions'): + return msg + _comps = _su.get('components') or [] + _root = next((_c for _c in _comps + if isinstance(_c, dict) and _c.get('id') == 'root'), None) + if _root is None or _root.get('component') == 'MaterialCard': + return msg + if any(isinstance(_c, dict) and _c.get('id') == _CHIP_CARD_ROOT_ID + for _c in _comps): + return msg + _root['id'] = _CHIP_CARD_ROOT_ID + _su['components'] = [{"id": "root", "component": "MaterialCard", + "children": [_CHIP_CARD_ROOT_ID], + "style": dict(_CHIP_CARD_STYLE)}] + _comps + return msg + except Exception as _cw_err: + logger.log_text('[chip_card] wrap skipped (chips ship unwrapped): ' + + str(_cw_err)[:200]) + return msg + +# A card's buttons ship BELOW the card, in their own trailing surface. This was +# introduced in v11.89 as a scroll fix and is not one (see the block above), but +# it is the layout the demos want: the answer card stays a clean read, and the +# next actions read as a footer under it rather than as part of the answer. +# The fields have to travel with the buttons, because a {"path": ...} binding +# only resolves inside the surface the button lives in. +_ACTION_SURFACE_STYLE = {"border": "none", "background": "transparent", + "padding": "0px", "margin": "0px"} + +def _action_surface_parts(surface_id, comps, children, data_model=None): + """A card's buttons (and the fields they read) as a trailing surface. + + comps are the components below the root, children their ids in render + order. The root is a transparent MaterialCard, so the block reads as a + footer detached from the card above rather than as a second card. + data_model is applied to this surface's /form, because a + button's {"path": ...} binding resolves against the surface it lives in - + moving a bound button out of the card means moving its fields out with it. + """ + try: + _msgs = [ + {"version": "v0.9", + "createSurface": {"surfaceId": surface_id, "catalogId": _a2ui_catalog_id()}}, + {"version": "v0.9", + "updateComponents": {"surfaceId": surface_id, "components": [ + {"id": "root", "component": "MaterialCard", + "children": ["actionCol"], "style": dict(_ACTION_SURFACE_STYLE)}, + {"id": "actionCol", "component": "MaterialColumn", + "children": list(children), "justify": "start", "align": "stretch", + "style": {"gap": "10px"}}, + ] + list(comps)}}, + ] + if data_model: + _msgs.append({"version": "v0.9", + "updateDataModel": {"surfaceId": surface_id, "path": "/form", + "value": data_model}}) + _parts = [] + for _m in _msgs: + _parts.extend(create_a2ui_parts(_m)) + return _parts + except Exception as _as_err: + logger.log_text('[action_surface] build failed: ' + str(_as_err)[:200]) + return [] + +def _pressed_surface_delete_parts(run_args, emitted_parts): + """Retire the surface whose button started this turn (v11.90). + + GE scrolls a press to the element of the PREVIOUS press, so the jump is + aimed at a surface we rendered one turn ago. We cannot move the client's + anchor, but we can delete what it points at: this turn deletes the surface + the press came from, which is exactly the anchor the NEXT press will use. + The choice itself stays visible - a press arrives with query.text (the + action's prompt), which GE renders as the user's message. + + Skipped for a surface this turn is also rendering: gate cards reuse their + ids and the rescoper hands the new incarnation a '-u' name, but a + replay or a partial patch can still put the pressed id back on the wire, + and deleting a surface we just drew would blank the answer. + + Kill switch: A2UI_KEEP_PRESSED_SURFACE=1 leaves every pressed surface in + place (the pre-v11.90 behaviour). + """ + if os.environ.get('A2UI_KEEP_PRESSED_SURFACE') == '1': + return [] + try: + _sid = '' + _nm = run_args.get('new_message') if isinstance(run_args, dict) else None + for _p in (getattr(_nm, 'parts', None) or []): + _t = getattr(_p, 'text', None) + if not (_t and 'userAction' in _t): + continue + try: + _ua = json.loads(_t).get('userAction', {}) or {} + except Exception: + continue + if _ua.get('surfaceId'): + _sid = str(_ua['surfaceId']) + break + if not _sid: + return [] + for _p in (emitted_parts or []): + for _m in _a2ui_iter_msgs(_p): + if _a2ui_surface_id(_m) == _sid: + logger.log_text('[press_retire] kept ' + _sid + + ' - this turn renders it') + return [] + logger.log_text('[press_retire] deleting the surface the press came from: ' + _sid) + return [_build_a2ui_part(_mk_msg('deleteSurface', surfaceId=_sid))] + except Exception as _pd_err: + logger.log_text('[press_retire] skipped (non-fatal): ' + str(_pd_err)[:200]) + return [] + from adk_agent.app.agent import app as adk_app, background_agent, INLINE_TOOL_DEADLINE, INLINE_IMAGE_DEADLINE import adk_agent.app.tools as _agent_tools +from adk_agent.app.tools import _tok_fp # token fingerprints for auth logs; never log token material import adk_agent.app.part_converters as part_converters # CRITICAL: Disable OpenTelemetry HTTPX instrumentation to prevent it from colliding @@ -1522,29 +1696,44 @@ def _g(_k, _d): _why = " | ".join(_why_bits) if _why_bits else "This may take a few minutes." _children.append("why") _comps.append({"id": "why", "component": "MaterialText", "text": _why, "usageHint": "caption"}) - _children.extend(["scopeField", "actions"]) _comps.append({"id": "col", "component": "MaterialColumn", "children": _children, "justify": "start", "align": "stretch", "style": {"gap": "10px"}}) - _comps.append({"id": "scopeField", "component": "MaterialInput", "label": _g("label_field", "Adjust scope"), "value": {"path": "/form/scope"}}) - _comps.append({"id": "actions", "component": "MaterialRow", "children": ["bInline", "bBg", "bRefine"], "justify": "spaceEvenly", "align": "center", "style": {"gap": "8px", "marginTop": "8px"}}) + # v11.89: the scope field and the buttons leave the card and become a + # trailing surface of their own, so that a press has this card sitting + # above it and the view stays put. The field travels WITH the buttons - + # bRefine reads it through {"path": "/form/scope"}, and that path is + # resolved against the surface the button is in. # v0.9 presses: the intent lives in the EVENT NAME (which cannot be lost to # a context-key collision) and context.prompt is the literal string GE # shows as the user's chat message. context.text is what this runtime's # gate reads - identical to prompt here, except on Adjust, where it is a # data binding the client resolves to whatever the user typed in the box. - _comps.append({"id": "bInline", "component": "MaterialButton", "label": _g("label_inline", "Run inline now"), "color": "primary", "variant": "raised", - "action": {"event": {"name": "preflight_confirm_inline", "context": {"prompt": "Run Inline: " + scope_text, "text": "Run Inline: " + scope_text, "pf": "1"}}}}) - _comps.append({"id": "bBg", "component": "MaterialButton", "label": _g("label_background", "Run in background"), - "action": {"event": {"name": "preflight_background", "context": {"prompt": "Run in Background: " + scope_text, "text": "Run in Background: " + scope_text}}}}) - _comps.append({"id": "bRefine", "component": "MaterialButton", "label": _g("label_adjust", "Adjust & re-propose"), - "action": {"event": {"name": "preflight_refine", "context": {"prompt": _g("label_adjust", "Adjust & re-propose"), "text": {"path": "/form/scope"}}}}}) + _act = [ + {"id": "scopeField", "component": "MaterialInput", "label": _g("label_field", "Adjust scope"), "value": {"path": "/form/scope"}}, + {"id": "actions", "component": "MaterialRow", "children": ["bInline", "bBg", "bRefine"], "justify": "spaceEvenly", "align": "center", "style": {"gap": "8px", "marginTop": "8px"}}, + {"id": "bInline", "component": "MaterialButton", "label": _g("label_inline", "Run inline now"), "variant": "raised", + "action": {"event": {"name": "preflight_confirm_inline", "context": {"prompt": "Run Inline: " + scope_text, "text": "Run Inline: " + scope_text, "pf": "1"}}}}, + {"id": "bBg", "component": "MaterialButton", "label": _g("label_background", "Run in background"), + "action": {"event": {"name": "preflight_background", "context": {"prompt": "Run in Background: " + scope_text, "text": "Run in Background: " + scope_text}}}}, + {"id": "bRefine", "component": "MaterialButton", "label": _g("label_adjust", "Adjust & re-propose"), + "action": {"event": {"name": "preflight_refine", "context": {"prompt": _g("label_adjust", "Adjust & re-propose"), "text": {"path": "/form/scope"}}}}}, + ] + # The card itself no longer carries a data model - since v11.89 the only + # bound component, scopeField, lives in the action surface. That surface + # keeps v11.70's ordering: createSurface -> updateComponents -> + # updateDataModel, the canonical sequence in the GE reference + # implementation guide, and GE is the renderer that has been dropping + # cards over ordering before. _card = [ {"version": "v0.9", "createSurface": {"surfaceId": "analysis-plan", "catalogId": _a2ui_catalog_id()}}, - {"version": "v0.9", "updateDataModel": {"surfaceId": "analysis-plan", "path": "/form", "value": {"scope": scope_text}}}, {"version": "v0.9", "updateComponents": {"surfaceId": "analysis-plan", "components": _comps}}, ] - _parts = [] + _lead_text = "📋 **" + _title + "**" + chr(10) + _intro + _parts = [a2a_types.Part(root=a2a_types.TextPart(text=_lead_text))] for _m in _card: _parts.extend(create_a2ui_parts(_m)) + _parts.extend(_action_surface_parts( + "analysis-plan-actions", _act, ["scopeField", "actions"], + {"scope": scope_text})) return _parts except Exception as _e: logger.log_text("[preflight_gate] card build failed (fail-open): " + str(_e)[:200]) @@ -1635,7 +1824,14 @@ def _g(_k, _d): _comps.append({"id": "goal", "component": "MaterialText", "text": _goal, "usageHint": "body"}) _children.append("intro") _comps.append({"id": "intro", "component": "MaterialText", "text": _intro_line, "usageHint": "caption"}) + # v11.89: the questions and the buttons go BELOW the card, in one + # trailing surface of their own, so that a press has the card above + # it and the view does not jump. The fields cannot stay behind: the + # Start button carries them as {"path": "/form/a"} bindings, and + # those resolve against the surface the button is in. _dm = {} + _act = [] + _act_children = [] _start_ctx = {"prompt": scope_text, "text": scope_text, "ra": "1"} for _qi in range(len(_qs)): _q, _s, _opts = _qs[_qi] @@ -1646,45 +1842,47 @@ def _g(_k, _d): # MaterialChips bound to /form/a. An untouched question falls # back to its suggestion server-side via the s context key. _qid = "qt" + str(_qi) - _children.append(_qid) - _comps.append({"id": _qid, "component": "MaterialText", "text": _q, "usageHint": "body"}) + _act_children.append(_qid) + _act.append({"id": _qid, "component": "MaterialText", "text": _q, "usageHint": "body"}) _oitems = [] for _o in _opts: _oitems.append({"label": _o, "value": _o}) - _children.append(_fid) - _comps.append({"id": _fid, "component": "MaterialChips", "value": {"path": "/form/" + _ak}, "options": _oitems}) + _act_children.append(_fid) + _act.append({"id": _fid, "component": "MaterialChips", "value": {"path": "/form/" + _ak}, "options": _oitems}) else: # Free-text answer: the question itself is the field label, the # suggestion is pre-filled so the user only edits what differs. - _children.append(_fid) - _comps.append({"id": _fid, "component": "MaterialInput", "label": _q, "value": {"path": "/form/" + _ak}}) + _act_children.append(_fid) + _act.append({"id": _fid, "component": "MaterialInput", "label": _q, "value": {"path": "/form/" + _ak}}) if _s: _dm[_ak] = _s _start_ctx["bq" + str(_qi)] = _q _start_ctx[_ak] = {"path": "/form/" + _ak} if _s: _start_ctx["s" + str(_qi)] = _s - _children.extend(["sep", "actions"]) - _comps.append({"id": "sep", "component": "MaterialDivider"}) _comps.append({"id": "col", "component": "MaterialColumn", "children": _children, "justify": "start", "align": "stretch", "style": {"gap": "10px"}}) - _comps.append({"id": "actions", "component": "MaterialRow", "children": ["bStart", "bAsis"], "justify": "spaceEvenly", "align": "center", "style": {"gap": "8px"}}) + _act_children.append("actions") + _act.append({"id": "actions", "component": "MaterialRow", "children": ["bStart", "bAsis"], "justify": "spaceEvenly", "align": "center", "style": {"gap": "8px"}}) # The model routinely leads label_start with its own emoji, and prefixing # ours unconditionally rendered "[rocket] [rocket] Confirm and start" # live. Only decorate a label that starts with plain ASCII. _lbl_start = _g("label_start", "Confirm & start autonomous task") if _lbl_start[:1].isascii(): _lbl_start = chr(0x1F680) + " " + _lbl_start - _comps.append({"id": "bStart", "component": "MaterialButton", "label": _lbl_start, "color": "primary", "variant": "raised", - "action": {"event": {"name": "autonomous_start", "context": _start_ctx}}}) - _comps.append({"id": "bAsis", "component": "MaterialButton", "label": _g("label_asis", "Start as-is"), - "action": {"event": {"name": "autonomous_start_asis", "context": {"prompt": scope_text, "text": scope_text, "ra": "1"}}}}) - _card = [{"version": "v0.9", "createSurface": {"surfaceId": "autonomous-briefing", "catalogId": _a2ui_catalog_id()}}] - if _dm: - _card.append({"version": "v0.9", "updateDataModel": {"surfaceId": "autonomous-briefing", "path": "/form", "value": _dm}}) - _card.append({"version": "v0.9", "updateComponents": {"surfaceId": "autonomous-briefing", "components": _comps}}) - _parts = [] + _act.append({"id": "bStart", "component": "MaterialButton", "label": _lbl_start, "variant": "raised", + "action": {"event": {"name": "autonomous_start", "context": _start_ctx}}}) + _act.append({"id": "bAsis", "component": "MaterialButton", "label": _g("label_asis", "Start as-is"), + "action": {"event": {"name": "autonomous_start_asis", "context": {"prompt": scope_text, "text": scope_text, "ra": "1"}}}}) + _card = [ + {"version": "v0.9", "createSurface": {"surfaceId": "autonomous-briefing", "catalogId": _a2ui_catalog_id()}}, + {"version": "v0.9", "updateComponents": {"surfaceId": "autonomous-briefing", "components": _comps}}, + ] + _lead_text = "🛰️ **" + _title + "**" + chr(10) + _intro_line + _parts = [a2a_types.Part(root=a2a_types.TextPart(text=_lead_text))] for _m in _card: _parts.extend(create_a2ui_parts(_m)) + _parts.extend(_action_surface_parts( + "autonomous-briefing-actions", _act, _act_children, _dm)) return _parts except Exception as _e: logger.log_text("[autonomous_briefing] card build failed (fail-open): " + str(_e)[:200]) @@ -2303,7 +2501,7 @@ def _recent_user_texts(_session, _exclude, _limit=2): # Pull the last few genuine user-request texts from the session (newest # first), skipping chip JSON, control actions, and internal re-prompts. # Used to give a converted background task the conversation context a terse - # follow-up (e.g. "しきい値分析をして") depends on. + # follow-up (e.g. "now run the threshold analysis") depends on. _out = [] try: for _ev in reversed(getattr(_session, 'events', None) or []): @@ -2581,7 +2779,11 @@ def _ma_rotate_token_objects(): break if _ua_ts: import hashlib as _idem_hl - _idem_key_raw = _idem_hl.sha1( + # sha256, not sha1: this is only an idempotency cache key, never a + # credential digest, but session_id is in it and a weak algorithm + # here costs nothing to avoid. Rolling this out makes in-flight + # duplicate presses miss the cache exactly once, on the deploy. + _idem_key_raw = _idem_hl.sha256( (session_id + '|' + _ua_surface + '|' + _ua_source + '|' + str(_ua_ts)).encode('utf-8') ).hexdigest() _idem_src = _ua_source @@ -2628,6 +2830,35 @@ def _ma_rotate_token_objects(): + " key=" + _idem_key_raw[:12] + " parts=" + str(len(_rp_parts) if _rp_parts else 0) ) if _rp_parts: + # v11.70: bare working transition first, then the + # replayed parts in their own working message - the + # executor's shape, the only one GE renders. See the + # analysis-plan card note in _process_request_body. + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=context.task_id, + status=TaskStatus(state=TaskState.working, timestamp=datetime.now(timezone.utc).isoformat()), + context_id=context.context_id, + final=False, + metadata={ + _get_adk_metadata_key('app_name'): runner.app_name, + _get_adk_metadata_key('user_id'): run_args['user_id'], + _get_adk_metadata_key('session_id'): run_args['session_id'], + }, + ) + ) + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=context.task_id, + status=TaskStatus( + state=TaskState.working, + message=Message(message_id=str(uuid.uuid4()), role=Role.agent, parts=_rp_parts), + timestamp=datetime.now(timezone.utc).isoformat(), + ), + context_id=context.context_id, + final=False, + ) + ) await event_queue.enqueue_event( TaskArtifactUpdateEvent( task_id=context.task_id, @@ -2708,7 +2939,7 @@ async def _process_request_body( if token: import builtins builtins._workspace_oauth_token = token - logger.log_text(f"TOKEN SET via builtins._workspace_oauth_token (prefix: {token[:20]}..., len: {len(token)})") + logger.log_text(f"TOKEN SET via builtins._workspace_oauth_token (fp: {_tok_fp(token)}, len: {len(token)})") # v11.6: per-session registry read by header_provider Strategy0. # This is the ONLY per-session store that stays fresh: mutating # session.state below does NOT persist (see comment there). @@ -2921,7 +3152,7 @@ async def _emit_bg_terminal(_t, _chip_specs): logger.log_text("[preflight_gate] bg direct-registration failed, emitted retry: " + str(_bg_reg.get('message', ''))[:160]) return - if _gate_scope and not _gate_skip: + if _gate_scope and not _gate_skip and os.environ.get("ENABLE_MANAGED_AGENT") == "1": # v11.6: pass the last human-typed message as a language # reference so an English chip prompt cannot flip the # card language (STEP 1 EXCEPTION in the classifier prompt). @@ -2929,6 +3160,27 @@ async def _emit_bg_terminal(_t, _chip_specs): if isinstance(_plan, dict) and _plan.get("category") == "ANALYSIS": _pf_parts = _build_preflight_card_parts(_plan, _gate_scope) if _pf_parts: + # v11.70: TWO working events, in this exact order - a + # bare transition first, THEN a separate working event + # whose only job is to carry the card's parts. This is + # the shape the ADK executor produces on the normal path, + # and the normal path is the only place GE has ever + # rendered our A2UI. Both single-event variants are + # confirmed dead: v11.67 sent the bare transition and put + # the parts only in the artifact (card dropped - GE + # treats the artifact as the turn's settled text); + # v11.69 put the parts ON the transition event itself + # (card dropped again, observed live 2026-08-22 - + # wire-diffed against a rendering welcome-card turn, the + # ONLY structural difference was one combined event vs a + # bare transition followed by a parts-bearing event, so + # GE evidently reads the first working event as a state + # transition and never renders the message riding on + # it). The short-circuit paths return before the + # executor runs, so they must emit both events + # themselves. Re-sending the identical parts in the + # artifact is deliberate and does not double-render - + # same ids, same surface. await event_queue.enqueue_event( TaskStatusUpdateEvent( task_id=context.task_id, @@ -2942,6 +3194,18 @@ async def _emit_bg_terminal(_t, _chip_specs): }, ) ) + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=context.task_id, + status=TaskStatus( + state=TaskState.working, + message=Message(message_id=str(uuid.uuid4()), role=Role.agent, parts=_pf_parts), + timestamp=datetime.now(timezone.utc).isoformat(), + ), + context_id=context.context_id, + final=False, + ) + ) await event_queue.enqueue_event( TaskArtifactUpdateEvent( task_id=context.task_id, @@ -2961,46 +3225,59 @@ async def _emit_bg_terminal(_t, _chip_specs): if idem_key: _store_idem_result(idem_key, _pf_parts) logger.log_text("[preflight_gate] rendered analysis-plan card and short-circuited (" + str(len(_pf_parts)) + " parts)") - if os.environ.get("ENABLE_MANAGED_AGENT") == "1": - return - elif isinstance(_plan, dict) and _plan.get("category") == "AUTONOMOUS": - # Interactive briefing BEFORE delegation - only when the - # classifier found material gaps; otherwise fall through - # (zero friction) and the root agent delegates directly. - _ab_parts = _build_autonomous_briefing_card_parts(_plan, _gate_scope) - if _ab_parts: - await event_queue.enqueue_event( - TaskStatusUpdateEvent( - task_id=context.task_id, - status=TaskStatus(state=TaskState.working, timestamp=datetime.now(timezone.utc).isoformat()), - context_id=context.context_id, - final=False, - metadata={ - _get_adk_metadata_key('app_name'): runner.app_name, - _get_adk_metadata_key('user_id'): run_args['user_id'], - _get_adk_metadata_key('session_id'): run_args['session_id'], - }, - ) + return + elif isinstance(_plan, dict) and _plan.get("category") == "AUTONOMOUS": + # Interactive briefing BEFORE delegation - only when the + # classifier found material gaps; otherwise fall through + # (zero friction) and the root agent delegates directly. + _ab_parts = _build_autonomous_briefing_card_parts(_plan, _gate_scope) + if _ab_parts: + # v11.70: bare transition, then the parts in their own + # working message - see the analysis-plan card above. + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=context.task_id, + status=TaskStatus(state=TaskState.working, timestamp=datetime.now(timezone.utc).isoformat()), + context_id=context.context_id, + final=False, + metadata={ + _get_adk_metadata_key('app_name'): runner.app_name, + _get_adk_metadata_key('user_id'): run_args['user_id'], + _get_adk_metadata_key('session_id'): run_args['session_id'], + }, ) - await event_queue.enqueue_event( - TaskArtifactUpdateEvent( - task_id=context.task_id, - last_chunk=True, - context_id=context.context_id, - artifact=Artifact(artifact_id=str(uuid.uuid4()), parts=_ab_parts), - ) + ) + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=context.task_id, + status=TaskStatus( + state=TaskState.working, + message=Message(message_id=str(uuid.uuid4()), role=Role.agent, parts=_ab_parts), + timestamp=datetime.now(timezone.utc).isoformat(), + ), + context_id=context.context_id, + final=False, ) - await event_queue.enqueue_event( - TaskStatusUpdateEvent( - task_id=context.task_id, - status=TaskStatus(state=TaskState.completed, timestamp=datetime.now(timezone.utc).isoformat()), - context_id=context.context_id, - final=True, - ) + ) + await event_queue.enqueue_event( + TaskArtifactUpdateEvent( + task_id=context.task_id, + last_chunk=True, + context_id=context.context_id, + artifact=Artifact(artifact_id=str(uuid.uuid4()), parts=_ab_parts), ) - if idem_key: - _store_idem_result(idem_key, _ab_parts) - logger.log_text("[autonomous_briefing] rendered briefing card and short-circuited (" + str(len(_ab_parts)) + " parts)") + ) + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + task_id=context.task_id, + status=TaskStatus(state=TaskState.completed, timestamp=datetime.now(timezone.utc).isoformat()), + context_id=context.context_id, + final=True, + ) + ) + if idem_key: + _store_idem_result(idem_key, _ab_parts) + logger.log_text("[autonomous_briefing] rendered briefing card and short-circuited (" + str(len(_ab_parts)) + " parts)") return except Exception as _pf_err: logger.log_text("[preflight_gate] gate error (fail-open, running agent): " + str(_pf_err)[:200]) @@ -3088,7 +3365,6 @@ async def _emit_bg_terminal(_t, _chip_specs): # those calls re-send the same broken tool declarations and fail too. _fatal_config_error = False - # ============================================================================= # Model Name Display — show which model is processing (once per agent) # Maps agent name → model string for the thinking accordion header. @@ -4834,27 +5110,12 @@ def _extract_report_parts(_text): if (_recovered_cards and _has_populated_card(_recovered_cards) and (not _orphan_card_surface_ids(_recovered_cards)) and (not _inline_converted)): - # Stream as a WORKING event so GE renders the surface from the - # live stream, mirroring the chip recovery below. - try: - _cd_evt = TaskStatusUpdateEvent( - task_id=context.task_id, - context_id=context.context_id, - status=TaskStatus( - state=TaskState.working, - message=Message( - message_id=str(uuid.uuid4()), - role=Role.agent, - parts=_recovered_cards, - ), - timestamp=datetime.now(timezone.utc).isoformat(), - ), - final=False, - ) - task_result_aggregator.process_event(_cd_evt) - await event_queue.enqueue_event(_cd_evt) - except Exception as _cd_stream_err: - logger.log_text("[card_reprompt] streaming recovered card failed: " + str(_cd_stream_err)) + # v11.63: the recovered card ships in the final artifact ONLY. + # Until now it was also streamed as a WORKING event, which under + # A2UI v0.9 sends createSurface twice for the same surfaceId - + # an explicit spec error that makes the client drop the surface, + # so the recovery rendered nothing at all. v0.8's beginRendering + # had no uniqueness rule, hence the original streaming pattern. artifact_media_parts.extend(_recovered_cards) _normal_media = _normal_media + _recovered_cards artifact_parts = artifact_text_parts + _normal_media + _suggestion_media @@ -4912,28 +5173,10 @@ def _extract_report_parts(_text): # discarded so the re-prompt can never duplicate the deliverable. _recovered_chips = [p for p in _cr_media if _is_suggestions_part(p)] if _has_populated_suggestions(_recovered_chips) and (not _inline_converted): - # Stream the chips as a WORKING event so GE renders the - # suggestions surface from the live stream (chips only in the - # final artifact may not render). Mirrors the B-1 pattern. - try: - _cr_evt = TaskStatusUpdateEvent( - task_id=context.task_id, - context_id=context.context_id, - status=TaskStatus( - state=TaskState.working, - message=Message( - message_id=str(uuid.uuid4()), - role=Role.agent, - parts=_recovered_chips, - ), - timestamp=datetime.now(timezone.utc).isoformat(), - ), - final=False, - ) - task_result_aggregator.process_event(_cr_evt) - await event_queue.enqueue_event(_cr_evt) - except Exception as _cr_stream_err: - logger.log_text("[chip_reprompt] streaming recovered chips failed: " + str(_cr_stream_err)) + # v11.63: chips ship in the final artifact ONLY - see the card + # recovery above. The v10.70 note that "chips only in the final + # artifact may not render" predates v0.9; the normal path already + # delivers its suggestions surface through the artifact alone. artifact_media_parts.extend(_recovered_chips) _suggestion_media = list(_recovered_chips) artifact_parts = artifact_text_parts + _normal_media + _suggestion_media @@ -4941,6 +5184,17 @@ def _extract_report_parts(_text): else: logger.log_text("[chip_reprompt] re-prompt yielded no usable chips - leaving turn as-is") + # v11.90: retire the surface this press came from, so the next press has + # no stale anchor to scroll to. Last thing appended to the artifact - + # artifact_parts is final from here on, and the delete has to travel + # with the deliverable (the G1/H1 caches below store what we send, and a + # replayed deleteSurface of an already-gone surface is a no-op). + # See the PRESS-SCROLL block near _card_wrap_chip_surface for why this + # is the only lever left, and v11.88 for the invisible-landing dead end. + _press_retire = _pressed_surface_delete_parts(run_args, artifact_parts) + if _press_retire: + artifact_parts = artifact_parts + _press_retire + # Inline overrun conversion (v10.79), exit B: the deadline watchdog may # have fired DURING a salvage phase above. If it converted, it already # emitted the final event and cached the replay parts - suppress the @@ -5186,14 +5440,14 @@ async def dispatch(self, request: Request, call_next): auth_header = request.headers.get("authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] - logger.log_text(f"MIDDLEWARE: ✅ Token from Authorization header (prefix={token[:25]}..., len={len(token)})") + logger.log_text(f"MIDDLEWARE: ✅ Token from Authorization header (fp={_tok_fp(token)}, len={len(token)})") # Strategy 2: x-authorization header (fallback) if not token: x_auth = request.headers.get("x-authorization", "") if x_auth.startswith("Bearer "): token = x_auth[7:] - logger.log_text(f"MIDDLEWARE: ✅ Token from x-authorization header (prefix={token[:25]}..., len={len(token)})") + logger.log_text(f"MIDDLEWARE: ✅ Token from x-authorization header (fp={_tok_fp(token)}, len={len(token)})") # Strategy 3: Parse JSON body for call_context.state.headers.authorization if not token and request.url.path.startswith("/a2a/"): @@ -5215,13 +5469,13 @@ async def dispatch(self, request: Request, call_next): # Check for auth_id key directly if auth_id and auth_id in state: token = state[auth_id] - logger.log_text(f"MIDDLEWARE: ✅ Token from body context.state['{auth_id}'] (prefix={str(token)[:25]}..., len={len(str(token))})") + logger.log_text(f"MIDDLEWARE: ✅ Token from body context.state['{auth_id}'] (fp={_tok_fp(str(token))}, len={len(str(token))})") # Check for headers.authorization in state elif 'headers' in state and isinstance(state['headers'], dict): h_auth = state['headers'].get('authorization', '') if h_auth.startswith("Bearer "): token = h_auth[7:] - logger.log_text(f"MIDDLEWARE: ✅ Token from body state.headers.authorization (prefix={token[:25]}..., len={len(token)})") + logger.log_text(f"MIDDLEWARE: ✅ Token from body state.headers.authorization (fp={_tok_fp(token)}, len={len(token)})") except Exception as e: logger.log_text(f"MIDDLEWARE: ⚠️ Body parse error: {type(e).__name__}: {e}") @@ -5813,7 +6067,11 @@ async def _bg_consume(_gen): "completed_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), }) await _send_push_notification(_fs, _demo_id, _task_id, "failed", str(_e)[:200]) - return {"status": "failed", "error": str(_e)[:200]} + # The detail is already in the log line above and in the ticket's + # log_tail, which is what the viewer and get_autonomous_task_status + # read. The HTTP body goes back over a public Cloud Run URL and the + # caller here is the task trigger, which only needs the status. + return {"status": "failed", "error": "task execution failed"} async def _send_push_notification(_fs, _demo_id, _task_id, _status, _message): diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/tools.py b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/tools.py index 33e9d4c3315..7226b6a156c 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/tools.py +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/adk_agent/app/tools.py @@ -49,6 +49,28 @@ def _patched_default(self, obj): return _orig_default(self, obj) json.JSONEncoder.default = _patched_default +_TOK_LABELS = {} + +def _tok_fp(_t): + # Never log token material. The auth diagnostics only need to tell one + # token from another across log lines (stale snapshot vs freshly rotated), + # which a prefix did by leaking the first 30 characters of a live OAuth + # access token into Cloud Logging. Hand out sequential in-process labels + # instead: nothing derived from the secret is emitted, so there is no + # digest to brute-force and no value to correlate across deployments. + # The table is bounded because a long-lived instance rotates tokens. + try: + _k = _t or '' + _lbl = _TOK_LABELS.get(_k) + if _lbl is None: + if len(_TOK_LABELS) >= 64: + _TOK_LABELS.clear() + _lbl = 'tok%d' % (len(_TOK_LABELS) + 1) + _TOK_LABELS[_k] = _lbl + return _lbl + except Exception: + return 'unavailable' + @@ -299,18 +321,43 @@ def get_bigquery_mcp_url(): # Using ?project= query parameter as the header alone was insufficient for public datasets return f"https://bigquery.googleapis.com/mcp?project={project_id}" +# The BigQuery MCP server offers six tools. Three of them are a behavioural +# hazard, not a token one: a 12-run A/B of the generated agent on 2026-08-23 +# had it open a plain figure question with 'list_table_ids' before its first +# useful call, twice - a whole model round trip (~7s) spent rediscovering +# table names the generated data-asset catalog already lists in the prompt. +# A tool that is not declared cannot be the opening move, and after this +# filter the listing calls stopped entirely. +# +# Do not sell this as a prompt-size win. Measured as sent to the model, the +# three dropped declarations are 2,046 characters of a 421,269-character +# prefill; the raw MCP listing is far larger, but ADK strips it down before +# it ships. The same A/B saw no change in end-to-end latency: a turn costs +# 4-10 sequential model round trips and this removes at most one of them. +# +# What survives, and why: +# execute_sql_readonly every read. +# execute_sql writes - INSERT/UPDATE/DELETE/MERGE have no other path. +# get_table_info SQL error recovery; five places in the instruction +# send the model here when a query fails on a column. +_BIGQUERY_TOOL_FILTER = ['execute_sql', 'execute_sql_readonly', 'get_table_info'] + + def get_bigquery_mcp_toolset(): """Creates a BigQuery MCP toolset. URL is project-scoped to ensure quota/perms.""" project_id = get_project_id() url = get_bigquery_mcp_url() if project_id == "UNKNOWN": print(" [CRITICAL] GOOGLE_CLOUD_PROJECT is missing! MCP calls will likely fail.") - + + # Escape hatch for a demo whose data-asset catalog does not describe + # everything the agent needs to discover at runtime. + _filter = None if os.environ.get("BQ_TOOL_FILTER_OFF", "").lower() in ("1", "true", "yes") else _BIGQUERY_TOOL_FILTER return McpToolset(connection_params=StreamableHTTPConnectionParams( - url=url, + url=url, headers={"x-goog-user-project": project_id}, timeout=300 - )) + ), tool_filter=_filter) def get_firestore_mcp_toolset(): """Creates a Firestore MCP toolset (data ops only; DB/index admin excluded @@ -324,13 +371,21 @@ def get_firestore_mcp_toolset(): timeout=300 ), tool_filter=[ 'get_document', 'add_document', 'update_document', 'delete_document', - 'list_documents', 'list_collections', + 'list_documents', + # list_collections is deliberately withheld. It returns project-wide + # metadata that bloats the context and reliably triggers + # MALFORMED_FUNCTION_CALL, and there is exactly one collection in this + # demo, whose name is already in the instruction. The prompt has told the + # model not to call it since v10.x and the model called it anyway on + # 2026-08-23; removing it from the toolset is the only thing that works. ]) def get_maps_mcp_toolset(): """Creates a Google Maps MCP toolset.""" dotenv.load_dotenv() maps_api_key = os.getenv('MAPS_API_KEY') + if not maps_api_key: + return None project_id = get_project_id() url = get_maps_mcp_url() return McpToolset(connection_params=StreamableHTTPConnectionParams( @@ -464,7 +519,7 @@ def _workspace_header_provider(context) -> dict: t = getattr(builtins, '_ws_session_tokens', {}).get(_sid) if t: token = t - _logger.warning(f"header_provider: OK Strategy0 - per-session registry (session={_sid}, prefix={token[:30]}..., len={len(token)})") + _logger.warning(f"header_provider: OK Strategy0 - per-session registry (session={_sid}, fp={_tok_fp(token)}, len={len(token)})") except Exception as ex: _logger.warning(f"header_provider: Strategy0 ERROR - registry lookup failed: {type(ex).__name__}: {ex}") @@ -474,7 +529,7 @@ def _workspace_header_provider(context) -> dict: t = getattr(builtins, '_workspace_oauth_token', '') if t: token = t - _logger.warning(f"header_provider: OK Strategy1 - token from builtins (prefix={token[:30]}..., len={len(token)})") + _logger.warning(f"header_provider: OK Strategy1 - token from builtins (fp={_tok_fp(token)}, len={len(token)})") # Strategy 2 (was 1): context.state - CREATE-time snapshot, may be stale. if not token and context and auth_id: @@ -487,7 +542,7 @@ def _workspace_header_provider(context) -> dict: t = state[auth_id] if auth_id in state else None if t: token = t - _logger.warning(f"header_provider: OK Strategy2 - token from context.state (prefix={token[:30]}..., len={len(token)}) - CREATE-time snapshot, may be stale") + _logger.warning(f"header_provider: OK Strategy2 - token from context.state (fp={_tok_fp(token)}, len={len(token)}) - CREATE-time snapshot, may be stale") else: _logger.warning(f"header_provider: Strategy2 MISS - context.state exists (type={type(state).__name__}) but auth_id '{auth_id}' not found. keys={list(state.keys()) if hasattr(state, 'keys') else 'N/A'}") except Exception as ex: @@ -505,7 +560,7 @@ def _workspace_header_provider(context) -> dict: t = session_state[auth_id] if auth_id in session_state else None if t: token = t - _logger.warning(f"header_provider: OK Strategy3 - token from context.session.state (prefix={token[:30]}..., len={len(token)}) - CREATE-time snapshot, may be stale") + _logger.warning(f"header_provider: OK Strategy3 - token from context.session.state (fp={_tok_fp(token)}, len={len(token)}) - CREATE-time snapshot, may be stale") except Exception as ex: _logger.warning(f"header_provider: Strategy3 ERROR - context.session.state access failed: {type(ex).__name__}: {ex}") @@ -600,7 +655,7 @@ def _workspace_header_provider(context) -> dict: t = getattr(builtins, '_ws_session_tokens', {}).get(_sid) if t: token = t - _logger.warning(f"header_provider: OK Strategy0 - per-session registry (session={_sid}, prefix={token[:30]}..., len={len(token)})") + _logger.warning(f"header_provider: OK Strategy0 - per-session registry (session={_sid}, fp={_tok_fp(token)}, len={len(token)})") except Exception as ex: _logger.warning(f"header_provider: Strategy0 ERROR - registry lookup failed: {type(ex).__name__}: {ex}") @@ -610,7 +665,7 @@ def _workspace_header_provider(context) -> dict: t = getattr(builtins, '_workspace_oauth_token', '') if t: token = t - _logger.warning(f"header_provider: OK Strategy1 - token from builtins (prefix={token[:30]}..., len={len(token)})") + _logger.warning(f"header_provider: OK Strategy1 - token from builtins (fp={_tok_fp(token)}, len={len(token)})") # Strategy 2 (was 1): context.state - CREATE-time snapshot, may be stale. if not token and context and auth_id: @@ -623,7 +678,7 @@ def _workspace_header_provider(context) -> dict: t = state[auth_id] if auth_id in state else None if t: token = t - _logger.warning(f"header_provider: OK Strategy2 - token from context.state (prefix={token[:30]}..., len={len(token)}) - CREATE-time snapshot, may be stale") + _logger.warning(f"header_provider: OK Strategy2 - token from context.state (fp={_tok_fp(token)}, len={len(token)}) - CREATE-time snapshot, may be stale") else: _logger.warning(f"header_provider: Strategy2 MISS - context.state exists (type={type(state).__name__}) but auth_id '{auth_id}' not found. keys={list(state.keys()) if hasattr(state, 'keys') else 'N/A'}") except Exception as ex: @@ -641,7 +696,7 @@ def _workspace_header_provider(context) -> dict: t = session_state[auth_id] if auth_id in session_state else None if t: token = t - _logger.warning(f"header_provider: OK Strategy3 - token from context.session.state (prefix={token[:30]}..., len={len(token)}) - CREATE-time snapshot, may be stale") + _logger.warning(f"header_provider: OK Strategy3 - token from context.session.state (fp={_tok_fp(token)}, len={len(token)}) - CREATE-time snapshot, may be stale") except Exception as ex: _logger.warning(f"header_provider: Strategy3 ERROR - context.session.state access failed: {type(ex).__name__}: {ex}") @@ -736,7 +791,7 @@ def factory(headers=None, timeout=None, auth=None): auth_header = headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] - _logger.warning(f"httpx_factory: Got token from headers (prefix={token[:30]}..., len={len(token)})") + _logger.warning(f"httpx_factory: Got token from headers (fp={_tok_fp(token)}, len={len(token)})") if not token: _logger.warning("httpx_factory: No token in headers, using default client") @@ -931,9 +986,8 @@ async def generate_image(prompt: str, tool_context: ToolContext) -> dict: Args: prompt: A highly detailed, descriptive prompt for the image. Include stylistic instructions (e.g., 'photorealistic', 'flat design'). - CRITICAL: The prompt text MUST be written in the EXACT SAME language that the user is using in the current chat session. - If the conversation is in Japanese, you MUST write the entire prompt in Japanese (e.g., '武田電気株式会社の見積状況をまとめたスライド...'). - This ensures all text inside the generated image is rendered in the user's language. + CRITICAL: The prompt text MUST be written in the EXACT SAME language that the user is using in the current chat session, + whatever that language is. This ensures all text inside the generated image is rendered in the user's language. Returns: A dictionary with status and detail keys. @@ -942,36 +996,31 @@ async def generate_image(prompt: str, tool_context: ToolContext) -> dict: import os import logging - import re location = os.environ.get("GOOGLE_CLOUD_LOCATION", "global") project = os.environ.get("GOOGLE_CLOUD_PROJECT") logging.info(f"generate_image called with prompt: {prompt}") logging.info(f"Using location: {location}, project: {project}") - # 1. Automatic language detection on the prompt text (Detect Japanese characters) - is_japanese = bool(re.search(r'[぀-ゟ゠-ヿ一-龯]', prompt)) + # Construct robust system-level style and language guidelines. + base_style_rule = ( + "\n\nCRITICAL STYLE RULE: NEVER add headers, watermarks, logos, or invented " + "company, brand or organisation names to the generated image. Image models like " + "to stamp generic corporate boilerplate onto slides; render only the text the " + "prompt above actually asks for." + ) - # 2. Construct robust system-level style and language guidelines based on detected language - base_style_rule = "\n\nCRITICAL STYLE RULE: NEVER include headers, watermarks, logos, or any text reading 'Consulting Firm' in the generated image." + # One rule for every language. Naming a language here would tilt every demo + # that is not in that language, and the model already knows which script the + # prompt is written in. + lang_rule = ( + "\n\nCRITICAL LANGUAGE RULE: ALL text elements inside the generated image " + "(including presentation titles, headers, table labels, chart legends, data points, bullet points, annotations, captions, and company names) " + "MUST be rendered EXCLUSIVELY in the SAME language and script as the prompt text above. " + "Do NOT translate anything into English, do NOT transliterate names into Latin characters, " + "and do NOT mix languages. This is a strict requirement." + ) - if is_japanese: - # Heavy reinforcement for Japanese rendering (Forces Imagen 3 to use Japanese fonts and text labels exclusively) - lang_rule = ( - "\n\nCRITICAL LANGUAGE RULE: ALL text elements inside the generated image " - "(including presentation titles, headers, table labels, chart legends, data points, bullet points, annotations, and company names) " - "MUST be rendered EXCLUSIVELY in Japanese. Do NOT use any English or Latin characters. " - "For example, render company names as '武田電気株式会社' (not Takeden Co), " - "and use Japanese for headers like 'エグゼクティブサマリー' or '保留中の見積処理状況'. " - "This is a strict requirement." - ) - else: - lang_rule = ( - "\n\nCRITICAL LANGUAGE RULE: ALL text elements inside the generated image " - "(including titles, labels, axis names, legends, bullet points, annotations, captions) " - "MUST be rendered in the SAME language as the prompt text above. Do NOT mix languages." - ) - final_prompt = prompt + base_style_rule + lang_rule client = genai_client.Client( @@ -1188,7 +1237,7 @@ def _upload_and_sign(): if os.environ.get("ENABLE_COMPUTER_USE") == "1": # ===================================================================== - # Computer Use (browser agent) -- Gemini 3.5 Flash built-in computer_use + # Computer Use (browser agent) -- Gemini 3.7 Flash built-in computer_use # tool driven over a self-hosted headless Chromium (Playwright). Adapted # from the official reference impl (github.com/google-gemini/ # computer-use-preview, Apache-2.0): same generate_content loop, action diff --git a/search/gemini-enterprise/ge-demo-generator/agent_template/viewer_app/main.py b/search/gemini-enterprise/ge-demo-generator/agent_template/viewer_app/main.py index 4450f5b42a7..300f7de8523 100644 --- a/search/gemini-enterprise/ge-demo-generator/agent_template/viewer_app/main.py +++ b/search/gemini-enterprise/ge-demo-generator/agent_template/viewer_app/main.py @@ -22,6 +22,7 @@ # mypy: ignore-errors # ruff: noqa +import logging import os import time import uuid @@ -207,7 +208,7 @@ def delete_task(task_id): defn_doc = defn_ref.get() if defn_doc.exists: defn_data = defn_doc.to_dict() - if defn_data.get("task_type") == "scheduled": + if defn_data.get("task_type") in ("scheduled", "scheduled_autonomous"): try: from google.cloud import scheduler_v1 _sc = scheduler_v1.CloudSchedulerClient() @@ -245,7 +246,11 @@ def list_activity(): }) return jsonify({"activities": activities}) except Exception as _e: - return jsonify({"activities": [], "error": str(_e)}) + # The detail goes to the log, not to the browser: this endpoint is on a + # public Cloud Run URL and the exception text can carry Firestore paths + # and project identifiers. + logging.exception("activity feed failed: %s", _e) + return jsonify({"activities": [], "error": "Could not load the activity feed."}) # --- Computer Use live-view (screencast of the sandbox browser) --- BROWSER_VIEW_HTML = _load_template("browser_view.html") @@ -289,5 +294,6 @@ def main(request): try: return app.full_dispatch_request() except Exception as e: - return str(e), 500 + logging.exception("request failed: %s", e) + return "Internal Server Error", 500 diff --git a/search/gemini-enterprise/ge-demo-generator/app/Code.gs b/search/gemini-enterprise/ge-demo-generator/app/Code.gs index e9ec00d20a1..ae851999b36 100644 --- a/search/gemini-enterprise/ge-demo-generator/app/Code.gs +++ b/search/gemini-enterprise/ge-demo-generator/app/Code.gs @@ -21,7 +21,7 @@ * * ── What Gets Generated ────────────────────────────────────────────── * • Synthetic business data (BigQuery tables + optional Firestore docs) - * • Dual-model ADK agent (Gemini 3.5 Flash-Lite root → Pro analysis) + * • Dual-model ADK agent (Gemini 3.7 Flash root + deep analysis sub-agent) * • MCP toolsets — BigQuery, Maps, Firestore, Google Workspace (Gmail, * Drive, Calendar, Chat, People), plus arbitrary GitHub MCP servers * • A2A (Agent-to-Agent) server with A2UI interactive components @@ -101,7 +101,7 @@ const CONFIG = { GITHUB_TOKEN: SCRIPT_PROPS.getProperty('GITHUB_TOKEN'), MAX_RETRIES: 3, RETRY_DELAY_MS: 1000, - APP_VERSION: 'v11.62-public', + APP_VERSION: 'v11.91-public', // Agent-template source: the generated setup script fetches the static // Python/JSON template files (agent_template/ in the repo) at run time. // TEMPLATE_REF may be a branch name (default 'main'): it is resolved to a @@ -847,7 +847,7 @@ function planAndGenerateData(userGoal, options) { } if (options.enableComputerUse) { prompt += `\n- **🖥️ COMPUTER USE (BROWSER AGENT) AVAILABLE**: - - The agent can operate a real headless web browser (Gemini 3.5 Flash Computer Use) to navigate, click, type, fill forms and extract data from sites that have NO API: competitor public pages, supplier/partner portals, government/regulatory sites, public data sources, and internal web apps. Browser runs happen as autonomous background tasks and the user can watch the live session. + - The agent can operate a real headless web browser (Gemini 3.7 Flash Computer Use) to navigate, click, type, fill forms and extract data from sites that have NO API: competitor public pages, supplier/partner portals, government/regulatory sites, public data sources, and internal web apps. Browser runs happen as autonomous background tasks and the user can watch the live session. - You MUST leverage this capability when generating the 'businessInstruction' and 'demoGuide' (prompts). - In 'businessInstruction', mention that the agent can autonomously browse external websites via a browser-automation background task to gather or act on data that has no API. - You MUST design at least TWO prompts (out of the 7 required) in the 'demoGuide' that explicitly ask the agent to browse an external website or portal to accomplish the goal. @@ -1002,7 +1002,7 @@ function getTechnicalInstruction_() { "to ensure the user can trace its logic back to the source data.\n" + "6b. **KNOWLEDGE CATALOG / METADATA-DRIVEN ANALYSIS (CRITICAL)**: Instruct the agent that it has access to the Knowledge Catalog (Dataplex) MCP tools " + "and MUST ground its analysis in metadata before composing BigQuery queries. Mandatory workflow: " + - "(a) for ANY exploratory or discovery question (e.g. 'what data do we have', 'what can you analyze', 'find data useful for X'), it MUST call 'search_entries' FIRST — before 'list_table_ids' / 'list_dataset_ids' — to discover and rank the relevant assets; " + + "(a) for a question ABOUT the metadata itself - how a metric is defined, what a code means, which asset is authoritative, how two assets relate - it MUST call 'search_entries' FIRST to discover and rank the relevant assets. A question about the DATA is answered from the data-asset catalog already in its prompt, or straight from SQL; a catalog call is never a warm-up for one; " + "(b) it MUST use 'lookup_entry' / 'lookup_context' (NOT 'get_table_info') to read column meanings, units, allowed values, data classifications, and table relationships (join keys); " + "(c) only then build the BigQuery query, selecting the correct tables and join keys based on the catalog metadata. Use 'get_table_info' only to confirm exact column types right before writing SQL, or during SQL error recovery. " + "If a catalog call returns nothing right after provisioning (metadata harvest can lag a few minutes), fall back to inspecting tables directly and retry catalog discovery later.\n" + @@ -1101,7 +1101,7 @@ function getTechnicalInstruction_() { "{\n" + " \"id\": \"orig_name_i\",\n" + " \"component\": \"MaterialText\",\n" + - " \"text\": \"[Original Item Name, e.g., 'エアコン5馬力']\",\n" + + " \"text\": \"[Original Item Name, e.g., 'Line item A']\",\n" + " \"usageHint\": \"body\"\n" + "},\n" + "{\n" + @@ -1136,7 +1136,8 @@ function getTechnicalInstruction_() { "With more than 3 candidates use a MaterialSelect instead (it DOES take a label, and the same options array of {label, value}). " + "Seed every bound path in the SAME block's updateDataModel, e.g. \"value\": { \"form\": { \"item_0_selected_sku\": \"SKU_CODE_A\", \"item_0_qty\": \"2\" } } at path \"/\", or one updateDataModel at path \"/form\" carrying the whole object.\n\n" + - "11. **SUGGESTION CHIPS (CRITICAL)**: At the END of EVERY response, you MUST append a lightweight A2UI suggestion chip bar using surfaceId 'suggestions' and a MaterialRow whose id is 'root' containing 3-4 MaterialButtons. The chip block MUST be COMPLETE: a single block containing BOTH the createSurface message AND the updateComponents message with all MaterialButton components — never emit createSurface alone. NEVER write any plain text or markdown headers (like \"Next Actions\", \"💡 Next Actions\", or other localized header equivalent) before the suggestions block; the system will automatically render the appropriate header. " + + "11. **SUGGESTION CHIPS (CRITICAL)**: **CHIPS GO IN THEIR OWN TRAILING SURFACE (READ FIRST)**: the 3-4 follow-up buttons are ALWAYS a separate 'suggestions' surface emitted AFTER the card, never a MaterialRow inside the card root's children. A turn's second A2UI surface does render - the rule that once said otherwise was wrong. Keeping the follow-ups out of the card keeps the answer card a clean read and makes the next actions a footer under it. **NO FOOTER ACTION ROW** on a result card for the same reason - its follow-ups belong in the trailing surface. **FOOTER SHAPE** applies only where a button cannot leave its card: the Welcome Card, whose buttons are its own content and which opens the conversation, and a compose/confirmation card whose button reads its own \"path\" bindings (a binding resolves only inside its own surface). There the card's main MaterialColumn MUST end with exactly two children, a MaterialDivider and then the MaterialRow of buttons (\"children\": [ ..., \"footerDivider\", \"actionRow\" ]). Nothing may follow that row, and the divider must sit immediately above it.\n" + + "At the END of EVERY such response, you MUST append a lightweight A2UI suggestion chip bar using surfaceId 'suggestions' and a MaterialRow whose id is 'root' containing 3-4 MaterialButtons. The chip block MUST be COMPLETE: a single block containing BOTH the createSurface message AND the updateComponents message with all MaterialButton components — never emit createSurface alone. NEVER write any plain text or markdown headers (like \"Next Actions\", \"💡 Next Actions\", or other localized header equivalent) before the suggestions block; the system will automatically render the appropriate header. " + "**BUTTON SCHEMA CONFORMANCE (CRITICAL)**: a MaterialButton carries its caption in its own flat 'label' string — NEVER give it a 'child' or nest a text component inside it. A MaterialButton with no 'label' renders as a BLANK button.\n" + "**NEVER BUILD THE CHIP BAR OUT OF MaterialChips**: MaterialChips has ONE action for ALL of its options, so whichever chip the user presses, the client sends that single action's context.prompt — every chip fires the FIRST chip's prompt. MaterialChips is for BOUND SELECTION inside a form (its 'value' points at a data-model path); a navigation chip bar MUST be one MaterialButton per chip, each with its OWN event name and context.prompt.\n" + "**NEVER POINT AT A CARD BY POSITION (CRITICAL)**: a card always renders BELOW the text of the same turn, never above it, so wording like \"the card above\" / \"the checkboxes above\" / \"as shown above\" (in any language) sends the user the wrong way. Name the card by what it is and point DOWN (\"in the approval card below\"), or drop the positional word entirely (\"select the items to approve and press Confirm\"). The chip bar is always last, so never describe it as being anywhere else.\n" + @@ -2241,6 +2242,205 @@ function buildManagedAgentInstruction_(businessInstruction, datasetId, fsCollect return text.split('__MA_SYSINSTR_EOF__').join('MA_SYSINSTR_EOF'); } +// ── Data asset catalog ──────────────────────────────────────────────────────── +// The generated agent's system instruction asserts that it already knows every +// table, every column and the period the data covers, and several of its rules +// ("the catalog above IS your schema", PATH 0, the no-rediscovery MUSTs) are only +// true because of that. These functions are what make them true: they turn the +// planned tables into adk_agent/app/data_assets.md, which agent.py substitutes +// into its [DATA_ASSET_CATALOG] placeholder at import time. +// +// The date coverage is the reason this exists at all. Without it the model opens +// a figure question with a MIN/MAX probe to find out what period the synthetic +// data covers - a whole round trip the user waits through, every fresh +// conversation. +const CATALOG_MAX_ENUM_VALUES = 8; +const CATALOG_MAX_ENUM_LEN = 40; +const CATALOG_MAX_DESC = 180; +const CATALOG_DATE_TYPES = ['DATE', 'DATETIME', 'TIMESTAMP']; + +/** + * Minimal RFC4180 CSV reader. The generated csvData carries quoted fields with + * embedded commas and newlines, so splitting on ',' would mis-count columns and + * hand the catalog garbage values to summarize. + * @param {string} text + * @return {Array>} + */ +function parseCsvRows_(text) { + const s = String(text || ''); + const rows = []; + let row = [], field = '', inQuotes = false; + for (let i = 0; i < s.length; i++) { + const c = s.charAt(i); + if (inQuotes) { + if (c === '"') { + if (s.charAt(i + 1) === '"') { field += '"'; i++; } else { inQuotes = false; } + } else { field += c; } + } else if (c === '"') { + inQuotes = true; + } else if (c === ',') { + row.push(field); field = ''; + } else if (c === '\n') { + row.push(field); rows.push(row); row = []; field = ''; + } else if (c !== '\r') { + field += c; + } + } + if (field !== '' || row.length) { row.push(field); rows.push(row); } + return rows; +} + +/** + * Best-effort BigQuery type for a column the plan gave no schema entry for. + * @param {Array} values non-empty values only + * @return {string} + */ +function inferCatalogType_(values) { + if (!values.length) return 'STRING'; + const dateRe = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?/; + if (values.every(v => dateRe.test(v))) { + return values.some(v => v.length > 10) ? 'TIMESTAMP' : 'DATE'; + } + if (values.every(v => /^-?\d+$/.test(v))) return 'INTEGER'; + if (values.every(v => !isNaN(parseFloat(v)) && isFinite(v))) return 'FLOAT'; + return 'STRING'; +} + +/** + * @param {string} text + * @param {number} limit + * @return {string} + */ +function clipCatalogText_(text, limit) { + const t = String(text == null ? '' : text).split(/\s+/).filter(Boolean).join(' '); + return t.length <= limit ? t : t.substring(0, limit - 3).replace(/\s+$/, '') + '...'; +} + +/** + * The one line of measured fact: how many rows, and what period they cover. + * Shared verbatim between the prompt catalog and the BigQuery table + * description, so Knowledge Catalog shows exactly what the agent was told. + * @param {number} rowCount + * @param {Array} coverage + * @return {string} + */ +function catalogFactsLine_(rowCount, coverage) { + return 'Rows: ' + rowCount + '. Coverage: ' + + (coverage.length ? coverage.join('; ') : 'no date column'); +} + +/** + * Row count and date coverage for one table, from the CSV that will be loaded. + * @param {Object} table + * @return {string} '' when the table has no parseable rows + */ +function buildTableFactsLine_(table) { + const rows = parseCsvRows_(table.csvData); + if (!rows.length) return ''; + const header = rows[0].map(h => String(h).trim()); + const dataRows = rows.slice(1).filter(r => r.some(c => String(c).trim() !== '')); + const schemaByName = {}; + (table.schema || []).forEach(f => { + if (f && f.name) schemaByName[String(f.name).trim()] = String(f.type || '').toUpperCase(); + }); + const coverage = []; + header.forEach((name, idx) => { + const present = dataRows + .map(r => String(idx < r.length ? r[idx] : '').trim()) + .filter(v => v !== ''); + const type = schemaByName[name] || inferCatalogType_(present); + if (CATALOG_DATE_TYPES.indexOf(type) !== -1 && present.length) { + const sorted = present.slice().sort(); + coverage.push('`' + name + '` ' + sorted[0].substring(0, 10) + + ' -> ' + sorted[sorted.length - 1].substring(0, 10)); + } + }); + return catalogFactsLine_(dataRows.length, coverage); +} + +/** + * The text written to BigQuery as the table description, and from there + * harvested into Knowledge Catalog: the authored grain sentence plus the same + * facts line the agent's prompt carries. + * @param {Object} table + * @return {string} '' when there is nothing to say + */ +function buildBqTableDescription_(table) { + const authored = String(table.description || '').trim(); + const facts = buildTableFactsLine_(table); + if (authored && facts) return authored + ' ' + facts; + return authored || facts; +} + +/** + * Markdown for the whole dataset. Returns '' when there is nothing to describe, + * which leaves the agent on its runtime-discovery fallback. + * @param {Array} tables + * @return {string} + */ +function buildDataAssetCatalog_(tables) { + if (!tables || !tables.length) return ''; + const sections = []; + tables.slice().sort((a, b) => String(a.tableName).localeCompare(String(b.tableName))) + .forEach(table => { + const rows = parseCsvRows_(table.csvData); + if (!rows.length) return; + const header = rows[0].map(h => String(h).trim()); + const dataRows = rows.slice(1).filter(r => r.some(c => String(c).trim() !== '')); + + const schemaByName = {}; + (table.schema || []).forEach(f => { + if (f && f.name) { + schemaByName[String(f.name).trim()] = { + type: String(f.type || '').toUpperCase(), + description: String(f.description || '').trim() + }; + } + }); + + const columnLines = [], coverage = []; + header.forEach((name, idx) => { + const present = dataRows + .map(r => String(idx < r.length ? r[idx] : '').trim()) + .filter(v => v !== ''); + const meta = schemaByName[name] || {}; + const type = meta.type || inferCatalogType_(present); + // Clipped BEFORE the value list is appended, never after: a long authored + // description would otherwise eat the budget and leave the enum severed + // mid-value ("Sprouts Farmers Marke..."), which is worse than no enum - + // the model writes the truncated string into a WHERE clause. + let desc = meta.description ? clipCatalogText_(meta.description, CATALOG_MAX_DESC) : ''; + + if (CATALOG_DATE_TYPES.indexOf(type) !== -1 && present.length) { + const sorted = present.slice().sort(); + coverage.push('`' + name + '` ' + sorted[0].substring(0, 10) + + ' -> ' + sorted[sorted.length - 1].substring(0, 10)); + if (!desc) desc = 'date column'; + } else if (type === 'STRING' && present.length) { + // A categorical column is far more useful to the model as its actual + // value list than as "STRING": it stops the agent guessing + // `WHERE status = 'Completed'` when the data says 'COMPLETE'. + const distinct = Object.keys(present.reduce((acc, v) => { acc[v] = 1; return acc; }, {})).sort(); + const longest = distinct.reduce((m, v) => Math.max(m, v.length), 0); + if (distinct.length <= CATALOG_MAX_ENUM_VALUES && longest <= CATALOG_MAX_ENUM_LEN && + distinct.length < present.length) { + const valuesTxt = 'values: ' + distinct.join(', '); + desc = desc ? (desc + ' | ' + valuesTxt) : valuesTxt; + } + } + columnLines.push('`' + name + '` ' + type + (desc ? ' - ' + desc : '')); + }); + + const tableDesc = clipCatalogText_(table.description || '', 300); + const lines = [tableDesc ? '### `' + table.tableName + '` - ' + tableDesc + : '### `' + table.tableName + '`']; + lines.push(catalogFactsLine_(dataRows.length, coverage)); + columnLines.forEach(c => lines.push(' - ' + c)); + sections.push(lines.join('\n')); + }); + return sections.join('\n\n'); +} + function generateSetupScript(params) { const { datasetId, systemInstruction, businessInstruction, referenceDate, publicDatasetId, suffix, tables, firestore, userGoal, dirName, agentShortName, oneSentenceSummary, operatingModel, enableWorkspaceMcp, enableComputerUse, enableManagedAgent, enableWorkspaceAuth, metadata } = params; @@ -2508,6 +2708,16 @@ function generateSetupScript(params) { ? buildManagedAgentInstruction_(businessInstruction || '', datasetId, fsCollection, true, workspaceAuthEnabled, preBrowseEnabled, operatingModel || '') : ''; + // Data-asset catalog. Base64 for the same reason the column descriptions + // below are: it is free text in an arbitrary language and must survive an + // unquoted heredoc without any escaping. Emitted into the project directory + // before the container build, so it ships inside the image. + const dataAssetCatalogMd = buildDataAssetCatalog_(tables); + const dataAssetCatalogCmd = dataAssetCatalogMd + ? `echo "🧾 Writing data-asset catalog for the agent prompt..."\n` + + `echo '${Utilities.base64Encode(dataAssetCatalogMd, Utilities.Charset.UTF_8)}' | base64 -d > adk_agent/app/data_assets.md\n` + : `echo "⚠️ No data-asset catalog written; the agent will discover the schema at runtime."\n`; + // Build local BQ creation commands let bqCommands = `echo "🗄 Creating BigQuery Dataset: ${datasetId}..."\n`; bqCommands += `bq mk --dataset --location=US ${datasetId} 2>/dev/null || echo " ✅ Dataset already exists."\n\n`; @@ -2575,8 +2785,12 @@ function generateSetupScript(params) { const schemaB64 = Utilities.base64Encode(JSON.stringify(bqSchema), Utilities.Charset.UTF_8); bqCommands += `echo '${schemaB64}' | base64 -d > ${table.tableName}_schema.json\n`; bqCommands += `bq update ${datasetId}.${table.tableName} ${table.tableName}_schema.json >/dev/null 2>&1 && echo " ✅ Column metadata: ${table.tableName}" || echo " ⚠️ Column metadata skipped: ${table.tableName}"\n`; - if (table.description) { - const descB64 = Utilities.base64Encode(table.description.toString(), Utilities.Charset.UTF_8); + // The grain sentence plus the measured facts (row count, date coverage), so + // the entry Knowledge Catalog harvests says the same thing as the agent's + // prompt. Column descriptions alone leave "what is one row here?" unanswered. + const bqTableDesc = buildBqTableDescription_(table); + if (bqTableDesc) { + const descB64 = Utilities.base64Encode(bqTableDesc, Utilities.Charset.UTF_8); bqCommands += `bq update --description "\$(echo '${descB64}' | base64 -d)" ${datasetId}.${table.tableName} >/dev/null 2>&1 || true\n`; } bqCommands += `rm -f ${table.tableName}_schema.json\n`; @@ -3492,6 +3706,26 @@ else fi rm -f "\$AUTH_RESP" +# A write that answered 200 is not proof the resource is READABLE, and the +# registration further down is the only thing that cares. Read it back and let +# the answer - not the write - decide whether the agent is registered with an +# authorization at all: Discovery Engine resolves +# authorizationConfig.agentAuthorization at create time and rejects the WHOLE +# registration with 404 NOT_FOUND when it names nothing, so a failed +# authorization would otherwise cost the demo its agent and its direct chat +# link, not just its Workspace tools. +AUTH_GET=\$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer \$TOKEN" \ + -H "X-Goog-User-Project: \$PROJECT_ID" \ + "https://discoveryengine.googleapis.com/v1alpha/projects/\$PROJECT_ID/locations/global/authorizations/\$AUTH_ID" 2>/dev/null || echo "000") +if [ "\$AUTH_GET" != "200" ]; then + echo " ⚠️ Authorization \$AUTH_ID is not readable (HTTP \$AUTH_GET) - the agent will be" + echo " registered WITHOUT it. The agent and its direct chat link still work;" + echo " Workspace tools fall back to the service account until this is fixed" + echo " and the script is re-run." + AUTH_ID="" +fi + `; } @@ -4158,9 +4392,15 @@ except Exception: echo " free trial subscription. Proceeding means you accept:" echo " - the Terms for data use (https://cloud.google.com/retail/data-use-terms)" echo " - the Gemini Enterprise (Agentspace) quality-of-service terms" - read -p " Start a free trial subscription automatically? (y/n) " -n 1 -r - echo - if [[ \$REPLY =~ ^[Yy]$ ]]; then + # The one decision in this script that a human has to make. An unattended + # run can pre-answer it with GE_FREE_TRIAL_CONSENT=y; unset and with no + # terminal, the read below sees EOF and the answer is no. + _GE_TRIAL_REPLY="\${GE_FREE_TRIAL_CONSENT:-}" + if [ -z "\$_GE_TRIAL_REPLY" ] && [ -t 0 ]; then + read -p " Start a free trial subscription automatically? (y/n) " -n 1 -r _GE_TRIAL_REPLY + echo + fi + if [[ "\$_GE_TRIAL_REPLY" =~ ^[Yy] ]]; then TRIAL_OUT=$(python3 - "\$PROJECT_ID" "\$GE_TOKEN" << 'PYEOF' import sys, json, time, datetime, urllib.request, urllib.error project_id, token = sys.argv[1], sys.argv[2] @@ -4250,11 +4490,12 @@ PYEOF fi fi - # Active subscription: offer to create the Gemini Enterprise app automatically. + # Active subscription: create the Gemini Enterprise app. Not offered - done. + # The subscription is the part that carries terms, and it is either already + # accepted or was just accepted above; the app itself carries none, so a y/n + # here is friction with no decision behind it and it is what stopped an + # otherwise unattended run. if [ "\$GE_LICENSE_STATE" = "ACTIVE" ]; then - read -p " Create a Gemini Enterprise app in this project automatically now? (y/n) " -n 1 -r - echo - if [[ \$REPLY =~ ^[Yy]$ ]]; then echo " ⏳ Creating Gemini Enterprise app (this can take a minute or two)..." CREATE_OUT=$(python3 - "\$PROJECT_ID" "\$GE_TOKEN" << 'PYEOF' import sys, json, time, urllib.request, urllib.error @@ -4326,7 +4567,6 @@ PYEOF echo " ⚠️ Automatic app creation failed:" echo "\$CREATE_OUT" | sed 's/^/ /' fi - fi fi fi @@ -4555,7 +4795,11 @@ MA_TOOLS_DIR="$(pwd)" # 1) Craft skills: self-authored packs (embedded at generation time from the # generator repo) + the Google Chrome modern-web-guidance skill (public, # Apache-2.0/CC-BY, cloned fresh at setup time). -rm -rf skills _mwg_tmp && mkdir -p skills +# Staged in _ma_skills/, NOT skills/: PHASE A runs before the demo asset +# directory exists, so it operates on whatever the user's CWD happens to be. +# This block used to \`rm -rf skills\` there, which silently destroyed a +# same-named directory the user already owned (hit live 2026-08-23). +rm -rf _ma_skills _mwg_tmp && mkdir -p _ma_skills ${managedSkillsBash}if git clone --depth 1 --quiet https://github.com/GoogleChrome/modern-web-guidance.git _mwg_tmp >/dev/null 2>&1; then rm -rf _mwg_tmp/.git # Publish-repo layout (verified 2026-07-12): skill packs live under skills/ @@ -4563,15 +4807,15 @@ ${managedSkillsBash}if git clone --depth 1 --quiet https://github.com/GoogleChro # SKILL.md or first-level skill dirs in case the layout changes. MA_MWG_COPIED="" if [ -f _mwg_tmp/skills/modern-web-guidance/SKILL.md ]; then - cp -r _mwg_tmp/skills/modern-web-guidance skills/ + cp -r _mwg_tmp/skills/modern-web-guidance _ma_skills/ MA_MWG_COPIED="yes" elif [ -f _mwg_tmp/SKILL.md ]; then - mkdir -p skills/modern-web-guidance - cp -r _mwg_tmp/. skills/modern-web-guidance/ + mkdir -p _ma_skills/modern-web-guidance + cp -r _mwg_tmp/. _ma_skills/modern-web-guidance/ MA_MWG_COPIED="yes" else for d in _mwg_tmp/*/ _mwg_tmp/skills/*/; do - if [ -f "\${d}SKILL.md" ]; then cp -r "$d" skills/; MA_MWG_COPIED="yes"; fi + if [ -f "\${d}SKILL.md" ]; then cp -r "$d" _ma_skills/; MA_MWG_COPIED="yes"; fi done fi rm -rf _mwg_tmp @@ -4592,7 +4836,7 @@ if git clone --depth 1 --quiet https://github.com/googleworkspace/cli.git _gws_t MA_GWS_COPIED="" for s in gws-shared gws-drive gws-gmail gws-calendar gws-chat gws-docs gws-sheets; do if [ -f "_gws_tmp/skills/\$s/SKILL.md" ]; then - cp -r "_gws_tmp/skills/\$s" skills/ + cp -r "_gws_tmp/skills/\$s" _ma_skills/ MA_GWS_COPIED="yes" fi done @@ -4608,18 +4852,18 @@ fi ` : ''} MA_SKILLS_SOURCE="" -if [ -n "$(ls -A skills 2>/dev/null)" ]; then +if [ -n "$(ls -A _ma_skills 2>/dev/null)" ]; then echo " 📤 Uploading skill packs to gs://$DASH_BUCKET/skills/ ..." - if gcloud storage cp -r skills/* "gs://$DASH_BUCKET/skills/" >/dev/null 2>&1; then + if gcloud storage cp -r _ma_skills/* "gs://$DASH_BUCKET/skills/" >/dev/null 2>&1; then MA_SKILLS_SOURCE="gs://$DASH_BUCKET/skills" - echo " ✅ Skills uploaded ($(find skills -name 'SKILL.md' | wc -l) skill packs)." + echo " ✅ Skills uploaded ($(find _ma_skills -name 'SKILL.md' | wc -l) skill packs)." else echo " ⚠️ Skill upload failed (agent will run without mounted skills)." fi else echo " ⚠️ No skill packs available (agent will run without mounted skills)." fi -rm -rf skills +rm -rf _ma_skills # 2) System instruction (quoted heredoc -> file; avoids argv-length limits). cat <<'__MA_SYSINSTR_EOF__' > managed_agent_instruction.txt @@ -5229,6 +5473,7 @@ echo "🔧 Configuring agent..." cp "$GE_TPL/adk_agent/app/tools.py" adk_agent/app/tools.py +${dataAssetCatalogCmd} mkdir -p adk_agent/app/catalogs echo ' Fetching the Gemini Enterprise A2UI v0.9 composite catalog...' curl -fsSL "https://www.gstatic.com/vertexaisearch/a2ui/v0_9/gemini_enterprise_composite_catalog.json" -o adk_agent/app/catalogs/gemini_enterprise_composite_catalog.json @@ -5249,6 +5494,25 @@ for p in glob.glob("adk_agent/app/examples/0.9/*.json"): open(p, "w", encoding="utf-8").write(s.replace("[CURRENCY]", sym)) __GE_CURR_SUB_EOF__ +_A2UI_PRUNE_LIST="${ enableWorkspaceMcp ? '' : 'chat_compose calendar_event_compose email_compose drive_file_compose chat_conversation_list drive_file_list contact_list' }" +# --- Prune few-shot examples for capabilities this demo does not have --- +# a2ui's CatalogConfig globs this directory, so EVERY file left in it is loaded +# into the system prompt whether or not the matching tools are registered. The +# seven Workspace surfaces are 21,793 characters - about 5.5k tokens of the +# ~100k the model prefills on every turn - teaching an agent to compose a Gmail +# draft it has no tool to send. Maps is not on this list: a Maps key is +# provisioned unconditionally, so maps_place_card always has a toolset behind it. +if [ -n "$_A2UI_PRUNE_LIST" ] && [ "$A2UI_KEEP_ALL_EXAMPLES" != "1" ]; then + _A2UI_PRUNED=0 + for _A2UI_EX in $_A2UI_PRUNE_LIST; do + if [ -f "adk_agent/app/examples/0.9/\${_A2UI_EX}.json" ]; then + rm -f "adk_agent/app/examples/0.9/\${_A2UI_EX}.json" + _A2UI_PRUNED=$((_A2UI_PRUNED + 1)) + fi + done + echo " OK - pruned $_A2UI_PRUNED A2UI example(s) for disabled capabilities." +fi + cp "$GE_TPL/adk_agent/app/agent.py" adk_agent/app/agent.py # --- Per-demo agent configuration (consumed by the static agent template) --- @@ -5413,6 +5677,11 @@ ${ (params.importedMcpList || []).some(m => m.type === 'remote' && (m.auth_type "ADK_ENABLE_MCP_GRACEFUL_ERROR_HANDLING=1", "ADK_DISABLE_JSON_SCHEMA_FOR_FUNC_DECL=1", `DEMO_ID=${dirName}`, + // The dataset name is already baked into the generated instruction, but + // _bigquery_scope_gate in agent.py reads it from the environment: it is the + // allow-list the gate blocks cross-dataset SQL against, and without this + // line the gate finds an empty allow-list and disables itself. + `BIGQUERY_DATASET=${datasetId}`, `DEMO_DATASET=${datasetId}`, `FS_COLLECTION=${fsCollection}`, `REFERENCE_DATE=${referenceDate}`, @@ -5428,6 +5697,16 @@ ${ (params.importedMcpList || []).some(m => m.type === 'remote' && (m.auth_type "WORKER_QUEUE=\$WORKER_QUEUE", "WORKER_QUEUE_LOCATION=\$WORKER_QUEUE_LOCATION" ]; + // A public dataset hosted somewhere other than `bigquery-public-data` is + // still a legitimate target for this demo, and the scope gate only exempts + // that one project by name. Tell it about this dataset explicitly, or the + // first public-data join gets blocked. + if (publicDatasetId && publicDatasetId.split('.').length >= 2 + && publicDatasetId.split('.')[0] !== 'bigquery-public-data') { + const publicParts = publicDatasetId.split('.'); + envVars.push(`BQ_ALLOWED_DATASETS=${publicParts[0]}.${publicParts[1]}`); + } + let secrets = []; let optionalSecrets = []; @@ -5816,14 +6095,39 @@ print(f"Error registering agent ({create_code}): {resp.get('error', '')}", file= sys.exit(1) EOF + # One definition for both the single-app and the pick-an-app branch below. + # \$1 = location, \$2 = engine id, \$3 = authorization id ("" = register + # without one). + register_ge_agent() { + python3 register_agent.py "\$1" "$PROJECT_NUMBER" "\$1" "\$2" "$TOKEN" "${dirName}" "$SERVICE_URL/a2a/app" "\$AGENT_DISPLAY_NAME" '${safeSummary}' "\$3" 2>&1 + } + + # An authorization that reads back fine can still be REFUSED by the + # registration (bound to another agent, wrong project number). A registered + # agent with degraded Workspace tools beats no agent at all, so fall back + # once, keep the direct chat link, and say plainly what was lost. + register_ge_agent_with_fallback() { + REG_OUTPUT=\$(register_ge_agent "\$1" "\$2" "\$3" || true) + echo "\$REG_OUTPUT" + AGENT_ID=\$(echo "\$REG_OUTPUT" | grep "AGENT_ID:" | cut -d':' -f2) + if [ -z "\$AGENT_ID" ] && [ ! -z "\$3" ]; then + echo "⚠️ Registration with the authorization failed - retrying without it..." + REG_OUTPUT=\$(register_ge_agent "\$1" "\$2" "" || true) + echo "\$REG_OUTPUT" + AGENT_ID=\$(echo "\$REG_OUTPUT" | grep "AGENT_ID:" | cut -d':' -f2) + if [ ! -z "\$AGENT_ID" ]; then + echo "⚠️ Registered WITHOUT end-user OAuth - Workspace tools run as the service" + echo " account. Fix the authorization and re-run this script to wire it in." + fi + fi + } + if [ "$APP_COUNT" = "1" ]; then SELECTED_APP_ID=$(echo "\${APP_NAMES[0]}" | awk -F'/' '{print \$NF}') SELECTED_LOC="\${APP_LOCS[0]}" echo "✅ Found exactly one Gemini Enterprise app ($SELECTED_APP_ID). Automating registration..." - REG_OUTPUT=$(python3 register_agent.py "$SELECTED_LOC" "$PROJECT_NUMBER" "$SELECTED_LOC" "$SELECTED_APP_ID" "$TOKEN" "${dirName}" "$SERVICE_URL/a2a/app" "\$AGENT_DISPLAY_NAME" '${safeSummary}' "$AUTH_ID" 2>&1) || true - echo "$REG_OUTPUT" - AGENT_ID=$(echo "$REG_OUTPUT" | grep "AGENT_ID:" | cut -d':' -f2) + register_ge_agent_with_fallback "$SELECTED_LOC" "$SELECTED_APP_ID" "$AUTH_ID" rm register_agent.py if [ -z "\$AGENT_ID" ]; then echo "⚠️ Gemini Enterprise registration failed, but the Cloud Run deployment itself is COMPLETE." @@ -5855,9 +6159,7 @@ EOF echo "✅ Selected app: \${APP_DISPLAY_NAMES[\$CHOICE]}. Automating registration..." - REG_OUTPUT=$(python3 register_agent.py "\$SELECTED_LOC" "\$PROJECT_NUMBER" "\$SELECTED_LOC" "\$SELECTED_APP_ID" "\$TOKEN" "${dirName}" "\$SERVICE_URL/a2a/app" "\$AGENT_DISPLAY_NAME" '${safeSummary}' "\$AUTH_ID" 2>&1) || true - echo "\$REG_OUTPUT" - AGENT_ID=$(echo "\$REG_OUTPUT" | grep "AGENT_ID:" | cut -d':' -f2) + register_ge_agent_with_fallback "\$SELECTED_LOC" "\$SELECTED_APP_ID" "\$AUTH_ID" rm register_agent.py if [ -z "\$AGENT_ID" ]; then echo "⚠️ Gemini Enterprise registration failed, but the Cloud Run deployment itself is COMPLETE." diff --git a/search/gemini-enterprise/ge-demo-generator/app/index.html b/search/gemini-enterprise/ge-demo-generator/app/index.html index 1d79f5591ab..23a30d9a296 100644 --- a/search/gemini-enterprise/ge-demo-generator/app/index.html +++ b/search/gemini-enterprise/ge-demo-generator/app/index.html @@ -22,8 +22,10 @@ ADK Agent Demo Generator - - + + +